acrylblends 0.7.4 → 0.7.5
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 +5 -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 +202 -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
|
@@ -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": "4721bb00960fa4e157e6070c330cf87c148739fc"
|
|
6
6
|
},
|
|
7
|
-
"files":
|
|
8
|
-
"digest": "sha256:
|
|
7
|
+
"files": 99,
|
|
8
|
+
"digest": "sha256:ce484a4cf2bc9781d188fe468e98c498052d50155bb72f84c0a1ad384b1a5e85"
|
|
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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region ../../plugins/acryl-db/src/facade/database.d.ts
|
|
2
|
+
/** Values supported by the portable SQL drivers. */
|
|
3
|
+
type SqlValue = null | number | bigint | string | Uint8Array;
|
|
4
|
+
/**
|
|
5
|
+
* Asynchronous SQL operations shared by a database and its transaction handles.
|
|
6
|
+
*
|
|
7
|
+
* `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all`
|
|
8
|
+
* execute one statement with positional bindings. Adapters may cache prepared statements by SQL text,
|
|
9
|
+
* so callers pass values as bindings instead of interpolating them. `run` resolves the number of rows
|
|
10
|
+
* the statement changed.
|
|
11
|
+
*/
|
|
12
|
+
interface DbExecutor {
|
|
13
|
+
exec(sql: string): Promise<void>;
|
|
14
|
+
run(sql: string, ...params: SqlValue[]): Promise<{
|
|
15
|
+
readonly changes: number;
|
|
16
|
+
}>;
|
|
17
|
+
get<T extends object>(sql: string, ...params: SqlValue[]): Promise<T | undefined>;
|
|
18
|
+
all<T extends object>(sql: string, ...params: SqlValue[]): Promise<T[]>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Minimal portable SQL database facade.
|
|
22
|
+
*
|
|
23
|
+
* All operations are asynchronous so adapters may execute outside the caller's runtime.
|
|
24
|
+
*
|
|
25
|
+
* `transaction` passes the callback a transaction handle. All work in the transaction
|
|
26
|
+
* must use that handle; the handle is invalid after the callback settles. Adapters must
|
|
27
|
+
* queue unrelated operations and other transactions until the transaction finishes. The
|
|
28
|
+
* returned promise settles after commit or rollback. Calling the database itself (including
|
|
29
|
+
* `transaction`) from inside a callback therefore waits for that transaction and
|
|
30
|
+
* never settles.
|
|
31
|
+
*
|
|
32
|
+
* When the callback rejects, the adapter must roll the transaction back before rejecting
|
|
33
|
+
* with that same error. If rollback fails, it must reject with a different error (for
|
|
34
|
+
* example an `AggregateError`) so callers cannot mistake the callback error for a
|
|
35
|
+
* guaranteed rollback.
|
|
36
|
+
*
|
|
37
|
+
* Closing is the adapter's: the plugin that opened a database closes it, so the facade an
|
|
38
|
+
* app receives has no `close`.
|
|
39
|
+
*/
|
|
40
|
+
interface Db extends DbExecutor {
|
|
41
|
+
transaction<T>(callback: (transaction: DbExecutor) => Promise<T>): Promise<T>;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../../plugins/acryl-db/src/facade/migrations.d.ts
|
|
45
|
+
type Migration = {
|
|
46
|
+
readonly version: number;
|
|
47
|
+
readonly statements: readonly string[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Apply all pending migrations atomically, tracking their version in the named metadata table.
|
|
51
|
+
*
|
|
52
|
+
* The bookkeeping SQL runs unchanged on SQLite and Postgres: no `STRICT` table, `ON CONFLICT DO NOTHING`
|
|
53
|
+
* instead of `INSERT OR IGNORE`, and the version written as a checked integer rather than a binding,
|
|
54
|
+
* since the two dialects spell placeholders differently.
|
|
55
|
+
*/
|
|
56
|
+
declare function applyMigrations(database: Db, migrations: readonly Migration[], options: {
|
|
57
|
+
readonly table: string;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region ../../plugins/acryl-db/src/facade/schema.d.ts
|
|
61
|
+
/** Validate a prefix before using it as part of a SQL identifier. */
|
|
62
|
+
declare function validateSchemaPrefix(prefix: string): void;
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region ../../plugins/acryl-db/src/migration-files.d.ts
|
|
65
|
+
/**
|
|
66
|
+
* Read every `NNN-name.sql` file of a folder, in number order.
|
|
67
|
+
* @throws when a `.sql` file is not named `NNN-name.sql`, or the numbers do not run 1, 2, 3, ... without gaps or repeats.
|
|
68
|
+
*/
|
|
69
|
+
declare function readMigrationFiles(folder: string | URL): Promise<Migration[]>;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region ../../plugins/acryl-db/src/index.d.ts
|
|
72
|
+
/**
|
|
73
|
+
* The `db` port: what an app's code sees of its relational database, whichever provider (`acryl-db-sqlite`, `acryl-db-postgres`) serves
|
|
74
|
+
* it. Plain code (an acrylblends app) opens a database with a provider's `openDb`; a Cordis plugin declares `inject: ['db']`, imports
|
|
75
|
+
* `acryl-db/cordis` for the `ctx.db` type, and never imports a driver.
|
|
76
|
+
*
|
|
77
|
+
* Bindings are positional `?` placeholders on every provider; a provider whose database spells them differently translates them.
|
|
78
|
+
* This entry imports nothing from Cordis, at runtime or in its types.
|
|
79
|
+
*/
|
|
80
|
+
/** The service name every `db` provider registers. */
|
|
81
|
+
declare const DB_SERVICE = "db";
|
|
82
|
+
/** The table that records an app's migration version. */
|
|
83
|
+
declare const MIGRATIONS_TABLE = "_migrations";
|
|
84
|
+
//#endregion
|
|
85
|
+
export { Migration as a, DbExecutor as c, validateSchemaPrefix as i, SqlValue as l, MIGRATIONS_TABLE as n, applyMigrations as o, readMigrationFiles as r, Db as s, DB_SERVICE as t };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region ../../plugins/acryl-db/src/facade/schema.ts
|
|
5
|
+
/** Validate a prefix before using it as part of a SQL identifier. */
|
|
6
|
+
function validateSchemaPrefix(prefix) {
|
|
7
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) throw new Error(`Invalid SQL schema prefix ${JSON.stringify(prefix)}`);
|
|
8
|
+
if (prefix.toLowerCase().startsWith("sqlite_")) throw new Error(`SQL schema prefix ${JSON.stringify(prefix)} is reserved`);
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region ../../plugins/acryl-db/src/facade/migrations.ts
|
|
12
|
+
/**
|
|
13
|
+
* Apply all pending migrations atomically, tracking their version in the named metadata table.
|
|
14
|
+
*
|
|
15
|
+
* The bookkeeping SQL runs unchanged on SQLite and Postgres: no `STRICT` table, `ON CONFLICT DO NOTHING`
|
|
16
|
+
* instead of `INSERT OR IGNORE`, and the version written as a checked integer rather than a binding,
|
|
17
|
+
* since the two dialects spell placeholders differently.
|
|
18
|
+
*/
|
|
19
|
+
async function applyMigrations(database, migrations, options) {
|
|
20
|
+
const { table } = options;
|
|
21
|
+
validateSchemaPrefix(table);
|
|
22
|
+
for (let index = 0; index < migrations.length; index++) if (migrations[index]?.version !== index + 1) throw new Error("Migrations must have contiguous versions starting at 1");
|
|
23
|
+
await database.transaction(async (transaction) => {
|
|
24
|
+
await transaction.exec(`CREATE TABLE IF NOT EXISTS ${table} (
|
|
25
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
26
|
+
version INTEGER NOT NULL CHECK (version >= 0)
|
|
27
|
+
)`);
|
|
28
|
+
await transaction.exec(`INSERT INTO ${table} (singleton, version) VALUES (1, 0) ON CONFLICT DO NOTHING`);
|
|
29
|
+
const row = await transaction.get(`SELECT version FROM ${table} WHERE singleton = 1`);
|
|
30
|
+
if (row === void 0) throw new Error("Schema metadata is missing");
|
|
31
|
+
const storedVersion = Number(row.version);
|
|
32
|
+
const currentVersion = migrations.at(-1)?.version ?? 0;
|
|
33
|
+
if (storedVersion > currentVersion) throw new Error(`Schema version ${storedVersion} is newer than supported version ${currentVersion}`);
|
|
34
|
+
for (const migration of migrations) {
|
|
35
|
+
if (migration.version <= storedVersion) continue;
|
|
36
|
+
for (const statement of migration.statements) await transaction.exec(statement);
|
|
37
|
+
await transaction.exec(`UPDATE ${table} SET version = ${String(migration.version)} WHERE singleton = 1`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region ../../plugins/acryl-db/src/migration-files.ts
|
|
43
|
+
/**
|
|
44
|
+
* An app's migrations as numbered SQL files: `migrations/001-notes.sql`, `002-tags.sql`, ... Each file is one migration, run as one
|
|
45
|
+
* `exec` (so it may hold several statements), numbered from 1 with no gaps.
|
|
46
|
+
*/
|
|
47
|
+
const FILE = /^(\d+)-[a-z0-9][a-z0-9-]*\.sql$/u;
|
|
48
|
+
/**
|
|
49
|
+
* Read every `NNN-name.sql` file of a folder, in number order.
|
|
50
|
+
* @throws when a `.sql` file is not named `NNN-name.sql`, or the numbers do not run 1, 2, 3, ... without gaps or repeats.
|
|
51
|
+
*/
|
|
52
|
+
async function readMigrationFiles(folder) {
|
|
53
|
+
const dir = typeof folder === "string" ? folder : fileURLToPath(folder);
|
|
54
|
+
const numbered = (await readdir(dir)).filter((name) => name.endsWith(".sql")).sort().map((name) => {
|
|
55
|
+
const match = FILE.exec(name);
|
|
56
|
+
if (match?.[1] === void 0) throw new Error(`acryl-db: migration file ${name} in ${dir} must be named NNN-name.sql, like 001-notes.sql`);
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
version: Number(match[1])
|
|
60
|
+
};
|
|
61
|
+
}).sort((a, b) => a.version - b.version);
|
|
62
|
+
numbered.forEach((file, index) => {
|
|
63
|
+
if (file.version !== index + 1) throw new Error(`acryl-db: migration ${file.name} in ${dir} should be number ${String(index + 1)}; the files must be numbered 1, 2, 3, ... with no gaps or repeats`);
|
|
64
|
+
});
|
|
65
|
+
return Promise.all(numbered.map(async (file) => ({
|
|
66
|
+
version: file.version,
|
|
67
|
+
statements: [await readFile(join(dir, file.name), "utf8")]
|
|
68
|
+
})));
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region ../../plugins/acryl-db/src/index.ts
|
|
72
|
+
/**
|
|
73
|
+
* The `db` port: what an app's code sees of its relational database, whichever provider (`acryl-db-sqlite`, `acryl-db-postgres`) serves
|
|
74
|
+
* it. Plain code (an acrylblends app) opens a database with a provider's `openDb`; a Cordis plugin declares `inject: ['db']`, imports
|
|
75
|
+
* `acryl-db/cordis` for the `ctx.db` type, and never imports a driver.
|
|
76
|
+
*
|
|
77
|
+
* Bindings are positional `?` placeholders on every provider; a provider whose database spells them differently translates them.
|
|
78
|
+
* This entry imports nothing from Cordis, at runtime or in its types.
|
|
79
|
+
*/
|
|
80
|
+
/** The service name every `db` provider registers. */
|
|
81
|
+
const DB_SERVICE = "db";
|
|
82
|
+
/** The table that records an app's migration version. */
|
|
83
|
+
const MIGRATIONS_TABLE = "_migrations";
|
|
84
|
+
//#endregion
|
|
85
|
+
export { validateSchemaPrefix as a, applyMigrations as i, MIGRATIONS_TABLE as n, readMigrationFiles as r, DB_SERVICE as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -418,6 +418,14 @@
|
|
|
418
418
|
"types": "./lib/node-server/register.d.ts",
|
|
419
419
|
"default": "./lib/node-server/register.js"
|
|
420
420
|
},
|
|
421
|
+
"./db": {
|
|
422
|
+
"types": "./lib/db.d.ts",
|
|
423
|
+
"default": "./lib/db.js"
|
|
424
|
+
},
|
|
425
|
+
"./db/sqlite": {
|
|
426
|
+
"types": "./lib/db/sqlite.d.ts",
|
|
427
|
+
"default": "./lib/db/sqlite.js"
|
|
428
|
+
},
|
|
421
429
|
"./package.json": "./package.json",
|
|
422
430
|
"./cli": {
|
|
423
431
|
"types": "./dist/index.d.ts",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WebBoxes s.r.o.
|
|
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
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The files under src/facade are derived from the SQLite facade of chord in pi (https://github.com/earendil-works/pi, packages/chord, commit d5b3de22d0e24a3e9c38a9410d3329b099717283), used under this license:
|
|
26
|
+
|
|
27
|
+
MIT License
|
|
28
|
+
|
|
29
|
+
Copyright (c) 2025 Mario Zechner
|
|
30
|
+
|
|
31
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
32
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
33
|
+
in the Software without restriction, including without limitation the rights
|
|
34
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
35
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
36
|
+
furnished to do so, subject to the following conditions:
|
|
37
|
+
|
|
38
|
+
The above copyright notice and this permission notice shall be included in all
|
|
39
|
+
copies or substantial portions of the Software.
|
|
40
|
+
|
|
41
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
42
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
43
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
44
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
45
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
46
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
47
|
+
SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-db",
|
|
3
|
+
"description": "The db port: the relational database an app plugin sees through inject: ['db'], with numbered SQL migrations. Types and the migration runner; a provider (acryl-db-sqlite) serves it. Vendored from ACRYL.",
|
|
4
|
+
"requires": [],
|
|
5
|
+
"inject": [],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
},
|
|
10
|
+
"mount": "it is the port, not a service: a plugin declares inject: ['db'] and imports its types from './plugins/acryl-db/src/index.ts' (or the ctx.db type from './plugins/acryl-db/src/cordis.ts'); add acryl-db-sqlite to provide it",
|
|
11
|
+
"omit": ["src/conformance.ts", "**/*.test.*", "**/*.case.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ctx.db` type for Cordis plugins: `import type {} from 'acryl-db/cordis'` beside `inject: ['db']`. Kept out of the root entry, so
|
|
3
|
+
* plain code that uses the port never names Cordis.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {} from '@deepseek-ai/cordis'
|
|
7
|
+
import type { Db } from './facade/database.ts'
|
|
8
|
+
|
|
9
|
+
declare module '@deepseek-ai/cordis' {
|
|
10
|
+
interface Context {
|
|
11
|
+
db: Db
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Values supported by the portable SQL drivers. */
|
|
2
|
+
export type SqlValue = null | number | bigint | string | Uint8Array;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asynchronous SQL operations shared by a database and its transaction handles.
|
|
6
|
+
*
|
|
7
|
+
* `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all`
|
|
8
|
+
* execute one statement with positional bindings. Adapters may cache prepared statements by SQL text,
|
|
9
|
+
* so callers pass values as bindings instead of interpolating them. `run` resolves the number of rows
|
|
10
|
+
* the statement changed.
|
|
11
|
+
*/
|
|
12
|
+
export interface DbExecutor {
|
|
13
|
+
exec(sql: string): Promise<void>;
|
|
14
|
+
run(sql: string, ...params: SqlValue[]): Promise<{ readonly changes: number }>;
|
|
15
|
+
get<T extends object>(sql: string, ...params: SqlValue[]): Promise<T | undefined>;
|
|
16
|
+
all<T extends object>(sql: string, ...params: SqlValue[]): Promise<T[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal portable SQL database facade.
|
|
21
|
+
*
|
|
22
|
+
* All operations are asynchronous so adapters may execute outside the caller's runtime.
|
|
23
|
+
*
|
|
24
|
+
* `transaction` passes the callback a transaction handle. All work in the transaction
|
|
25
|
+
* must use that handle; the handle is invalid after the callback settles. Adapters must
|
|
26
|
+
* queue unrelated operations and other transactions until the transaction finishes. The
|
|
27
|
+
* returned promise settles after commit or rollback. Calling the database itself (including
|
|
28
|
+
* `transaction`) from inside a callback therefore waits for that transaction and
|
|
29
|
+
* never settles.
|
|
30
|
+
*
|
|
31
|
+
* When the callback rejects, the adapter must roll the transaction back before rejecting
|
|
32
|
+
* with that same error. If rollback fails, it must reject with a different error (for
|
|
33
|
+
* example an `AggregateError`) so callers cannot mistake the callback error for a
|
|
34
|
+
* guaranteed rollback.
|
|
35
|
+
*
|
|
36
|
+
* Closing is the adapter's: the plugin that opened a database closes it, so the facade an
|
|
37
|
+
* app receives has no `close`.
|
|
38
|
+
*/
|
|
39
|
+
export interface Db extends DbExecutor {
|
|
40
|
+
transaction<T>(callback: (transaction: DbExecutor) => Promise<T>): Promise<T>;
|
|
41
|
+
}
|