acrylblends 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +193 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/README.md +2 -1
  4. package/docs/framework/UPSTREAM.md +1 -1
  5. package/docs/framework/getting-started/deploy.md +5 -5
  6. package/docs/framework/getting-started/service-worker.md +5 -3
  7. package/docs/framework/guides/create-acrylblends.md +2 -2
  8. package/docs/framework/guides/data.md +237 -0
  9. package/docs/framework/guides/forms-and-sessions.md +34 -30
  10. package/docs/framework/upstream.json +3 -3
  11. package/lib/db/sqlite.d.ts +27 -0
  12. package/lib/db/sqlite.js +197 -0
  13. package/lib/db.d.ts +2 -0
  14. package/lib/db.js +2 -0
  15. package/lib/index-D4FAsBZu.d.ts +85 -0
  16. package/lib/src-MWcsMS8i.js +85 -0
  17. package/package.json +9 -1
  18. package/plugins/acryl-db/LICENSE +47 -0
  19. package/plugins/acryl-db/plugin.json +12 -0
  20. package/plugins/acryl-db/src/cordis.ts +13 -0
  21. package/plugins/acryl-db/src/facade/database.ts +41 -0
  22. package/plugins/acryl-db/src/facade/migrations.ts +50 -0
  23. package/plugins/acryl-db/src/facade/schema.ts +9 -0
  24. package/plugins/acryl-db/src/index.ts +19 -0
  25. package/plugins/acryl-db/src/migration-files.ts +31 -0
  26. package/plugins/acryl-db/upstream.json +18 -0
  27. package/plugins/acryl-db-sqlite/LICENSE +47 -0
  28. package/plugins/acryl-db-sqlite/plugin.json +13 -0
  29. package/plugins/acryl-db-sqlite/src/facade/node.ts +199 -0
  30. package/plugins/acryl-db-sqlite/src/index.ts +88 -0
  31. package/plugins/acryl-db-sqlite/src/open.ts +61 -0
  32. package/plugins/acryl-db-sqlite/upstream.json +21 -0
  33. package/plugins/acryl-inspect/upstream.json +1 -1
  34. package/plugins/acryl-node-server/upstream.json +1 -1
  35. package/plugins/acryl-router/upstream.json +1 -1
  36. package/plugins/acryl-router-http/upstream.json +1 -1
  37. package/plugins/acryl-state/upstream.json +1 -1
  38. package/plugins/desktop/upstream.json +1 -1
  39. package/plugins/desktop-web/main.ts +13 -3
  40. package/plugins/desktop-web/upstream.json +1 -1
  41. package/plugins/tsconfig.json +1 -0
  42. package/templates/todo/upstream.json +1 -1
@@ -17,15 +17,17 @@ fire(app)
17
17
 
18
18
  A request the app does not answer (`404`) can be passed on to the network by giving `fetch` as an option, so a Service Worker can serve some routes and leave the others to the origin:
19
19
 
20
- ```ts no-check
20
+ ```ts lib=webworker
21
21
  import { Acryl } from 'acrylblends'
22
22
  import { handle } from 'acrylblends/service-worker'
23
23
 
24
+ declare const self: ServiceWorkerGlobalScope
25
+
24
26
  const app = new Acryl()
25
27
 
26
28
  app.get('/offline', (c) => c.text('Served by the Service Worker'))
27
29
 
