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.
- package/dist/index.js +193 -1
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/deploy.md +5 -5
- package/docs/framework/getting-started/service-worker.md +5 -3
- package/docs/framework/guides/create-acrylblends.md +2 -2
- package/docs/framework/guides/data.md +237 -0
- package/docs/framework/guides/forms-and-sessions.md +34 -30
- package/docs/framework/upstream.json +3 -3
- package/lib/db/sqlite.d.ts +27 -0
- package/lib/db/sqlite.js +197 -0
- package/lib/db.d.ts +2 -0
- package/lib/db.js +2 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/src-MWcsMS8i.js +85 -0
- package/package.json +9 -1
- package/plugins/acryl-db/LICENSE +47 -0
- package/plugins/acryl-db/plugin.json +12 -0
- package/plugins/acryl-db/src/cordis.ts +13 -0
- package/plugins/acryl-db/src/facade/database.ts +41 -0
- package/plugins/acryl-db/src/facade/migrations.ts +50 -0
- package/plugins/acryl-db/src/facade/schema.ts +9 -0
- package/plugins/acryl-db/src/index.ts +19 -0
- package/plugins/acryl-db/src/migration-files.ts +31 -0
- package/plugins/acryl-db/upstream.json +18 -0
- package/plugins/acryl-db-sqlite/LICENSE +47 -0
- package/plugins/acryl-db-sqlite/plugin.json +13 -0
- package/plugins/acryl-db-sqlite/src/facade/node.ts +199 -0
- package/plugins/acryl-db-sqlite/src/index.ts +88 -0
- package/plugins/acryl-db-sqlite/src/open.ts +61 -0
- package/plugins/acryl-db-sqlite/upstream.json +21 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-node-server/upstream.json +1 -1
- package/plugins/acryl-router/upstream.json +1 -1
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +13 -3
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/tsconfig.json +1 -0
- 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
|
|
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
|
|
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 `
|
|
7
|
-
|
|
8
|
-
Node.js 22.19 and later include SQLite, so there is nothing to install
|
|
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 {
|
|
12
|
-
|
|
13
|
-
export function
|
|
14
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
73
|
+
export function createApp(db: Db) {
|
|
71
74
|
const userFor = (token: string | undefined) =>
|
|
72
75
|
token
|
|
73
|
-
? (
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
5
|
+
"commit": "8e23e14336647943d4232047e3ea253e434535d3"
|
|
6
6
|
},
|
|
7
|
-
"files":
|
|
8
|
-
"digest": "sha256:
|
|
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 };
|
package/lib/db/sqlite.js
ADDED
|
@@ -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