├── .github └── workflows │ ├── check-branch.yml │ └── pages-hosting-merge.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── docs ├── .partials │ ├── functions-note.md │ └── initialization-note.md ├── .vitepress │ ├── config.ts │ └── theme │ │ ├── custom.css │ │ └── index.ts ├── api │ ├── batch.md │ ├── createaggregatefunction.md │ ├── createcallbackfunction.md │ ├── createscalarfunction.md │ ├── deletedatabasefile.md │ ├── destroy.md │ ├── getdatabasefile.md │ ├── getdatabaseinfo.md │ ├── overwritedatabasefile.md │ ├── reactivequery.md │ ├── sql.md │ └── transaction.md ├── drizzle │ └── setup.md ├── guide │ ├── introduction.md │ └── setup.md ├── index.md ├── kysely │ ├── migrations.md │ └── setup.md └── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── favicon.svg │ ├── logo-dark.png │ └── logo-light.png ├── package-lock.json ├── package.json ├── src ├── client.ts ├── drivers │ ├── sqlite-kvvfs-driver.ts │ ├── sqlite-memory-driver.ts │ └── sqlite-opfs-driver.ts ├── drizzle │ ├── client.ts │ └── index.ts ├── index.ts ├── kysely │ ├── client.ts │ └── index.ts ├── lib │ ├── convert-rows-to-objects.ts │ ├── create-mutex.ts │ ├── debounce.ts │ ├── get-database-key.ts │ ├── get-query-key.ts │ ├── mutation-lock.ts │ ├── normalize-database-file.ts │ ├── normalize-sql.ts │ ├── normalize-statement.ts │ ├── parse-database-path.ts │ └── sql-tag.ts ├── messages.ts ├── processor.ts ├── types.ts └── worker.ts ├── test ├── batch.test.ts ├── benchmarks │ └── batch.bench.ts ├── create-aggregate-function.test.ts ├── create-callback-function.test.ts ├── create-scalar-function.test.ts ├── delete-database-file.test.ts ├── destroy.test.ts ├── drizzle │ └── driver.test.ts ├── get-database-file.test.ts ├── get-database-info.test.ts ├── init.test.ts ├── kysely │ ├── dialect.test.ts │ ├── migrations.test.ts │ └── migrations │ │ ├── 2023-08-01.ts │ │ ├── 2023-08-02.ts │ │ └── index.ts ├── overwrite-database-file.test.ts ├── reactive-query.test.ts ├── sql.test.ts ├── test-utils │ ├── sleep.ts │ └── test-variation.ts └── transaction.test.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.ts /.github/workflows/check-branch.yml: -------------------------------------------------------------------------------- 1 | name: Check branch 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | - name: Install Dependencies 18 | run: 'npm ci' 19 | - name: Run Tests 20 | run: 'npm run test:ci' 21 | typecheck: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Use Node.js 27 | uses: actions/setup-node@v4 28 | - name: Install Dependencies 29 | run: 'npm ci' 30 | - name: Check Types 31 | run: 'npm run typecheck' 32 | format: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v3 37 | - name: Use Node.js 38 | uses: actions/setup-node@v4 39 | - name: Install Dependencies 40 | run: 'npm ci' 41 | - name: Check Formatting 42 | run: 'npm run format:check' 43 | -------------------------------------------------------------------------------- /.github/workflows/pages-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation to Cloudflare Pages on merge 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | deployments: write 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Build 16 | run: 'npm ci && npm run docs:build' 17 | - name: Publish 18 | uses: cloudflare/pages-action@1 19 | with: 20 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 21 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 22 | projectName: 'sqlocal-docs' 23 | directory: 'docs/.vitepress/dist' 24 | gitHubToken: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VitePress docs artifacts 2 | docs/.vitepress/dist 3 | docs/.vitepress/cache 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pnpm-debug.log* 12 | lerna-debug.log* 13 | 14 | node_modules 15 | dist 16 | dist-ssr 17 | *.local 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | !.vscode/settings.json 23 | .idea 24 | .DS_Store 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | dist 3 | README.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "jsxSingleQuote": false, 10 | "bracketSpacing": true, 11 | "bracketSameLine": false, 12 | "arrowParens": "always", 13 | "singleAttributePerLine": false 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "vitest.explorer", 5 | "qufiwefefwoyn.inline-sql-syntax" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "testing.automaticallyOpenPeekView": "never", 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dallas Hoffman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLocal 2 | 3 | SQLocal makes it easy to run SQLite3 in the browser, backed by the origin private file system. It wraps the [WebAssembly build of SQLite3](https://sqlite.org/wasm/doc/trunk/index.md) and gives you a simple interface to interact with databases running on device. 4 | 5 | [Documentation](https://sqlocal.dev) - [GitHub](https://github.com/DallasHoff/sqlocal) - [NPM](https://www.npmjs.com/package/sqlocal) - [Fund](https://www.paypal.com/biz/fund?id=U3ZNM2Q26WJY8) 6 | 7 | ## Features 8 | 9 | - 🔎 Locally executes any query that SQLite3 supports 10 | - 🧵 Runs the SQLite engine in a web worker so queries do not block the main thread 11 | - 📂 Persists data to the origin private file system, which is optimized for fast file I/O 12 | - 🔒 Each user can have their own private database instance 13 | - 🚀 Simple API; just name your database and start running SQL queries 14 | - 🛠️ Works with Kysely and Drizzle ORM for making type-safe queries 15 | 16 | ## Examples 17 | 18 | ```javascript 19 | import { SQLocal } from 'sqlocal'; 20 | 21 | // Create a client with a name for the SQLite file to save in 22 | // the origin private file system 23 | const { sql } = new SQLocal('database.sqlite3'); 24 | 25 | // Use the "sql" tagged template to execute a SQL statement 26 | // against the SQLite database 27 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`; 28 | 29 | // Execute a parameterized statement just by inserting 30 | // parameters in the SQL string 31 | const items = ['bread', 'milk', 'rice']; 32 | for (let item of items) { 33 | await sql`INSERT INTO groceries (name) VALUES (${item})`; 34 | } 35 | 36 | // SELECT queries and queries with the RETURNING clause will 37 | // return the matched records as an array of objects 38 | const data = await sql`SELECT * FROM groceries`; 39 | console.log(data); 40 | ``` 41 | 42 | Log: 43 | 44 | ```javascript 45 | [ 46 | { id: 1, name: 'bread' }, 47 | { id: 2, name: 'milk' }, 48 | { id: 3, name: 'rice' } 49 | ] 50 | ``` 51 | 52 | Or, you can use SQLocal as a driver for [Kysely](https://kysely.dev/) or [Drizzle ORM](https://orm.drizzle.team/) to make fully-typed queries. 53 | 54 | ### Kysely 55 | 56 | ```typescript 57 | import { SQLocalKysely } from 'sqlocal/kysely'; 58 | import { Kysely, Generated } from 'kysely'; 59 | 60 | // Initialize SQLocalKysely and pass the dialect to Kysely 61 | const { dialect } = new SQLocalKysely('database.sqlite3'); 62 | const db = new Kysely({ dialect }); 63 | 64 | // Define your schema 65 | // (passed to the Kysely generic above) 66 | type DB = { 67 | groceries: { 68 | id: Generated; 69 | name: string; 70 | }; 71 | }; 72 | 73 | // Make type-safe queries 74 | const data = await db 75 | .selectFrom('groceries') 76 | .select('name') 77 | .orderBy('name', 'asc') 78 | .execute(); 79 | console.log(data); 80 | ``` 81 | 82 | See the Kysely documentation for [getting started](https://kysely.dev/docs/getting-started?dialect=sqlite). 83 | 84 | ### Drizzle 85 | 86 | ```typescript 87 | import { SQLocalDrizzle } from 'sqlocal/drizzle'; 88 | import { drizzle } from 'drizzle-orm/sqlite-proxy'; 89 | import { sqliteTable, int, text } from 'drizzle-orm/sqlite-core'; 90 | 91 | // Initialize SQLocalDrizzle and pass the driver to Drizzle 92 | const { driver } = new SQLocalDrizzle('database.sqlite3'); 93 | const db = drizzle(driver); 94 | 95 | // Define your schema 96 | const groceries = sqliteTable('groceries', { 97 | id: int('id').primaryKey({ autoIncrement: true }), 98 | name: text('name').notNull(), 99 | }); 100 | 101 | // Make type-safe queries 102 | const data = await db 103 | .select({ name: groceries.name }) 104 | .from(groceries) 105 | .orderBy(groceries.name) 106 | .all(); 107 | console.log(data); 108 | ``` 109 | 110 | See the Drizzle ORM documentation for [declaring your schema](https://orm.drizzle.team/docs/sql-schema-declaration) and [making queries](https://orm.drizzle.team/docs/crud). 111 | 112 | ## Install 113 | 114 | Install the SQLocal package in your application using your package manager. 115 | 116 | ```sh 117 | npm install sqlocal 118 | # or... 119 | yarn add sqlocal 120 | # or... 121 | pnpm install sqlocal 122 | ``` 123 | 124 | ### Cross-Origin Isolation 125 | 126 | In order to persist data to the origin private file system, this package relies on APIs that require cross-origin isolation, so the page you use this package on must be served with the following HTTP headers. Otherwise, the browser will block access to the origin private file system. 127 | 128 | ```http 129 | Cross-Origin-Embedder-Policy: require-corp 130 | Cross-Origin-Opener-Policy: same-origin 131 | ``` 132 | 133 | If your development server uses Vite, you can do this by adding the following to your Vite configuration. 134 | 135 | ```javascript 136 | plugins: [ 137 | { 138 | name: 'configure-response-headers', 139 | configureServer: (server) => { 140 | server.middlewares.use((_req, res, next) => { 141 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); 142 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); 143 | next(); 144 | }); 145 | }, 146 | }, 147 | ], 148 | ``` 149 | 150 | ### Vite Configuration 151 | 152 | Vite currently has an issue that prevents it from loading web worker files correctly with the default configuration. If you use Vite, please add the below to your Vite configuration to fix this. 153 | 154 | ```javascript 155 | optimizeDeps: { 156 | exclude: ['sqlocal'], 157 | }, 158 | ``` 159 | -------------------------------------------------------------------------------- /docs/.partials/functions-note.md: -------------------------------------------------------------------------------- 1 | ::: tip NOTE 2 | Each function that you create will be connection-specific. If you create more than one connection using additional `SQLocal` instances but want to use the same function in queries sent over the other connections as well, you will need to create the function on each instance. 3 | ::: 4 | -------------------------------------------------------------------------------- /docs/.partials/initialization-note.md: -------------------------------------------------------------------------------- 1 | ::: tip NOTE 2 | If you are using the [Kysely Query Builder](/kysely/setup) or [Drizzle ORM](/drizzle/setup) for type-safe queries, you will initialize the client with a child class of `SQLocal`. See the corresponding setup page. Usage is the same otherwise. 3 | ::: 4 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: 'SQLocal', 6 | description: 7 | 'SQLocal makes it easy to run SQLite3 in the browser, backed by the origin private file system.', 8 | cleanUrls: true, 9 | head: [ 10 | ['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }], 11 | ['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }], 12 | [ 13 | 'link', 14 | { 15 | rel: 'icon', 16 | type: 'image/png', 17 | sizes: '32x32', 18 | href: '/favicon-32x32.png', 19 | }, 20 | ], 21 | ['link', { rel: 'icon', href: '/favicon.ico' }], 22 | ], 23 | themeConfig: { 24 | logo: { 25 | light: '/logo-light.png', 26 | dark: '/logo-dark.png', 27 | }, 28 | search: { provider: 'local' }, 29 | nav: [ 30 | { text: 'Introduction', link: '/guide/introduction' }, 31 | { text: 'Setup', link: '/guide/setup' }, 32 | { text: 'Shell', link: 'https://shell.sqlocal.dev/' }, 33 | ], 34 | sidebar: [ 35 | { 36 | text: 'Getting Started', 37 | items: [ 38 | { text: 'Introduction', link: '/guide/introduction' }, 39 | { text: 'Setup', link: '/guide/setup' }, 40 | ], 41 | }, 42 | { 43 | text: 'Methods', 44 | items: [ 45 | { 46 | text: 'sql', 47 | link: '/api/sql', 48 | }, 49 | { 50 | text: 'batch', 51 | link: '/api/batch', 52 | }, 53 | { 54 | text: 'transaction', 55 | link: '/api/transaction', 56 | }, 57 | { 58 | text: 'reactiveQuery', 59 | link: '/api/reactivequery', 60 | }, 61 | { 62 | text: 'getDatabaseInfo', 63 | link: '/api/getdatabaseinfo', 64 | }, 65 | { 66 | text: 'getDatabaseFile', 67 | link: '/api/getdatabasefile', 68 | }, 69 | { 70 | text: 'overwriteDatabaseFile', 71 | link: '/api/overwritedatabasefile', 72 | }, 73 | { 74 | text: 'deleteDatabaseFile', 75 | link: '/api/deletedatabasefile', 76 | }, 77 | { 78 | text: 'createCallbackFunction', 79 | link: '/api/createcallbackfunction', 80 | }, 81 | { 82 | text: 'createScalarFunction', 83 | link: '/api/createscalarfunction', 84 | }, 85 | { 86 | text: 'createAggregateFunction', 87 | link: '/api/createaggregatefunction', 88 | }, 89 | { 90 | text: 'destroy', 91 | link: '/api/destroy', 92 | }, 93 | ], 94 | }, 95 | { 96 | text: 'Kysely Query Builder', 97 | items: [ 98 | { text: 'Kysely Setup', link: '/kysely/setup' }, 99 | { text: 'Kysely Migrations', link: '/kysely/migrations' }, 100 | ], 101 | }, 102 | { 103 | text: 'Drizzle ORM', 104 | items: [{ text: 'Drizzle Setup', link: '/drizzle/setup' }], 105 | }, 106 | ], 107 | socialLinks: [ 108 | { 109 | icon: 'github', 110 | link: 'https://github.com/DallasHoff/sqlocal', 111 | ariaLabel: 'GitHub', 112 | }, 113 | { 114 | icon: { 115 | svg: '', 116 | }, 117 | link: 'https://www.npmjs.com/package/sqlocal', 118 | ariaLabel: 'NPM', 119 | }, 120 | { 121 | icon: { 122 | svg: '', 123 | }, 124 | link: 'https://www.paypal.com/biz/fund?id=U3ZNM2Q26WJY8', 125 | ariaLabel: 'Fund', 126 | }, 127 | ], 128 | footer: { 129 | message: 'Released under the MIT License', 130 | copyright: 'Copyright © 2023-present Dallas Hoffman', 131 | }, 132 | }, 133 | }); 134 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: hsl(265, 68%, 71%); 3 | --vp-c-brand-2: hsl(265, 68%, 63%); 4 | --vp-c-brand-3: hsl(265, 68%, 55%); 5 | --vp-c-brand-soft: hsl(265, 68%, 63%, 0.2); 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import './custom.css'; 3 | 4 | export default DefaultTheme; 5 | -------------------------------------------------------------------------------- /docs/api/batch.md: -------------------------------------------------------------------------------- 1 | # batch 2 | 3 | Execute a batch of SQL queries against the database in an atomic way. 4 | 5 | ## Usage 6 | 7 | Access or destructure `batch` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { batch } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `batch` function takes a group of SQL queries, passes them to the database all together, and executes them inside a transaction. If any of the queries fail, `batch` will throw an error, and the transaction will be rolled back automatically. If all queries succeed, the transaction will be committed and the results from each query will be returned. 18 | 19 | Provide a function to `batch` that returns an array of SQL queries constructed using the `sql` tagged template function passed to it. This `sql` tag function works similarly to the [`sql` tag function used for single queries](sql.md), but the queries passed to `batch` should not be individually `await`ed. Await the call to `batch`, and each query will be executed against the database in order. 20 | 21 | ```javascript 22 | const senderId = 1; 23 | const receiverId = 2; 24 | const coins = 4856; 25 | 26 | // Ensures that these 3 queries either all succeed 27 | // or get rolled back 28 | await batch((sql) => [ 29 | sql`INSERT INTO transfer (senderId, receiverId, coins) VALUES (${senderId}, ${receiverId}, ${coins})`, 30 | sql`UPDATE player SET coins = coins - ${coins} WHERE id = ${senderId}`, 31 | sql`UPDATE player SET coins = coins + ${coins} WHERE id = ${receiverId}`, 32 | ]); 33 | 34 | // Results from queries will also be returned as 35 | // items in an array, one item per query 36 | const [players, transfers] = await batch((sql) => [ 37 | sql`SELECT * FROM player WHERE id = ${senderId} OR id = ${receiverId}`, 38 | sql`SELECT * FROM transfer WHERE senderId = ${senderId}`, 39 | ]); 40 | ``` 41 | 42 | `batch` ensures atomicity and isolation for the queries it executes, but if you also need to execute other logic between queries, you should use the [`transaction` method](transaction.md). 43 | -------------------------------------------------------------------------------- /docs/api/createaggregatefunction.md: -------------------------------------------------------------------------------- 1 | # createAggregateFunction 2 | 3 | Create a SQL function that can be called from queries to combine multiple rows into a single result row. 4 | 5 | ## Usage 6 | 7 | Access or destructure `createAggregateFunction` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { createAggregateFunction } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | This method takes a string to name a custom SQL function as its first argument and an object containing two functions (`step` and `final`) as its second argument. After running `createAggregateFunction`, the aggregate function that you defined can be called from subsequent SQL queries. Arguments passed to the function in the SQL query will be passed to the JavaScript `step` function. The `step` function will run for every row in the SQL query. After each row is processed, the `final` function will run, and its return value will be passed back to SQLite to use to complete the query. 18 | 19 | This can be used to combine rows together in a query based on some custom logic. For example, the below aggregate function can be used to find the most common value for a column, such as the most common category used in a table of tasks. 20 | 21 | ```javascript 22 | const values = new Map(); 23 | 24 | await createAggregateFunction('mostCommon', { 25 | step: (value) => { 26 | const valueCount = values.get(value) ?? 0; 27 | values.set(value, valueCount + 1); 28 | }, 29 | final: () => { 30 | const valueEntries = Array.from(values.entries()); 31 | const sortedEntries = valueEntries.sort((a, b) => b[1] - a[1]); 32 | const mostCommonValue = sortedEntries[0][0]; 33 | values.clear(); 34 | return mostCommonValue; 35 | }, 36 | }); 37 | 38 | await sql`SELECT mostCommon(category) AS mostCommonCategory FROM tasks`; 39 | ``` 40 | 41 | Aggregate functions can also be used in a query's HAVING clause to filter groups of rows. Here, we use the `mostCommon` function that we created in the previous example to find which days of the week have "Cleaning" as the most common category of task. 42 | 43 | ```javascript 44 | await sql` 45 | SELECT dayOfWeek 46 | FROM tasks 47 | GROUP BY dayOfWeek 48 | HAVING mostCommon(category) = 'Cleaning' 49 | `; 50 | ``` 51 | 52 | 53 | -------------------------------------------------------------------------------- /docs/api/createcallbackfunction.md: -------------------------------------------------------------------------------- 1 | # createCallbackFunction 2 | 3 | Create a SQL function that can be called from queries to trigger a JavaScript callback. 4 | 5 | ## Usage 6 | 7 | Access or destructure `createCallbackFunction` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { createCallbackFunction } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | This method takes a string to name a custom SQL function as its first argument and a callback function as its second argument which the SQL function will call. After running `createCallbackFunction`, the function that you defined can be called from subsequent SQL queries. Arguments passed to the function in the SQL query will be passed to the JavaScript callback. 18 | 19 | A good use-case for this is making SQL triggers that notify your application when certain mutations are made in the database. For example, let's create a `logInsert` callback function that takes a table name and a record name to log a message. 20 | 21 | ```javascript 22 | await createCallbackFunction('logInsert', (tableName, recordName) => { 23 | console.log(`New ${tableName} record created with name: ${recordName}`); 24 | }); 25 | ``` 26 | 27 | Then, we can create a temporary trigger that calls `logInsert` whenever we insert a row into our `groceries` table. 28 | 29 | ```javascript 30 | await sql` 31 | CREATE TEMP TRIGGER logGroceriesInsert AFTER INSERT ON groceries 32 | BEGIN 33 | SELECT logInsert('groceries', new.name); 34 | END 35 | `; 36 | ``` 37 | 38 | Now, a message will be automatically logged whenever a query on the same connection inserts into the `groceries` table. 39 | 40 | ```javascript 41 | await sql`INSERT INTO groceries (name) VALUES ('bread')`; 42 | ``` 43 | 44 | ```log 45 | New groceries record created with name: bread 46 | ``` 47 | 48 | 49 | -------------------------------------------------------------------------------- /docs/api/createscalarfunction.md: -------------------------------------------------------------------------------- 1 | # createScalarFunction 2 | 3 | Create a SQL function that can be called from queries to transform column values or to filter rows. 4 | 5 | ## Usage 6 | 7 | Access or destructure `createScalarFunction` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { createScalarFunction } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | This method takes a string to name a custom SQL function as its first argument and a callback function as its second argument which the SQL function will call. After running `createScalarFunction`, the function that you defined can be called from subsequent SQL queries. Arguments passed to the function in the SQL query will be passed to the JavaScript callback, and the return value of the callback will be passed back to SQLite to use to complete the query. 18 | 19 | This can be used to perform custom transformations on column values in a query. For example, you could define a function that converts temperatures from Celsius to Fahrenheit. 20 | 21 | ```javascript 22 | await createScalarFunction('toFahrenheit', (celsius) => { 23 | return celsius * (9 / 5) + 32; 24 | }); 25 | 26 | await sql`SELECT celsius, toFahrenheit(celsius) AS fahrenheit FROM temperatures`; 27 | ``` 28 | 29 | Scalar functions can also be used in a query's WHERE clause to filter rows. 30 | 31 | ```javascript 32 | await createScalarFunction('isEven', (num) => num % 2 === 0); 33 | 34 | await sql`SELECT num FROM nums WHERE isEven(num)`; 35 | ``` 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/api/deletedatabasefile.md: -------------------------------------------------------------------------------- 1 | # deleteDatabaseFile 2 | 3 | Delete the SQLite database file. 4 | 5 | ## Usage 6 | 7 | Access or destructure `deleteDatabaseFile` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { deleteDatabaseFile } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `deleteDatabaseFile` method returns a `Promise` to delete the `SQLocal` instance's associated database file and [temporary files](https://www.sqlite.org/tempfiles.html). After this method completes, the `SQLocal` client will reinitialize, and any subsequent mutation queries will create a new database file. 18 | 19 | ```javascript 20 | await deleteDatabaseFile(); 21 | ``` 22 | 23 | The method also accepts an optional argument: a callback function to run after the database is deleted but before connections from other SQLocal client instances are allowed to access the new database, a good time to run migrations. 24 | 25 | ```javascript 26 | await deleteDatabaseFile(async () => { 27 | // Run your migrations 28 | }); 29 | ``` 30 | 31 | Since calling `deleteDatabaseFile` will reset all connections to the database file, the configured `onInit` statements and `onConnect` hook (see [Options](../guide/setup.md#options)) will re-run on any SQLocal clients connected to the database when it is cleared. The client that initiated the deletion will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. 32 | -------------------------------------------------------------------------------- /docs/api/destroy.md: -------------------------------------------------------------------------------- 1 | # destroy 2 | 3 | Disconnect a SQLocal client from the database and terminate its worker thread. 4 | 5 | ## Usage 6 | 7 | Access or destructure `destroy` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { destroy } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `destroy` method takes no arguments. It will return a `Promise` to close the connection to the SQLite database file and then terminate the web worker that the `SQLocal` client uses internally to run queries. 18 | 19 | It will also execute [`PRAGMA optimize`](https://www.sqlite.org/pragma.html#pragma_optimize) on the database before closing the connection. 20 | 21 | Call `destroy` if you want to clean up an `SQLocal` instance because you are finished querying its associated database for the remainder of the session. **Avoid** calling `destroy` after each query and then initializing a new `SQLocal` instance for the next query. 22 | 23 | ```javascript 24 | await destroy(); 25 | ``` 26 | 27 | ::: warning 28 | Once the `destroy` method is called on an `SQLocal` instance, any subsequent attempts to make queries through that instance will throw an error. You will need to initialize a new instance of `SQLocal` to make new queries. 29 | ::: 30 | -------------------------------------------------------------------------------- /docs/api/getdatabasefile.md: -------------------------------------------------------------------------------- 1 | # getDatabaseFile 2 | 3 | Access the SQLite database file so that it can be uploaded to the server or allowed to be downloaded by the user. 4 | 5 | ## Usage 6 | 7 | Access or destructure `getDatabaseFile` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { getDatabaseFile } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `getDatabaseFile` method takes no arguments. It will return a `Promise` for a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) object. That object can then be used to upload or download the database file of the `SQLocal` instance. 18 | 19 | For example, you can upload the database file to your server. 20 | 21 | ```javascript 22 | const databaseFile = await getDatabaseFile(); 23 | const formData = new FormData(); 24 | formData.set('databaseFile', databaseFile); 25 | 26 | await fetch('https://example.com/upload', { 27 | method: 'POST', 28 | body: formData, 29 | }); 30 | ``` 31 | 32 | Or, you can use it to make the database file available to the user for download. 33 | 34 | ```javascript 35 | const databaseFile = await getDatabaseFile(); 36 | const fileUrl = URL.createObjectURL(databaseFile); 37 | 38 | const a = document.createElement('a'); 39 | a.href = fileUrl; 40 | a.download = 'database.sqlite3'; 41 | a.click(); 42 | a.remove(); 43 | 44 | URL.revokeObjectURL(fileUrl); 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/api/getdatabaseinfo.md: -------------------------------------------------------------------------------- 1 | # getDatabaseInfo 2 | 3 | Retrieve information about the SQLite database file. 4 | 5 | ## Usage 6 | 7 | Access or destructure `getDatabaseInfo` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { getDatabaseInfo } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `getDatabaseInfo` method takes no arguments. It will return a `Promise` for an object that contains information about the database file being used by the `SQLocal` instance. 18 | 19 | ```javascript 20 | const databaseInfo = await getDatabaseInfo(); 21 | ``` 22 | 23 | The returned object contains the following properties: 24 | 25 | - **`databasePath`** (`string`) - The name of the database file. This will be identical to the value passed to the `SQLocal` constructor at initialization. 26 | - **`databaseSizeBytes`** (`number`) - An integer representing the current file size of the database in bytes. 27 | - **`storageType`** (`'memory' | 'opfs'`) - A string indicating whether the database is saved in the origin private file system or in memory. The database only falls back to being saved in memory if the OPFS cannot be used, such as when the browser does not support it. 28 | - **`persisted`** (`boolean`) - This is `true` if the database is saved in the origin private file system _and_ the application has used [`navigator.storage.persist()`](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist) to instruct the browser not to automatically evict the site's storage. 29 | 30 | If the `SQLocal` instance failed to initialize a database connection, these properties may be `undefined`. 31 | -------------------------------------------------------------------------------- /docs/api/overwritedatabasefile.md: -------------------------------------------------------------------------------- 1 | # overwriteDatabaseFile 2 | 3 | Replace the contents of the SQLite database file. 4 | 5 | ## Usage 6 | 7 | Access or destructure `overwriteDatabaseFile` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { overwriteDatabaseFile } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `overwriteDatabaseFile` method takes a database file as a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File), [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob), [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), or [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) object and returns a `Promise` to replace the `SQLocal` instance's associated database file with the one provided. 18 | 19 | For example, you can download a database file from your server to replace the local file. 20 | 21 | ```javascript 22 | const response = await fetch('https://example.com/download?id=12345'); 23 | const databaseFile = await response.blob(); 24 | await overwriteDatabaseFile(databaseFile); 25 | ``` 26 | 27 | If the database file might be large, you could alternatively pass the `ReadableStream` from the response's `body`, and SQLocal will stream the database to the client in chunks. 28 | 29 | ```javascript 30 | const response = await fetch('https://example.com/download?id=12345'); 31 | const databaseStream = response.body; 32 | if (databaseStream === null) throw new Error('No database found'); 33 | await overwriteDatabaseFile(databaseStream); 34 | ``` 35 | 36 | Or, your app may allow the user to import a database file through a form. 37 | 38 | ```javascript 39 | const fileInput = document.querySelector('input[type="file"]'); 40 | const databaseFile = fileInput.files[0]; 41 | await overwriteDatabaseFile(databaseFile); 42 | ``` 43 | 44 | The method also accepts a second, optional argument: a callback function to run after the overwrite but before connections from other SQLocal client instances are allowed to access the new database, a good time to run migrations. 45 | 46 | ```javascript 47 | await overwriteDatabaseFile(databaseFile, async () => { 48 | // Run your migrations 49 | }); 50 | ``` 51 | 52 | Since calling `overwriteDatabaseFile` will reset all connections to the database file, the configured `onInit` statements and `onConnect` hook (see [Options](../guide/setup.md#options)) will re-run on any SQLocal clients connected to the database when it is overwritten. The client that initiated the overwrite will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. 53 | -------------------------------------------------------------------------------- /docs/api/reactivequery.md: -------------------------------------------------------------------------------- 1 | # reactiveQuery 2 | 3 | Subscribe to a SQL query and receive the latest results whenever the read tables change. 4 | 5 | ## Usage 6 | 7 | Access or destructure `reactiveQuery` from the `SQLocal` client. To enable this feature, the `reactive` option must be set to `true`. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { reactiveQuery } = new SQLocal({ 13 | databasePath: 'database.sqlite3', 14 | reactive: true, 15 | }); 16 | ``` 17 | 18 | 19 | 20 | The `reactiveQuery` method takes a SQL query and allows you to subscribe to its results. When you call the `subscribe` method it returns, the query will run and its result data will be passed to your callback, and any time the database tables that are read from in that query get updated, the query will automatically re-run and pass the latest results to the callback. 21 | 22 | The query can automatically react to mutations made on the database by the same _or_ other `SQLocal` instances that have the `reactive` option set to `true`, even if they are done in other windows/tabs of the web app. Inserts, updates, or deletes to the relevent database tables from any scope will trigger the subscription. 23 | 24 | ```javascript 25 | const subscription = reactiveQuery( 26 | (sql) => sql`SELECT name FROM groceries` 27 | ).subscribe((data) => { 28 | console.log('Grocery List Updated:', data); 29 | }); 30 | ``` 31 | 32 | The query can be any SQL statement that reads one or more tables. It can be passed using a `sql` tag function available in the `reactiveQuery` callback that works similarly to the [`sql` tag function used for single queries](sql.md). It can also be a query built with Drizzle or Kysely; see the "Query Builders" section below. 33 | 34 | You can then call `subscribe` on the object returned from `reactiveQuery` to register a callback that gets called an initial time and then again whenever the one or more of the queried tables are changed. The latest result data from the query will be passed as the first argument to your callback. 35 | 36 | You can also pass a second callback to `subscribe` that will be called if there are any errors when running your query. 37 | 38 | ```javascript 39 | const groceries = reactiveQuery((sql) => sql`SELECT name FROM groceries`); 40 | 41 | const subscription = groceries.subscribe( 42 | (data) => { 43 | console.log('Grocery List Updated:', data); 44 | }, 45 | (err) => { 46 | console.error('Query Error:', err); 47 | } 48 | ); 49 | ``` 50 | 51 | To stop receiving updates and clean up the subscription, call `unsubscribe` on the object returned from `subscribe`. 52 | 53 | ```javascript 54 | subscription.unsubscribe(); 55 | ``` 56 | 57 | Note that mutations that happen inside a [transaction](transaction.md) will not trigger reactive queries until the transaction is committed. This ensures your data does not get out of sync in the case that the transaction is rolled back. Also, because of [SQLite's "Truncate Optimization"](https://sqlite.org/lang_delete.html#truncateopt), reactive queries will not be triggered by DELETE statements that have no WHERE clause, RETURNING clause, or table triggers. 58 | 59 | ## Query Builders 60 | 61 | If you are using a query builder, you can use it to create the reactive query, rather than use the `sql` tag function. The data emitted in the `subscribe` callback will then be fully typed by the query builder. 62 | 63 | ### Drizzle 64 | 65 | With Drizzle ORM, construct a query and pass it to `reactiveQuery` without executing it. 66 | 67 | ```javascript 68 | const subscription = reactiveQuery( 69 | db.select({ name: groceries.name }).from(groceries) 70 | ).subscribe((data) => { 71 | // data is typed as { name: string; }[] 72 | console.log('Grocery List Updated:', data); 73 | }); 74 | ``` 75 | 76 | ### Kysely 77 | 78 | With Kysely, construct a query, call the `compile` method on it, and pass it to `reactiveQuery`. 79 | 80 | ```javascript 81 | const subscription = reactiveQuery( 82 | db.selectFrom('groceries').select('name').compile() 83 | ).subscribe((data) => { 84 | // data is typed as { name: string; }[] 85 | console.log('Grocery List Updated:', data); 86 | }); 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/api/sql.md: -------------------------------------------------------------------------------- 1 | # sql 2 | 3 | Execute SQL queries against the database. 4 | 5 | ## Usage 6 | 7 | Access or destructure `sql` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { sql } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | `sql` is used as a [tagged template](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates). Values interpolated into the query string will be passed to the database as parameters to that query. 18 | 19 | ```javascript 20 | const item = 'Bread'; 21 | const quantity = 2; 22 | await sql`INSERT INTO groceries (name, quantity) VALUES (${item}, ${quantity})`; 23 | ``` 24 | 25 | SELECT queries and queries with the RETURNING clause will return the matched records as an array of objects. 26 | 27 | ```javascript 28 | const data = await sql`SELECT * FROM groceries`; 29 | console.log(data); 30 | ``` 31 | 32 | Example result: 33 | 34 | ```javascript 35 | [ 36 | { id: 1, name: 'Rice', quantity: 4 }, 37 | { id: 2, name: 'Milk', quantity: 1 }, 38 | { id: 3, name: 'Bread', quantity: 2 }, 39 | ]; 40 | ``` 41 | 42 | Multiple statements can be passed in the query, but note that the results returned will only include results from the first value-returning statement. Also, only one statement in the query can have parameter bindings. Because of these restrictions, it is recommended to pass only one SQL statement per call to `sql`. To run multiple statements together, use the [`batch` method](batch.md). 43 | 44 | ```javascript 45 | // Warning: only returns the row with id 1. 46 | const result = await sql` 47 | SELECT * FROM foo WHERE id = 1; 48 | SELECT * FROM foo WHERE id = 2; 49 | `; 50 | 51 | // Recommended: one statement per query 52 | const result1 = await sql`SELECT * FROM foo WHERE id = 1;`; 53 | const result2 = await sql`SELECT * FROM foo WHERE id = 2;`; 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/api/transaction.md: -------------------------------------------------------------------------------- 1 | # transaction 2 | 3 | Execute SQL transactions against the database. 4 | 5 | ## Usage 6 | 7 | Access or destructure `transaction` from the `SQLocal` client. 8 | 9 | ```javascript 10 | import { SQLocal } from 'sqlocal'; 11 | 12 | const { transaction } = new SQLocal('database.sqlite3'); 13 | ``` 14 | 15 | 16 | 17 | The `transaction` method provides a way to execute a transaction on the database, ensuring atomicity and isolation of the SQL queries executed within it. `transaction` takes a callback that is passed a `tx` object containing a `sql` tagged template for executing SQL within the transaction. 18 | 19 | This `sql` tag function passed in the `tx` object works similarly to the [`sql` tag function used for single queries](sql.md), but it ensures that the queries are executed in the context of the open transaction. Any logic can be carried out in the callback between the queries as needed. 20 | 21 | If any of the queries fail or any other error is thrown within the callback, `transaction` will throw an error and the transaction will be rolled back automatically. If the callback completes successfully, the transaction will be committed. 22 | 23 | The callback can return any value desired, and if the transaction succeeds, this value will be returned from `transaction`. 24 | 25 | ```javascript 26 | const productName = 'rice'; 27 | const productPrice = 2.99; 28 | 29 | const newProductId = await transaction(async (tx) => { 30 | const [product] = await tx.sql` 31 | INSERT INTO groceries (name) VALUES (${productName}) RETURNING * 32 | `; 33 | await tx.sql` 34 | INSERT INTO prices (groceryId, price) VALUES (${product.id}, ${productPrice}) 35 | `; 36 | return product.id; 37 | }); 38 | ``` 39 | 40 | ## Drizzle 41 | 42 | Drizzle queries can also be used with `transaction` by passing them to the `tx` object's `query` function. `query` will execute the Drizzle query as part of the transaction and its return value will be typed according to Drizzle. 43 | 44 | This is the recommended way to execute transactions when using Drizzle with SQLocal. The [`transaction` method provided by Drizzle](https://orm.drizzle.team/docs/transactions) does not ensure isolation, so queries executed outside of the Drizzle transaction at the same time may create a data inconsistency. 45 | 46 | ```javascript 47 | const productName = 'rice'; 48 | const productPrice = 2.99; 49 | 50 | const newProductId = await transaction(async (tx) => { 51 | const [product] = await tx.query( 52 | db.insert(groceries).values({ name: productName }).returning() 53 | ); 54 | await tx.query( 55 | db.insert(prices).values({ groceryId: product.id, price: productPrice }) 56 | ); 57 | return product.id; 58 | }); 59 | ``` 60 | 61 | ## Kysely 62 | 63 | Kysely queries can be used with `transaction` by calling Kysely's `compile` method on the queries and passing them to the `tx` object's `query` function. `query` will execute the Kysely query as part of the transaction and its return value will be typed according to Kysely. 64 | 65 | Functionally, SQLocal's `transaction` method and [Kysely's `transaction` method](https://kysely.dev/docs/examples/transactions/simple-transaction) are very similar. Both can ensure atomicity and isolation of the transaction, so either method can be used to the same effect as preferred. 66 | 67 | ```javascript 68 | const productName = 'rice'; 69 | const productPrice = 2.99; 70 | 71 | const newProductId = await transaction(async (tx) => { 72 | const [product] = await tx.query( 73 | db 74 | .insertInto('groceries') 75 | .values({ name: productName }) 76 | .returningAll() 77 | .compile() 78 | ); 79 | await tx.query( 80 | db 81 | .insertInto('prices') 82 | .values({ groceryId: product.id, price: productPrice }) 83 | .compile() 84 | ); 85 | return product.id; 86 | }); 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/drizzle/setup.md: -------------------------------------------------------------------------------- 1 | # Drizzle ORM Setup 2 | 3 | SQLocal provides a driver for [Drizzle ORM](https://orm.drizzle.team/) so that you can use it to make fully typed queries against databases in your TypeScript codebase. 4 | 5 | ## Install 6 | 7 | Install the Drizzle ORM package alongside SQLocal in your application using your package manager. 8 | 9 | ::: code-group 10 | 11 | ```sh [npm] 12 | npm install sqlocal drizzle-orm 13 | ``` 14 | 15 | ```sh [yarn] 16 | yarn add sqlocal drizzle-orm 17 | ``` 18 | 19 | ```sh [pnpm] 20 | pnpm install sqlocal drizzle-orm 21 | ``` 22 | 23 | ::: 24 | 25 | ## Initialize 26 | 27 | SQLocal provides the Drizzle ORM driver from a child class of `SQLocal` called `SQLocalDrizzle` imported from `sqlocal/drizzle`. This class has all the same methods as `SQLocal` but adds `driver` and `batchDriver` which you pass to the `drizzle` instance. 28 | 29 | ```typescript 30 | import { SQLocalDrizzle } from 'sqlocal/drizzle'; 31 | import { drizzle } from 'drizzle-orm/sqlite-proxy'; 32 | 33 | const { driver, batchDriver } = new SQLocalDrizzle('database.sqlite3'); 34 | export const db = drizzle(driver, batchDriver); 35 | ``` 36 | 37 | Now, any queries you run through this Drizzle instance will be executed against the database passed to `SQLocalDrizzle`. 38 | 39 | ## Define Schema 40 | 41 | Define your schema using the functions that Drizzle ORM provides. You will need to import the table definitions where you will be making queries. See the [Drizzle documentation](https://orm.drizzle.team/docs/sql-schema-declaration). 42 | 43 | ```typescript 44 | import { sqliteTable, int, text } from 'drizzle-orm/sqlite-core'; 45 | 46 | export const groceries = sqliteTable('groceries', { 47 | id: int('id').primaryKey({ autoIncrement: true }), 48 | name: text('name').notNull(), 49 | }); 50 | ``` 51 | 52 | ## Make Queries 53 | 54 | Import your Drizzle instance to start making type-safe queries. 55 | 56 | ```typescript 57 | const data = await db 58 | .select({ name: groceries.name, quantity: groceries.quantity }) 59 | .from(groceries) 60 | .orderBy(groceries.name) 61 | .all(); 62 | console.log(data); 63 | ``` 64 | 65 | See the [Drizzle documentation](https://orm.drizzle.team/docs/crud) for more examples. 66 | 67 | ## Transactions 68 | 69 | [Drizzle's `transaction` method](https://orm.drizzle.team/docs/transactions) cannot isolate transactions from outside queries. It is recommended to use the `transaction` method of `SQLocalDrizzle` instead. See the [`transaction` documentation](../api/transaction.md#drizzle). 70 | -------------------------------------------------------------------------------- /docs/guide/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | SQLocal makes it easy to run SQLite3 in the browser, backed by the origin private file system which provides high-performance read/write access to a SQLite database file stored on the user's device. 4 | 5 | SQLocal acts as a lightweight wrapper of the [WebAssembly build of SQLite3](https://sqlite.org/wasm/doc/trunk/index.md) and gives you a simple interface to interact with databases running locally. It can also act as a database driver for [Kysely](/kysely/setup) or [Drizzle ORM](/drizzle/setup) to make fully-typed queries. 6 | 7 | Having the ability to store and query relational data on device makes it possible to build powerful, local-first web apps and games no matter the complexity of your data model. 8 | 9 | ## Examples 10 | 11 | ```javascript 12 | import { SQLocal } from 'sqlocal'; 13 | 14 | // Create a client with a name for the SQLite file to save in 15 | // the origin private file system 16 | const { sql } = new SQLocal('database.sqlite3'); 17 | 18 | // Use the "sql" tagged template to execute a SQL statement 19 | // against the SQLite database 20 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`; 21 | 22 | // Execute a parameterized statement just by inserting 23 | // parameters in the SQL string 24 | const items = ['bread', 'milk', 'rice']; 25 | for (let item of items) { 26 | await sql`INSERT INTO groceries (name) VALUES (${item})`; 27 | } 28 | 29 | // SELECT queries and queries with the RETURNING clause will 30 | // return the matched records as an array of objects 31 | const data = await sql`SELECT * FROM groceries`; 32 | console.log(data); 33 | ``` 34 | 35 | Log: 36 | 37 | ```javascript 38 | [ 39 | { id: 1, name: 'bread' }, 40 | { id: 2, name: 'milk' }, 41 | { id: 3, name: 'rice' }, 42 | ]; 43 | ``` 44 | 45 | ### Kysely 46 | 47 | ```typescript 48 | import { SQLocalKysely } from 'sqlocal/kysely'; 49 | import { Kysely, Generated } from 'kysely'; 50 | 51 | // Initialize SQLocalKysely and pass the dialect to Kysely 52 | const { dialect } = new SQLocalKysely('database.sqlite3'); 53 | const db = new Kysely({ dialect }); 54 | 55 | // Define your schema 56 | // (passed to the Kysely generic above) 57 | type DB = { 58 | groceries: { 59 | id: Generated; 60 | name: string; 61 | }; 62 | }; 63 | 64 | // Make type-safe queries 65 | const data = await db 66 | .selectFrom('groceries') 67 | .select('name') 68 | .orderBy('name', 'asc') 69 | .execute(); 70 | console.log(data); 71 | ``` 72 | 73 | See the Kysely documentation for [getting started](https://kysely.dev/docs/getting-started?dialect=sqlite). 74 | 75 | ### Drizzle 76 | 77 | ```typescript 78 | import { SQLocalDrizzle } from 'sqlocal/drizzle'; 79 | import { drizzle } from 'drizzle-orm/sqlite-proxy'; 80 | import { sqliteTable, int, text } from 'drizzle-orm/sqlite-core'; 81 | 82 | // Initialize SQLocalDrizzle and pass the driver to Drizzle 83 | const { driver } = new SQLocalDrizzle('database.sqlite3'); 84 | const db = drizzle(driver); 85 | 86 | // Define your schema 87 | const groceries = sqliteTable('groceries', { 88 | id: int('id').primaryKey({ autoIncrement: true }), 89 | name: text('name').notNull(), 90 | }); 91 | 92 | // Make type-safe queries 93 | const data = await db 94 | .select({ name: groceries.name }) 95 | .from(groceries) 96 | .orderBy(groceries.name) 97 | .all(); 98 | console.log(data); 99 | ``` 100 | 101 | See the Drizzle ORM documentation for [declaring your schema](https://orm.drizzle.team/docs/sql-schema-declaration) and [making queries](https://orm.drizzle.team/docs/crud). 102 | -------------------------------------------------------------------------------- /docs/guide/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Prepare the SQLocal client and connect to a database. 4 | 5 | ## Install 6 | 7 | Install the SQLocal package in your application using your package manager. 8 | 9 | ::: code-group 10 | 11 | ```sh [npm] 12 | npm install sqlocal 13 | ``` 14 | 15 | ```sh [yarn] 16 | yarn add sqlocal 17 | ``` 18 | 19 | ```sh [pnpm] 20 | pnpm install sqlocal 21 | ``` 22 | 23 | ::: 24 | 25 | ## Cross-Origin Isolation 26 | 27 | In order to persist data to the origin private file system, this package relies on APIs that require cross-origin isolation, so the page you use this package on must be served with the following HTTP headers. Otherwise, the browser will block access to the origin private file system. 28 | 29 | ```http 30 | Cross-Origin-Embedder-Policy: require-corp 31 | Cross-Origin-Opener-Policy: same-origin 32 | ``` 33 | 34 | How this is configured will depend on what web server or hosting service your application uses. If your development server uses Vite, [see the configuration below](#vite-configuration). 35 | 36 | ## Initialize 37 | 38 | Import the `SQLocal` class to initialize your client for interacting with a local SQLite database. 39 | 40 | ```javascript 41 | import { SQLocal } from 'sqlocal'; 42 | 43 | export const db = new SQLocal('database.sqlite3'); 44 | ``` 45 | 46 | Pass the file name for your SQLite database file to the `SQLocal` constructor, and the client will connect to that database file. If your file does not already exist in the origin private file system, it will be created automatically. 47 | 48 | The file extension that you use does not matter for functionality. It is conventional to use `.sqlite3` or `.db`, but feel free to use whatever extension you need to (e.g., you are using [SQLite as an application file format](https://www.sqlite.org/aff_short.html)). 49 | 50 | You will probably also want to export the client so that you can use it throughout your application. 51 | 52 | If your application needs to query multiple databases, you can initialize another instance of `SQLocal` for each database. 53 | 54 | With the client initialized, you are ready to [start making queries](/api/sql). 55 | 56 | 57 | 58 | ## Options 59 | 60 | The `SQLocal` constructor can also be passed an object to accept additional options. 61 | 62 | ```javascript 63 | export const db = new SQLocal({ 64 | databasePath: 'database.sqlite3', 65 | readOnly: true, 66 | verbose: true, 67 | reactive: true, 68 | onInit: (sql) => {}, 69 | onConnect: (reason) => {}, 70 | }); 71 | ``` 72 | 73 | - **`databasePath`** (`string`) - The file name for the database file. This is the only required option. 74 | - **`readOnly`** (`boolean`) - If `true`, connect to the database in read-only mode. Attempts to run queries that would mutate the database will throw an error. 75 | - **`verbose`** (`boolean`) - If `true`, any SQL executed on the database will be logged to the console. 76 | - **`reactive`** (`boolean`) - If `true`, listening for database table changes is enabled, allowing the client to work with the [`reactiveQuery` method](../api/reactivequery.md). 77 | - **`onInit`** (`function`) - A callback that will be run once when the client has initialized but before it has connected to the database. This callback should return an array of SQL statements (using the passed `sql` tagged template function, similar to the [`batch` method](../api/batch.md)) that should be executed before any other statements on the database connection. The `onInit` callback will be called only once, but the statements will be executed every time the client creates a new database connection. This makes it the best way to set up any `PRAGMA` settings, temporary tables, views, or triggers for the connection. 78 | - **`onConnect`** (`function`) - A callback that will be run after the client has connected to the database. This will happen at initialization and any time [`overwriteDatabaseFile`](/api/overwritedatabasefile) or [`deleteDatabaseFile`](/api/deletedatabasefile) is called on any SQLocal client connected to the same database. The callback is passed a string (`'initial' | 'overwrite' | 'delete'`) that indicates why the callback was executed. This callback is useful for syncing your application's state with data from the newly-connected database. 79 | - **`processor`** (`SQLocalProcessor | Worker`) - Allows you to override how this instance communicates with the SQLite database. This is for advanced use-cases, such as for using custom compilations or forks of SQLite or for cases where you need to initialize the web worker yourself rather than have SQLocal do it. 80 | 81 | ## Vite Configuration 82 | 83 | Vite currently has an issue that prevents it from loading web worker files correctly with the default configuration. If you use Vite, please add the below to your [Vite configuration](https://vitejs.dev/config/) to fix this. Don't worry: it will have no impact on production performance. 84 | 85 | ```javascript 86 | optimizeDeps: { 87 | exclude: ['sqlocal'], 88 | }, 89 | ``` 90 | 91 | To enable cross-origin isolation (required for origin private file system persistence) for the Vite development server, you can add this to your Vite configuration. Just don't forget to also configure your _production_ web server to use the same HTTP headers. 92 | 93 | ```javascript 94 | plugins: [ 95 | { 96 | name: 'configure-response-headers', 97 | configureServer: (server) => { 98 | server.middlewares.use((_req, res, next) => { 99 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); 100 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); 101 | next(); 102 | }); 103 | }, 104 | }, 105 | ], 106 | ``` 107 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | title: SQLocal 5 | titleTemplate: false 6 | 7 | hero: 8 | name: 'SQLocal' 9 | text: 'Local-First Database' 10 | tagline: Run SQLite3 in the browser, backed by the origin private file system. 11 | image: 12 | light: 13 | src: '/logo-light.png' 14 | dark: 15 | src: '/logo-dark.png' 16 | actions: 17 | - theme: brand 18 | text: Get Started 19 | link: /guide/introduction 20 | - theme: alt 21 | text: View on GitHub 22 | link: https://github.com/DallasHoff/sqlocal 23 | 24 | features: 25 | - title: Any Query 26 | icon: 🔎 27 | details: Locally executes any query that SQLite3 supports 28 | - title: Threaded 29 | icon: 🧵 30 | details: Runs the SQLite engine in a web worker so queries do not block the main thread 31 | - title: Persisted 32 | icon: 📂 33 | details: Persists data to the origin private file system, which is optimized for fast file I/O 34 | - title: Per-User 35 | icon: 🔒 36 | details: Each user can have their own private database instance 37 | - title: Simple API 38 | icon: 🚀 39 | details: Just name your database and start running SQL queries 40 | - title: TypeScript 41 | icon: 🛠️ 42 | details: Works with Kysely and Drizzle ORM for making type-safe queries 43 | --- 44 | -------------------------------------------------------------------------------- /docs/kysely/migrations.md: -------------------------------------------------------------------------------- 1 | # Kysely Migrations 2 | 3 | As you update your app, you need to ensure that every user's database schema remains compatible with your app's logic. To do this, you can run [Kysely migrations](https://kysely.dev/docs/migrations) in the frontend through your SQLocal client. 4 | 5 | ## Create Migrations 6 | 7 | Since SQLocal runs in the frontend, your app's migrations should be included in the frontend bundle as well. To prepare migrations to use with Kysely in the frontend, we need to build a migrations object where each entry has a `string` key and a value that is a Kysely `Migration` object. 8 | 9 | For example, let's consider the file structure below. We have each `Migration` live in its own file in the `migrations` directory, and we have an `index.ts` file where we combine all the `Migration`s into the full migrations object. 10 | 11 | ``` 12 | . 13 | ├── database/ 14 | │ ├── migrations/ 15 | │ │ ├── 2023-08-01.ts 16 | │ │ ├── 2023-08-02.ts 17 | │ │ └── index.ts 18 | │ ├── client.ts 19 | │ ├── migrator.ts 20 | │ └── schema.ts 21 | └── main.ts 22 | ``` 23 | 24 | Each `Migration` has an `up` method and a `down` method which run Kysely queries. The `up` method migrates the database, and the `down` method does the inverse of the `up` method in case you ever need to rollback migrations. 25 | 26 | With your `Migration` object written, import it into the `index.ts` file, and add it to the migrations object, which should be the type `Record`. The keys you use here will determine the order that Kysely runs the migrations in, so they need to be numbered or start with a date or timestamp. 27 | 28 | ::: code-group 29 | 30 | ```typescript [index.ts] 31 | import { Migration } from 'kysely'; 32 | import { Migration20230801 } from './2023-08-01'; 33 | import { Migration20230802 } from './2023-08-02'; 34 | 35 | export const migrations: Record = { 36 | '2023-08-01': Migration20230801, 37 | '2023-08-02': Migration20230802, 38 | }; 39 | ``` 40 | 41 | ```typescript [2023-08-01.ts] 42 | import type { Kysely, Migration } from 'kysely'; 43 | 44 | export const Migration20230801: Migration = { 45 | async up(db: Kysely) { 46 | await db.schema 47 | .createTable('groceries') 48 | .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement()) 49 | .addColumn('name', 'text', (cb) => cb.notNull()) 50 | .execute(); 51 | }, 52 | async down(db: Kysely) { 53 | await db.schema.dropTable('groceries').execute(); 54 | }, 55 | }; 56 | ``` 57 | 58 | ::: 59 | 60 | ## Create Migrator 61 | 62 | With the migrations object ready, we can create the Kysely `Migrator` that will read those migrations to execute them. `Migrator` needs to be passed `db`, which is your `Kysely` instance initialized with the `SQLocalKysely` dialect, and a `provider` which implements a `getMigrations` method to fetch the migrations object we made before. This can be accomplished with a dynamic `import` of the migrations from the `index.ts` file. 63 | 64 | ::: code-group 65 | 66 | ```typescript [migrator.ts] 67 | import { Migrator } from 'kysely'; 68 | import { db } from './client'; 69 | 70 | export const migrator = new Migrator({ 71 | db, 72 | provider: { 73 | async getMigrations() { 74 | const { migrations } = await import('./migrations/'); 75 | return migrations; 76 | }, 77 | }, 78 | }); 79 | ``` 80 | 81 | ```typescript [client.ts] 82 | import { SQLocalKysely } from 'sqlocal/kysely'; 83 | import { Kysely } from 'kysely'; 84 | import type { Database } from './schema'; 85 | 86 | const { dialect } = new SQLocalKysely('database.sqlite3'); 87 | export const db = new Kysely({ dialect }); 88 | ``` 89 | 90 | ```typescript [schema.ts] 91 | import type { Generated } from 'kysely'; 92 | 93 | export type Database = { 94 | groceries: GroceriesTable; 95 | }; 96 | 97 | export type GroceriesTable = { 98 | id: Generated; 99 | name: string; 100 | quantity: number; 101 | }; 102 | ``` 103 | 104 | ::: 105 | 106 | ## Run Migrations 107 | 108 | All that's left now is to put that `Migrator` to use. Import it wherever your app initializes and call its `migrateToLatest` method. This will execute, in order, any of the migrations that have not yet been run against the database instance that was passed to the `Migrator`. 109 | 110 | ```typescript [main.ts] 111 | import { migrator } from './database/migrator'; 112 | 113 | await migrator.migrateToLatest(); 114 | ``` 115 | 116 | The `Migrator` also has other methods to run migrations as needed. 117 | 118 | ```typescript 119 | // run the next migration 120 | await migrator.migrateUp(); 121 | // rollback the last migration 122 | await migrator.migrateDown(); 123 | // migrate to the point of the migration passed by key 124 | await migrator.migrateTo('2023-08-01'); 125 | ``` 126 | -------------------------------------------------------------------------------- /docs/kysely/setup.md: -------------------------------------------------------------------------------- 1 | # Kysely Query Builder Setup 2 | 3 | SQLocal provides a dialect for the [Kysely](https://kysely.dev/) query builder so that you can use it to make fully typed queries against databases in your TypeScript codebase. 4 | 5 | ## Install 6 | 7 | Install the Kysely package alongside SQLocal in your application using your package manager. 8 | 9 | ::: code-group 10 | 11 | ```sh [npm] 12 | npm install sqlocal kysely 13 | ``` 14 | 15 | ```sh [yarn] 16 | yarn add sqlocal kysely 17 | ``` 18 | 19 | ```sh [pnpm] 20 | pnpm install sqlocal kysely 21 | ``` 22 | 23 | ::: 24 | 25 | ## Initialize 26 | 27 | SQLocal provides the Kysely dialect from a child class of `SQLocal` called `SQLocalKysely` imported from `sqlocal/kysely`. This class has all the same methods as `SQLocal` and adds `dialect` which you pass to the `Kysely` instance. 28 | 29 | ```typescript 30 | import { SQLocalKysely } from 'sqlocal/kysely'; 31 | import { Kysely } from 'kysely'; 32 | 33 | const { dialect } = new SQLocalKysely('database.sqlite3'); 34 | export const db = new Kysely({ dialect }); 35 | ``` 36 | 37 | Now, any queries you run through this Kysely instance will be executed against the database passed to `SQLocalKysely`. 38 | 39 | ## Define Schema 40 | 41 | With Kysely, your schema is defined using TypeScript object types. See the [Kysely documentation](https://kysely.dev/docs/getting-started#types). 42 | 43 | ```typescript 44 | import type { Generated } from 'kysely'; 45 | 46 | export type Database = { 47 | groceries: GroceriesTable; 48 | }; 49 | 50 | export type GroceriesTable = { 51 | id: Generated; 52 | name: string; 53 | quantity: number; 54 | }; 55 | ``` 56 | 57 | With this defined, pass the database type to the Kysely instance, and your queries built with Kysely will now have auto-complete and type checking. 58 | 59 | ```typescript 60 | export const db = new Kysely({ dialect }); 61 | ``` 62 | 63 | ## Make Queries 64 | 65 | Import your Kysely instance to start making type-safe queries. 66 | 67 | ```typescript 68 | const data = await db 69 | .selectFrom('groceries') 70 | .select(['name', 'quantity']) 71 | .orderBy('name', 'asc') 72 | .execute(); 73 | console.log(data); 74 | ``` 75 | 76 | See the [Kysely documentation](https://kysely.dev/docs/category/examples) for more examples. 77 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/logo-dark.png -------------------------------------------------------------------------------- /docs/public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DallasHoff/sqlocal/c1058678e62c92e8997d2bcdd62f2551983732cb/docs/public/logo-light.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqlocal", 3 | "version": "0.15.2", 4 | "type": "module", 5 | "types": "./dist/index.d.ts", 6 | "browser": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "browser": "./dist/index.js", 12 | "import": "./dist/index.js" 13 | }, 14 | "./drizzle": { 15 | "types": "./dist/drizzle/index.d.ts", 16 | "browser": "./dist/drizzle/index.js", 17 | "import": "./dist/drizzle/index.js" 18 | }, 19 | "./kysely": { 20 | "types": "./dist/kysely/index.d.ts", 21 | "browser": "./dist/kysely/index.js", 22 | "import": "./dist/kysely/index.js" 23 | } 24 | }, 25 | "sideEffects": false, 26 | "files": [ 27 | "dist", 28 | "src" 29 | ], 30 | "scripts": { 31 | "build": "tsc --project tsconfig.build.json", 32 | "typecheck": "tsc --noEmit", 33 | "test": "vitest --browser=chrome", 34 | "test:ui": "vitest --ui --browser=chrome", 35 | "test:ci": "vitest run --browser=chrome", 36 | "test:safari": "vitest --browser=safari --browser.headless=false", 37 | "benchmark": "vitest bench --browser=chrome", 38 | "format": "prettier . --write", 39 | "format:check": "prettier . --check", 40 | "docs:dev": "vitepress dev docs", 41 | "docs:build": "vitepress build docs", 42 | "docs:preview": "vitepress preview docs", 43 | "prepublishOnly": "npm run build && npm run test:ci" 44 | }, 45 | "dependencies": { 46 | "@sqlite.org/sqlite-wasm": "^3.50.4-build1", 47 | "coincident": "^1.2.3" 48 | }, 49 | "devDependencies": { 50 | "@vitest/browser": "^3.2.4", 51 | "@vitest/ui": "^3.2.4", 52 | "drizzle-orm": "^0.44.6", 53 | "kysely": "^0.28.7", 54 | "prettier": "^3.6.2", 55 | "taze": "^19.7.0", 56 | "typescript": "^5.9.3", 57 | "vite": "^7.1.9", 58 | "vitepress": "^1.6.4", 59 | "vitest": "^3.2.4", 60 | "webdriverio": "^9.20.0", 61 | "wrangler": "^4.42.0" 62 | }, 63 | "peerDependencies": { 64 | "drizzle-orm": "*", 65 | "kysely": "*" 66 | }, 67 | "peerDependenciesMeta": { 68 | "kysely": { 69 | "optional": true 70 | }, 71 | "drizzle-orm": { 72 | "optional": true 73 | } 74 | }, 75 | "author": "Dallas Hoffman", 76 | "license": "MIT", 77 | "description": "SQLocal makes it easy to run SQLite3 in the browser, backed by the origin private file system.", 78 | "keywords": [ 79 | "browser", 80 | "sqlite", 81 | "sql", 82 | "database", 83 | "wasm", 84 | "opfs", 85 | "worker", 86 | "drizzle", 87 | "kysely" 88 | ], 89 | "repository": { 90 | "type": "git", 91 | "url": "git+https://github.com/DallasHoff/sqlocal.git" 92 | }, 93 | "homepage": "https://sqlocal.dev", 94 | "funding": { 95 | "type": "paypal", 96 | "url": "https://www.paypal.com/biz/fund?id=U3ZNM2Q26WJY8" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import coincident from 'coincident'; 2 | import type { 3 | CallbackUserFunction, 4 | QueryKey, 5 | RawResultData, 6 | Sqlite3Method, 7 | ScalarUserFunction, 8 | Statement, 9 | DatabaseInfo, 10 | ClientConfig, 11 | StatementInput, 12 | Transaction, 13 | DatabasePath, 14 | AggregateUserFunction, 15 | ReactiveQuery, 16 | } from './types.js'; 17 | import type { 18 | BatchMessage, 19 | BroadcastMessage, 20 | ConfigMessage, 21 | DeleteMessage, 22 | DestroyMessage, 23 | EffectsMessage, 24 | ExportMessage, 25 | FunctionMessage, 26 | GetInfoMessage, 27 | ImportMessage, 28 | OmitQueryKey, 29 | OutputMessage, 30 | QueryMessage, 31 | TransactionMessage, 32 | WorkerProxy, 33 | } from './messages.js'; 34 | import { SQLocalProcessor } from './processor.js'; 35 | import { sqlTag } from './lib/sql-tag.js'; 36 | import { convertRowsToObjects } from './lib/convert-rows-to-objects.js'; 37 | import { normalizeStatement } from './lib/normalize-statement.js'; 38 | import { getQueryKey } from './lib/get-query-key.js'; 39 | import { normalizeSql } from './lib/normalize-sql.js'; 40 | import { mutationLock } from './lib/mutation-lock.js'; 41 | import { normalizeDatabaseFile } from './lib/normalize-database-file.js'; 42 | import { SQLiteMemoryDriver } from './drivers/sqlite-memory-driver.js'; 43 | import { SQLiteKvvfsDriver } from './drivers/sqlite-kvvfs-driver.js'; 44 | import { getDatabaseKey } from './lib/get-database-key.js'; 45 | 46 | export class SQLocal { 47 | protected config: ClientConfig; 48 | protected clientKey: QueryKey; 49 | protected processor: SQLocalProcessor | Worker; 50 | protected isDestroyed: boolean = false; 51 | protected bypassMutationLock: boolean = false; 52 | protected transactionQueryKeyQueue: QueryKey[] = []; 53 | protected userCallbacks = new Map(); 54 | protected queriesInProgress = new Map< 55 | QueryKey, 56 | [ 57 | resolve: (message: OutputMessage) => void, 58 | reject: (error: unknown) => void, 59 | ] 60 | >(); 61 | 62 | protected proxy: WorkerProxy; 63 | protected reinitChannel: BroadcastChannel; 64 | protected effectsChannel?: BroadcastChannel; 65 | 66 | constructor(databasePath: DatabasePath); 67 | constructor(config: ClientConfig); 68 | constructor(config: DatabasePath | ClientConfig) { 69 | const clientConfig = 70 | typeof config === 'string' ? { databasePath: config } : config; 71 | const { onInit, onConnect, processor, ...commonConfig } = clientConfig; 72 | const { databasePath } = commonConfig; 73 | 74 | this.config = clientConfig; 75 | this.clientKey = getQueryKey(); 76 | const dbKey = getDatabaseKey(databasePath, this.clientKey); 77 | 78 | this.reinitChannel = new BroadcastChannel(`_sqlocal_reinit_(${dbKey})`); 79 | 80 | if (commonConfig.reactive) { 81 | this.effectsChannel = new BroadcastChannel(`_sqlocal_effects_(${dbKey})`); 82 | } 83 | 84 | if (typeof processor !== 'undefined') { 85 | this.processor = processor; 86 | } else if (databasePath === 'local' || databasePath === ':localStorage:') { 87 | const driver = new SQLiteKvvfsDriver('local'); 88 | this.processor = new SQLocalProcessor(driver); 89 | } else if ( 90 | databasePath === 'session' || 91 | databasePath === ':sessionStorage:' 92 | ) { 93 | const driver = new SQLiteKvvfsDriver('session'); 94 | this.processor = new SQLocalProcessor(driver); 95 | } else if ( 96 | typeof globalThis.Worker !== 'undefined' && 97 | databasePath !== ':memory:' 98 | ) { 99 | this.processor = new Worker(new URL('./worker', import.meta.url), { 100 | type: 'module', 101 | }); 102 | } else { 103 | const driver = new SQLiteMemoryDriver(); 104 | this.processor = new SQLocalProcessor(driver); 105 | } 106 | 107 | if (this.processor instanceof SQLocalProcessor) { 108 | this.processor.onmessage = (message) => this.processMessageEvent(message); 109 | this.proxy = globalThis as WorkerProxy; 110 | } else { 111 | this.processor.addEventListener('message', this.processMessageEvent); 112 | this.proxy = coincident(this.processor) as WorkerProxy; 113 | } 114 | 115 | this.processor.postMessage({ 116 | type: 'config', 117 | config: { 118 | ...commonConfig, 119 | clientKey: this.clientKey, 120 | onInitStatements: onInit?.(sqlTag) ?? [], 121 | }, 122 | } satisfies ConfigMessage); 123 | } 124 | 125 | protected processMessageEvent = ( 126 | event: OutputMessage | MessageEvent 127 | ): void => { 128 | const message = event instanceof MessageEvent ? event.data : event; 129 | const queries = this.queriesInProgress; 130 | 131 | switch (message.type) { 132 | case 'success': 133 | case 'data': 134 | case 'buffer': 135 | case 'info': 136 | case 'error': 137 | if (message.queryKey && queries.has(message.queryKey)) { 138 | const [resolve, reject] = queries.get(message.queryKey)!; 139 | if (message.type === 'error') { 140 | reject(message.error); 141 | } else { 142 | resolve(message); 143 | } 144 | queries.delete(message.queryKey); 145 | } else if (message.type === 'error') { 146 | throw message.error; 147 | } 148 | break; 149 | 150 | case 'callback': 151 | const userCallback = this.userCallbacks.get(message.name); 152 | 153 | if (userCallback) { 154 | userCallback(...(message.args ?? [])); 155 | } 156 | break; 157 | 158 | case 'event': 159 | this.config.onConnect?.(message.reason); 160 | break; 161 | } 162 | }; 163 | 164 | protected createQuery = async ( 165 | message: OmitQueryKey< 166 | | QueryMessage 167 | | BatchMessage 168 | | TransactionMessage 169 | | FunctionMessage 170 | | GetInfoMessage 171 | | ImportMessage 172 | | ExportMessage 173 | | DeleteMessage 174 | | DestroyMessage 175 | > 176 | ): Promise => { 177 | return mutationLock( 178 | 'shared', 179 | this.bypassMutationLock || 180 | message.type === 'import' || 181 | message.type === 'delete', 182 | this.config, 183 | async () => { 184 | if (this.isDestroyed === true) { 185 | throw new Error( 186 | 'This SQLocal client has been destroyed. You will need to initialize a new client in order to make further queries.' 187 | ); 188 | } 189 | 190 | const queryKey = getQueryKey(); 191 | 192 | switch (message.type) { 193 | case 'import': 194 | this.processor.postMessage( 195 | { 196 | ...message, 197 | queryKey, 198 | } satisfies ImportMessage, 199 | [message.database] 200 | ); 201 | break; 202 | default: 203 | this.processor.postMessage({ 204 | ...message, 205 | queryKey, 206 | } satisfies 207 | | QueryMessage 208 | | BatchMessage 209 | | TransactionMessage 210 | | FunctionMessage 211 | | GetInfoMessage 212 | | ExportMessage 213 | | DeleteMessage 214 | | DestroyMessage); 215 | break; 216 | } 217 | 218 | return new Promise((resolve, reject) => { 219 | this.queriesInProgress.set(queryKey, [resolve, reject]); 220 | }); 221 | } 222 | ); 223 | }; 224 | 225 | protected broadcast = (message: BroadcastMessage): void => { 226 | this.reinitChannel.postMessage(message); 227 | }; 228 | 229 | protected exec = async ( 230 | sql: string, 231 | params: unknown[], 232 | method: Sqlite3Method = 'all', 233 | transactionKey?: QueryKey 234 | ): Promise => { 235 | const message = await this.createQuery({ 236 | type: 'query', 237 | transactionKey, 238 | sql, 239 | params, 240 | method, 241 | }); 242 | 243 | const data: RawResultData = { 244 | rows: [], 245 | columns: [], 246 | }; 247 | 248 | if (message.type === 'data') { 249 | data.rows = message.data[0]?.rows ?? []; 250 | data.columns = message.data[0]?.columns ?? []; 251 | } 252 | 253 | return data; 254 | }; 255 | 256 | protected execBatch = async ( 257 | statements: Statement[] 258 | ): Promise => { 259 | const message = await this.createQuery({ 260 | type: 'batch', 261 | statements, 262 | }); 263 | const data = new Array(statements.length).fill({ 264 | rows: [], 265 | columns: [], 266 | }) as RawResultData[]; 267 | 268 | if (message.type === 'data') { 269 | message.data.forEach((result, resultIndex) => { 270 | data[resultIndex] = result; 271 | }); 272 | } 273 | 274 | return data; 275 | }; 276 | 277 | sql = async >( 278 | queryTemplate: TemplateStringsArray | string, 279 | ...params: unknown[] 280 | ): Promise => { 281 | const statement = normalizeSql(queryTemplate, params); 282 | const { rows, columns } = await this.exec( 283 | statement.sql, 284 | statement.params, 285 | 'all' 286 | ); 287 | const resultRecords = convertRowsToObjects(rows, columns); 288 | return resultRecords as Result[]; 289 | }; 290 | 291 | batch = async >( 292 | passStatements: (sql: typeof sqlTag) => Statement[] 293 | ): Promise => { 294 | const statements = passStatements(sqlTag); 295 | const data = await this.execBatch(statements); 296 | 297 | return data.map(({ rows, columns }) => { 298 | const resultRecords = convertRowsToObjects(rows, columns); 299 | return resultRecords as Result[]; 300 | }); 301 | }; 302 | 303 | beginTransaction = async (): Promise => { 304 | const transactionKey = getQueryKey(); 305 | 306 | await this.createQuery({ 307 | type: 'transaction', 308 | transactionKey, 309 | action: 'begin', 310 | }); 311 | 312 | const query = async >( 313 | passStatement: StatementInput 314 | ): Promise => { 315 | const statement = normalizeStatement(passStatement); 316 | if (statement.exec) { 317 | this.transactionQueryKeyQueue.push(transactionKey); 318 | return statement.exec(); 319 | } 320 | const { rows, columns } = await this.exec( 321 | statement.sql, 322 | statement.params, 323 | 'all', 324 | transactionKey 325 | ); 326 | const resultRecords = convertRowsToObjects(rows, columns) as Result[]; 327 | return resultRecords; 328 | }; 329 | 330 | const sql = async >( 331 | queryTemplate: TemplateStringsArray | string, 332 | ...params: unknown[] 333 | ): Promise => { 334 | const statement = normalizeSql(queryTemplate, params); 335 | const resultRecords = await query(statement); 336 | return resultRecords; 337 | }; 338 | 339 | const commit = async (): Promise => { 340 | await this.createQuery({ 341 | type: 'transaction', 342 | transactionKey, 343 | action: 'commit', 344 | }); 345 | }; 346 | 347 | const rollback = async (): Promise => { 348 | await this.createQuery({ 349 | type: 'transaction', 350 | transactionKey, 351 | action: 'rollback', 352 | }); 353 | }; 354 | 355 | return { 356 | query, 357 | sql, 358 | commit, 359 | rollback, 360 | }; 361 | }; 362 | 363 | transaction = async ( 364 | transaction: (tx: { 365 | sql: Transaction['sql']; 366 | query: Transaction['query']; 367 | }) => Promise 368 | ): Promise => { 369 | return mutationLock('exclusive', false, this.config, async () => { 370 | let tx: Transaction | undefined; 371 | this.bypassMutationLock = true; 372 | 373 | try { 374 | tx = await this.beginTransaction(); 375 | const result = await transaction({ 376 | sql: tx.sql, 377 | query: tx.query, 378 | }); 379 | await tx.commit(); 380 | return result; 381 | } catch (err) { 382 | await tx?.rollback(); 383 | throw err; 384 | } finally { 385 | this.bypassMutationLock = false; 386 | } 387 | }); 388 | }; 389 | 390 | reactiveQuery = >( 391 | passStatement: StatementInput 392 | ): ReactiveQuery => { 393 | let value: Result[] = []; 394 | let gotFirstValue = false; 395 | let isListening = false; 396 | let updateCount = 0; 397 | 398 | const statement = normalizeStatement(passStatement); 399 | const watchedTables = new Set(); 400 | const subObservers = new Set<(results: Result[]) => void>(); 401 | const errObservers = new Set<(err: Error) => void>(); 402 | 403 | const runStatement = async (): Promise => { 404 | try { 405 | const updateOrder = ++updateCount; 406 | 407 | if (watchedTables.size === 0) { 408 | const usedTables = await this.sql( 409 | "SELECT name, wr FROM tables_used(?) WHERE type = 'table'", 410 | statement.sql 411 | ); 412 | const readTables = new Set(); 413 | const writtenTables = new Set(); 414 | 415 | usedTables.forEach((table) => { 416 | if (typeof table.name !== 'string') return; 417 | table.wr 418 | ? writtenTables.add(table.name) 419 | : readTables.add(table.name); 420 | }); 421 | 422 | if (readTables.size === 0) { 423 | throw new Error('The passed SQL does not read any tables.'); 424 | } 425 | 426 | if ( 427 | Array.from(writtenTables).some((table) => readTables.has(table)) 428 | ) { 429 | throw new Error( 430 | 'The passed SQL would mutate one or more of the tables that it reads. Doing this in a reactive query would create an infinite loop.' 431 | ); 432 | } 433 | 434 | readTables.forEach((name) => watchedTables.add(name)); 435 | } 436 | 437 | const results = statement.exec 438 | ? await statement.exec() 439 | : await this.sql(statement.sql, ...statement.params); 440 | 441 | if (updateOrder === updateCount) { 442 | value = results; 443 | gotFirstValue = true; 444 | subObservers.forEach((observer) => observer(value)); 445 | } 446 | } catch (err) { 447 | errObservers.forEach((observer) => { 448 | observer(err instanceof Error ? err : new Error(String(err))); 449 | }); 450 | } 451 | }; 452 | 453 | const onEffect = (message: MessageEvent): void => { 454 | if (message.data.tables.some((table) => watchedTables.has(table))) { 455 | runStatement(); 456 | } 457 | }; 458 | 459 | return { 460 | get value() { 461 | return value; 462 | }, 463 | subscribe: ( 464 | onData: (results: Result[]) => void, 465 | onError?: (err: Error) => void 466 | ) => { 467 | if (!this.effectsChannel) { 468 | throw new Error( 469 | 'This SQLocal instance is not configured for reactive queries. Set the "reactive" option to enable them.' 470 | ); 471 | } 472 | 473 | if (!onError) { 474 | onError = (err) => { 475 | throw err; 476 | }; 477 | } 478 | 479 | subObservers.add(onData); 480 | errObservers.add(onError); 481 | 482 | if (!isListening) { 483 | this.effectsChannel.addEventListener('message', onEffect); 484 | isListening = true; 485 | runStatement(); 486 | } else if (gotFirstValue) { 487 | onData(value); 488 | } 489 | 490 | return { 491 | unsubscribe: () => { 492 | subObservers.delete(onData); 493 | errObservers.delete(onError); 494 | if (subObservers.size !== 0) return; 495 | this.effectsChannel?.removeEventListener('message', onEffect); 496 | isListening = false; 497 | }, 498 | }; 499 | }, 500 | }; 501 | }; 502 | 503 | createCallbackFunction = async ( 504 | funcName: string, 505 | func: CallbackUserFunction['func'] 506 | ): Promise => { 507 | await this.createQuery({ 508 | type: 'function', 509 | functionName: funcName, 510 | functionType: 'callback', 511 | }); 512 | 513 | this.userCallbacks.set(funcName, func); 514 | }; 515 | 516 | createScalarFunction = async ( 517 | funcName: string, 518 | func: ScalarUserFunction['func'] 519 | ): Promise => { 520 | const key = `_sqlocal_func_${funcName}`; 521 | const attachFunction = () => { 522 | this.proxy[key] = func; 523 | }; 524 | 525 | if (this.proxy === globalThis) { 526 | attachFunction(); 527 | } 528 | 529 | await this.createQuery({ 530 | type: 'function', 531 | functionName: funcName, 532 | functionType: 'scalar', 533 | }); 534 | 535 | if (this.proxy !== globalThis) { 536 | attachFunction(); 537 | } 538 | }; 539 | 540 | createAggregateFunction = async ( 541 | funcName: string, 542 | func: AggregateUserFunction['func'] 543 | ): Promise => { 544 | const key = `_sqlocal_func_${funcName}`; 545 | const attachFunction = () => { 546 | this.proxy[`${key}_step`] = func.step; 547 | this.proxy[`${key}_final`] = func.final; 548 | }; 549 | 550 | if (this.proxy === globalThis) { 551 | attachFunction(); 552 | } 553 | 554 | await this.createQuery({ 555 | type: 'function', 556 | functionName: funcName, 557 | functionType: 'aggregate', 558 | }); 559 | 560 | if (this.proxy !== globalThis) { 561 | attachFunction(); 562 | } 563 | }; 564 | 565 | getDatabaseInfo = async (): Promise => { 566 | const message = await this.createQuery({ type: 'getinfo' }); 567 | 568 | if (message.type === 'info') { 569 | return message.info; 570 | } else { 571 | throw new Error('The database failed to return valid information.'); 572 | } 573 | }; 574 | 575 | getDatabaseFile = async (): Promise => { 576 | const message = await this.createQuery({ type: 'export' }); 577 | 578 | if (message.type === 'buffer') { 579 | return new File([message.buffer], message.bufferName, { 580 | type: 'application/x-sqlite3', 581 | }); 582 | } else { 583 | throw new Error('The database failed to export.'); 584 | } 585 | }; 586 | 587 | overwriteDatabaseFile = async ( 588 | databaseFile: 589 | | File 590 | | Blob 591 | | ArrayBuffer 592 | | Uint8Array 593 | | ReadableStream>, 594 | beforeUnlock?: () => void | Promise 595 | ): Promise => { 596 | await mutationLock('exclusive', false, this.config, async () => { 597 | try { 598 | this.broadcast({ 599 | type: 'close', 600 | clientKey: this.clientKey, 601 | }); 602 | 603 | const database = await normalizeDatabaseFile(databaseFile, 'buffer'); 604 | 605 | await this.createQuery({ 606 | type: 'import', 607 | database, 608 | }); 609 | 610 | if (typeof beforeUnlock === 'function') { 611 | this.bypassMutationLock = true; 612 | await beforeUnlock(); 613 | } 614 | 615 | this.broadcast({ 616 | type: 'reinit', 617 | clientKey: this.clientKey, 618 | reason: 'overwrite', 619 | }); 620 | } finally { 621 | this.bypassMutationLock = false; 622 | } 623 | }); 624 | }; 625 | 626 | deleteDatabaseFile = async ( 627 | beforeUnlock?: () => void | Promise 628 | ): Promise => { 629 | await mutationLock('exclusive', false, this.config, async () => { 630 | try { 631 | this.broadcast({ 632 | type: 'close', 633 | clientKey: this.clientKey, 634 | }); 635 | 636 | await this.createQuery({ 637 | type: 'delete', 638 | }); 639 | 640 | if (typeof beforeUnlock === 'function') { 641 | this.bypassMutationLock = true; 642 | await beforeUnlock(); 643 | } 644 | 645 | this.broadcast({ 646 | type: 'reinit', 647 | clientKey: this.clientKey, 648 | reason: 'delete', 649 | }); 650 | } finally { 651 | this.bypassMutationLock = false; 652 | } 653 | }); 654 | }; 655 | 656 | destroy = async (): Promise => { 657 | await this.createQuery({ type: 'destroy' }); 658 | 659 | if ( 660 | typeof globalThis.Worker !== 'undefined' && 661 | this.processor instanceof Worker 662 | ) { 663 | this.processor.removeEventListener('message', this.processMessageEvent); 664 | this.processor.terminate(); 665 | } 666 | 667 | this.queriesInProgress.clear(); 668 | this.userCallbacks.clear(); 669 | this.reinitChannel.close(); 670 | this.effectsChannel?.close(); 671 | this.isDestroyed = true; 672 | }; 673 | 674 | [Symbol.dispose] = () => { 675 | this.destroy(); 676 | }; 677 | 678 | [Symbol.asyncDispose] = async () => { 679 | await this.destroy(); 680 | }; 681 | } 682 | -------------------------------------------------------------------------------- /src/drivers/sqlite-kvvfs-driver.ts: -------------------------------------------------------------------------------- 1 | import type { JsStorageDb } from '@sqlite.org/sqlite-wasm'; 2 | import type { 3 | DriverConfig, 4 | Sqlite3InitModule, 5 | SQLocalDriver, 6 | } from '../types.js'; 7 | import { SQLiteMemoryDriver } from './sqlite-memory-driver.js'; 8 | 9 | export class SQLiteKvvfsDriver 10 | extends SQLiteMemoryDriver 11 | implements SQLocalDriver 12 | { 13 | declare protected db?: JsStorageDb; 14 | 15 | constructor( 16 | override readonly storageType: 'local' | 'session', 17 | sqlite3InitModule?: Sqlite3InitModule 18 | ) { 19 | super(sqlite3InitModule); 20 | } 21 | 22 | override async init(config: DriverConfig): Promise { 23 | const flags = this.getFlags(config); 24 | 25 | if (config.readOnly) { 26 | throw new Error( 27 | `SQLite storage type "${this.storageType}" does not support read-only mode.` 28 | ); 29 | } 30 | 31 | if (!this.sqlite3InitModule) { 32 | const { default: sqlite3InitModule } = await import( 33 | '@sqlite.org/sqlite-wasm' 34 | ); 35 | this.sqlite3InitModule = sqlite3InitModule; 36 | } 37 | 38 | if (!this.sqlite3) { 39 | this.sqlite3 = await this.sqlite3InitModule(); 40 | } 41 | 42 | if (this.db) { 43 | await this.destroy(); 44 | } 45 | 46 | this.db = new this.sqlite3.oo1.JsStorageDb({ 47 | filename: this.storageType, 48 | flags, 49 | }); 50 | this.config = config; 51 | this.initWriteHook(); 52 | } 53 | 54 | override async isDatabasePersisted(): Promise { 55 | return navigator.storage?.persisted(); 56 | } 57 | 58 | override async getDatabaseSizeBytes(): Promise { 59 | if (!this.db) throw new Error('Driver not initialized'); 60 | 61 | return this.db.storageSize(); 62 | } 63 | 64 | override async import( 65 | database: 66 | | ArrayBuffer 67 | | Uint8Array 68 | | ReadableStream> 69 | ): Promise { 70 | const memdb = new SQLiteMemoryDriver(); 71 | await memdb.init({}); 72 | await memdb.import(database); 73 | await this.clear(); 74 | await memdb.exec({ 75 | sql: `VACUUM INTO 'file:${this.storageType}?vfs=kvvfs'`, 76 | }); 77 | await memdb.destroy(); 78 | } 79 | 80 | override async clear(): Promise { 81 | if (!this.db) throw new Error('Driver not initialized'); 82 | 83 | this.db.clearStorage(); 84 | } 85 | 86 | override async destroy(): Promise { 87 | this.closeDb(); 88 | this.writeCallbacks.clear(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/drivers/sqlite-memory-driver.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DataChange, 3 | DriverConfig, 4 | DriverStatement, 5 | RawResultData, 6 | Sqlite3, 7 | Sqlite3Db, 8 | Sqlite3InitModule, 9 | Sqlite3StorageType, 10 | SQLocalDriver, 11 | UserFunction, 12 | } from '../types.js'; 13 | import { normalizeDatabaseFile } from '../lib/normalize-database-file.js'; 14 | import type { PreparedStatement } from '@sqlite.org/sqlite-wasm'; 15 | 16 | export class SQLiteMemoryDriver implements SQLocalDriver { 17 | protected sqlite3?: Sqlite3; 18 | protected db?: Sqlite3Db; 19 | protected config?: DriverConfig; 20 | protected pointers: number[] = []; 21 | protected writeCallbacks = new Set<(change: DataChange) => void>(); 22 | 23 | readonly storageType: Sqlite3StorageType = 'memory'; 24 | 25 | constructor(protected sqlite3InitModule?: Sqlite3InitModule) {} 26 | 27 | async init(config: DriverConfig): Promise { 28 | const { databasePath } = config; 29 | const flags = this.getFlags(config); 30 | 31 | if (!this.sqlite3InitModule) { 32 | const { default: sqlite3InitModule } = await import( 33 | '@sqlite.org/sqlite-wasm' 34 | ); 35 | this.sqlite3InitModule = sqlite3InitModule; 36 | } 37 | 38 | if (!this.sqlite3) { 39 | this.sqlite3 = await this.sqlite3InitModule(); 40 | } 41 | 42 | if (this.db) { 43 | await this.destroy(); 44 | } 45 | 46 | this.db = new this.sqlite3.oo1.DB(databasePath, flags); 47 | this.config = config; 48 | this.initWriteHook(); 49 | } 50 | 51 | onWrite(callback: (change: DataChange) => void): () => void { 52 | this.writeCallbacks.add(callback); 53 | 54 | return () => { 55 | this.writeCallbacks.delete(callback); 56 | }; 57 | } 58 | 59 | async exec(statement: DriverStatement): Promise { 60 | if (!this.db) throw new Error('Driver not initialized'); 61 | 62 | return this.execOnDb(this.db, statement); 63 | } 64 | 65 | async execBatch(statements: DriverStatement[]): Promise { 66 | if (!this.db) throw new Error('Driver not initialized'); 67 | 68 | const results: RawResultData[] = []; 69 | 70 | this.db.transaction((tx) => { 71 | const prepared = new Map(); 72 | 73 | try { 74 | for (let statement of statements) { 75 | let stmt = prepared.get(statement.sql); 76 | 77 | if (!stmt) { 78 | const newStmt = tx.prepare(statement.sql); 79 | prepared.set(statement.sql, newStmt); 80 | stmt = newStmt; 81 | } 82 | 83 | if (statement.params?.length) { 84 | stmt.bind(statement.params); 85 | } 86 | 87 | let columns: string[] = []; 88 | let rows: unknown[][] = []; 89 | 90 | while (stmt.step()) { 91 | columns = stmt.getColumnNames([]); 92 | rows.push(stmt.get([])); 93 | } 94 | 95 | results.push({ columns, rows }); 96 | stmt.reset(); 97 | } 98 | } finally { 99 | prepared.forEach((stmt) => { 100 | stmt.finalize(); 101 | }); 102 | } 103 | }); 104 | 105 | return results; 106 | } 107 | 108 | async isDatabasePersisted(): Promise { 109 | return false; 110 | } 111 | 112 | async getDatabaseSizeBytes(): Promise { 113 | const sizeResult = await this.exec({ 114 | sql: `SELECT page_count * page_size AS size 115 | FROM pragma_page_count(), pragma_page_size()`, 116 | method: 'get', 117 | }); 118 | const size = sizeResult?.rows?.[0]; 119 | 120 | if (typeof size !== 'number') { 121 | throw new Error('Failed to query database size'); 122 | } 123 | 124 | return size; 125 | } 126 | 127 | async createFunction(fn: UserFunction): Promise { 128 | if (!this.db) throw new Error('Driver not initialized'); 129 | 130 | switch (fn.type) { 131 | case 'callback': 132 | case 'scalar': 133 | this.db.createFunction({ 134 | name: fn.name, 135 | xFunc: (_: number, ...args: any[]) => fn.func(...args), 136 | arity: -1, 137 | }); 138 | break; 139 | case 'aggregate': 140 | this.db.createFunction({ 141 | name: fn.name, 142 | xStep: (_: number, ...args: any[]) => fn.func.step(...args), 143 | xFinal: (_: number, ...args: any[]) => fn.func.final(...args), 144 | arity: -1, 145 | }); 146 | break; 147 | } 148 | } 149 | 150 | async import( 151 | database: 152 | | ArrayBuffer 153 | | Uint8Array 154 | | ReadableStream> 155 | ): Promise { 156 | if (!this.sqlite3 || !this.db || !this.config) { 157 | throw new Error('Driver not initialized'); 158 | } 159 | 160 | const data = await normalizeDatabaseFile(database, 'buffer'); 161 | const dataPointer = this.sqlite3.wasm.allocFromTypedArray(data); 162 | this.pointers.push(dataPointer); 163 | const resultCode = this.sqlite3.capi.sqlite3_deserialize( 164 | this.db, 165 | 'main', 166 | dataPointer, 167 | data.byteLength, 168 | data.byteLength, 169 | this.config.readOnly 170 | ? this.sqlite3.capi.SQLITE_DESERIALIZE_READONLY 171 | : this.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE 172 | ); 173 | this.db.checkRc(resultCode); 174 | } 175 | 176 | async export(): Promise<{ 177 | name: string; 178 | data: ArrayBuffer | Uint8Array; 179 | }> { 180 | if (!this.sqlite3 || !this.db) { 181 | throw new Error('Driver not initialized'); 182 | } 183 | 184 | return { 185 | name: 'database.sqlite3', 186 | data: this.sqlite3.capi.sqlite3_js_db_export(this.db), 187 | }; 188 | } 189 | 190 | async clear(): Promise {} 191 | 192 | async destroy(): Promise { 193 | this.closeDb(); 194 | this.pointers.forEach((pointer) => this.sqlite3?.wasm.dealloc(pointer)); 195 | this.pointers = []; 196 | this.writeCallbacks.clear(); 197 | } 198 | 199 | protected getFlags(config: DriverConfig): string { 200 | const { readOnly, verbose } = config; 201 | const parts = [readOnly === true ? 'r' : 'cw', verbose === true ? 't' : '']; 202 | return parts.join(''); 203 | } 204 | 205 | protected execOnDb(db: Sqlite3Db, statement: DriverStatement): RawResultData { 206 | const statementData: RawResultData = { 207 | rows: [], 208 | columns: [], 209 | }; 210 | 211 | const rows = db.exec({ 212 | sql: statement.sql, 213 | bind: statement.params, 214 | returnValue: 'resultRows', 215 | rowMode: 'array', 216 | columnNames: statementData.columns, 217 | }); 218 | 219 | switch (statement.method) { 220 | case 'run': 221 | break; 222 | case 'get': 223 | statementData.rows = rows[0] ?? []; 224 | break; 225 | case 'all': 226 | default: 227 | statementData.rows = rows; 228 | break; 229 | } 230 | 231 | return statementData; 232 | } 233 | 234 | protected initWriteHook() { 235 | if (!this.config?.reactive) return; 236 | 237 | if (!this.sqlite3 || !this.db) { 238 | throw new Error('Driver not initialized'); 239 | } 240 | 241 | const opMap: Record = { 242 | [this.sqlite3.capi.SQLITE_INSERT]: 'insert', 243 | [this.sqlite3.capi.SQLITE_UPDATE]: 'update', 244 | [this.sqlite3.capi.SQLITE_DELETE]: 'delete', 245 | }; 246 | 247 | this.sqlite3.capi.sqlite3_update_hook( 248 | this.db, 249 | (_ctx, opId, _db, table, rowid) => { 250 | this.writeCallbacks.forEach((cb) => { 251 | cb({ table, rowid, operation: opMap[opId] }); 252 | }); 253 | }, 254 | 0 255 | ); 256 | } 257 | 258 | protected closeDb(): void { 259 | if (this.db) { 260 | this.db.close(); 261 | this.db = undefined; 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/drivers/sqlite-opfs-driver.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DriverConfig, 3 | Sqlite3InitModule, 4 | Sqlite3StorageType, 5 | SQLocalDriver, 6 | } from '../types.js'; 7 | import { normalizeDatabaseFile } from '../lib/normalize-database-file.js'; 8 | import { parseDatabasePath } from '../lib/parse-database-path.js'; 9 | import { SQLiteMemoryDriver } from './sqlite-memory-driver.js'; 10 | 11 | export class SQLiteOpfsDriver 12 | extends SQLiteMemoryDriver 13 | implements SQLocalDriver 14 | { 15 | override readonly storageType: Sqlite3StorageType = 'opfs'; 16 | 17 | constructor(sqlite3InitModule?: Sqlite3InitModule) { 18 | super(sqlite3InitModule); 19 | } 20 | 21 | override async init(config: DriverConfig): Promise { 22 | const { databasePath } = config; 23 | const flags = this.getFlags(config); 24 | 25 | if (!databasePath) { 26 | throw new Error('No databasePath specified'); 27 | } 28 | 29 | if (!this.sqlite3InitModule) { 30 | const { default: sqlite3InitModule } = await import( 31 | '@sqlite.org/sqlite-wasm' 32 | ); 33 | this.sqlite3InitModule = sqlite3InitModule; 34 | } 35 | 36 | if (!this.sqlite3) { 37 | this.sqlite3 = await this.sqlite3InitModule(); 38 | } 39 | 40 | if (!('opfs' in this.sqlite3)) { 41 | throw new Error('OPFS not available'); 42 | } 43 | 44 | if (this.db) { 45 | await this.destroy(); 46 | } 47 | 48 | this.db = new this.sqlite3.oo1.OpfsDb(databasePath, flags); 49 | this.config = config; 50 | this.initWriteHook(); 51 | } 52 | 53 | override async isDatabasePersisted(): Promise { 54 | return navigator.storage?.persisted(); 55 | } 56 | 57 | override async import( 58 | database: 59 | | ArrayBuffer 60 | | Uint8Array 61 | | ReadableStream> 62 | ): Promise { 63 | if (!this.sqlite3 || !this.config?.databasePath) { 64 | throw new Error('Driver not initialized'); 65 | } 66 | 67 | await this.destroy(); 68 | 69 | const data = await normalizeDatabaseFile(database, 'callback'); 70 | await this.sqlite3.oo1.OpfsDb.importDb(this.config.databasePath, data); 71 | } 72 | 73 | override async export(): Promise<{ 74 | name: string; 75 | data: ArrayBuffer | Uint8Array; 76 | }> { 77 | if (!this.db || !this.config?.databasePath) { 78 | throw new Error('Driver not initialized'); 79 | } 80 | 81 | let name, data; 82 | 83 | const path = parseDatabasePath(this.config.databasePath); 84 | const { directories, getDirectoryHandle } = path; 85 | name = path.fileName; 86 | const tempFileName = `backup-${Date.now()}--${name}`; 87 | const tempFilePath = `${directories.join('/')}/${tempFileName}`; 88 | 89 | this.db.exec({ sql: 'VACUUM INTO ?', bind: [tempFilePath] }); 90 | 91 | const dirHandle = await getDirectoryHandle(); 92 | const fileHandle = await dirHandle.getFileHandle(tempFileName); 93 | const file = await fileHandle.getFile(); 94 | data = await file.arrayBuffer(); 95 | await dirHandle.removeEntry(tempFileName); 96 | 97 | return { name, data }; 98 | } 99 | 100 | override async clear(): Promise { 101 | if (!this.config?.databasePath) throw new Error('Driver not initialized'); 102 | 103 | await this.destroy(); 104 | 105 | const { getDirectoryHandle, fileName, tempFileNames } = parseDatabasePath( 106 | this.config.databasePath 107 | ); 108 | const dirHandle = await getDirectoryHandle(); 109 | const fileNames = [fileName, ...tempFileNames]; 110 | 111 | await Promise.all( 112 | fileNames.map(async (name) => { 113 | return dirHandle.removeEntry(name).catch((err) => { 114 | if (!(err instanceof DOMException && err.name === 'NotFoundError')) { 115 | throw err; 116 | } 117 | }); 118 | }) 119 | ); 120 | } 121 | 122 | override async destroy(): Promise { 123 | this.closeDb(); 124 | this.writeCallbacks.clear(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/drizzle/client.ts: -------------------------------------------------------------------------------- 1 | import { SQLocal } from '../index.js'; 2 | import type { RawResultData, Sqlite3Method } from '../types.js'; 3 | 4 | export class SQLocalDrizzle extends SQLocal { 5 | driver = async ( 6 | sql: string, 7 | params: unknown[], 8 | method: Sqlite3Method 9 | ): Promise => { 10 | if ( 11 | /^begin\b/i.test(sql) && 12 | typeof globalThis.sessionStorage !== 'undefined' && 13 | !sessionStorage._sqlocal_sent_drizzle_transaction_warning 14 | ) { 15 | console.warn( 16 | "Drizzle's transaction method cannot isolate transactions from outside queries. It is recommended to use the transaction method of SQLocalDrizzle instead (See https://sqlocal.dev/api/transaction#drizzle)." 17 | ); 18 | sessionStorage._sqlocal_sent_drizzle_transaction_warning = '1'; 19 | } 20 | const transactionKey = this.transactionQueryKeyQueue.shift(); 21 | return this.exec(sql, params, method, transactionKey); 22 | }; 23 | 24 | batchDriver = async ( 25 | queries: { sql: string; params: unknown[]; method: Sqlite3Method }[] 26 | ): Promise => { 27 | return this.execBatch(queries); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/drizzle/index.ts: -------------------------------------------------------------------------------- 1 | export { SQLocalDrizzle } from './client.js'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { SQLocal } from './client.js'; 2 | export { SQLocalProcessor } from './processor.js'; 3 | 4 | export { SQLiteOpfsDriver } from './drivers/sqlite-opfs-driver.js'; 5 | export { SQLiteMemoryDriver } from './drivers/sqlite-memory-driver.js'; 6 | export { SQLiteKvvfsDriver } from './drivers/sqlite-kvvfs-driver.js'; 7 | 8 | export type * from './types.js'; 9 | -------------------------------------------------------------------------------- /src/kysely/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompiledQuery, 3 | SqliteAdapter, 4 | SqliteIntrospector, 5 | SqliteQueryCompiler, 6 | } from 'kysely'; 7 | import type { DatabaseConnection, Dialect, Driver, QueryResult } from 'kysely'; 8 | import { SQLocal } from '../index.js'; 9 | import type { Transaction } from '../types.js'; 10 | 11 | export class SQLocalKysely extends SQLocal { 12 | dialect: Dialect = { 13 | createAdapter: () => new SqliteAdapter(), 14 | createDriver: () => new SQLocalKyselyDriver(this), 15 | createIntrospector: (db) => new SqliteIntrospector(db), 16 | createQueryCompiler: () => new SqliteQueryCompiler(), 17 | }; 18 | } 19 | 20 | class SQLocalKyselyDriver implements Driver { 21 | constructor(private client: SQLocalKysely) {} 22 | 23 | async init(): Promise {} 24 | 25 | async acquireConnection(): Promise { 26 | return new SQLocalKyselyConnection(this.client); 27 | } 28 | 29 | async releaseConnection(): Promise {} 30 | 31 | async beginTransaction(connection: SQLocalKyselyConnection): Promise { 32 | connection.transaction = await this.client.beginTransaction(); 33 | } 34 | 35 | async commitTransaction(connection: SQLocalKyselyConnection): Promise { 36 | await connection.transaction?.commit(); 37 | connection.transaction = null; 38 | } 39 | 40 | async rollbackTransaction( 41 | connection: SQLocalKyselyConnection 42 | ): Promise { 43 | await connection.transaction?.rollback(); 44 | connection.transaction = null; 45 | } 46 | 47 | async destroy(): Promise { 48 | await this.client.destroy(); 49 | } 50 | } 51 | 52 | class SQLocalKyselyConnection implements DatabaseConnection { 53 | transaction: Transaction | null = null; 54 | 55 | constructor(private client: SQLocalKysely) {} 56 | 57 | async executeQuery( 58 | query: CompiledQuery 59 | ): Promise> { 60 | let rows; 61 | 62 | if (this.transaction === null) { 63 | rows = await this.client.sql(query.sql, ...query.parameters); 64 | } else { 65 | rows = await this.transaction.query(query); 66 | } 67 | 68 | return { 69 | rows: rows as Result[], 70 | }; 71 | } 72 | 73 | async *streamQuery(): AsyncGenerator { 74 | throw new Error('SQLite3 does not support streaming.'); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/kysely/index.ts: -------------------------------------------------------------------------------- 1 | export { SQLocalKysely } from './client.js'; 2 | -------------------------------------------------------------------------------- /src/lib/convert-rows-to-objects.ts: -------------------------------------------------------------------------------- 1 | function isArrayOfArrays(rows: unknown[] | unknown[][]): rows is unknown[][] { 2 | return !rows.some((row) => !Array.isArray(row)); 3 | } 4 | 5 | export function convertRowsToObjects( 6 | rows: unknown[] | unknown[][], 7 | columns: string[] 8 | ): Record[] { 9 | let checkedRows: unknown[][]; 10 | 11 | if (isArrayOfArrays(rows)) { 12 | checkedRows = rows; 13 | } else { 14 | checkedRows = [rows]; 15 | } 16 | 17 | return checkedRows.map((row) => { 18 | const rowObj = {} as Record; 19 | columns.forEach((column, columnIndex) => { 20 | rowObj[column] = row[columnIndex]; 21 | }); 22 | 23 | return rowObj; 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/create-mutex.ts: -------------------------------------------------------------------------------- 1 | type Mutex = { 2 | lock: () => Promise; 3 | unlock: () => Promise; 4 | }; 5 | 6 | export function createMutex(): Mutex { 7 | let promise: Promise | undefined; 8 | let resolve: (() => void) | undefined; 9 | 10 | const lock = async () => { 11 | while (promise) { 12 | await promise; 13 | } 14 | 15 | promise = new Promise((res) => { 16 | resolve = res; 17 | }); 18 | }; 19 | 20 | const unlock = async () => { 21 | const res = resolve; 22 | promise = undefined; 23 | resolve = undefined; 24 | res?.(); 25 | }; 26 | 27 | return { lock, unlock }; 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/debounce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lodash (Custom Build) 3 | * Build: `lodash modularize exports="es" include="debounce" -p -o ./` 4 | * Copyright JS Foundation and other contributors 5 | * Released under MIT license 6 | * Based on Underscore.js 1.8.3 7 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 8 | */ 9 | 10 | export type DebounceOptions = { 11 | leading?: boolean; 12 | maxWait?: number; 13 | trailing?: boolean; 14 | }; 15 | 16 | export type DebouncedFunction any> = { 17 | (...args: Parameters): ReturnType | undefined; 18 | cancel: () => void; 19 | flush: () => ReturnType | undefined; 20 | }; 21 | 22 | export function debounce any>( 23 | func: T, 24 | wait: number, 25 | options?: DebounceOptions 26 | ): DebouncedFunction { 27 | let lastArgs: Parameters | undefined; 28 | let lastThis: any; 29 | let maxWait: number; 30 | let result: ReturnType | undefined; 31 | let timerId: ReturnType | undefined; 32 | let lastCallTime: number | undefined; 33 | let lastInvokeTime = 0; 34 | 35 | let leading = false; 36 | let maxing = false; 37 | let trailing = true; 38 | 39 | if (typeof func !== 'function') { 40 | throw new TypeError('Expected a function'); 41 | } 42 | 43 | wait = Number(wait) || 0; 44 | 45 | if (typeof options === 'object' && options !== null) { 46 | leading = !!options.leading; 47 | maxing = 'maxWait' in options; 48 | maxWait = maxing ? Math.max(Number(options.maxWait) || 0, wait) : 0; 49 | trailing = 'trailing' in options ? !!options.trailing : trailing; 50 | } 51 | 52 | function invokeFunc(time: number): ReturnType | undefined { 53 | const args = lastArgs!; 54 | const thisArg = lastThis; 55 | 56 | lastArgs = lastThis = undefined; 57 | lastInvokeTime = time; 58 | result = func.apply(thisArg, args); 59 | return result; 60 | } 61 | 62 | function leadingEdge(time: number): ReturnType | undefined { 63 | lastInvokeTime = time; 64 | timerId = setTimeout(timerExpired, wait); 65 | return leading ? invokeFunc(time) : result; 66 | } 67 | 68 | function remainingWait(time: number): number { 69 | const timeSinceLastCall = time - (lastCallTime ?? 0); 70 | const timeSinceLastInvoke = time - lastInvokeTime; 71 | const timeWaiting = wait - timeSinceLastCall; 72 | 73 | return maxing 74 | ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 75 | : timeWaiting; 76 | } 77 | 78 | function shouldInvoke(time: number): boolean { 79 | const timeSinceLastCall = time - (lastCallTime ?? 0); 80 | const timeSinceLastInvoke = time - lastInvokeTime; 81 | 82 | return ( 83 | lastCallTime === undefined || 84 | timeSinceLastCall >= wait || 85 | timeSinceLastCall < 0 || 86 | (maxing && timeSinceLastInvoke >= maxWait) 87 | ); 88 | } 89 | 90 | function timerExpired() { 91 | const time = Date.now(); 92 | if (shouldInvoke(time)) { 93 | return trailingEdge(time); 94 | } 95 | timerId = setTimeout(timerExpired, remainingWait(time)); 96 | } 97 | 98 | function trailingEdge(time: number): ReturnType | undefined { 99 | timerId = undefined; 100 | 101 | if (trailing && lastArgs) { 102 | return invokeFunc(time); 103 | } 104 | lastArgs = lastThis = undefined; 105 | return result; 106 | } 107 | 108 | function cancel() { 109 | if (timerId !== undefined) { 110 | clearTimeout(timerId); 111 | } 112 | lastInvokeTime = 0; 113 | lastArgs = lastCallTime = lastThis = timerId = undefined; 114 | } 115 | 116 | function flush(): ReturnType | undefined { 117 | return timerId === undefined ? result : trailingEdge(Date.now()); 118 | } 119 | 120 | function debounced() { 121 | const time = Date.now(); 122 | const isInvoking = shouldInvoke(time); 123 | 124 | // @ts-ignore 125 | lastArgs = arguments; 126 | // @ts-ignore 127 | lastThis = this; 128 | lastCallTime = time; 129 | 130 | if (isInvoking) { 131 | if (timerId === undefined) { 132 | return leadingEdge(lastCallTime); 133 | } 134 | if (maxing) { 135 | timerId = setTimeout(timerExpired, wait); 136 | return invokeFunc(lastCallTime); 137 | } 138 | } 139 | 140 | if (timerId === undefined) { 141 | timerId = setTimeout(timerExpired, wait); 142 | } 143 | 144 | return result; 145 | } 146 | 147 | debounced.cancel = cancel; 148 | debounced.flush = flush; 149 | 150 | return debounced; 151 | } 152 | -------------------------------------------------------------------------------- /src/lib/get-database-key.ts: -------------------------------------------------------------------------------- 1 | import type { DatabasePath } from '../types.js'; 2 | import { getQueryKey } from './get-query-key.js'; 3 | 4 | export function getDatabaseKey(databasePath: DatabasePath, clientKey: string) { 5 | switch (databasePath) { 6 | case 'session': 7 | case ':sessionStorage:': 8 | // The sessionStorage DB can be shared between clients in the same tab 9 | let sessionKey = sessionStorage._sqlocal_session_key; 10 | 11 | if (!sessionKey) { 12 | sessionKey = getQueryKey(); 13 | sessionStorage._sqlocal_session_key = sessionKey; 14 | } 15 | 16 | return `session:${sessionKey}`; 17 | 18 | case 'local': 19 | case ':localStorage:': 20 | // There's only one localStorage DB per origin 21 | return 'local'; 22 | 23 | case ':memory:': 24 | // Each memory DB is unique to a client 25 | return `memory:${clientKey}`; 26 | 27 | default: 28 | // OPFS DBs are shared by path across same-origin tabs 29 | return `path:${databasePath}`; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/get-query-key.ts: -------------------------------------------------------------------------------- 1 | import type { QueryKey } from '../types.js'; 2 | 3 | export function getQueryKey(): QueryKey { 4 | return crypto.randomUUID(); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/mutation-lock.ts: -------------------------------------------------------------------------------- 1 | import type { ClientConfig } from '../types.js'; 2 | 3 | export async function mutationLock( 4 | mode: LockMode, 5 | bypass: boolean, 6 | config: ClientConfig, 7 | mutation: () => Promise 8 | ): Promise { 9 | if (!bypass && 'locks' in navigator) { 10 | return navigator.locks.request( 11 | `_sqlocal_mutation_(${config.databasePath})`, 12 | { mode }, 13 | mutation 14 | ); 15 | } else { 16 | return mutation(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/normalize-database-file.ts: -------------------------------------------------------------------------------- 1 | type DatabaseFileInput = 2 | | File 3 | | Blob 4 | | ArrayBuffer 5 | | Uint8Array 6 | | ReadableStream>; 7 | 8 | export function normalizeDatabaseFile( 9 | dbFile: DatabaseFileInput, 10 | convertStreamTo: 'callback' 11 | ): Promise< 12 | | ArrayBuffer 13 | | Uint8Array 14 | | (() => Promise | undefined>) 15 | >; 16 | export function normalizeDatabaseFile( 17 | dbFile: DatabaseFileInput, 18 | convertStreamTo: 'buffer' 19 | ): Promise>; 20 | export function normalizeDatabaseFile( 21 | dbFile: DatabaseFileInput, 22 | convertStreamTo?: undefined 23 | ): Promise< 24 | | ArrayBuffer 25 | | Uint8Array 26 | | ReadableStream> 27 | >; 28 | export async function normalizeDatabaseFile( 29 | dbFile: DatabaseFileInput, 30 | convertStreamTo?: 'callback' | 'buffer' 31 | ): Promise< 32 | | ArrayBuffer 33 | | Uint8Array 34 | | ReadableStream> 35 | | (() => Promise | undefined>) 36 | > { 37 | let bufferOrStream: 38 | | ArrayBuffer 39 | | Uint8Array 40 | | ReadableStream>; 41 | 42 | if (dbFile instanceof Blob) { 43 | bufferOrStream = dbFile.stream(); 44 | } else { 45 | bufferOrStream = dbFile; 46 | } 47 | 48 | if (bufferOrStream instanceof ReadableStream && convertStreamTo) { 49 | const stream = bufferOrStream; 50 | const reader = stream.getReader(); 51 | 52 | switch (convertStreamTo) { 53 | case 'callback': 54 | return async () => { 55 | const chunk = await reader.read(); 56 | return chunk.value; 57 | }; 58 | 59 | case 'buffer': 60 | const chunks: Uint8Array[] = []; 61 | let streamDone = false; 62 | 63 | while (!streamDone) { 64 | const chunk = await reader.read(); 65 | if (chunk.value) chunks.push(chunk.value); 66 | streamDone = chunk.done; 67 | } 68 | 69 | const arrayLength = chunks.reduce((length, chunk) => { 70 | return length + chunk.length; 71 | }, 0); 72 | const buffer = new Uint8Array(arrayLength); 73 | let offset = 0; 74 | 75 | chunks.forEach((chunk) => { 76 | buffer.set(chunk, offset); 77 | offset += chunk.length; 78 | }); 79 | 80 | return buffer.buffer; 81 | } 82 | } else { 83 | return bufferOrStream; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/normalize-sql.ts: -------------------------------------------------------------------------------- 1 | import type { Statement } from '../types.js'; 2 | import { sqlTag } from './sql-tag.js'; 3 | 4 | export function normalizeSql( 5 | maybeQueryTemplate: TemplateStringsArray | string, 6 | params: unknown[] 7 | ): Statement { 8 | let statement: Statement; 9 | 10 | if (typeof maybeQueryTemplate === 'string') { 11 | statement = { sql: maybeQueryTemplate, params }; 12 | } else { 13 | statement = sqlTag(maybeQueryTemplate, ...params); 14 | } 15 | 16 | return statement; 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/normalize-statement.ts: -------------------------------------------------------------------------------- 1 | import type { RunnableQuery as DrizzleQuery } from 'drizzle-orm/runnable-query'; 2 | import type { SqliteRemoteResult } from 'drizzle-orm/sqlite-proxy'; 3 | import type { StatementInput, Statement } from '../types.js'; 4 | import { sqlTag } from './sql-tag.js'; 5 | 6 | type NormalStatement = Statement & { 7 | exec?: >() => Promise; 8 | }; 9 | 10 | function isDrizzleStatement( 11 | statement: StatementInput 12 | ): statement is DrizzleQuery< 13 | Result extends SqliteRemoteResult ? any : Result[], 14 | 'sqlite' 15 | > { 16 | return ( 17 | typeof statement === 'object' && 18 | statement !== null && 19 | 'getSQL' in statement && 20 | typeof statement.getSQL === 'function' 21 | ); 22 | } 23 | 24 | function isStatement(statement: unknown): statement is Statement { 25 | return ( 26 | typeof statement === 'object' && 27 | statement !== null && 28 | 'sql' in statement === true && 29 | typeof statement.sql === 'string' && 30 | 'params' in statement === true 31 | ); 32 | } 33 | 34 | export function normalizeStatement(statement: StatementInput): NormalStatement { 35 | if (typeof statement === 'function') { 36 | statement = statement(sqlTag); 37 | } 38 | 39 | if (isDrizzleStatement(statement)) { 40 | try { 41 | if (!('toSQL' in statement && typeof statement.toSQL === 'function')) { 42 | throw 1; 43 | } 44 | const drizzleStatement = statement.toSQL(); 45 | if (!isStatement(drizzleStatement)) { 46 | throw 2; 47 | } 48 | const exec = 49 | 'all' in statement && typeof statement.all === 'function' 50 | ? statement.all 51 | : undefined; 52 | return { 53 | ...drizzleStatement, 54 | exec: exec ? () => exec() : undefined, 55 | }; 56 | } catch { 57 | throw new Error('The passed statement could not be parsed.'); 58 | } 59 | } 60 | 61 | const sql = statement.sql; 62 | let params: unknown[] = []; 63 | 64 | if ('params' in statement) { 65 | params = statement.params; 66 | } else if ('parameters' in statement) { 67 | params = statement.parameters as unknown[]; 68 | } 69 | 70 | return { sql, params }; 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/parse-database-path.ts: -------------------------------------------------------------------------------- 1 | type DatabasePathInfo = { 2 | directories: string[]; 3 | fileName: string; 4 | tempFileNames: string[]; 5 | getDirectoryHandle: () => Promise; 6 | }; 7 | 8 | export function parseDatabasePath(path: string): DatabasePathInfo { 9 | const directories = path.split(/[\\/]/).filter((part) => part !== ''); 10 | const fileName = directories.pop(); 11 | 12 | if (!fileName) { 13 | throw new Error('Database path is invalid.'); 14 | } 15 | 16 | const tempFileNames = ['journal', 'wal', 'shm'].map( 17 | (ext) => `${fileName}-${ext}` 18 | ); 19 | 20 | const getDirectoryHandle = async (): Promise => { 21 | let dirHandle = await navigator.storage.getDirectory(); 22 | for (let dirName of directories) 23 | dirHandle = await dirHandle.getDirectoryHandle(dirName); 24 | return dirHandle; 25 | }; 26 | 27 | return { 28 | directories, 29 | fileName, 30 | tempFileNames, 31 | getDirectoryHandle, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/sql-tag.ts: -------------------------------------------------------------------------------- 1 | import type { Statement } from '../types.js'; 2 | 3 | export function sqlTag( 4 | queryTemplate: TemplateStringsArray, 5 | ...params: unknown[] 6 | ): Statement { 7 | return { 8 | sql: queryTemplate.join('?'), 9 | params, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/messages.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConnectReason, 3 | DatabaseInfo, 4 | ProcessorConfig, 5 | QueryKey, 6 | Sqlite3Method, 7 | UserFunction, 8 | } from './types.js'; 9 | 10 | export type Message = InputMessage | OutputMessage; 11 | export type OmitQueryKey = T extends Message ? Omit : never; 12 | export type WorkerProxy = (typeof globalThis | ProxyHandler) & 13 | Record any>; 14 | 15 | // Input messages 16 | 17 | export type InputMessage = 18 | | QueryMessage 19 | | BatchMessage 20 | | TransactionMessage 21 | | FunctionMessage 22 | | ConfigMessage 23 | | GetInfoMessage 24 | | ImportMessage 25 | | ExportMessage 26 | | DeleteMessage 27 | | DestroyMessage; 28 | export type QueryMessage = { 29 | type: 'query'; 30 | queryKey: QueryKey; 31 | transactionKey?: QueryKey; 32 | sql: string; 33 | params: unknown[]; 34 | method: Sqlite3Method; 35 | }; 36 | export type BatchMessage = { 37 | type: 'batch'; 38 | queryKey: QueryKey; 39 | statements: { 40 | sql: string; 41 | params: unknown[]; 42 | method?: Sqlite3Method; 43 | }[]; 44 | }; 45 | export type TransactionMessage = { 46 | type: 'transaction'; 47 | queryKey: QueryKey; 48 | transactionKey: QueryKey; 49 | action: 'begin' | 'rollback' | 'commit'; 50 | }; 51 | export type FunctionMessage = { 52 | type: 'function'; 53 | queryKey: QueryKey; 54 | functionName: string; 55 | functionType: UserFunction['type']; 56 | }; 57 | export type ConfigMessage = { 58 | type: 'config'; 59 | config: ProcessorConfig; 60 | }; 61 | export type GetInfoMessage = { 62 | type: 'getinfo'; 63 | queryKey: QueryKey; 64 | }; 65 | export type ImportMessage = { 66 | type: 'import'; 67 | queryKey: QueryKey; 68 | database: 69 | | ArrayBuffer 70 | | Uint8Array 71 | | ReadableStream>; 72 | }; 73 | export type ExportMessage = { 74 | type: 'export'; 75 | queryKey: QueryKey; 76 | }; 77 | export type DeleteMessage = { 78 | type: 'delete'; 79 | queryKey: QueryKey; 80 | }; 81 | export type DestroyMessage = { 82 | type: 'destroy'; 83 | queryKey: QueryKey; 84 | }; 85 | 86 | // Output messages 87 | 88 | export type OutputMessage = 89 | | SuccessMessage 90 | | ErrorMessage 91 | | DataMessage 92 | | BufferMessage 93 | | CallbackMessage 94 | | InfoMessage 95 | | EventMessage; 96 | export type SuccessMessage = { 97 | type: 'success'; 98 | queryKey: QueryKey; 99 | }; 100 | export type ErrorMessage = { 101 | type: 'error'; 102 | queryKey: QueryKey | null; 103 | error: unknown; 104 | }; 105 | export type DataMessage = { 106 | type: 'data'; 107 | queryKey: QueryKey; 108 | data: { 109 | columns: string[]; 110 | rows: unknown[] | unknown[][]; 111 | }[]; 112 | }; 113 | export type BufferMessage = { 114 | type: 'buffer'; 115 | queryKey: QueryKey; 116 | bufferName: string; 117 | buffer: ArrayBuffer | Uint8Array; 118 | }; 119 | export type CallbackMessage = { 120 | type: 'callback'; 121 | name: string; 122 | args: unknown[]; 123 | }; 124 | export type InfoMessage = { 125 | type: 'info'; 126 | queryKey: QueryKey; 127 | info: DatabaseInfo; 128 | }; 129 | export type EventMessage = { 130 | type: 'event'; 131 | event: 'connect'; 132 | reason: ConnectReason; 133 | }; 134 | 135 | // Broadcast messages 136 | 137 | export type BroadcastMessage = ReinitBroadcast | CloseBroadcast; 138 | export type ReinitBroadcast = { 139 | type: 'reinit'; 140 | clientKey: QueryKey; 141 | reason: ConnectReason; 142 | }; 143 | export type CloseBroadcast = { 144 | type: 'close'; 145 | clientKey: QueryKey; 146 | }; 147 | 148 | export type EffectsMessage = { 149 | type: 'effects'; 150 | tables: string[]; 151 | }; 152 | -------------------------------------------------------------------------------- /src/processor.ts: -------------------------------------------------------------------------------- 1 | import coincident from 'coincident'; 2 | import type { 3 | ProcessorConfig, 4 | UserFunction, 5 | QueryKey, 6 | ConnectReason, 7 | SQLocalDriver, 8 | } from './types.js'; 9 | import type { 10 | BatchMessage, 11 | BroadcastMessage, 12 | ConfigMessage, 13 | DataMessage, 14 | DeleteMessage, 15 | DestroyMessage, 16 | EffectsMessage, 17 | ExportMessage, 18 | FunctionMessage, 19 | GetInfoMessage, 20 | ImportMessage, 21 | InputMessage, 22 | OutputMessage, 23 | QueryMessage, 24 | TransactionMessage, 25 | WorkerProxy, 26 | } from './messages.js'; 27 | import { createMutex } from './lib/create-mutex.js'; 28 | import { SQLiteMemoryDriver } from './drivers/sqlite-memory-driver.js'; 29 | import { debounce } from './lib/debounce.js'; 30 | import { getDatabaseKey } from './lib/get-database-key.js'; 31 | 32 | export class SQLocalProcessor { 33 | protected driver: SQLocalDriver; 34 | protected config: ProcessorConfig = {}; 35 | protected userFunctions = new Map(); 36 | 37 | protected initMutex = createMutex(); 38 | protected transactionMutex = createMutex(); 39 | protected transactionKey: QueryKey | null = null; 40 | 41 | protected proxy: WorkerProxy; 42 | protected dirtyTables = new Set(); 43 | protected effectsChannel?: BroadcastChannel; 44 | protected reinitChannel?: BroadcastChannel; 45 | 46 | onmessage?: (message: OutputMessage, transfer: Transferable[]) => void; 47 | 48 | constructor(driver: SQLocalDriver) { 49 | const isInWorker = 50 | typeof WorkerGlobalScope !== 'undefined' && 51 | globalThis instanceof WorkerGlobalScope; 52 | const proxy = isInWorker ? coincident(globalThis) : globalThis; 53 | this.proxy = proxy as WorkerProxy; 54 | this.driver = driver; 55 | } 56 | 57 | protected init = async (reason: ConnectReason): Promise => { 58 | if (!this.config.databasePath || !this.config.clientKey) return; 59 | 60 | await this.initMutex.lock(); 61 | 62 | try { 63 | try { 64 | await this.driver.init(this.config); 65 | } catch { 66 | console.warn( 67 | `Persistence failed, so ${this.config.databasePath} will not be saved. For origin private file system persistence, make sure your web server is configured to use the correct HTTP response headers (See https://sqlocal.dev/guide/setup#cross-origin-isolation).` 68 | ); 69 | this.config.databasePath = ':memory:'; 70 | this.driver = new SQLiteMemoryDriver(); 71 | await this.driver.init(this.config); 72 | } 73 | 74 | const dbKey = getDatabaseKey( 75 | this.config.databasePath, 76 | this.config.clientKey 77 | ); 78 | 79 | this.reinitChannel = new BroadcastChannel(`_sqlocal_reinit_(${dbKey})`); 80 | this.reinitChannel.onmessage = ( 81 | event: MessageEvent 82 | ) => { 83 | const message = event.data; 84 | if (this.config.clientKey === message.clientKey) return; 85 | 86 | switch (message.type) { 87 | case 'reinit': 88 | this.init(message.reason); 89 | break; 90 | case 'close': 91 | this.driver.destroy(); 92 | break; 93 | } 94 | }; 95 | 96 | if (this.config.reactive) { 97 | this.effectsChannel = new BroadcastChannel( 98 | `_sqlocal_effects_(${dbKey})` 99 | ); 100 | 101 | this.driver.onWrite(async (change) => { 102 | this.dirtyTables.add(change.table); 103 | await this.transactionMutex.lock(); 104 | this.emitEffectsDebounced(); 105 | await this.transactionMutex.unlock(); 106 | }); 107 | } 108 | 109 | await Promise.all( 110 | Array.from(this.userFunctions.values()).map((fn) => { 111 | return this.initUserFunction(fn); 112 | }) 113 | ); 114 | 115 | await this.execInitStatements(); 116 | this.emitMessage({ type: 'event', event: 'connect', reason }); 117 | } catch (error) { 118 | this.emitMessage({ 119 | type: 'error', 120 | error, 121 | queryKey: null, 122 | }); 123 | 124 | await this.destroy(); 125 | } finally { 126 | await this.initMutex.unlock(); 127 | } 128 | }; 129 | 130 | postMessage = async ( 131 | event: InputMessage | MessageEvent, 132 | _transfer?: Transferable 133 | ): Promise => { 134 | const message = event instanceof MessageEvent ? event.data : event; 135 | 136 | await this.initMutex.lock(); 137 | 138 | switch (message.type) { 139 | case 'config': 140 | this.editConfig(message); 141 | break; 142 | case 'query': 143 | case 'batch': 144 | case 'transaction': 145 | this.exec(message); 146 | break; 147 | case 'function': 148 | this.createUserFunction(message); 149 | break; 150 | case 'getinfo': 151 | this.getDatabaseInfo(message); 152 | break; 153 | case 'import': 154 | this.importDb(message); 155 | break; 156 | case 'export': 157 | this.exportDb(message); 158 | break; 159 | case 'delete': 160 | this.deleteDb(message); 161 | break; 162 | case 'destroy': 163 | this.destroy(message); 164 | break; 165 | } 166 | 167 | await this.initMutex.unlock(); 168 | }; 169 | 170 | protected emitMessage = ( 171 | message: OutputMessage, 172 | transfer: Transferable[] = [] 173 | ): void => { 174 | if (this.onmessage) { 175 | this.onmessage(message, transfer); 176 | } 177 | }; 178 | 179 | protected emitEffects = (): void => { 180 | if (!this.effectsChannel || this.dirtyTables.size === 0) return; 181 | 182 | this.effectsChannel.postMessage({ 183 | type: 'effects', 184 | tables: [...this.dirtyTables], 185 | } satisfies EffectsMessage); 186 | 187 | this.dirtyTables.clear(); 188 | }; 189 | 190 | protected emitEffectsDebounced = debounce(() => this.emitEffects(), 32, { 191 | maxWait: 180, 192 | }); 193 | 194 | protected editConfig = (message: ConfigMessage): void => { 195 | this.config = message.config; 196 | this.init('initial'); 197 | }; 198 | 199 | protected exec = async ( 200 | message: QueryMessage | BatchMessage | TransactionMessage 201 | ): Promise => { 202 | try { 203 | const response: DataMessage = { 204 | type: 'data', 205 | queryKey: message.queryKey, 206 | data: [], 207 | }; 208 | 209 | switch (message.type) { 210 | case 'query': 211 | const partOfTransaction = 212 | this.transactionKey !== null && 213 | this.transactionKey === message.transactionKey; 214 | 215 | try { 216 | if (!partOfTransaction) { 217 | await this.transactionMutex.lock(); 218 | } 219 | const statementData = await this.driver.exec(message); 220 | response.data.push(statementData); 221 | } finally { 222 | if (!partOfTransaction) { 223 | await this.transactionMutex.unlock(); 224 | } 225 | } 226 | break; 227 | 228 | case 'batch': 229 | try { 230 | await this.transactionMutex.lock(); 231 | const results = await this.driver.execBatch(message.statements); 232 | response.data.push(...results); 233 | } finally { 234 | await this.transactionMutex.unlock(); 235 | } 236 | break; 237 | 238 | case 'transaction': 239 | if (message.action === 'begin') { 240 | await this.transactionMutex.lock(); 241 | this.transactionKey = message.transactionKey; 242 | await this.driver.exec({ sql: 'BEGIN' }); 243 | } 244 | 245 | if ( 246 | (message.action === 'commit' || message.action === 'rollback') && 247 | this.transactionKey !== null && 248 | this.transactionKey === message.transactionKey 249 | ) { 250 | const sql = message.action === 'commit' ? 'COMMIT' : 'ROLLBACK'; 251 | await this.driver.exec({ sql }); 252 | this.transactionKey = null; 253 | await this.transactionMutex.unlock(); 254 | } 255 | break; 256 | } 257 | 258 | this.emitMessage(response); 259 | } catch (error) { 260 | this.emitMessage({ 261 | type: 'error', 262 | error, 263 | queryKey: message.queryKey, 264 | }); 265 | } 266 | }; 267 | 268 | protected execInitStatements = async (): Promise => { 269 | if (this.config.onInitStatements) { 270 | for (let statement of this.config.onInitStatements) { 271 | await this.driver.exec(statement); 272 | } 273 | } 274 | }; 275 | 276 | protected getDatabaseInfo = async ( 277 | message: GetInfoMessage 278 | ): Promise => { 279 | try { 280 | this.emitMessage({ 281 | type: 'info', 282 | queryKey: message.queryKey, 283 | info: { 284 | databasePath: this.config.databasePath, 285 | storageType: this.driver.storageType, 286 | databaseSizeBytes: await this.driver.getDatabaseSizeBytes(), 287 | persisted: await this.driver.isDatabasePersisted(), 288 | }, 289 | }); 290 | } catch (error) { 291 | this.emitMessage({ 292 | type: 'error', 293 | queryKey: message.queryKey, 294 | error, 295 | }); 296 | } 297 | }; 298 | 299 | protected createUserFunction = async ( 300 | message: FunctionMessage 301 | ): Promise => { 302 | const { functionName: name, functionType: type, queryKey } = message; 303 | let fn: UserFunction; 304 | 305 | if (this.userFunctions.has(name)) { 306 | this.emitMessage({ 307 | type: 'error', 308 | error: new Error( 309 | `A user-defined function with the name "${name}" has already been created for this SQLocal instance.` 310 | ), 311 | queryKey, 312 | }); 313 | return; 314 | } 315 | 316 | switch (type) { 317 | case 'callback': 318 | fn = { 319 | type, 320 | name, 321 | func: (...args: any[]) => { 322 | this.emitMessage({ type: 'callback', name, args }); 323 | }, 324 | }; 325 | break; 326 | case 'scalar': 327 | fn = { 328 | type, 329 | name, 330 | func: this.proxy[`_sqlocal_func_${name}`], 331 | }; 332 | break; 333 | case 'aggregate': 334 | fn = { 335 | type, 336 | name, 337 | func: { 338 | step: this.proxy[`_sqlocal_func_${name}_step`], 339 | final: this.proxy[`_sqlocal_func_${name}_final`], 340 | }, 341 | }; 342 | break; 343 | } 344 | 345 | try { 346 | await this.initUserFunction(fn); 347 | this.emitMessage({ 348 | type: 'success', 349 | queryKey, 350 | }); 351 | } catch (error) { 352 | this.emitMessage({ 353 | type: 'error', 354 | error, 355 | queryKey, 356 | }); 357 | } 358 | }; 359 | 360 | protected initUserFunction = async (fn: UserFunction): Promise => { 361 | await this.driver.createFunction(fn); 362 | this.userFunctions.set(fn.name, fn); 363 | }; 364 | 365 | protected importDb = async (message: ImportMessage): Promise => { 366 | const { queryKey, database } = message; 367 | let errored = false; 368 | 369 | try { 370 | await this.driver.import(database); 371 | 372 | if (this.driver.storageType === 'memory') { 373 | await this.execInitStatements(); 374 | } 375 | } catch (error) { 376 | this.emitMessage({ 377 | type: 'error', 378 | error, 379 | queryKey, 380 | }); 381 | errored = true; 382 | } finally { 383 | if (this.driver.storageType !== 'memory') { 384 | await this.init('overwrite'); 385 | } 386 | } 387 | 388 | if (!errored) { 389 | this.emitMessage({ 390 | type: 'success', 391 | queryKey, 392 | }); 393 | } 394 | }; 395 | 396 | protected exportDb = async (message: ExportMessage): Promise => { 397 | const { queryKey } = message; 398 | 399 | try { 400 | const { name, data } = await this.driver.export(); 401 | 402 | this.emitMessage( 403 | { 404 | type: 'buffer', 405 | queryKey, 406 | bufferName: name, 407 | buffer: data, 408 | }, 409 | [data] 410 | ); 411 | } catch (error) { 412 | this.emitMessage({ 413 | type: 'error', 414 | error, 415 | queryKey, 416 | }); 417 | } 418 | }; 419 | 420 | protected deleteDb = async (message: DeleteMessage): Promise => { 421 | const { queryKey } = message; 422 | let errored = false; 423 | 424 | try { 425 | await this.driver.clear(); 426 | } catch (error) { 427 | this.emitMessage({ 428 | type: 'error', 429 | error, 430 | queryKey, 431 | }); 432 | errored = true; 433 | } finally { 434 | await this.init('delete'); 435 | } 436 | 437 | if (!errored) { 438 | this.emitMessage({ 439 | type: 'success', 440 | queryKey, 441 | }); 442 | } 443 | }; 444 | 445 | protected destroy = async (message?: DestroyMessage): Promise => { 446 | await this.driver.exec({ sql: 'PRAGMA optimize' }); 447 | await this.driver.destroy(); 448 | 449 | if (this.effectsChannel) { 450 | this.emitEffectsDebounced.flush(); 451 | this.effectsChannel.close(); 452 | this.effectsChannel = undefined; 453 | } 454 | 455 | if (this.reinitChannel) { 456 | this.reinitChannel.close(); 457 | this.reinitChannel = undefined; 458 | } 459 | 460 | if (message) { 461 | this.emitMessage({ 462 | type: 'success', 463 | queryKey: message.queryKey, 464 | }); 465 | } 466 | }; 467 | } 468 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Database, Sqlite3Static } from '@sqlite.org/sqlite-wasm'; 2 | import type { CompiledQuery as KyselyQuery } from 'kysely'; 3 | import type { RunnableQuery as DrizzleQuery } from 'drizzle-orm/runnable-query'; 4 | import type { SqliteRemoteResult } from 'drizzle-orm/sqlite-proxy'; 5 | import type { sqlTag } from './lib/sql-tag.js'; 6 | import type { SQLocalProcessor } from './processor.js'; 7 | 8 | type IsAny = boolean extends (T extends never ? true : false) ? true : false; 9 | 10 | // SQLite 11 | 12 | export type Sqlite3 = Sqlite3Static; 13 | export type Sqlite3InitModule = () => Promise; 14 | export type Sqlite3Db = Database; 15 | export type Sqlite3Method = 'get' | 'all' | 'run' | 'values'; 16 | export type Sqlite3StorageType = 17 | | (string & {}) 18 | | 'memory' 19 | | 'opfs' 20 | | 'local' 21 | | 'session'; 22 | 23 | // Queries 24 | 25 | export type Statement = { 26 | sql: string; 27 | params: unknown[]; 28 | }; 29 | 30 | export type ReturningStatement = 31 | | Statement 32 | | (IsAny> extends true ? never : KyselyQuery) 33 | | (IsAny> extends true 34 | ? never 35 | : DrizzleQuery< 36 | Result extends SqliteRemoteResult ? any : Result[], 37 | 'sqlite' 38 | >); 39 | 40 | export type StatementInput = 41 | | ReturningStatement 42 | | ((sql: typeof sqlTag) => ReturningStatement); 43 | 44 | export type Transaction = { 45 | query: >( 46 | passStatement: StatementInput 47 | ) => Promise; 48 | sql: >( 49 | queryTemplate: TemplateStringsArray | string, 50 | ...params: unknown[] 51 | ) => Promise; 52 | commit: () => Promise; 53 | rollback: () => Promise; 54 | }; 55 | 56 | export type ReactiveQuery = { 57 | readonly value: Result[]; 58 | subscribe: ( 59 | onData: (results: Result[]) => void, 60 | onError?: (err: Error) => void 61 | ) => { 62 | unsubscribe: () => void; 63 | }; 64 | }; 65 | 66 | export type RawResultData = { 67 | rows: unknown[] | unknown[][]; 68 | columns: string[]; 69 | }; 70 | 71 | // Driver 72 | 73 | export interface SQLocalDriver { 74 | readonly storageType: Sqlite3StorageType; 75 | init: (config: DriverConfig) => Promise; 76 | exec: (statement: DriverStatement) => Promise; 77 | execBatch: (statements: DriverStatement[]) => Promise; 78 | onWrite: (callback: (change: DataChange) => void) => () => void; 79 | isDatabasePersisted: () => Promise; 80 | getDatabaseSizeBytes: () => Promise; 81 | createFunction: (fn: UserFunction) => Promise; 82 | import: ( 83 | database: 84 | | ArrayBuffer 85 | | Uint8Array 86 | | ReadableStream> 87 | ) => Promise; 88 | export: () => Promise<{ 89 | name: string; 90 | data: ArrayBuffer | Uint8Array; 91 | }>; 92 | clear: () => Promise; 93 | destroy: () => Promise; 94 | } 95 | 96 | export type DriverConfig = { 97 | databasePath?: DatabasePath; 98 | reactive?: boolean; 99 | readOnly?: boolean; 100 | verbose?: boolean; 101 | }; 102 | 103 | export type DriverStatement = { 104 | sql: string; 105 | params?: any[]; 106 | method?: Sqlite3Method; 107 | }; 108 | 109 | // Database status 110 | 111 | export type DatabasePath = 112 | | (string & {}) 113 | | ':memory:' 114 | | 'local' 115 | | ':localStorage:' 116 | | 'session' 117 | | ':sessionStorage:'; 118 | 119 | export type QueryKey = string; 120 | export type ConnectReason = 'initial' | 'overwrite' | 'delete'; 121 | 122 | export type ClientConfig = { 123 | databasePath: DatabasePath; 124 | reactive?: boolean; 125 | readOnly?: boolean; 126 | verbose?: boolean; 127 | onInit?: (sql: typeof sqlTag) => void | Statement[]; 128 | onConnect?: (reason: ConnectReason) => void; 129 | processor?: SQLocalProcessor | Worker; 130 | }; 131 | 132 | export type ProcessorConfig = { 133 | databasePath?: DatabasePath; 134 | reactive?: boolean; 135 | readOnly?: boolean; 136 | verbose?: boolean; 137 | clientKey?: QueryKey; 138 | onInitStatements?: Statement[]; 139 | }; 140 | 141 | export type DatabaseInfo = { 142 | databasePath?: DatabasePath; 143 | databaseSizeBytes?: number; 144 | storageType?: Sqlite3StorageType; 145 | persisted?: boolean; 146 | }; 147 | 148 | export type DataChange = { 149 | operation: 'insert' | 'update' | 'delete'; 150 | table: string; 151 | rowid: BigInt; 152 | }; 153 | 154 | // User functions 155 | 156 | export type UserFunction = 157 | | CallbackUserFunction 158 | | ScalarUserFunction 159 | | AggregateUserFunction; 160 | export type CallbackUserFunction = { 161 | type: 'callback'; 162 | name: string; 163 | func: (...args: any[]) => void; 164 | }; 165 | export type ScalarUserFunction = { 166 | type: 'scalar'; 167 | name: string; 168 | func: (...args: any[]) => any; 169 | }; 170 | export type AggregateUserFunction = { 171 | type: 'aggregate'; 172 | name: string; 173 | func: { 174 | step: (...args: any[]) => void; 175 | final: (...args: any[]) => any; 176 | }; 177 | }; 178 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import { SQLiteOpfsDriver } from './drivers/sqlite-opfs-driver.js'; 2 | import { SQLocalProcessor } from './processor.js'; 3 | 4 | const driver = new SQLiteOpfsDriver(); 5 | const processor = new SQLocalProcessor(driver); 6 | 7 | self.onmessage = (message) => { 8 | processor.postMessage(message); 9 | }; 10 | 11 | processor.onmessage = (message, transfer) => { 12 | self.postMessage(message, transfer); 13 | }; 14 | -------------------------------------------------------------------------------- /test/batch.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('batch'))('batch ($type)', ({ path }) => { 6 | const { sql, batch } = new SQLocal(path); 7 | 8 | beforeEach(async () => { 9 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 10 | }); 11 | 12 | afterEach(async () => { 13 | await sql`DROP TABLE groceries`; 14 | }); 15 | 16 | it('should perform successful batch query', async () => { 17 | const txData = await batch((sql) => [ 18 | sql`INSERT INTO groceries (name) VALUES ('apples') RETURNING *`, 19 | sql`INSERT INTO groceries (name) VALUES ('bananas')`, 20 | ]); 21 | 22 | expect(txData).toEqual([[{ id: 1, name: 'apples' }], []]); 23 | 24 | const selectData = await sql`SELECT * FROM groceries`; 25 | expect(selectData.length).toBe(2); 26 | }); 27 | 28 | it('should rollback failed batch query', async () => { 29 | const txData = await batch((sql) => [ 30 | sql`INSERT INTO groceries (name) VALUES ('carrots') RETURNING *`, 31 | sql`INSERT INT groceries (name) VALUES ('lettuce') RETURNING *`, 32 | ]).catch(() => [[], []]); 33 | 34 | expect(txData).toEqual([[], []]); 35 | 36 | const selectData = await sql`SELECT * FROM groceries`; 37 | expect(selectData.length).toBe(0); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/benchmarks/batch.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, describe } from 'vitest'; 2 | import { SQLocal } from '../../src/client.js'; 3 | import { testVariation } from '../test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('batch-bench'))( 6 | 'batch ($type)', 7 | async ({ path }) => { 8 | const { sql, batch } = new SQLocal(path); 9 | 10 | await sql`DROP TABLE IF EXISTS groceries`; 11 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY, name TEXT NOT NULL, num INTEGER NOT NULL)`; 12 | 13 | bench('batch large replace', async () => { 14 | await batch((sql) => { 15 | return new Array(5000).fill(null).map((_, i) => { 16 | const id = (i % 500) + 1; 17 | return sql`INSERT OR REPLACE INTO groceries (id, name, num) VALUES (${id}, ${'item' + id}, ${(i % 15) + 1})`; 18 | }); 19 | }); 20 | }); 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /test/create-aggregate-function.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('create-aggregate-function'))( 6 | 'createAggregateFunction ($type)', 7 | ({ path }) => { 8 | const { sql, createAggregateFunction } = new SQLocal(path); 9 | 10 | beforeAll(async () => { 11 | const values = new Map(); 12 | 13 | await createAggregateFunction('mostCommon', { 14 | step: (value: unknown) => { 15 | const valueCount = values.get(value) ?? 0; 16 | values.set(value, valueCount + 1); 17 | }, 18 | final: () => { 19 | const valueEntries = Array.from(values.entries()); 20 | const sortedEntries = valueEntries.sort((a, b) => b[1] - a[1]); 21 | const mostCommonValue = sortedEntries[0][0]; 22 | values.clear(); 23 | return mostCommonValue; 24 | }, 25 | }); 26 | }); 27 | 28 | beforeEach(async () => { 29 | await sql`CREATE TABLE nums (num REAL NOT NULL)`; 30 | }); 31 | 32 | afterEach(async () => { 33 | await sql`DROP TABLE nums`; 34 | }); 35 | 36 | it('should create and use aggregate function in SELECT clause', async () => { 37 | await sql`INSERT INTO nums (num) VALUES (0), (3), (2), (7), (3), (1), (5), (3), (3), (2)`; 38 | 39 | const results = await sql`SELECT mostCommon(num) AS mostCommon FROM nums`; 40 | 41 | expect(results).toEqual([{ mostCommon: 3 }]); 42 | }); 43 | 44 | it('should create and use aggregate function in HAVING clause', async () => { 45 | await sql`INSERT INTO nums (num) VALUES (1), (2), (2), (2), (4), (5), (5), (6)`; 46 | 47 | const results = await sql` 48 | SELECT mod(num, 2) AS isOdd 49 | FROM nums 50 | GROUP BY isOdd 51 | HAVING mostCommon(num) = 5 52 | `; 53 | 54 | expect(results).toEqual([{ isOdd: 1 }]); 55 | }); 56 | 57 | it('should not replace an existing implementation', async () => { 58 | const createBadFn = async () => { 59 | await createAggregateFunction('mostCommon', { 60 | step: () => {}, 61 | final: () => 0, 62 | }); 63 | }; 64 | 65 | await expect(createBadFn).rejects.toThrowError(); 66 | }); 67 | } 68 | ); 69 | -------------------------------------------------------------------------------- /test/create-callback-function.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('create-callback-function'))( 6 | 'createCallbackFunction ($type)', 7 | ({ path }) => { 8 | const { sql, createCallbackFunction } = new SQLocal(path); 9 | 10 | beforeEach(async () => { 11 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 12 | }); 13 | 14 | afterEach(async () => { 15 | await sql`DROP TABLE groceries`; 16 | }); 17 | 18 | it('should create and trigger callback function', async () => { 19 | let callbackRan = false; 20 | let callbackValue = ''; 21 | 22 | await createCallbackFunction('testCallback', (value: string) => { 23 | callbackRan = true; 24 | callbackValue = value; 25 | }); 26 | 27 | const createBadFn = async () => 28 | await createCallbackFunction('testCallback', () => {}); 29 | await expect(createBadFn).rejects.toThrowError(); 30 | 31 | await sql` 32 | CREATE TEMP TRIGGER groceriesInsertTrigger AFTER INSERT ON groceries 33 | BEGIN 34 | SELECT testCallback(new.name); 35 | END 36 | `; 37 | 38 | await sql`INSERT INTO groceries (name) VALUES ('bread')`; 39 | 40 | expect(callbackRan).toBe(true); 41 | expect(callbackValue).toBe('bread'); 42 | 43 | const duplicateFunction = async () => 44 | await createCallbackFunction('testCallback', () => {}); 45 | await expect(duplicateFunction).rejects.toThrowError(); 46 | }); 47 | } 48 | ); 49 | -------------------------------------------------------------------------------- /test/create-scalar-function.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('create-scalar-function'))( 6 | 'createScalarFunction ($type)', 7 | ({ path, type }) => { 8 | const { sql, createScalarFunction } = new SQLocal(path); 9 | 10 | beforeEach(async () => { 11 | await sql`CREATE TABLE nums (num REAL NOT NULL)`; 12 | }); 13 | 14 | afterEach(async () => { 15 | await sql`DROP TABLE nums`; 16 | }); 17 | 18 | it('should create and use scalar function in SELECT clause', async () => { 19 | await createScalarFunction('double', (num: number) => num * 2); 20 | 21 | const createBadFn = async () => 22 | await createScalarFunction('double', (num: number) => num * 3); 23 | await expect(createBadFn).rejects.toThrowError(); 24 | 25 | await sql`INSERT INTO nums (num) VALUES (0), (2), (3.5), (-11.11)`; 26 | 27 | const results = await sql`SELECT num, double(num) AS doubled FROM nums`; 28 | 29 | expect(results).toEqual([ 30 | { num: 0, doubled: 0 }, 31 | { num: 2, doubled: 4 }, 32 | { num: 3.5, doubled: 7 }, 33 | { num: -11.11, doubled: -22.22 }, 34 | ]); 35 | }); 36 | 37 | it('should create and use scalar function in WHERE clause', async () => { 38 | await createScalarFunction('isEven', (num: number) => num % 2 === 0); 39 | 40 | await sql`INSERT INTO nums (num) VALUES (2), (3), (4), (5), (6)`; 41 | 42 | const results1 = await sql`SELECT num FROM nums WHERE isEven(num)`; 43 | expect(results1).toEqual([{ num: 2 }, { num: 4 }, { num: 6 }]); 44 | 45 | const results2 = await sql`SELECT * FROM nums WHERE isEven(num) != TRUE`; 46 | expect(results2).toEqual([{ num: 3 }, { num: 5 }]); 47 | }); 48 | 49 | it('should enable the REGEXP syntax', async () => { 50 | await createScalarFunction( 51 | 'regexp', 52 | (pattern: string, value: unknown) => { 53 | const regexp = new RegExp(pattern); 54 | return regexp.test(String(value)); 55 | } 56 | ); 57 | 58 | await sql`INSERT INTO nums (num) VALUES (29), (328), (4578), (59), (60), (5428)`; 59 | 60 | const results1 = await sql`SELECT num FROM nums WHERE num REGEXP '9$'`; 61 | expect(results1).toEqual([{ num: 29 }, { num: 59 }]); 62 | 63 | const results2 = 64 | await sql`SELECT num FROM nums WHERE num REGEXP '\\d{3}'`; 65 | expect(results2).toEqual([{ num: 328 }, { num: 4578 }, { num: 5428 }]); 66 | 67 | const results3 = 68 | await sql`SELECT num FROM nums WHERE num REGEXP '^(4|5).*[89]$'`; 69 | expect(results3).toEqual([{ num: 4578 }, { num: 59 }, { num: 5428 }]); 70 | }); 71 | 72 | it('should not overwrite a function from a different client instance', async () => { 73 | const db1 = new SQLocal(type === 'opfs' ? 'dupe-fn-db1.sqlite3' : path); 74 | const db2 = new SQLocal(type === 'opfs' ? 'dupe-fn-db2.sqlite3' : path); 75 | 76 | await db1.createScalarFunction('addTax', (num: number) => num * 1.06); 77 | await db2.createScalarFunction('addTax', (num: number) => num * 1.07); 78 | 79 | const [result1] = await db1.sql`SELECT addTax(2) AS withTax`; 80 | const [result2] = await db2.sql`SELECT addTax(2) AS withTax`; 81 | 82 | expect(result1.withTax).toBe(2.12); 83 | expect(result2.withTax).toBe(2.14); 84 | }); 85 | } 86 | ); 87 | -------------------------------------------------------------------------------- /test/delete-database-file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { sleep } from './test-utils/sleep.js'; 4 | import type { ClientConfig, ConnectReason } from '../src/types.js'; 5 | import { testVariation } from './test-utils/test-variation.js'; 6 | 7 | describe.each(testVariation('delete-db'))( 8 | 'deleteDatabaseFile ($type)', 9 | ({ path, type }) => { 10 | it('should delete the database file', async () => { 11 | let onConnectReason: ConnectReason | null = null; 12 | let beforeUnlockCalled = false; 13 | 14 | const { sql, deleteDatabaseFile, destroy } = new SQLocal({ 15 | databasePath: path, 16 | onConnect: (reason) => (onConnectReason = reason), 17 | }); 18 | 19 | await vi.waitUntil(() => onConnectReason === 'initial'); 20 | onConnectReason = null; 21 | 22 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 23 | await sql`INSERT INTO nums (num) VALUES (123)`; 24 | 25 | const nums1 = await sql`SELECT * FROM nums`; 26 | expect(nums1).toEqual([{ num: 123 }]); 27 | 28 | await deleteDatabaseFile(() => { 29 | beforeUnlockCalled = true; 30 | }); 31 | 32 | expect(onConnectReason).toBe('delete'); 33 | expect(beforeUnlockCalled).toBe(true); 34 | 35 | await sql`CREATE TABLE letters (letter TEXT NOT NULL)`; 36 | await sql`INSERT INTO letters (letter) VALUES ('x')`; 37 | 38 | const letters = await sql`SELECT * FROM letters`; 39 | expect(letters).toEqual([{ letter: 'x' }]); 40 | 41 | const nums2 = sql`SELECT * FROM nums`; 42 | await expect(nums2).rejects.toThrow(); 43 | 44 | await deleteDatabaseFile(); 45 | await destroy(); 46 | }); 47 | 48 | it( 49 | 'should or should not notify other instances of a delete', 50 | { timeout: type === 'opfs' ? 2000 : undefined }, 51 | async () => { 52 | let onConnectReason1: ConnectReason | null = null; 53 | let onConnectReason2: ConnectReason | null = null; 54 | 55 | const db1 = new SQLocal({ 56 | databasePath: path, 57 | onConnect: (reason) => (onConnectReason1 = reason), 58 | }); 59 | const db2 = new SQLocal({ 60 | databasePath: path, 61 | onConnect: (reason) => (onConnectReason2 = reason), 62 | }); 63 | 64 | await vi.waitUntil(() => onConnectReason1 === 'initial'); 65 | onConnectReason1 = null; 66 | await vi.waitUntil(() => onConnectReason2 === 'initial'); 67 | onConnectReason2 = null; 68 | 69 | await db1.deleteDatabaseFile(); 70 | 71 | if (type !== 'memory') { 72 | await vi.waitUntil(() => onConnectReason2 === 'delete'); 73 | expect(onConnectReason2).toBe('delete'); 74 | } else { 75 | expect(onConnectReason2).toBe(null); 76 | } 77 | 78 | expect(onConnectReason1).toBe('delete'); 79 | 80 | await db2.deleteDatabaseFile(); 81 | await db2.destroy(); 82 | await db1.destroy(); 83 | } 84 | ); 85 | 86 | it('should restore user functions', async () => { 87 | const db = new SQLocal(path); 88 | await db.createScalarFunction('double', (num: number) => num * 2); 89 | 90 | const num1 = await db.sql`SELECT double(1) AS num`; 91 | expect(num1).toEqual([{ num: 2 }]); 92 | 93 | await db.deleteDatabaseFile(); 94 | 95 | const num2 = await db.sql`SELECT double(2) AS num`; 96 | expect(num2).toEqual([{ num: 4 }]); 97 | 98 | await db.destroy(); 99 | }); 100 | 101 | it('should not interrupt a transaction with database deletion', async () => { 102 | const { sql, transaction, deleteDatabaseFile, destroy } = new SQLocal( 103 | path 104 | ); 105 | const createTable = async () => { 106 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 107 | }; 108 | 109 | const order: number[] = []; 110 | 111 | await createTable(); 112 | await Promise.all([ 113 | transaction(async (tx) => { 114 | order.push(1); 115 | await tx.sql`INSERT INTO nums (num) VALUES (1)`; 116 | await sleep(100); 117 | order.push(3); 118 | await tx.sql`INSERT INTO nums (num) VALUES (3)`; 119 | }), 120 | (async () => { 121 | await sleep(50); 122 | order.push(2); 123 | await deleteDatabaseFile(); 124 | await createTable(); 125 | await sql`INSERT INTO nums (num) VALUES (2)`; 126 | })(), 127 | ]); 128 | 129 | const data = await sql`SELECT * FROM nums`; 130 | expect(data).toEqual([{ num: 2 }]); 131 | expect(order).toEqual([1, 2, 3]); 132 | 133 | await deleteDatabaseFile(); 134 | await destroy(); 135 | }); 136 | 137 | it( 138 | 'should run onInit statements before other queries after deletion', 139 | { timeout: type === 'opfs' ? 1500 : undefined }, 140 | async () => { 141 | const databasePath = path; 142 | const onInit: ClientConfig['onInit'] = (sql) => { 143 | return [sql`PRAGMA foreign_keys = ON`]; 144 | }; 145 | 146 | const results: number[] = []; 147 | 148 | const db1 = new SQLocal({ databasePath, onInit }); 149 | const db2 = new SQLocal({ databasePath, onInit }); 150 | 151 | const [{ foreign_keys: result1 }] = await db1.sql`PRAGMA foreign_keys`; 152 | results.push(result1); 153 | await db1.sql`PRAGMA foreign_keys = OFF`; 154 | const [{ foreign_keys: result2 }] = await db1.sql`PRAGMA foreign_keys`; 155 | results.push(result2); 156 | await db1.deleteDatabaseFile(); 157 | const [{ foreign_keys: result3 }] = await db1.sql`PRAGMA foreign_keys`; 158 | results.push(result3); 159 | const [{ foreign_keys: result4 }] = await db2.sql`PRAGMA foreign_keys`; 160 | results.push(result4); 161 | 162 | expect(results).toEqual([1, 0, 1, 1]); 163 | 164 | await db1.destroy(); 165 | await db2.deleteDatabaseFile(); 166 | await db2.destroy(); 167 | } 168 | ); 169 | } 170 | ); 171 | -------------------------------------------------------------------------------- /test/destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('destroy'))('destroy ($type)', ({ path }) => { 6 | const { sql, destroy } = new SQLocal(path); 7 | 8 | beforeEach(async () => { 9 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 10 | }); 11 | 12 | afterEach(async () => { 13 | const { sql } = new SQLocal(path); 14 | await sql`DROP TABLE IF EXISTS groceries`; 15 | }); 16 | 17 | it('should destroy the client', async () => { 18 | const insert1 = 19 | await sql`INSERT INTO groceries (name) VALUES ('pasta') RETURNING name`; 20 | expect(insert1).toEqual([{ name: 'pasta' }]); 21 | 22 | await destroy(); 23 | 24 | const insert2Fn = async () => 25 | await sql`INSERT INTO groceries (name) VALUES ('sauce') RETURNING name`; 26 | await expect(insert2Fn).rejects.toThrowError(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/drizzle/driver.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { SQLocalDrizzle } from '../../src/drizzle/index.js'; 3 | import { drizzle } from 'drizzle-orm/sqlite-proxy'; 4 | import { int, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 5 | import { desc, eq, relations, sql as dsql } from 'drizzle-orm'; 6 | import { sleep } from '../test-utils/sleep.js'; 7 | import { testVariation } from '../test-utils/test-variation.js'; 8 | 9 | describe.each(testVariation('drizzle-driver'))( 10 | 'drizzle driver ($type)', 11 | ({ path }) => { 12 | const { sql, driver, batchDriver, transaction, reactiveQuery } = 13 | new SQLocalDrizzle({ databasePath: path, reactive: true }); 14 | 15 | const groceries = sqliteTable('groceries', { 16 | id: int('id').primaryKey({ autoIncrement: true }), 17 | name: text('name').notNull(), 18 | inStock: int('in_stock', { mode: 'boolean' }), 19 | }); 20 | 21 | const groceriesRelations = relations(groceries, ({ many }) => ({ 22 | prices: many(prices), 23 | })); 24 | 25 | const prices = sqliteTable('prices', { 26 | id: int('id').primaryKey({ autoIncrement: true }), 27 | groceryId: int('groceryId').notNull(), 28 | price: real('price').notNull(), 29 | }); 30 | 31 | const pricesRelations = relations(prices, ({ one }) => ({ 32 | grocery: one(groceries, { 33 | fields: [prices.groceryId], 34 | references: [groceries.id], 35 | }), 36 | })); 37 | 38 | const db = drizzle(driver, batchDriver, { 39 | schema: { groceries, groceriesRelations, prices, pricesRelations }, 40 | }); 41 | 42 | beforeEach(async () => { 43 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, in_stock INTEGER)`; 44 | await sql`CREATE TABLE prices (id INTEGER PRIMARY KEY AUTOINCREMENT, groceryId INTEGER NOT NULL, price REAL NOT NULL)`; 45 | }); 46 | 47 | afterEach(async () => { 48 | await sql`DROP TABLE groceries`; 49 | await sql`DROP TABLE prices`; 50 | }); 51 | 52 | it('should execute queries', async () => { 53 | const insert1Prepared = db 54 | .insert(groceries) 55 | .values({ name: dsql.placeholder('name') }) 56 | .returning({ name: groceries.name }) 57 | .prepare(); 58 | const items = ['bread', 'milk', 'rice']; 59 | 60 | for (let item of items) { 61 | const insert1 = await insert1Prepared.get({ name: item }); 62 | expect(insert1).toEqual({ name: item }); 63 | } 64 | 65 | const select1 = await db.select().from(groceries).all(); 66 | expect(select1).toEqual([ 67 | { id: 1, name: 'bread', inStock: null }, 68 | { id: 2, name: 'milk', inStock: null }, 69 | { id: 3, name: 'rice', inStock: null }, 70 | ]); 71 | 72 | const delete1 = await db 73 | .delete(groceries) 74 | .where(eq(groceries.id, 2)) 75 | .returning() 76 | .get(); 77 | expect(delete1).toEqual({ id: 2, name: 'milk', inStock: null }); 78 | 79 | const update1 = await db 80 | .update(groceries) 81 | .set({ name: 'white rice', inStock: true }) 82 | .where(eq(groceries.id, 3)) 83 | .returning({ name: groceries.name, inStock: groceries.inStock }) 84 | .all(); 85 | expect(update1).toEqual([{ name: 'white rice', inStock: true }]); 86 | 87 | const select2 = await db 88 | .select({ name: groceries.name }) 89 | .from(groceries) 90 | .orderBy(desc(groceries.id)) 91 | .all(); 92 | expect(select2).toEqual([{ name: 'white rice' }, { name: 'bread' }]); 93 | }); 94 | 95 | it('should accept batched queries', async () => { 96 | const data = await db.batch([ 97 | db.insert(groceries).values({ name: 'bread' }), 98 | db 99 | .insert(groceries) 100 | .values({ name: 'rice' }) 101 | .returning({ name: groceries.name }), 102 | db 103 | .insert(groceries) 104 | .values({ name: 'milk', inStock: false }) 105 | .returning(), 106 | db.select().from(groceries), 107 | ]); 108 | 109 | expect(data).toEqual([ 110 | { rows: [], columns: [] }, 111 | [{ name: 'rice' }], 112 | [{ id: 3, name: 'milk', inStock: false }], 113 | [ 114 | { id: 1, name: 'bread', inStock: null }, 115 | { id: 2, name: 'rice', inStock: null }, 116 | { id: 3, name: 'milk', inStock: false }, 117 | ], 118 | ]); 119 | }); 120 | 121 | it('should execute relational queries', async () => { 122 | await db.batch([ 123 | db.insert(groceries).values([{ name: 'chicken' }, { name: 'beef' }]), 124 | db.insert(prices).values([ 125 | { groceryId: 1, price: 3.29 }, 126 | { groceryId: 1, price: 2.99 }, 127 | { groceryId: 1, price: 3.79 }, 128 | { groceryId: 2, price: 5.29 }, 129 | { groceryId: 2, price: 4.49 }, 130 | ]), 131 | ]); 132 | 133 | const data = await db.query.groceries.findMany({ 134 | columns: { 135 | name: true, 136 | }, 137 | with: { 138 | prices: { 139 | columns: { 140 | price: true, 141 | }, 142 | }, 143 | }, 144 | }); 145 | 146 | expect(data).toEqual([ 147 | { 148 | name: 'chicken', 149 | prices: [{ price: 3.29 }, { price: 2.99 }, { price: 3.79 }], 150 | }, 151 | { 152 | name: 'beef', 153 | prices: [{ price: 5.29 }, { price: 4.49 }], 154 | }, 155 | ]); 156 | }); 157 | 158 | it('should perform successful transaction using sqlocal way', async () => { 159 | const productName = 'rice'; 160 | const productPrice = 2.99; 161 | 162 | const newProduct = await transaction(async (tx) => { 163 | const [product] = await tx.query( 164 | db.insert(groceries).values({ name: productName }).returning() 165 | ); 166 | await tx.query( 167 | db 168 | .insert(prices) 169 | .values({ groceryId: product.id, price: productPrice }) 170 | ); 171 | return product; 172 | }); 173 | 174 | expect(newProduct).toEqual({ id: 1, name: productName, inStock: null }); 175 | 176 | const selectData1 = await db.select().from(groceries).all(); 177 | expect(selectData1.length).toBe(1); 178 | const selectData2 = await db.select().from(prices).all(); 179 | expect(selectData2.length).toBe(1); 180 | }); 181 | 182 | it('should rollback failed transaction using sqlocal way', async () => { 183 | const recordCount = await transaction(async ({ query }) => { 184 | await query(db.insert(groceries).values({ name: 'apples' })); 185 | await query(db.insert(groceries).values({ nam: 'bananas' } as any)); 186 | const data = await query(db.select().from(groceries)); 187 | return data.length; 188 | }).catch(() => null); 189 | 190 | expect(recordCount).toBe(null); 191 | 192 | const data = await db.select().from(groceries).all(); 193 | expect(data.length).toBe(0); 194 | }); 195 | 196 | it('should isolate transaction mutations using sqlocal way', async () => { 197 | const order: number[] = []; 198 | 199 | await Promise.all([ 200 | transaction(async ({ query }) => { 201 | order.push(1); 202 | await query(db.insert(groceries).values({ name: 'a' })); 203 | await sleep(200); 204 | order.push(3); 205 | await query(db.insert(groceries).values({ name: 'b' })); 206 | }), 207 | (async () => { 208 | await sleep(100); 209 | order.push(2); 210 | await db.update(groceries).set({ name: 'x' }).run(); 211 | })(), 212 | ]); 213 | 214 | const data = await db 215 | .select({ name: groceries.name }) 216 | .from(groceries) 217 | .all(); 218 | 219 | expect(data).toEqual([{ name: 'x' }, { name: 'x' }]); 220 | expect(order).toEqual([1, 2, 3]); 221 | }); 222 | 223 | it('should perform successful transaction using drizzle way', async () => { 224 | const productName = 'rice'; 225 | const productPrice = 2.99; 226 | 227 | const newProduct = await db.transaction(async (tx) => { 228 | const product = await tx 229 | .insert(groceries) 230 | .values({ name: productName }) 231 | .returning() 232 | .get(); 233 | await tx 234 | .insert(prices) 235 | .values({ groceryId: product.id, price: productPrice }) 236 | .run(); 237 | return product; 238 | }); 239 | 240 | expect(newProduct).toEqual({ id: 1, name: productName, inStock: null }); 241 | 242 | const selectData1 = await db.select().from(groceries).all(); 243 | expect(selectData1.length).toBe(1); 244 | const selectData2 = await db.select().from(prices).all(); 245 | expect(selectData2.length).toBe(1); 246 | }); 247 | 248 | it('should rollback failed transaction using drizzle way', async () => { 249 | await db 250 | .transaction(async (tx) => { 251 | await tx.insert(groceries).values({ name: 'apples' }).run(); 252 | await tx 253 | .insert(groceries) 254 | .values({ nam: 'bananas' } as any) 255 | .run(); 256 | }) 257 | .catch(() => {}); 258 | 259 | const data = await db.select().from(groceries).all(); 260 | expect(data.length).toBe(0); 261 | }); 262 | 263 | it('should NOT isolate transaction mutations using drizzle way', async () => { 264 | const order: number[] = []; 265 | 266 | await Promise.all([ 267 | db.transaction(async (tx) => { 268 | order.push(1); 269 | await tx.insert(groceries).values({ name: 'a' }).run(); 270 | await sleep(200); 271 | order.push(3); 272 | await tx.insert(groceries).values({ name: 'b' }).run(); 273 | }), 274 | (async () => { 275 | await sleep(100); 276 | order.push(2); 277 | await db.update(groceries).set({ name: 'x' }).run(); 278 | })(), 279 | ]); 280 | 281 | const data = await db 282 | .select({ name: groceries.name }) 283 | .from(groceries) 284 | .all(); 285 | 286 | expect(data).toEqual([{ name: 'x' }, { name: 'b' }]); 287 | expect(order).toEqual([1, 2, 3]); 288 | }); 289 | 290 | it('should support reactive queries', async () => { 291 | await db.insert(groceries).values({ name: 'bread', inStock: true }).run(); 292 | 293 | let list: { name: string; inStock: boolean | null }[] = []; 294 | let expectedList: { name: string; inStock: boolean | null }[] = [ 295 | { name: 'bread', inStock: true }, 296 | ]; 297 | 298 | const reactive = reactiveQuery(db.select().from(groceries)); 299 | const { unsubscribe } = reactive.subscribe((data) => { 300 | list = data.map(({ name, inStock }) => ({ name, inStock })); 301 | }); 302 | await vi.waitUntil(() => list.length === 1); 303 | 304 | expect(list).toEqual(expectedList); 305 | expect(reactive.value).toEqual( 306 | expectedList.map((item, i) => ({ ...item, id: i + 1 })) 307 | ); 308 | 309 | await db.insert(groceries).values({ name: 'rice', inStock: false }).run(); 310 | expectedList.push({ name: 'rice', inStock: false }); 311 | await vi.waitUntil(() => list.length === 2); 312 | 313 | expect(list).toEqual(expectedList); 314 | expect(reactive.value).toEqual( 315 | expectedList.map((item, i) => ({ ...item, id: i + 1 })) 316 | ); 317 | unsubscribe(); 318 | }); 319 | } 320 | ); 321 | -------------------------------------------------------------------------------- /test/get-database-file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('get-db-file'))( 6 | 'getDatabaseFile ($type)', 7 | ({ path, type }) => { 8 | const fileName = type !== 'opfs' ? 'database.sqlite3' : path; 9 | const paths = [[], [''], ['top'], ['one', 'two']]; 10 | 11 | it( 12 | 'should return the requested database file', 13 | { timeout: ['local', 'session'].includes(type) ? 3000 : 1500 }, 14 | async () => { 15 | for (let path of paths) { 16 | const databasePath = [...path, fileName].join('/'); 17 | const { sql, getDatabaseFile, deleteDatabaseFile } = new SQLocal( 18 | databasePath 19 | ); 20 | 21 | await sql`CREATE TABLE nums (num REAL NOT NULL)`; 22 | const file = await getDatabaseFile(); 23 | const now = new Date().getTime(); 24 | 25 | expect(file).toBeInstanceOf(File); 26 | expect(file.name).toBe(fileName); 27 | expect(file.size).toBe(16384); 28 | expect(file.type).toBe('application/x-sqlite3'); 29 | expect(now - file.lastModified).toBeLessThan(50); 30 | 31 | await deleteDatabaseFile(); 32 | } 33 | } 34 | ); 35 | 36 | it('should not throw when requested database has not been created', async () => { 37 | const databasePath = type === 'opfs' ? 'new.sqlite3' : path; 38 | const { getDatabaseFile } = new SQLocal(databasePath); 39 | await expect(getDatabaseFile()).resolves.not.toThrow(); 40 | }); 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /test/get-database-info.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('get-db-info'))( 6 | 'getDatabaseInfo ($type)', 7 | ({ type, path }) => { 8 | const { sql, getDatabaseInfo, deleteDatabaseFile } = new SQLocal(path); 9 | 10 | beforeEach(async () => { 11 | await deleteDatabaseFile(); 12 | }); 13 | 14 | it('should return information about the database', async () => { 15 | const info1 = await getDatabaseInfo(); 16 | 17 | expect(info1).toEqual({ 18 | databasePath: path, 19 | databaseSizeBytes: 0, 20 | storageType: type, 21 | persisted: false, 22 | }); 23 | 24 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 25 | await sql`INSERT INTO nums (num) VALUES (493), (820), (361), (125)`; 26 | 27 | const info2 = await getDatabaseInfo(); 28 | expect(info2.databaseSizeBytes).toBeGreaterThan(0); 29 | 30 | await deleteDatabaseFile(); 31 | }); 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /test/init.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterAll, 3 | afterEach, 4 | beforeEach, 5 | describe, 6 | expect, 7 | it, 8 | vi, 9 | } from 'vitest'; 10 | import { SQLocal, SQLocalProcessor } from '../src/index.js'; 11 | import { SQLiteMemoryDriver } from '../src/drivers/sqlite-memory-driver.js'; 12 | import { testVariation } from './test-utils/test-variation.js'; 13 | 14 | describe.each(testVariation('init'))('init ($type)', ({ path, type }) => { 15 | const { sql, deleteDatabaseFile } = new SQLocal(path); 16 | 17 | beforeEach(async () => { 18 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 19 | await sql`INSERT INTO nums (num) VALUES (0)`; 20 | }); 21 | 22 | afterEach(async () => { 23 | await sql`DROP TABLE nums`; 24 | }); 25 | 26 | afterAll(async () => { 27 | await deleteDatabaseFile(); 28 | }); 29 | 30 | it('should be cross-origin isolated', () => { 31 | expect(crossOriginIsolated).toBe(true); 32 | }); 33 | 34 | it('should or should not create a file in the OPFS', async () => { 35 | const opfs = await navigator.storage.getDirectory(); 36 | 37 | if (type === 'opfs') { 38 | const fileHandle = await opfs.getFileHandle(path); 39 | const file = await fileHandle.getFile(); 40 | expect(file.size).toBeGreaterThan(0); 41 | } else { 42 | await expect(opfs.getFileHandle(path)).rejects.toThrowError(); 43 | } 44 | }); 45 | 46 | it('should call onInit and onConnect', async () => { 47 | let onInitCalled = false; 48 | let onConnectCalled = false; 49 | 50 | const { sql, destroy } = new SQLocal({ 51 | databasePath: path, 52 | onInit: (sql) => { 53 | onInitCalled = true; 54 | return [sql`PRAGMA foreign_keys = ON`]; 55 | }, 56 | onConnect: () => { 57 | onConnectCalled = true; 58 | }, 59 | }); 60 | 61 | expect(onInitCalled).toBe(true); 62 | await vi.waitUntil(() => onConnectCalled === true); 63 | 64 | const [foreignKeys] = await sql`PRAGMA foreign_keys`; 65 | expect(foreignKeys).toEqual({ foreign_keys: 1 }); 66 | 67 | await destroy(); 68 | }); 69 | 70 | it('should enable read-only mode', async () => { 71 | const { sql, getDatabaseInfo, destroy } = new SQLocal({ 72 | databasePath: path, 73 | readOnly: true, 74 | }); 75 | const { storageType } = await getDatabaseInfo(); 76 | 77 | const expectedError = 78 | 'SQLITE_READONLY: sqlite3 result code 8: attempt to write a readonly database'; 79 | 80 | if (storageType === 'memory') { 81 | const write = async () => { 82 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 83 | }; 84 | await expect(write).rejects.toThrowError(expectedError); 85 | 86 | const data = await sql`SELECT (2 + 2) as result`; 87 | expect(data).toEqual([{ result: 4 }]); 88 | } else { 89 | const write = async () => { 90 | await sql`INSERT INTO nums (num) VALUES (1)`; 91 | }; 92 | await expect(write).rejects.toThrowError(expectedError); 93 | 94 | const data = await sql`SELECT * FROM nums`; 95 | expect(data).toEqual([{ num: 0 }]); 96 | } 97 | 98 | await destroy(); 99 | }); 100 | 101 | it('should accept custom processors', async () => { 102 | const driver = new SQLiteMemoryDriver(); 103 | const processor = new SQLocalProcessor(driver); 104 | const db = new SQLocal({ databasePath: ':custom:', processor }); 105 | const info = await db.getDatabaseInfo(); 106 | 107 | expect(info).toEqual({ 108 | databasePath: ':custom:', 109 | databaseSizeBytes: 0, 110 | storageType: 'memory', 111 | persisted: false, 112 | }); 113 | }); 114 | 115 | it('should support explicit resource management syntax', async () => { 116 | let asyncSpy, syncSpy, controlSpy; 117 | 118 | // asynchronous syntax 119 | { 120 | await using db = new SQLocal(path); 121 | asyncSpy = vi.spyOn(db, 'destroy'); 122 | expect(asyncSpy).toHaveBeenCalledTimes(0); 123 | } 124 | 125 | expect(asyncSpy).toHaveBeenCalledTimes(1); 126 | 127 | // synchronous syntax 128 | { 129 | using db = new SQLocal(path); 130 | syncSpy = vi.spyOn(db, 'destroy'); 131 | expect(syncSpy).toHaveBeenCalledTimes(0); 132 | } 133 | 134 | expect(syncSpy).toHaveBeenCalledTimes(1); 135 | 136 | // traditional syntax 137 | { 138 | const db = new SQLocal(path); 139 | controlSpy = vi.spyOn(db, 'destroy'); 140 | expect(controlSpy).toHaveBeenCalledTimes(0); 141 | } 142 | 143 | expect(controlSpy).toHaveBeenCalledTimes(0); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/kysely/dialect.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { Kysely, ParseJSONResultsPlugin } from 'kysely'; 3 | import type { Generated } from 'kysely'; 4 | import { jsonArrayFrom } from 'kysely/helpers/sqlite'; 5 | import { SQLocalKysely } from '../../src/kysely/index.js'; 6 | import { sleep } from '../test-utils/sleep.js'; 7 | import { testVariation } from '../test-utils/test-variation.js'; 8 | 9 | describe.each(testVariation('kysely-dialect'))( 10 | 'kysely dialect ($type)', 11 | ({ path }) => { 12 | const { dialect, transaction, reactiveQuery } = new SQLocalKysely({ 13 | databasePath: path, 14 | reactive: true, 15 | }); 16 | const db = new Kysely({ 17 | dialect, 18 | plugins: [new ParseJSONResultsPlugin()], 19 | }); 20 | 21 | type DB = { 22 | groceries: { 23 | id: Generated; 24 | name: string; 25 | }; 26 | prices: { 27 | id: Generated; 28 | groceryId: number; 29 | price: number; 30 | }; 31 | }; 32 | 33 | beforeEach(async () => { 34 | await db.schema 35 | .createTable('groceries') 36 | .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement()) 37 | .addColumn('name', 'text', (cb) => cb.notNull()) 38 | .execute(); 39 | await db.schema 40 | .createTable('prices') 41 | .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement()) 42 | .addColumn('groceryId', 'integer', (cb) => cb.notNull()) 43 | .addColumn('price', 'real', (cb) => cb.notNull()) 44 | .execute(); 45 | }); 46 | 47 | afterEach(async () => { 48 | await db.schema.dropTable('groceries').execute(); 49 | await db.schema.dropTable('prices').execute(); 50 | }); 51 | 52 | it('should execute queries', async () => { 53 | const items = ['bread', 'milk', 'rice']; 54 | for (let item of items) { 55 | const insert1 = await db 56 | .insertInto('groceries') 57 | .values({ name: item }) 58 | .returning(['name']) 59 | .execute(); 60 | expect(insert1).toEqual([{ name: item }]); 61 | } 62 | 63 | const select1 = await db.selectFrom('groceries').selectAll().execute(); 64 | expect(select1).toEqual([ 65 | { id: 1, name: 'bread' }, 66 | { id: 2, name: 'milk' }, 67 | { id: 3, name: 'rice' }, 68 | ]); 69 | 70 | const delete1 = await db 71 | .deleteFrom('groceries') 72 | .where('id', '=', 2) 73 | .returningAll() 74 | .execute(); 75 | expect(delete1).toEqual([{ id: 2, name: 'milk' }]); 76 | 77 | const update1 = await db 78 | .updateTable('groceries') 79 | .set({ name: 'white rice' }) 80 | .where('id', '=', 3) 81 | .returning(['name']) 82 | .execute(); 83 | expect(update1).toEqual([{ name: 'white rice' }]); 84 | 85 | const select2 = await db 86 | .selectFrom('groceries') 87 | .select('name') 88 | .orderBy('id', 'desc') 89 | .execute(); 90 | expect(select2).toEqual([{ name: 'white rice' }, { name: 'bread' }]); 91 | }); 92 | 93 | it('should execute queries with relations', async () => { 94 | await db 95 | .insertInto('groceries') 96 | .values([{ name: 'chicken' }, { name: 'beef' }]) 97 | .execute(); 98 | await db 99 | .insertInto('prices') 100 | .values([ 101 | { groceryId: 1, price: 3.29 }, 102 | { groceryId: 1, price: 2.99 }, 103 | { groceryId: 1, price: 3.79 }, 104 | { groceryId: 2, price: 5.29 }, 105 | { groceryId: 2, price: 4.49 }, 106 | ]) 107 | .execute(); 108 | 109 | const data = await db 110 | .selectFrom('groceries') 111 | .select('name') 112 | .select((eb) => [ 113 | jsonArrayFrom( 114 | eb 115 | .selectFrom('prices') 116 | .select('price') 117 | .whereRef('groceries.id', '=', 'prices.groceryId') 118 | ).as('prices'), 119 | ]) 120 | .execute(); 121 | 122 | expect(data).toEqual([ 123 | { 124 | name: 'chicken', 125 | prices: [{ price: 3.29 }, { price: 2.99 }, { price: 3.79 }], 126 | }, 127 | { 128 | name: 'beef', 129 | prices: [{ price: 5.29 }, { price: 4.49 }], 130 | }, 131 | ]); 132 | }); 133 | 134 | it('should perform successful transaction using sqlocal way', async () => { 135 | const productName = 'rice'; 136 | const productPrice = 2.99; 137 | 138 | const newProductId = await transaction(async (tx) => { 139 | const [product] = await tx.query( 140 | db 141 | .insertInto('groceries') 142 | .values({ name: productName }) 143 | .returningAll() 144 | .compile() 145 | ); 146 | await tx.query( 147 | db 148 | .insertInto('prices') 149 | .values({ groceryId: product.id, price: productPrice }) 150 | .compile() 151 | ); 152 | return product.id; 153 | }); 154 | 155 | expect(newProductId).toBe(1); 156 | 157 | const selectData1 = await db 158 | .selectFrom('groceries') 159 | .selectAll() 160 | .execute(); 161 | expect(selectData1.length).toBe(1); 162 | const selectData2 = await db.selectFrom('prices').selectAll().execute(); 163 | expect(selectData2.length).toBe(1); 164 | }); 165 | 166 | it('should rollback failed transaction using sqlocal way', async () => { 167 | const recordCount = await transaction(async ({ query }) => { 168 | await query( 169 | db.insertInto('groceries').values({ name: 'carrots' }).compile() 170 | ); 171 | await query( 172 | db 173 | .insertInto('groeries' as any) 174 | .values({ name: 'lettuce' }) 175 | .compile() 176 | ); 177 | const data = await query( 178 | db.selectFrom('groceries').selectAll().compile() 179 | ); 180 | return data.length; 181 | }).catch(() => null); 182 | 183 | expect(recordCount).toBe(null); 184 | 185 | const data = await db.selectFrom('groceries').selectAll().execute(); 186 | expect(data.length).toBe(0); 187 | }); 188 | 189 | it('should isolate transaction mutations using sqlocal way', async () => { 190 | const order: number[] = []; 191 | 192 | await Promise.all([ 193 | transaction(async ({ query }) => { 194 | order.push(1); 195 | await query( 196 | db.insertInto('groceries').values({ name: 'a' }).compile() 197 | ); 198 | await sleep(200); 199 | order.push(3); 200 | await query( 201 | db.insertInto('groceries').values({ name: 'b' }).compile() 202 | ); 203 | }), 204 | (async () => { 205 | await sleep(100); 206 | order.push(2); 207 | await db.updateTable('groceries').set({ name: 'x' }).execute(); 208 | })(), 209 | ]); 210 | 211 | const data = await db.selectFrom('groceries').select(['name']).execute(); 212 | 213 | expect(data).toEqual([{ name: 'x' }, { name: 'x' }]); 214 | expect(order).toEqual([1, 2, 3]); 215 | }); 216 | 217 | it('should perform successful transaction using kysely way', async () => { 218 | await db.transaction().execute(async (tx) => { 219 | await tx.insertInto('groceries').values({ name: 'apples' }).execute(); 220 | await tx.insertInto('groceries').values({ name: 'bananas' }).execute(); 221 | }); 222 | 223 | const data = await db.selectFrom('groceries').selectAll().execute(); 224 | expect(data.length).toBe(2); 225 | }); 226 | 227 | it('should rollback failed transaction using kysely way', async () => { 228 | await db 229 | .transaction() 230 | .execute(async (tx) => { 231 | await tx 232 | .insertInto('groceries') 233 | .values({ name: 'carrots' }) 234 | .execute(); 235 | await tx 236 | .insertInto('groeries' as any) 237 | .values({ name: 'lettuce' }) 238 | .execute(); 239 | }) 240 | .catch(() => {}); 241 | 242 | const data = await db.selectFrom('groceries').selectAll().execute(); 243 | expect(data.length).toBe(0); 244 | }); 245 | 246 | it('should isolate transaction mutations using kysely way', async () => { 247 | const order: number[] = []; 248 | 249 | await Promise.all([ 250 | db.transaction().execute(async (tx) => { 251 | order.push(1); 252 | await tx.insertInto('groceries').values({ name: 'a' }).execute(); 253 | await sleep(200); 254 | order.push(3); 255 | await tx.insertInto('groceries').values({ name: 'b' }).execute(); 256 | }), 257 | (async () => { 258 | await sleep(100); 259 | order.push(2); 260 | await db.updateTable('groceries').set({ name: 'x' }).execute(); 261 | })(), 262 | ]); 263 | 264 | const data = await db.selectFrom('groceries').select(['name']).execute(); 265 | 266 | expect(data).toEqual([{ name: 'x' }, { name: 'x' }]); 267 | expect(order).toEqual([1, 2, 3]); 268 | }); 269 | 270 | it('should introspect the database', async () => { 271 | const schemas = await db.introspection.getSchemas(); 272 | expect(schemas).toEqual([]); 273 | 274 | const tables = await db.introspection.getTables(); 275 | const { name: tableName, columns } = tables[0]; 276 | expect(tableName).toBe('groceries'); 277 | expect(columns).toEqual([ 278 | { 279 | name: 'id', 280 | dataType: 'INTEGER', 281 | hasDefaultValue: false, 282 | isAutoIncrementing: true, 283 | isNullable: true, 284 | }, 285 | { 286 | name: 'name', 287 | dataType: 'TEXT', 288 | hasDefaultValue: false, 289 | isAutoIncrementing: false, 290 | isNullable: false, 291 | }, 292 | ]); 293 | }); 294 | 295 | it('should support reactive queries', async () => { 296 | await db 297 | .insertInto('groceries') 298 | .values([{ name: 'bread' }]) 299 | .execute(); 300 | 301 | let list: string[] = []; 302 | let expectedList: string[] = ['bread']; 303 | 304 | const reactive = reactiveQuery( 305 | db.selectFrom('groceries').selectAll().compile() 306 | ); 307 | const { unsubscribe } = reactive.subscribe((data) => { 308 | list = data.map((item) => item.name); 309 | }); 310 | await vi.waitUntil(() => list.length === 1); 311 | 312 | expect(list).toEqual(expectedList); 313 | expect(reactive.value.map((item) => item.name)).toEqual(expectedList); 314 | 315 | await db 316 | .insertInto('groceries') 317 | .values([{ name: 'rice' }]) 318 | .execute(); 319 | expectedList.push('rice'); 320 | await vi.waitUntil(() => list.length === 2); 321 | 322 | expect(list).toEqual(expectedList); 323 | expect(reactive.value.map((item) => item.name)).toEqual(expectedList); 324 | unsubscribe(); 325 | }); 326 | } 327 | ); 328 | -------------------------------------------------------------------------------- /test/kysely/migrations.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from 'vitest'; 2 | import { SQLocalKysely } from '../../src/kysely/index.js'; 3 | import { Kysely, Migrator } from 'kysely'; 4 | import { testVariation } from '../test-utils/test-variation.js'; 5 | 6 | describe.each(testVariation('kysely-migrations'))( 7 | 'kysely migrations ($type)', 8 | ({ path }) => { 9 | const { dialect, deleteDatabaseFile } = new SQLocalKysely(path); 10 | const db = new Kysely({ dialect }); 11 | 12 | const migrator = new Migrator({ 13 | db, 14 | provider: { 15 | async getMigrations() { 16 | const { migrations } = await import('./migrations/index.js'); 17 | return migrations; 18 | }, 19 | }, 20 | }); 21 | 22 | const getTableNames = async () => { 23 | const tables = await db.introspection.getTables(); 24 | return tables.map((table) => table.name); 25 | }; 26 | 27 | const getColumnNames = async (tableName: string) => { 28 | const tables = await db.introspection.getTables(); 29 | const table = tables.find((table) => table.name === tableName); 30 | return table?.columns.map((column) => column.name); 31 | }; 32 | 33 | afterEach(async () => { 34 | await deleteDatabaseFile(); 35 | }); 36 | 37 | it('should migrate the database', async () => { 38 | expect(await getTableNames()).toEqual([]); 39 | 40 | await migrator.migrateToLatest(); 41 | expect(await getTableNames()).toEqual(['groceries']); 42 | expect(await getColumnNames('groceries')).toEqual([ 43 | 'id', 44 | 'name', 45 | 'quantity', 46 | ]); 47 | 48 | await migrator.migrateDown(); 49 | expect(await getTableNames()).toEqual(['groceries']); 50 | expect(await getColumnNames('groceries')).toEqual(['id', 'name']); 51 | 52 | await migrator.migrateDown(); 53 | expect(await getTableNames()).toEqual([]); 54 | 55 | await migrator.migrateUp(); 56 | expect(await getTableNames()).toEqual(['groceries']); 57 | expect(await getColumnNames('groceries')).toEqual(['id', 'name']); 58 | 59 | await migrator.migrateDown(); 60 | expect(await getTableNames()).toEqual([]); 61 | }); 62 | } 63 | ); 64 | -------------------------------------------------------------------------------- /test/kysely/migrations/2023-08-01.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | import type { Migration } from 'kysely'; 3 | 4 | export const Migration20230801: Migration = { 5 | async up(db: Kysely) { 6 | await db.schema 7 | .createTable('groceries') 8 | .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement()) 9 | .addColumn('name', 'text', (cb) => cb.notNull()) 10 | .execute(); 11 | }, 12 | async down(db: Kysely) { 13 | await db.schema.dropTable('groceries').execute(); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /test/kysely/migrations/2023-08-02.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | import type { Migration } from 'kysely'; 3 | 4 | export const Migration20230802: Migration = { 5 | async up(db: Kysely) { 6 | await db.schema 7 | .alterTable('groceries') 8 | .addColumn('quantity', 'integer') 9 | .execute(); 10 | }, 11 | async down(db: Kysely) { 12 | await db.schema.alterTable('groceries').dropColumn('quantity').execute(); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/kysely/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import type { Migration } from 'kysely'; 2 | import { Migration20230801 } from './2023-08-01.js'; 3 | import { Migration20230802 } from './2023-08-02.js'; 4 | 5 | export const migrations: Record = { 6 | '2023-08-02': Migration20230802, 7 | '2023-08-01': Migration20230801, 8 | }; 9 | -------------------------------------------------------------------------------- /test/overwrite-database-file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { sleep } from './test-utils/sleep.js'; 4 | import type { ClientConfig } from '../src/types.js'; 5 | import { testVariation } from './test-utils/test-variation.js'; 6 | 7 | describe.each(testVariation('overwrite-db'))( 8 | 'overwriteDatabaseFile ($type)', 9 | ({ path, type }) => { 10 | it('should replace the contents of a database', async () => { 11 | const eventValues = new Set(); 12 | const isKvvfs = ['local', 'session'].includes(type); 13 | 14 | // KVVFS instances always point to the same database, so 15 | // we can skip this test 16 | if (isKvvfs) return; 17 | 18 | const db1 = new SQLocal({ 19 | databasePath: type === 'opfs' ? 'overwrite-test-db1.sqlite3' : path, 20 | onConnect: (reason) => eventValues.add(`connect1(${reason})`), 21 | }); 22 | const db2 = new SQLocal({ 23 | databasePath: type === 'opfs' ? 'overwrite-test-db2.sqlite3' : path, 24 | onConnect: (reason) => eventValues.add(`connect2(${reason})`), 25 | }); 26 | 27 | await db1.sql`CREATE TABLE letters (letter TEXT NOT NULL)`; 28 | await db1.sql`INSERT INTO letters (letter) VALUES ('a'), ('b'), ('c')`; 29 | 30 | await db2.sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 31 | await db2.sql`INSERT INTO nums (num) VALUES (1), (2), (3)`; 32 | 33 | await vi.waitUntil(() => { 34 | return ( 35 | eventValues.has('connect1(initial)') && 36 | eventValues.has('connect2(initial)') 37 | ); 38 | }); 39 | eventValues.clear(); 40 | 41 | const lettersFile = await db1.getDatabaseFile(); 42 | const numsFile = await db2.getDatabaseFile(); 43 | const letters = [{ letter: 'a' }, { letter: 'b' }, { letter: 'c' }]; 44 | const nums = [{ num: 1 }, { num: 2 }, { num: 3 }]; 45 | 46 | // With a File 47 | await db1.overwriteDatabaseFile(numsFile, () => { 48 | eventValues.add('unlock1'); 49 | }); 50 | 51 | expect(eventValues.has('unlock1')).toBe(true); 52 | 53 | if (type !== 'memory') { 54 | expect(eventValues.has('connect1(overwrite)')).toBe(true); 55 | expect(eventValues.has('connect2(overwrite)')).toBe(false); 56 | } 57 | 58 | const letters1 = db1.sql`SELECT * FROM letters`; 59 | await expect(letters1).rejects.toThrow(); 60 | const nums1 = db1.sql`SELECT * FROM nums`; 61 | await expect(nums1).resolves.toEqual(nums); 62 | 63 | // With a ReadableStream 64 | await db1.overwriteDatabaseFile(lettersFile.stream()); 65 | 66 | const letters2 = db1.sql`SELECT * FROM letters`; 67 | await expect(letters2).resolves.toEqual(letters); 68 | const nums2 = db1.sql`SELECT * FROM nums`; 69 | await expect(nums2).rejects.toThrow(); 70 | 71 | // With an ArrayBuffer 72 | const numsBuffer = await numsFile.arrayBuffer(); 73 | await db1.overwriteDatabaseFile(numsBuffer); 74 | 75 | const letters3 = db1.sql`SELECT * FROM letters`; 76 | await expect(letters3).rejects.toThrow(); 77 | const nums3 = db1.sql`SELECT * FROM nums`; 78 | await expect(nums3).resolves.toEqual(nums); 79 | 80 | // Ensure data can still be added 81 | await db1.sql`INSERT INTO nums (num) VALUES (4), (5)`; 82 | const nums4 = db1.sql`SELECT * FROM nums`; 83 | await expect(nums4).resolves.toEqual([...nums, { num: 4 }, { num: 5 }]); 84 | 85 | // Clean up 86 | await db1.deleteDatabaseFile(); 87 | await db1.destroy(); 88 | await db2.deleteDatabaseFile(); 89 | await db2.destroy(); 90 | }); 91 | 92 | it( 93 | 'should or should not notify other instances of an overwrite', 94 | { timeout: type === 'opfs' ? 2000 : undefined }, 95 | async () => { 96 | const eventValues = new Set(); 97 | const db1 = new SQLocal({ 98 | databasePath: path, 99 | onConnect: (reason) => eventValues.add(`connect1(${reason})`), 100 | }); 101 | const db2 = new SQLocal({ 102 | databasePath: path, 103 | onConnect: (reason) => eventValues.add(`connect2(${reason})`), 104 | }); 105 | 106 | await db2.sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 107 | await db2.sql`INSERT INTO nums (num) VALUES (123)`; 108 | 109 | await vi.waitUntil(() => { 110 | return ( 111 | eventValues.has('connect1(initial)') && 112 | eventValues.has('connect2(initial)') 113 | ); 114 | }); 115 | eventValues.clear(); 116 | 117 | if (type !== 'memory') { 118 | const nums1 = await db1.sql`SELECT * FROM nums`; 119 | expect(nums1).toEqual([{ num: 123 }]); 120 | } 121 | 122 | const dbFile = await db2.getDatabaseFile(); 123 | await db2.sql`INSERT INTO nums (num) VALUES (456)`; 124 | await db1.overwriteDatabaseFile(dbFile, async () => { 125 | await db1.sql`INSERT INTO nums (num) VALUES (789)`; 126 | eventValues.add('unlock1'); 127 | }); 128 | 129 | if (type !== 'memory') { 130 | await vi.waitUntil(() => eventValues.size === 3); 131 | expect(eventValues.has('unlock1')).toBe(true); 132 | expect(eventValues.has('connect1(overwrite)')).toBe(true); 133 | expect(eventValues.has('connect2(overwrite)')).toBe(true); 134 | } else { 135 | await vi.waitUntil(() => eventValues.size === 1); 136 | expect(eventValues.has('unlock1')).toBe(true); 137 | } 138 | 139 | const expectedNums = [{ num: 123 }, { num: 789 }]; 140 | const nums2 = await db1.sql`SELECT * FROM nums`; 141 | expect(nums2).toEqual(expectedNums); 142 | 143 | if (type !== 'memory') { 144 | const nums3 = await db2.sql`SELECT * FROM nums`; 145 | expect(nums3).toEqual(expectedNums); 146 | } 147 | 148 | await db1.destroy(); 149 | await db2.deleteDatabaseFile(); 150 | await db2.destroy(); 151 | } 152 | ); 153 | 154 | it('should restore user functions', async () => { 155 | const db = new SQLocal(path); 156 | await db.createScalarFunction('double', (num: number) => num * 2); 157 | 158 | const num1 = await db.sql`SELECT double(1) AS num`; 159 | expect(num1).toEqual([{ num: 2 }]); 160 | 161 | const dbFile = await db.getDatabaseFile(); 162 | await db.overwriteDatabaseFile(dbFile); 163 | await sleep(100); 164 | 165 | const num2 = await db.sql`SELECT double(2) AS num`; 166 | expect(num2).toEqual([{ num: 4 }]); 167 | 168 | await db.deleteDatabaseFile(); 169 | await db.destroy(); 170 | }); 171 | 172 | it('should not interrupt a transaction with database overwrite', async () => { 173 | const { 174 | sql, 175 | transaction, 176 | getDatabaseFile, 177 | overwriteDatabaseFile, 178 | deleteDatabaseFile, 179 | destroy, 180 | } = new SQLocal(path); 181 | 182 | const order: number[] = []; 183 | 184 | await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; 185 | const dbFile = await getDatabaseFile(); 186 | 187 | await Promise.all([ 188 | transaction(async (tx) => { 189 | order.push(1); 190 | await tx.sql`INSERT INTO nums (num) VALUES (1)`; 191 | await sleep(100); 192 | order.push(3); 193 | await tx.sql`INSERT INTO nums (num) VALUES (3)`; 194 | }), 195 | (async () => { 196 | await sleep(50); 197 | order.push(2); 198 | await overwriteDatabaseFile(dbFile); 199 | await sql`INSERT INTO nums (num) VALUES (2)`; 200 | })(), 201 | ]); 202 | 203 | const data = await sql`SELECT * FROM nums`; 204 | expect(data).toEqual([{ num: 2 }]); 205 | expect(order).toEqual([1, 2, 3]); 206 | 207 | await deleteDatabaseFile(); 208 | await destroy(); 209 | }); 210 | 211 | it( 212 | 'should run onInit statements before other queries after overwrite', 213 | { timeout: type === 'opfs' ? 1500 : undefined }, 214 | async () => { 215 | const databasePath = path; 216 | const onInit: ClientConfig['onInit'] = (sql) => { 217 | return [sql`PRAGMA foreign_keys = ON`]; 218 | }; 219 | 220 | const results: number[] = []; 221 | 222 | const db1 = new SQLocal({ databasePath, onInit }); 223 | const db2 = new SQLocal({ databasePath, onInit }); 224 | 225 | const [{ foreign_keys: result1 }] = await db1.sql`PRAGMA foreign_keys`; 226 | results.push(result1); 227 | await db1.sql`PRAGMA foreign_keys = OFF`; 228 | const [{ foreign_keys: result2 }] = await db1.sql`PRAGMA foreign_keys`; 229 | results.push(result2); 230 | const file = await db2.getDatabaseFile(); 231 | await db1.overwriteDatabaseFile(file); 232 | const [{ foreign_keys: result3 }] = await db1.sql`PRAGMA foreign_keys`; 233 | results.push(result3); 234 | const [{ foreign_keys: result4 }] = await db2.sql`PRAGMA foreign_keys`; 235 | results.push(result4); 236 | 237 | expect(results).toEqual([1, 0, 1, 1]); 238 | 239 | await db1.destroy(); 240 | await db2.deleteDatabaseFile(); 241 | await db2.destroy(); 242 | } 243 | ); 244 | } 245 | ); 246 | -------------------------------------------------------------------------------- /test/reactive-query.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterAll, 3 | afterEach, 4 | beforeEach, 5 | describe, 6 | expect, 7 | it, 8 | vi, 9 | } from 'vitest'; 10 | import { SQLocal } from '../src/client.js'; 11 | import { sleep } from './test-utils/sleep.js'; 12 | import { testVariation } from './test-utils/test-variation.js'; 13 | 14 | describe.each(testVariation('reactive-query'))( 15 | 'reactiveQuery ($type)', 16 | async ({ path, type }) => { 17 | const db1 = new SQLocal({ databasePath: path, reactive: true }); 18 | const db2 = new SQLocal({ databasePath: path, reactive: true }); 19 | 20 | beforeEach(async () => { 21 | for (let db of [db1, db2]) { 22 | await db.sql`CREATE TABLE IF NOT EXISTS groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 23 | await db.sql`CREATE TABLE IF NOT EXISTS todos (title TEXT NOT NULL)`; 24 | } 25 | }); 26 | 27 | afterEach(async () => { 28 | for (let db of [db1, db2]) { 29 | await db.sql`DROP TABLE IF EXISTS groceries`; 30 | await db.sql`DROP TABLE IF EXISTS todos`; 31 | } 32 | }); 33 | 34 | afterAll(async () => { 35 | for (let db of [db1, db2]) { 36 | await db.destroy(); 37 | } 38 | }); 39 | 40 | it( 41 | 'should notify other instances of data changes', 42 | { timeout: 3000 }, 43 | async () => { 44 | // Each in-memory database is fresh 45 | if (type === 'memory') return; 46 | 47 | let list1: string[] = []; 48 | let list2: string[] = []; 49 | let todosUpdated = false; 50 | 51 | const reactive1 = db1.reactiveQuery( 52 | (sql) => sql`SELECT * FROM groceries` 53 | ); 54 | const reactive2 = db2.reactiveQuery( 55 | (sql) => sql`SELECT * FROM groceries` 56 | ); 57 | const reactiveExtra = db2.reactiveQuery( 58 | (sql) => sql`SELECT * FROM todos` 59 | ); 60 | const callback1 = vi.fn( 61 | (data: Record[]) => 62 | (list1 = data.map(({ name }) => name)) 63 | ); 64 | const callback2 = vi.fn( 65 | (data: Record[]) => 66 | (list2 = data.map(({ name }) => name)) 67 | ); 68 | const { unsubscribe: unsubscribe1 } = reactive1.subscribe(callback1); 69 | const { unsubscribe: unsubscribe2 } = reactive2.subscribe(callback2); 70 | const { unsubscribe: unsubscribeExtra } = reactiveExtra.subscribe( 71 | () => (todosUpdated = true) 72 | ); 73 | 74 | let expected: string[] = []; 75 | expect(list1).toEqual(expected); 76 | expect(list2).toEqual(expected); 77 | 78 | // Insert 79 | await db1.sql`INSERT INTO groceries (name) VALUES ('bread'), ('eggs')`; 80 | await vi.waitUntil(() => list1.length === 2 && list2.length === 2); 81 | 82 | expected = ['bread', 'eggs']; 83 | expect(list1).toEqual(expected); 84 | expect(list2).toEqual(expected); 85 | 86 | // Update 87 | await db2.sql`UPDATE groceries SET name = 'wheat bread' WHERE name = 'bread'`; 88 | await vi.waitUntil( 89 | () => list1.includes('wheat bread') && list2.includes('wheat bread') 90 | ); 91 | 92 | expected = ['wheat bread', 'eggs']; 93 | expect(list1).toEqual(expected); 94 | expect(list2).toEqual(expected); 95 | 96 | // Delete 97 | await db1.sql`DELETE FROM groceries WHERE name = 'wheat bread'`; 98 | await vi.waitUntil(() => list1.length === 1 && list2.length === 1); 99 | 100 | expected = ['eggs']; 101 | expect(list1).toEqual(expected); 102 | expect(list2).toEqual(expected); 103 | 104 | // Edit unrelated table 105 | todosUpdated = false; 106 | const prevCallCount = callback1.mock.calls.length; 107 | await db1.sql`INSERT INTO todos (title) VALUES ('laundry')`; 108 | await vi.waitUntil(() => todosUpdated === true); 109 | await sleep(100); 110 | 111 | const newCallCount = callback1.mock.calls.length; 112 | expect(newCallCount).toBe(prevCallCount); 113 | 114 | // Unsubscribe 115 | unsubscribe1(); 116 | await db1.sql`INSERT INTO groceries (name) VALUES ('rice')`; 117 | await vi.waitUntil(() => list2.length === 2); 118 | await sleep(100); 119 | 120 | expect(list1).toEqual(['eggs']); 121 | expect(list2).toEqual(['eggs', 'rice']); 122 | 123 | // Unsubscribing again is a no-op 124 | unsubscribe1(); 125 | unsubscribe2(); 126 | unsubscribeExtra(); 127 | } 128 | ); 129 | 130 | it('should not notify other in-memory databases', async () => { 131 | if (type !== 'memory') return; 132 | 133 | const reactive1 = db1.reactiveQuery( 134 | (sql) => sql`SELECT * FROM groceries` 135 | ); 136 | const reactive2 = db2.reactiveQuery( 137 | (sql) => sql`SELECT * FROM groceries` 138 | ); 139 | let list1: string[] | null = null; 140 | let list2: string[] | null = null; 141 | const callback1 = vi.fn((data: Record[]) => { 142 | list1 = data.map(({ name }) => name); 143 | }); 144 | const callback2 = vi.fn((data: Record[]) => { 145 | list2 = data.map(({ name }) => name); 146 | }); 147 | const sub1 = reactive1.subscribe(callback1); 148 | const sub2 = reactive2.subscribe(callback2); 149 | await vi.waitUntil(() => list1 !== null && list2 !== null); 150 | 151 | await db1.sql`INSERT INTO groceries (name) VALUES ('celery')`; 152 | await vi.waitUntil(() => list1?.length === 1); 153 | 154 | expect(list1).toEqual(['celery']); 155 | expect(list2).toEqual([]); 156 | expect(callback1).toHaveBeenCalledTimes(2); 157 | expect(callback2).toHaveBeenCalledTimes(1); 158 | 159 | await db2.sql`INSERT INTO groceries (name) VALUES ('carrots')`; 160 | await vi.waitUntil(() => list2?.length === 1); 161 | 162 | expect(list1).toEqual(['celery']); 163 | expect(list2).toEqual(['carrots']); 164 | expect(callback1).toHaveBeenCalledTimes(2); 165 | expect(callback2).toHaveBeenCalledTimes(2); 166 | 167 | sub1.unsubscribe(); 168 | sub2.unsubscribe(); 169 | }); 170 | 171 | it( 172 | 'should notify reactive queries on the same instance', 173 | { timeout: 2000 }, 174 | async () => { 175 | // This test will only involve db1 176 | let list1: string[] = []; 177 | let list2: string[] = []; 178 | const reactive1 = db1.reactiveQuery((sql) => sql`SELECT * FROM todos`); 179 | const reactive2 = db1.reactiveQuery( 180 | (sql) => sql`SELECT * FROM todos WHERE title LIKE 'clean %'` 181 | ); 182 | const callback1 = vi.fn((data: Record[]) => { 183 | list1 = data.map(({ title }) => title); 184 | }); 185 | const callback2 = vi.fn((data: Record[]) => { 186 | list2 = data.map(({ title }) => title); 187 | }); 188 | let subscription1, subscription2; 189 | 190 | // Initial query result should be emitted on subscription 191 | await db1.sql`INSERT INTO todos (title) VALUES ('vacuum')`; 192 | await sleep(100); 193 | subscription1 = reactive1.subscribe(callback1); 194 | await vi.waitUntil(() => list1.length === 1); 195 | 196 | expect(list1).toEqual(['vacuum']); 197 | expect(callback1).toHaveBeenCalledOnce(); 198 | 199 | // Subscription should only emit once for multiple changes close together 200 | await Promise.all([ 201 | db1.sql`INSERT INTO todos (title) VALUES ('clean bedroom')`, 202 | db1.sql`INSERT INTO todos (title) VALUES ('dust')`, 203 | ]); 204 | await vi.waitUntil(() => list1.length === 3); 205 | 206 | expect(list1.sort()).toEqual( 207 | ['vacuum', 'dust', 'clean bedroom'].sort() 208 | ); 209 | expect(callback1).toHaveBeenCalledTimes(2); 210 | 211 | // Delete with WHERE clause 212 | await db1.sql`DELETE FROM todos WHERE title = 'dust'`; 213 | await vi.waitUntil(() => list1.length === 2); 214 | 215 | expect(list1).toEqual(['vacuum', 'clean bedroom']); 216 | expect(callback1).toHaveBeenCalledTimes(3); 217 | 218 | // Start a second subscription 219 | subscription2 = reactive2.subscribe(callback2); 220 | await vi.waitUntil(() => list2.length === 1); 221 | 222 | expect(list1).toEqual(['vacuum', 'clean bedroom']); 223 | expect(list2).toEqual(['clean bedroom']); 224 | expect(callback1).toHaveBeenCalledTimes(3); 225 | expect(callback2).toHaveBeenCalledTimes(1); 226 | 227 | // Insert something for the second subscription 228 | await db1.sql`INSERT INTO todos (title) VALUES ('clean kitchen')`; 229 | await vi.waitUntil(() => list1.length === 3 && list2.length === 2); 230 | 231 | expect(list1).toEqual(['vacuum', 'clean bedroom', 'clean kitchen']); 232 | expect(list2).toEqual(['clean bedroom', 'clean kitchen']); 233 | expect(callback1).toHaveBeenCalledTimes(4); 234 | expect(callback2).toHaveBeenCalledTimes(2); 235 | 236 | // Delete something the second subscription does not retrieve 237 | await db1.sql`DELETE FROM todos WHERE title = 'vacuum'`; 238 | await vi.waitUntil(() => list1.length === 2 && list2.length === 2); 239 | 240 | const expectedList = ['clean bedroom', 'clean kitchen']; 241 | expect(list1).toEqual(expectedList); 242 | expect(list2).toEqual(expectedList); 243 | expect(callback1).toHaveBeenCalledTimes(5); 244 | expect(callback2).toHaveBeenCalledTimes(3); 245 | 246 | // The SQLite "Truncate Optimization" will cause this statement to not emit an event 247 | // https://sqlite.org/lang_delete.html#truncateopt 248 | await db1.sql`DELETE FROM todos`; 249 | await sleep(100); 250 | 251 | expect(list1).toEqual(expectedList); 252 | expect(list2).toEqual(expectedList); 253 | expect(callback1).toHaveBeenCalledTimes(5); 254 | expect(callback2).toHaveBeenCalledTimes(3); 255 | await expect(db1.sql`SELECT * FROM todos`).resolves.toEqual([]); 256 | 257 | // Unsubscribe 258 | subscription1.unsubscribe(); 259 | subscription2.unsubscribe(); 260 | } 261 | ); 262 | 263 | it('should notify multiple subscribers to the same query', async () => { 264 | const reactive = db1.reactiveQuery((sql) => sql`SELECT * FROM groceries`); 265 | let list1: string[] | null = null; 266 | let list2: string[] | null = null; 267 | let expectedList: string[] = []; 268 | const callback1 = vi.fn((data: Record[]) => { 269 | list1 = data.map(({ name }) => name); 270 | }); 271 | const callback2 = vi.fn((data: Record[]) => { 272 | list2 = data.map(({ name }) => name); 273 | }); 274 | 275 | // The value property should contain an empty array initially 276 | expect(reactive.value).toEqual([]); 277 | 278 | // Make 2 subscriptions 279 | let { unsubscribe: unsubscribe1 } = reactive.subscribe(callback1); 280 | let { unsubscribe: unsubscribe2 } = reactive.subscribe(callback2); 281 | await vi.waitUntil(() => list1 !== null && list2 !== null); 282 | 283 | expect(callback1).toHaveBeenCalledTimes(1); 284 | expect(callback2).toHaveBeenCalledTimes(1); 285 | expect(list1).toEqual(expectedList); 286 | expect(list2).toEqual(expectedList); 287 | expect(reactive.value.map(({ name }) => name)).toEqual(expectedList); 288 | 289 | // Inserting data should notify both subscribers 290 | await db1.sql`INSERT INTO groceries (name) VALUES ('apples'), ('oranges')`; 291 | expectedList = ['apples', 'oranges']; 292 | await vi.waitUntil(() => list1?.length === 2 && list2?.length === 2); 293 | 294 | expect(callback1).toHaveBeenCalledTimes(2); 295 | expect(callback2).toHaveBeenCalledTimes(2); 296 | expect(list1).toEqual(expectedList); 297 | expect(list2).toEqual(expectedList); 298 | expect(reactive.value.map(({ name }) => name)).toEqual(expectedList); 299 | 300 | // Unsubscribe 1 and make sure only the other subscriber is notified again 301 | unsubscribe1(); 302 | await db1.sql`INSERT INTO groceries (name) VALUES ('bananas')`; 303 | expectedList = ['apples', 'oranges', 'bananas']; 304 | await vi.waitUntil(() => list1?.length === 2 && list2?.length === 3); 305 | 306 | expect(callback1).toHaveBeenCalledTimes(2); 307 | expect(callback2).toHaveBeenCalledTimes(3); 308 | expect(list1).toEqual(['apples', 'oranges']); 309 | expect(list2).toEqual(expectedList); 310 | expect(reactive.value.map(({ name }) => name)).toEqual(expectedList); 311 | 312 | // Resubscribe the second subscription 313 | ({ unsubscribe: unsubscribe1 } = reactive.subscribe(callback1)); 314 | await vi.waitUntil(() => list1?.length === 3 && list2?.length === 3); 315 | 316 | expect(callback1).toHaveBeenCalledTimes(3); 317 | expect(callback2).toHaveBeenCalledTimes(3); 318 | expect(list1).toEqual(expectedList); 319 | expect(list2).toEqual(expectedList); 320 | expect(reactive.value.map(({ name }) => name)).toEqual(expectedList); 321 | 322 | // Make another data change 323 | await db1.sql`INSERT INTO groceries (name) VALUES ('grapes')`; 324 | expectedList = ['apples', 'oranges', 'bananas', 'grapes']; 325 | await vi.waitUntil(() => list1?.length === 4 && list2?.length === 4); 326 | 327 | expect(callback1).toHaveBeenCalledTimes(4); 328 | expect(callback2).toHaveBeenCalledTimes(4); 329 | expect(list1).toEqual(expectedList); 330 | expect(list2).toEqual(expectedList); 331 | expect(reactive.value.map(({ name }) => name)).toEqual(expectedList); 332 | 333 | // Unsubscribe 334 | unsubscribe1(); 335 | unsubscribe2(); 336 | }); 337 | 338 | it('should not emit uncommitted data changes', async () => { 339 | let currentStep = 0; 340 | const stepsEmitted: number[] = []; 341 | const dataEmitted: Record[][] = []; 342 | 343 | const { unsubscribe } = db1 344 | .reactiveQuery((sql) => sql`SELECT name FROM groceries`) 345 | .subscribe((data) => { 346 | stepsEmitted.push(currentStep); 347 | dataEmitted.push(data); 348 | }); 349 | 350 | await vi.waitUntil(() => stepsEmitted.length > 0); 351 | 352 | expect(stepsEmitted).toEqual([0]); 353 | expect(dataEmitted).toEqual([[]]); 354 | 355 | await db1.transaction(async (tx) => { 356 | currentStep = 1; 357 | await tx.sql`INSERT INTO groceries (name) VALUES ('apples')`; 358 | await sleep(50); 359 | 360 | currentStep = 2; 361 | await tx.sql`INSERT INTO groceries (name) VALUES ('oranges')`; 362 | await sleep(200); 363 | 364 | currentStep = 3; 365 | await tx.sql`INSERT INTO groceries (name) VALUES ('bananas')`; 366 | await sleep(100); 367 | 368 | currentStep = 4; 369 | }); 370 | 371 | await vi.waitUntil(() => stepsEmitted.length > 1); 372 | 373 | expect(stepsEmitted).toEqual([0, 4]); 374 | expect(dataEmitted).toEqual([ 375 | [], 376 | [{ name: 'apples' }, { name: 'oranges' }, { name: 'bananas' }], 377 | ]); 378 | 379 | unsubscribe(); 380 | }); 381 | 382 | it('should not emit rolled back data changes', async () => { 383 | let currentStep = 0; 384 | const stepsEmitted: number[] = []; 385 | const dataEmitted: Record[][] = []; 386 | 387 | const { unsubscribe } = db1 388 | .reactiveQuery((sql) => sql`SELECT name FROM groceries`) 389 | .subscribe((data) => { 390 | stepsEmitted.push(currentStep); 391 | dataEmitted.push(data); 392 | }); 393 | 394 | await vi.waitUntil(() => stepsEmitted.length > 0); 395 | 396 | expect(stepsEmitted).toEqual([0]); 397 | expect(dataEmitted).toEqual([[]]); 398 | 399 | await db1 400 | .transaction(async (tx) => { 401 | currentStep = 1; 402 | await tx.sql`INSERT INTO groceries (name) VALUES ('apples')`; 403 | await sleep(250); 404 | 405 | currentStep = 2; 406 | await tx.sql`INSERT INT groceries (name) VALUES ('oranges')`; 407 | await sleep(100); 408 | 409 | currentStep = 3; 410 | }) 411 | .catch(() => {}); 412 | 413 | await vi.waitUntil(() => stepsEmitted.length > 1); 414 | 415 | expect(stepsEmitted).toEqual([0, 2]); 416 | expect(dataEmitted).toEqual([[], []]); 417 | 418 | unsubscribe(); 419 | }); 420 | 421 | it('should require the reactive setting to be true', async () => { 422 | const db = new SQLocal({ databasePath: path }); 423 | const reactive = db.reactiveQuery((sql) => sql`SELECT * FROM foo`); 424 | 425 | expect(() => reactive.subscribe(() => {})).toThrowError(); 426 | 427 | await db.destroy(); 428 | }); 429 | 430 | it('should require SQL that reads a table and does not write to that same table', async () => { 431 | const callback = vi.fn(); 432 | const errors: Error[] = []; 433 | const testStatements = [ 434 | `SELECT 2 + 2`, 435 | `DELETE FROM todos WHERE title = ''`, 436 | `UPDATE todos SET title = 'To Do: ' || title`, 437 | `INSERT INTO todos SELECT * FROM todos`, 438 | ]; 439 | 440 | for (let testStatement of testStatements) { 441 | const { unsubscribe } = db1 442 | .reactiveQuery(() => ({ sql: testStatement, params: [] })) 443 | .subscribe(callback, (err) => errors.push(err)); 444 | await sleep(100); 445 | 446 | expect(errors.length).toBe(testStatements.indexOf(testStatement) + 1); 447 | unsubscribe(); 448 | } 449 | 450 | expect(callback).not.toHaveBeenCalled(); 451 | expect(errors.every((err) => err instanceof Error)).toBe(true); 452 | }); 453 | } 454 | ); 455 | -------------------------------------------------------------------------------- /test/sql.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { testVariation } from './test-utils/test-variation.js'; 4 | 5 | describe.each(testVariation('sql'))('sql ($type)', ({ path }) => { 6 | const { sql } = new SQLocal(path); 7 | 8 | beforeEach(async () => { 9 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 10 | }); 11 | 12 | afterEach(async () => { 13 | await sql`DROP TABLE groceries`; 14 | }); 15 | 16 | it('should execute queries', async () => { 17 | const items = ['bread', 'milk', 'rice']; 18 | for (let item of items) { 19 | const insert1 = 20 | await sql`INSERT INTO groceries (name) VALUES (${item}) RETURNING name`; 21 | expect(insert1).toEqual([{ name: item }]); 22 | } 23 | 24 | const select1 = await sql`SELECT * FROM groceries`; 25 | expect(select1).toEqual([ 26 | { id: 1, name: 'bread' }, 27 | { id: 2, name: 'milk' }, 28 | { id: 3, name: 'rice' }, 29 | ]); 30 | 31 | const multiSelect1 = 32 | await sql`SELECT * FROM groceries WHERE id = ${3}; SELECT * FROM groceries WHERE id = 2;`; 33 | expect(multiSelect1).toEqual([{ id: 3, name: 'rice' }]); 34 | 35 | const multiSelect2 = async () => 36 | await sql`SELECT * FROM groceries WHERE id = ${3}; SELECT * FROM groceries WHERE id = ${2};`; 37 | await expect(multiSelect2).rejects.toThrow(); 38 | 39 | const delete1 = await sql`DELETE FROM groceries WHERE id = 2 RETURNING *`; 40 | expect(delete1).toEqual([{ id: 2, name: 'milk' }]); 41 | 42 | const update1 = 43 | await sql`UPDATE groceries SET name = 'white rice' WHERE id = 3 RETURNING name`; 44 | expect(update1).toEqual([{ name: 'white rice' }]); 45 | 46 | const select2 = await sql`SELECT name FROM groceries ORDER BY id DESC`; 47 | expect(select2).toEqual([{ name: 'white rice' }, { name: 'bread' }]); 48 | 49 | const sqlStr = 'SELECT name FROM groceries WHERE id = ?'; 50 | const select3 = await sql(sqlStr, 1); 51 | expect(select3).toEqual([{ name: 'bread' }]); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/test-utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(durationMs: number): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(); 5 | }, durationMs); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /test/test-utils/test-variation.ts: -------------------------------------------------------------------------------- 1 | import type { Sqlite3StorageType } from '../../src/types.js'; 2 | 3 | export function testVariation( 4 | testSuiteKey: string 5 | ): { type: Sqlite3StorageType; path: string }[] { 6 | return [ 7 | { type: 'opfs', path: `${testSuiteKey}-test.sqlite3` }, 8 | { type: 'memory', path: ':memory:' }, 9 | { type: 'local', path: ':localStorage:' }, 10 | { type: 'session', path: ':sessionStorage:' }, 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /test/transaction.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { SQLocal } from '../src/index.js'; 3 | import { sleep } from './test-utils/sleep.js'; 4 | import { testVariation } from './test-utils/test-variation.js'; 5 | 6 | describe.each(testVariation('transaction'))( 7 | 'transaction ($type)', 8 | ({ path }) => { 9 | const { sql, batch, transaction } = new SQLocal(path); 10 | 11 | beforeEach(async () => { 12 | await sql`CREATE TABLE groceries (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`; 13 | await sql`CREATE TABLE prices (id INTEGER PRIMARY KEY AUTOINCREMENT, groceryId INTEGER NOT NULL, price REAL NOT NULL)`; 14 | }); 15 | 16 | afterEach(async () => { 17 | await sql`DROP TABLE groceries`; 18 | await sql`DROP TABLE prices`; 19 | }); 20 | 21 | it('should perform successful transaction', async () => { 22 | const productName = 'rice'; 23 | const productPrice = 2.99; 24 | 25 | const newProductId = await transaction(async (tx) => { 26 | const [product] = await tx.sql` 27 | INSERT INTO groceries (name) VALUES (${productName}) RETURNING * 28 | `; 29 | await tx.sql` 30 | INSERT INTO prices (groceryId, price) VALUES (${product.id}, ${productPrice}) 31 | `; 32 | return product.id; 33 | }); 34 | 35 | expect(newProductId).toBe(1); 36 | 37 | const selectData1 = await sql`SELECT * FROM groceries`; 38 | expect(selectData1.length).toBe(1); 39 | const selectData2 = await sql`SELECT * FROM prices`; 40 | expect(selectData2.length).toBe(1); 41 | }); 42 | 43 | it('should rollback failed transaction', async () => { 44 | const txData = await transaction(async (tx) => { 45 | await tx.sql`INSERT INTO groceries (name) VALUES ('carrots')`; 46 | await tx.sql`INSERT INT groceries (name) VALUES ('lettuce')`; 47 | return true; 48 | }).catch(() => false); 49 | 50 | expect(txData).toEqual(false); 51 | 52 | const selectData = await sql`SELECT * FROM groceries`; 53 | expect(selectData.length).toBe(0); 54 | }); 55 | 56 | it('should isolate transaction mutations from outside queries', async () => { 57 | const order: number[] = []; 58 | 59 | await Promise.all([ 60 | transaction(async (tx) => { 61 | order.push(1); 62 | await tx.sql`INSERT INTO groceries (name) VALUES ('a')`; 63 | await sleep(200); 64 | order.push(3); 65 | await tx.sql`INSERT INTO groceries (name) VALUES ('b')`; 66 | }), 67 | (async () => { 68 | await sleep(100); 69 | order.push(2); 70 | await sql`UPDATE groceries SET name = 'x'`; 71 | })(), 72 | ]); 73 | 74 | const data = await sql`SELECT name FROM groceries`; 75 | 76 | expect(data).toEqual([{ name: 'x' }, { name: 'x' }]); 77 | expect(order).toEqual([1, 2, 3]); 78 | }); 79 | 80 | it('should isolate transaction mutations from outside batch queries', async () => { 81 | const order: number[] = []; 82 | 83 | await Promise.all([ 84 | transaction(async (tx) => { 85 | order.push(1); 86 | await tx.sql`INSERT INTO groceries (name) VALUES ('a')`; 87 | await sleep(200); 88 | order.push(3); 89 | await tx.sql`INSERT INTO groceries (name) VALUES ('b')`; 90 | }), 91 | (async () => { 92 | await sleep(100); 93 | order.push(2); 94 | await batch((sql) => [sql`UPDATE groceries SET name = 'x'`]); 95 | })(), 96 | ]); 97 | 98 | const data = await sql`SELECT name FROM groceries`; 99 | 100 | expect(data).toEqual([{ name: 'x' }, { name: 'x' }]); 101 | expect(order).toEqual([1, 2, 3]); 102 | }); 103 | 104 | it('should complete concurrent transactions', async () => { 105 | const transactions = Promise.all([ 106 | transaction(async (tx) => { 107 | await tx.sql`INSERT INTO groceries (name) VALUES ('a') RETURNING name`; 108 | await sleep(100); 109 | return tx.sql`SELECT name FROM groceries`; 110 | }), 111 | transaction(async (tx) => { 112 | await sleep(50); 113 | return tx.sql`INSERT INTO groceries (name) VALUES ('b') RETURNING name`; 114 | }), 115 | ]); 116 | 117 | await expect(transactions).resolves.toEqual([ 118 | [{ name: 'a' }], 119 | [{ name: 'b' }], 120 | ]); 121 | 122 | const data = await sql`SELECT name FROM groceries`; 123 | expect(data).toEqual([{ name: 'a' }, { name: 'b' }]); 124 | }); 125 | } 126 | ); 127 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["test"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "lib": ["ES2020", "WebWorker"], 6 | 7 | /* Building */ 8 | "moduleResolution": "NodeNext", 9 | "moduleDetection": "force", 10 | "esModuleInterop": true, 11 | "useDefineForClassFields": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "sourceMap": true, 16 | "declaration": true, 17 | "outDir": "./dist", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "skipLibCheck": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noImplicitOverride": true, 26 | "verbatimModuleSyntax": true 27 | }, 28 | "include": ["src", "test"], 29 | "exclude": ["**/node_modules/**", "dist"] 30 | } 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | testTimeout: 1000, 8 | hookTimeout: 1000, 9 | teardownTimeout: 1000, 10 | includeTaskLocation: true, 11 | browser: { 12 | enabled: true, 13 | headless: true, 14 | screenshotFailures: false, 15 | provider: 'webdriverio', 16 | instances: [ 17 | { browser: 'chrome', headless: true }, 18 | { browser: 'safari', headless: false }, 19 | ], 20 | }, 21 | }, 22 | optimizeDeps: { 23 | exclude: ['@sqlite.org/sqlite-wasm'], 24 | }, 25 | plugins: [ 26 | { 27 | enforce: 'pre', 28 | name: 'configure-response-headers', 29 | configureServer: (server) => { 30 | server.middlewares.use((_req, res, next) => { 31 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); 32 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); 33 | next(); 34 | }); 35 | }, 36 | }, 37 | ], 38 | }); 39 | --------------------------------------------------------------------------------