28
- addEventListener('fetch', handle(app, { fetch: globalThis.fetch }))
30
+ self.addEventListener('fetch', handle(app, { fetch: globalThis.fetch }))
29
31
  ```
30
32
 
31
- `app.fire()` (a method of the app) is deprecated in favour of `fire()` from this module.
33
+ The example is type-checked with the TypeScript `WebWorker` library: declare `self` as a `ServiceWorkerGlobalScope` as above. `app.fire()` (a method of the app) is deprecated in favour of `fire()` from this module.
@@ -8,8 +8,8 @@
8
8
  | Template | What you get |
9
9
  | --- | --- |
10
10
  | `nodejs` | an Acryl application served with `serve()` from `acrylblends/node-server`; run with Node.js 22.19 or newer |
11
- | `notes` | a full-stack starter: accounts (scrypt, cookie session), notes in SQLite (`node:sqlite`), validated forms, search, a JSON API with a typed test client, csrf, a test for every route, a client-component counter |
12
- | `chat` | an AI chat starter: the answer streams token by token from an OpenAI-compatible API (DeepSeek by default, key only from `DEEPSEEK_API_KEY`), a client component with a Stop button that also stops the request to the model, history in SQLite, tests with a mock model |
11
+ | `notes` | a full-stack starter: accounts (scrypt, cookie session), notes in SQLite through the [`db` port](data.md) with numbered migrations, validated forms, search, a JSON API with a typed test client, csrf, a test for every route, a client-component counter |
12
+ | `chat` | an AI chat starter: the answer streams token by token from an OpenAI-compatible API (DeepSeek by default, key only from `DEEPSEEK_API_KEY`), a client component with a Stop button that also stops the request to the model, history in SQLite through the [`db` port](data.md), tests with a mock model |
13
13
  | `bun` (experimental) | an Acryl application with `export default app`, run with `bun` |
14
14
  | `acryl-app` | a Cordis app built from plugins, with an agent that can extend it while it runs (the same app `npx acrylblends init --template agent` creates) |
15
15
 
@@ -0,0 +1,237 @@
1
+ # Data: the `db` port, migrations and choosing a store
2
+
3
+ An app that keeps records (users, notes, orders) keeps them in a SQL database, and reaches it through one small interface, `db`. Handlers and
4
+ stores take a `Db` as an argument and never import a driver, so the file or server behind it can change without touching them. SQLite is the
5
+ provider today; a Postgres provider (Postgres or Supabase through `DATABASE_URL`) is planned and serves the same interface.
6
+
7
+ ## Choosing
8
+
9
+ | The app | Use | State |
10
+ | --- | --- | --- |
11
+ | Normal web or desktop app, users, lists, orders | `db` with SQLite (the `notes` and `chat` templates) | available |
12
+ | Hosted, many users | `db` with Postgres or Supabase | planned |
13
+ | Tiny, one person, a few screens | `kv`, named JSON documents with no SQL | planned |
14
+ | Agents or long tasks that must survive a restart | `durable`, in the same database as `db` | planned |
15
+
16
+ Start with `db`. It is the only one of the four you can use today, and SQL is what people and coding agents already know.
17
+
18
+ ## Open the database
19
+
20
+ `openDb` from `acrylblends/db/sqlite` opens one SQLite file, applies your migrations, and returns the database next to the function that closes it:
21
+
22
+ ```ts
23
+ import { openDb } from 'acrylblends/db/sqlite'
24
+
25
+ const { db, close } = await openDb({ path: 'data/app.sqlite', migrations: 'migrations' })
26
+ ```
27
+
28
+ - `path` is the file, created with its folder when it does not exist. `':memory:'` opens a database that lives and dies with the process, which
29
+ is what tests use.
30
+ - `migrations` is a folder of numbered SQL files, relative to the working directory (see below).
31
+ - `db` is the port: `exec`, `run`, `get`, `all` and `transaction`, every one returning a promise. It has no `close`, so no part of the app can
32
+ close the database another part is using. `close` is the opener's: call it once, when the process stops.
33
+
34
+ Pass `db` to the code that needs it. Nothing opens the database at import time, so importing a module never creates a file:
35
+
36
+ ```ts
37
+ import type { Db } from 'acrylblends/db'
38
+
39
+ export function notesStore(db: Db) {
40
+ return {
41
+ async titles(userId: number): Promise<string[]> {
42
+ const rows = await db.all<{ title: string }>(
43
+ 'SELECT title FROM notes WHERE user_id = ? ORDER BY updated_at DESC',
44
+ userId,
45
+ )
46
+ return rows.map((row) => row.title)
47
+ },
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Migrations
53
+
54
+ The shape of the database lives in `migrations/`, one numbered file per change:
55
+
56
+ ```
57
+ migrations/
58
+ 001-notes.sql
59
+ 002-tags.sql
60
+ ```
61
+
62
+ ```sql
63
+ -- migrations/002-tags.sql
64
+ CREATE TABLE tags (
65
+ note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
66
+ name TEXT NOT NULL,
67
+ PRIMARY KEY (note_id, name)
68
+ );
69
+ ```
70
+
71
+ - Files are named `NNN-name.sql` and numbered from 1 with no gaps. A gap, a repeated number or a file named otherwise stops the app at start
72
+ with a message that names it.
73
+ - On start, `openDb` applies the files that have not run yet, in order, **in one transaction**: a failing file leaves the database as it was.
74
+ - The version is recorded in the `_migrations` table. Do not touch that table.
75
+ - **Never edit a file that has been applied.** A database that already ran `001` does not run it again, so an edit changes only new databases.
76
+ To change a table, add the next file (`ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`).
77
+ - A database whose version is newer than your files (an older build started on newer data) is refused. The app does not start.
78
+ - A migration is plain SQL. Put the data change next to the shape change in the same file when they belong together.
79
+
80
+ To add a feature that needs a column or a table, the work is always the same three steps: write the next migration, change the store that
81
+ reads and writes it, and change the handlers and views that use the store.
82
+
83
+ ## Reading and writing
84
+
85
+ ```ts
86
+ // One row, or undefined.
87
+ const note = await db.get<Note>('SELECT * FROM notes WHERE id = ? AND user_id = ?', id, userId)
88
+
89
+ // Many rows.
90
+ const notes = await db.all<Note>('SELECT * FROM notes WHERE user_id = ? ORDER BY id', userId)
91
+
92
+ // A write: how many rows it changed.
93
+ const { changes } = await db.run('DELETE FROM notes WHERE id = ? AND user_id = ?', id, userId)
94
+ if (changes === 0) return c.notFound()
95
+
96
+ // A write that gives back the row it made.
97
+ const created = await db.get<Note>(
98
+ 'INSERT INTO notes (user_id, title, body, updated_at) VALUES (?, ?, ?, ?) RETURNING *',
99
+ userId, title, body, new Date().toISOString(),
100
+ )
101
+ ```
102
+
103
+ - Values go in as **bindings** after the SQL (`?`, in order), never inside the SQL text. This is what keeps a title like `'); DROP TABLE notes;--`
104
+ a title. A binding is `null`, a number, a bigint, a string or a `Uint8Array`; turn a boolean into `0` or `1` and a date into
105
+ `toISOString()` yourself.
106
+ - `exec(sql)` runs SQL text with no bindings, and may hold several statements. Use it for setup, not for user input.
107
+ - `db.get` has no `lastInsertRowid`. To get the new row or its id, end the insert with `RETURNING *` or `RETURNING id`.
108
+ - `changes` is what makes "update only if nothing changed meanwhile" possible: `UPDATE ... WHERE id = ? AND version = ?` and check `changes`.
109
+
110
+ ## Transactions
111
+
112
+ Changes that must happen together go in `transaction`. Everything inside goes through the handle the callback receives:
113
+
114
+ ```ts
115
+ await db.transaction(async (tx) => {
116
+ const { changes } = await tx.run('UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?', amount, from, amount)
117
+ if (changes === 0) throw new Error('not enough money')
118
+ await tx.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', amount, to)
119
+ })
120
+ ```
121
+
122
+ - If the callback throws, the transaction is rolled back and `transaction` rejects with that same error.
123
+ - Other calls, and other transactions, wait until the transaction finishes. So **never call `db` itself inside the callback**, only `tx`: that
124
+ call would wait for a transaction that waits for it, and never return.
125
+ - The handle stops working when the callback settles. Do not keep it.
126
+
127
+ ## Writing SQL that moves to Postgres
128
+
129
+ The Postgres provider is not released yet, and it will serve the same interface. SQL that stays inside this list runs on both:
130
+
131
+ - Placeholders are `?` on every provider.
132
+ - Use `RETURNING` to get a new row, not a driver-specific "last id".
133
+ - Keep dates as ISO 8601 text (`new Date().toISOString()`), and booleans as `0` and `1`.
134
+ - Keep identifiers lower case and unquoted.
135
+ - `INTEGER PRIMARY KEY` makes an automatically numbered key in SQLite. The identity column of another database is spelled differently, so keep key
136
+ definitions in migration files, where a different provider's version of the file can differ.
137
+ - SQLite lets a column hold a value of another type than it declares; most other databases do not. Store what you declare.
138
+
139
+ ## In tests
140
+
141
+ A test opens its own database in memory, with the same migrations as production, and closes it:
142
+
143
+ ```ts
144
+ import { afterEach, beforeEach, expect, test } from 'vitest'
145
+ import { openDb } from 'acrylblends/db/sqlite'
146
+ import { notesStore } from './db.ts'
147
+
148
+ let close: () => Promise<void>
149
+ let store: ReturnType<typeof notesStore>
150
+
151
+ beforeEach(async () => {
152
+ const opened = await openDb({ path: ':memory:', migrations: 'migrations' })
153
+ close = opened.close
154
+ store = notesStore(opened.db)
155
+ })
156
+ afterEach(() => close())
157
+
158
+ test('a new store has no notes', async () => {
159
+ expect(await store.titles(1)).toEqual([])
160
+ })
161
+ ```
162
+
163
+ ## In production
164
+
165
+ Open the database once at start, serve, and close it when the process is told to stop, after the server has stopped taking requests:
166
+
167
+ ```ts
168
+ import { serve } from 'acrylblends/node-server'
169
+ import { openDb } from 'acrylblends/db/sqlite'
170
+ import { createApp } from './app.ts'
171
+
172
+ const { db, close } = await openDb({ path: process.env.DATABASE_FILE ?? 'data/app.sqlite', migrations: 'migrations' })
173
+ const server = serve({ fetch: createApp(db).fetch, port: Number(process.env.PORT ?? 3000) })
174
+
175
+ process.once('SIGTERM', async () => {
176
+ server.close()
177
+ await close()
178
+ })
179
+ ```
180
+
181
+ `close` waits for the calls and the transaction that are running, flushes the write-ahead log into the main file, and releases the file, so a
182
+ copy of `data/app.sqlite` made afterwards is complete. Keep the file on a disk that outlives a deploy; on a host with only an ephemeral disk the
183
+ data goes with the machine.
184
+
185
+ ## Why the database is opened in one place
186
+
187
+ Only one file of an app opens the database: the **opener** (`src/db.ts` in the templates). Every other file receives a `Db` as an argument and
188
+ imports its type from `acrylblends/db`. Nothing outside the opener imports `node:sqlite`, `pg`, `postgres`, `@electric-sql/pglite` or another
189
+ driver, or `acrylblends/db/sqlite` itself.
190
+
191
+ Why it matters:
192
+
193
+ - **Changing the database is one file.** Moving from SQLite to Postgres changes what the opener calls, and no route, store or test.
194
+ - **A test opens its own database.** Code that opens a database by itself cannot be given an in-memory one, and a test that reaches the real file
195
+ breaks the next one.
196
+ - **There is one place to close it.** The code that opened a database closes it; the port has no `close`, so a route cannot do it by mistake.
197
+ - **An agent that adds a feature keeps the shape.** A coding agent asked for "tags" writes a migration and a store function, and has no reason to
198
+ reach for a driver when the interface is in front of it.
199
+
200
+ The rule is checked, not hoped for. The app names its opener once, in `package.json`:
201
+
202
+ ```json
203
+ {
204
+ "acryl": { "opener": "src/db.ts" }
205
+ }
206
+ ```
207
+
208
+ `acrylblends check-deps` reads every `.ts`, `.tsx` and `.js` file in `src/`, `test/`, `tests/` and `extensions/`, and fails when a file other than
209
+ the opener imports a database driver, an opener such as `acrylblends/db/sqlite` or `acryl-db-sqlite`, or (outside `src/plugins/desktop`) `electron`.
210
+ It names the file and the line:
211
+
212
+ ```
213
+ src/routes.ts:3: "node:sqlite" is a database driver; take a Db (import type { Db } from "acrylblends/db") as an argument and let src/db.ts open the database
214
+ ```
215
+
216
+ The `notes` and `chat` templates run it in `npm test` and `npm run check`. List more than one opener as an array (`"opener": ["src/db.ts", "src/app.ts"]`).
217
+ In a Blend app, `acrylblends add db` declares `src/app.ts`, where the provider is mounted, and a plugin that declares `inject: ['db']` must not import
218
+ `acryl-db-sqlite`: it receives `ctx.db`.
219
+
220
+ ## In a Cordis app
221
+
222
+ An ACRYL app gets the same interface as a service. The `acryl-db-sqlite` plugin row opens the file (by default `data.sqlite` in the app home),
223
+ applies the `migrations` folder, and provides `db`; a plugin declares `inject: ['db']` and imports the types only:
224
+
225
+ ```ts
226
+ import type { Context } from '@deepseek-ai/cordis'
227
+ import type {} from 'acryl-db'
228
+
229
+ export const name = 'notes'
230
+ export const inject = ['db']
231
+
232
+ export function apply(ctx: Context) {
233
+ void ctx.db.run('INSERT INTO notes (title) VALUES (?)', 'first')
234
+ }
235
+ ```
236
+
237
+ The plugin never imports a driver, so replacing the SQLite row by another provider is a change to the configuration.
@@ -3,31 +3,34 @@
3
3
  This page shows the parts a small multi-user app needs, in the order an app uses them: a database, passwords, a cookie session, HTML forms with server-side validation, and a JSON API that shares the same rules.
4
4
  It is the shape of a "Notes" app built from these docs alone (sign up, log in, create, edit, delete and search notes, a typed test client for the API).
5
5
 
6
- ## A database with `node:sqlite`
7
-
8
- Node.js 22.19 and later include SQLite, so there is nothing to install. `DatabaseSync` is synchronous, which suits a small app.
6
+ ## A database with the `db` port
7
+
8
+ `openDb` from `acrylblends/db/sqlite` opens a SQLite file (Node.js 22.19 and later include SQLite, so there is nothing to install), applies the SQL files in `migrations/`, and returns `{ db, close }`.
9
+ Every call on `db` returns a promise, so `await` it. The [data guide](data.md) has the whole story: migrations, transactions, tests and shutdown.
10
+
11
+ ```sql
12
+ -- migrations/001-notes.sql
13
+ CREATE TABLE users (
14
+ id INTEGER PRIMARY KEY,
15
+ email TEXT NOT NULL UNIQUE,
16
+ password_hash TEXT NOT NULL
17
+ );
18
+
19
+ CREATE TABLE sessions (
20
+ token TEXT PRIMARY KEY,
21
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
22
+ );
23
+ ```
9
24
 
10
25
  ```ts
11
- import { DatabaseSync } from 'node:sqlite'
12
-
13
- export function openDb(path = process.env.NOTES_DB ?? 'notes.db'): DatabaseSync {
14
- const db = new DatabaseSync(path)
15
- db.exec(`
16
- CREATE TABLE IF NOT EXISTS users (
17
- id INTEGER PRIMARY KEY,
18
- email TEXT NOT NULL UNIQUE,
19
- password_hash TEXT NOT NULL
20
- );
21
- CREATE TABLE IF NOT EXISTS sessions (
22
- token TEXT PRIMARY KEY,
23
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
24
- );
25
- `)
26
- return db
26
+ import { openDb } from 'acrylblends/db/sqlite'
27
+
28
+ export function openNotesDb(path = process.env.NOTES_DB ?? 'data/notes.sqlite') {
29
+ return openDb({ path, migrations: 'migrations' })
27
30
  }
28
31
  ```
29
32
 
30
- Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then passes `openDb(':memory:')`, and importing the app module does not create a file.
33
+ Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then opens `openNotesDb(':memory:')`, and importing the app module does not create a file.
31
34
 
32
35
  ## Passwords with `node:crypto`
33
36
 
@@ -63,20 +66,21 @@ import { Acryl } from 'acrylblends'
63
66
  import { createMiddleware } from 'acrylblends/factory'
64
67
  import { deleteCookie, getCookie, setCookie } from 'acrylblends/cookie'
65
68
  import { randomBytes } from 'node:crypto'
66
- import type { DatabaseSync } from 'node:sqlite'
69
+ import type { Db } from 'acrylblends/db'
67
70
 
68
71
  type Env = { Variables: { user: { id: number; email: string } } }
69
72
 
70
- export function createApp(db: DatabaseSync) {
73
+ export function createApp(db: Db) {
71
74
  const userFor = (token: string | undefined) =>
72
75
  token
73
- ? (db
74
- .prepare('SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?')
75
- .get(token) as { id: number; email: string } | undefined)
76
+ ? db.get<{ id: number; email: string }>(
77
+ 'SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?',
78
+ token
79
+ )
76
80
  : undefined
77
81
 
78
82
  const requireUser = createMiddleware<Env>(async (c, next) => {
79
- const user = userFor(getCookie(c, 'sid'))
83
+ const user = await userFor(getCookie(c, 'sid'))
80
84
  if (!user) return c.redirect('/login')
81
85
  c.set('user', user)
82
86
  await next()
@@ -87,16 +91,16 @@ export function createApp(db: DatabaseSync) {
87
91
  app.use('/notes/*', requireUser)
88
92
  app.get('/notes', (c) => c.text(`Hello ${c.get('user').email}`))
89
93
 
90
- app.post('/login-example', (c) => {
94
+ app.post('/login-example', async (c) => {
91
95
  const token = randomBytes(32).toString('hex')
92
- db.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, 1)
96
+ await db.run('INSERT INTO sessions (token, user_id) VALUES (?, ?)', token, 1)
93
97
  setCookie(c, 'sid', token, { path: '/', httpOnly: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30 })
94
98
  return c.redirect('/notes', 303)
95
99
  })
96
100
 
97
- app.post('/logout', (c) => {
101
+ app.post('/logout', async (c) => {
98
102
  const token = getCookie(c, 'sid')
99
- if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
103
+ if (token) await db.run('DELETE FROM sessions WHERE token = ?', token)
100
104
  deleteCookie(c, 'sid', { path: '/' })
101
105
  return c.redirect('/login', 303)
102
106
  })
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "eada6fb06a5cafb6c2c92a97effeba567dbd44ba"
5
+ "commit": "8e23e14336647943d4232047e3ea253e434535d3"
6
6
  },
7
- "files": 98,
8
- "digest": "sha256:396f882ac7b612455edc59961c52472fadc5f95ea1a778208e7edd860e4e6727"
7
+ "files": 99,
8
+ "digest": "sha256:2ff7cb6e36f1a04caece3bc14736ff23fd6eb2f86752453255973344e3344a0a"
9
9
  }
@@ -0,0 +1,27 @@
1
+ import { c as DbExecutor, l as SqlValue, s as Db } from "../index-D4FAsBZu.js";
2
+
3
+ //#region ../../plugins/acryl-db-sqlite/src/open.d.ts
4
+ interface OpenDbOptions {
5
+ /** The database file, its folder created if missing; `:memory:` for a database that lives until `close`. */
6
+ readonly path: string;
7
+ /** A folder of `NNN-name.sql` files numbered from 1, as a path or a file URL, applied in order before `openDb` resolves. */
8
+ readonly migrations?: string | URL;
9
+ /** How long SQLite waits for a competing file lock, in milliseconds. Defaults to 5000. */
10
+ readonly busyTimeoutMs?: number;
11
+ /** The WAL auto-checkpoint threshold in pages; 0 disables it. Defaults to 1000. */
12
+ readonly walAutoCheckpointPages?: number;
13
+ }
14
+ /** An open database: the port, and the one function that closes it. */
15
+ interface OpenDb {
16
+ readonly db: Db;
17
+ /** Waits for running operations and transactions, checkpoints the WAL and releases the file. Calling it again does nothing. */
18
+ close(): Promise<void>;
19
+ }
20
+ /**
21
+ * Open, configure and migrate a SQLite database.
22
+ * @throws when the migration files are misnamed or misnumbered, or a migration fails (the file is closed, and unchanged), or the
23
+ * database's schema is newer than the migrations.
24
+ */
25
+ declare function openDb(options: OpenDbOptions): Promise<OpenDb>;
26
+ //#endregion
27
+ export { type Db, type DbExecutor, OpenDb, OpenDbOptions, type SqlValue, openDb };
@@ -0,0 +1,197 @@
1
+ import { i as applyMigrations, n as MIGRATIONS_TABLE, r as readMigrationFiles } from "../src-MWcsMS8i.js";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ //#region ../../plugins/acryl-db-sqlite/src/facade/node.ts
6
+ const DEFAULT_WAL_AUTO_CHECKPOINT_PAGES = 1e3;
7
+ const DEFAULT_BUSY_TIMEOUT_MS = 5e3;
8
+ const ignore = () => {};
9
+ /**
10
+ * Runs operations in call order. An operation starts immediately when nothing is running or waiting;
11
+ * otherwise it waits for everything before it. An asynchronous operation holds the queue until it settles.
12
+ */
13
+ var SerialOperationQueue = class {
14
+ tail = Promise.resolve();
15
+ pending = 0;
16
+ run(operation) {
17
+ if (this.pending > 0) return this.enqueue(operation);
18
+ try {
19
+ return Promise.resolve(operation());
20
+ } catch (error) {
21
+ return Promise.reject(error);
22
+ }
23
+ }
24
+ runAsync(operation) {
25
+ if (this.pending > 0) return this.enqueue(operation);
26
+ this.pending++;
27
+ const { promise: barrier, resolve: releaseBarrier } = Promise.withResolvers();
28
+ this.tail = barrier;
29
+ let started;
30
+ try {
31
+ started = operation();
32
+ } catch (error) {
33
+ started = Promise.reject(error);
34
+ }
35
+ return started.finally(() => {
36
+ this.pending--;
37
+ releaseBarrier();
38
+ });
39
+ }
40
+ enqueue(operation) {
41
+ this.pending++;
42
+ return this.release(this.tail.then(operation));
43
+ }
44
+ release(operation) {
45
+ const settled = operation.finally(() => {
46
+ this.pending--;
47
+ });
48
+ this.tail = settled.then(ignore, ignore);
49
+ return settled;
50
+ }
51
+ };
52
+ /**
53
+ * Executes SQL on one connection. Prepared statements are cached per connection by SQL text, so the
54
+ * database and its transaction handles share them across transactions.
55
+ */
56
+ var NodeSqliteExecutor = class {
57
+ database;
58
+ statements;
59
+ constructor(database, statements) {
60
+ this.database = database;
61
+ this.statements = statements;
62
+ }
63
+ exec(sql) {
64
+ return this.runOperation(() => {
65
+ this.database.exec(sql);
66
+ });
67
+ }
68
+ run(sql, ...params) {
69
+ return this.runOperation(() => ({ changes: Number(this.statement(sql).run(...params).changes) }));
70
+ }
71
+ get(sql, ...params) {
72
+ return this.runOperation(() => this.statement(sql).get(...params));
73
+ }
74
+ all(sql, ...params) {
75
+ return this.runOperation(() => this.statement(sql).all(...params));
76
+ }
77
+ statement(sql) {
78
+ let statement = this.statements.get(sql);
79
+ if (statement === void 0) {
80
+ statement = this.database.prepare(sql);
81
+ this.statements.set(sql, statement);
82
+ }
83
+ return statement;
84
+ }
85
+ };
86
+ var NodeSqliteTransaction = class extends NodeSqliteExecutor {
87
+ scope;
88
+ constructor(database, statements, scope) {
89
+ super(database, statements);
90
+ this.scope = scope;
91
+ }
92
+ async runOperation(operation) {
93
+ if (!this.scope.active) throw new Error("SQLite transaction handle is no longer active");
94
+ return operation();
95
+ }
96
+ };
97
+ /** `Db` adapter backed by Node's built-in `node:sqlite`. */
98
+ var NodeSqliteDatabase = class extends NodeSqliteExecutor {
99
+ access = new SerialOperationQueue();
100
+ closed = false;
101
+ constructor(database) {
102
+ super(database, /* @__PURE__ */ new Map());
103
+ }
104
+ transaction(callback) {
105
+ return this.access.runAsync(async () => {
106
+ this.database.exec("BEGIN IMMEDIATE");
107
+ const scope = { active: true };
108
+ try {
109
+ const result = await callback(new NodeSqliteTransaction(this.database, this.statements, scope));
110
+ scope.active = false;
111
+ this.database.exec("COMMIT");
112
+ return result;
113
+ } catch (error) {
114
+ scope.active = false;
115
+ try {
116
+ this.database.exec("ROLLBACK");
117
+ } catch (rollbackError) {
118
+ throw new AggregateError([error, rollbackError], "SQLite transaction failed and rollback failed");
119
+ }
120
+ throw error;
121
+ }
122
+ });
123
+ }
124
+ close() {
125
+ return this.access.run(() => {
126
+ if (this.closed) return;
127
+ this.closed = true;
128
+ this.statements.clear();
129
+ try {
130
+ this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
131
+ } finally {
132
+ this.database.close();
133
+ }
134
+ });
135
+ }
136
+ runOperation(operation) {
137
+ return this.access.run(operation);
138
+ }
139
+ };
140
+ /** Open and configure a Node-backed SQLite database facade. */
141
+ async function openNodeSqliteDatabase(path, options = {}) {
142
+ const checkpointPages = options.walAutoCheckpointPages ?? DEFAULT_WAL_AUTO_CHECKPOINT_PAGES;
143
+ const timeout = options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
144
+ if (path !== ":memory:") await mkdir(dirname(path), { recursive: true });
145
+ const adapter = new NodeSqliteDatabase(new DatabaseSync(path, { timeout }));
146
+ try {
147
+ await adapter.exec("PRAGMA journal_mode = WAL");
148
+ await adapter.exec("PRAGMA synchronous = NORMAL");
149
+ await adapter.exec(`PRAGMA wal_autocheckpoint = ${checkpointPages}`);
150
+ return adapter;
151
+ } catch (error) {
152
+ try {
153
+ await adapter.close();
154
+ } catch {}
155
+ throw error;
156
+ }
157
+ }
158
+ //#endregion
159
+ //#region ../../plugins/acryl-db-sqlite/src/open.ts
160
+ /**
161
+ * The plain way to open the SQLite provider, for code without Cordis (an acrylblends app): `openDb({ path, migrations })` opens one
162
+ * `node:sqlite` file with the ported facade's settings (WAL, `synchronous = NORMAL`, a busy timeout) and foreign keys enforced, applies the
163
+ * numbered migrations, and resolves the `db` port with the function that closes it. The Cordis plugin is a thin wrapper over this, so both
164
+ * run one code path.
165
+ * This entry imports nothing from Cordis.
166
+ */
167
+ /**
168
+ * Open, configure and migrate a SQLite database.
169
+ * @throws when the migration files are misnamed or misnumbered, or a migration fails (the file is closed, and unchanged), or the
170
+ * database's schema is newer than the migrations.
171
+ */
172
+ async function openDb(options) {
173
+ const migrations = options.migrations === void 0 ? [] : await readMigrationFiles(options.migrations);
174
+ const database = await openNodeSqliteDatabase(options.path, {
175
+ ...options.busyTimeoutMs === void 0 ? {} : { busyTimeoutMs: options.busyTimeoutMs },
176
+ ...options.walAutoCheckpointPages === void 0 ? {} : { walAutoCheckpointPages: options.walAutoCheckpointPages }
177
+ });
178
+ try {
179
+ await database.exec("PRAGMA foreign_keys = ON");
180
+ await applyMigrations(database, migrations, { table: MIGRATIONS_TABLE });
181
+ } catch (error) {
182
+ await database.close();
183
+ throw error;
184
+ }
185
+ return {
186
+ db: {
187
+ exec: (sql) => database.exec(sql),
188
+ run: (sql, ...params) => database.run(sql, ...params),
189
+ get: (sql, ...params) => database.get(sql, ...params),
190
+ all: (sql, ...params) => database.all(sql, ...params),
191
+ transaction: (work) => database.transaction(work)
192
+ },
193
+ close: () => database.close()
194
+ };
195
+ }
196
+ //#endregion
197
+ export { openDb };
package/lib/db.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { a as Migration, c as DbExecutor, i as validateSchemaPrefix, l as SqlValue, n as MIGRATIONS_TABLE, o as applyMigrations, r as readMigrationFiles, s as Db, t as DB_SERVICE } from "./index-D4FAsBZu.js";
2
+ export { DB_SERVICE, type Db, type DbExecutor, MIGRATIONS_TABLE, type Migration, type SqlValue, applyMigrations, readMigrationFiles, validateSchemaPrefix };
package/lib/db.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as validateSchemaPrefix, i as applyMigrations, n as MIGRATIONS_TABLE, r as readMigrationFiles, t as DB_SERVICE } from "./src-MWcsMS8i.js";
2
+ export { DB_SERVICE, MIGRATIONS_TABLE, applyMigrations, readMigrationFiles, validateSchemaPrefix };