@coffre/db 0.0.0 → 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -3
  3. package/dist/commits-BuW-NL3n.js +30 -0
  4. package/dist/connect.d.ts +21 -0
  5. package/dist/connect.js +155 -0
  6. package/dist/database-CWpo5aF0.d.ts +39 -0
  7. package/dist/dialect.d.ts +88 -0
  8. package/dist/dialect.js +128 -0
  9. package/dist/hyperdrive.d.ts +32 -0
  10. package/dist/hyperdrive.js +53 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +34 -0
  13. package/dist/log.d.ts +72 -0
  14. package/dist/log.js +133 -0
  15. package/dist/migrate.d.ts +10 -0
  16. package/dist/migrate.js +129 -0
  17. package/dist/migrations/postgres/0000_baseline.sql +502 -0
  18. package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
  19. package/dist/migrations/postgres/meta/_journal.json +13 -0
  20. package/dist/migrations/sqlite/0000_baseline.sql +293 -0
  21. package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  22. package/dist/migrations/sqlite/meta/_journal.json +13 -0
  23. package/dist/portable.d.ts +33 -0
  24. package/dist/portable.js +12 -0
  25. package/dist/relations-DqgxtlKu.js +58 -0
  26. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  27. package/dist/schema-Cd4WOnB5.d.ts +2503 -0
  28. package/dist/schema-sqlite.d.ts +2587 -0
  29. package/dist/schema-sqlite.js +401 -0
  30. package/dist/schema-version.d.ts +11 -0
  31. package/dist/schema-version.js +16 -0
  32. package/dist/schema.d.ts +2 -0
  33. package/dist/schema.js +448 -0
  34. package/package.json +91 -5
  35. package/src/baseline/postgres.sql +238 -0
  36. package/src/baseline/sqlite.sql +27 -0
  37. package/src/baseline.ts +104 -0
  38. package/src/commits.ts +47 -0
  39. package/src/connect.ts +168 -0
  40. package/src/database.ts +63 -0
  41. package/src/dialect.ts +174 -0
  42. package/src/drizzle.postgres.config.ts +7 -0
  43. package/src/drizzle.sqlite.config.ts +7 -0
  44. package/src/hyperdrive.ts +62 -0
  45. package/src/log.ts +177 -0
  46. package/src/migrate-main.ts +8 -0
  47. package/src/migrate.ts +206 -0
  48. package/src/migrations/postgres/0000_baseline.sql +502 -0
  49. package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
  50. package/src/migrations/postgres/meta/_journal.json +13 -0
  51. package/src/migrations/sqlite/0000_baseline.sql +293 -0
  52. package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  53. package/src/migrations/sqlite/meta/_journal.json +13 -0
  54. package/src/portable.ts +54 -0
  55. package/src/relations.ts +53 -0
  56. package/src/schema-version.ts +13 -0
  57. package/src/schema.sqlite.ts +511 -0
  58. package/src/schema.ts +548 -0
  59. package/src/verify-runtime.ts +120 -0
@@ -0,0 +1,238 @@
1
+ -- The schema on Postgres. Generated by `pnpm db:generate` from
2
+ -- baseline/postgres.sql and src/schema.ts; edit those, not this.
3
+
4
+ -- @schema
5
+
6
+ -- The chain starts at sequence 0 from 32 zero bytes.
7
+ INSERT INTO audit_chain_head (only_row, next_seq, head_hash)
8
+ VALUES (true, 0, decode(repeat('00', 32), 'hex'));
9
+ --> statement-breakpoint
10
+
11
+ -- The audit log only grows, for every login, its owner's included. The
12
+ -- grants below already keep the runtime login from changing it; this stops
13
+ -- a bug or a careless statement by the owner too, who can still lift it
14
+ -- with ALTER TABLE ... DISABLE TRIGGER. What an owner changes anyway, the
15
+ -- entries' MACs and chain show.
16
+ CREATE FUNCTION audit_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
17
+ BEGIN
18
+ RAISE EXCEPTION 'audit_log is append-only' USING ERRCODE = 'insufficient_privilege';
19
+ END
20
+ $$;
21
+ --> statement-breakpoint
22
+ CREATE TRIGGER audit_log_no_change BEFORE UPDATE OR DELETE ON audit_log
23
+ FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
24
+ --> statement-breakpoint
25
+ CREATE TRIGGER audit_log_no_truncate BEFORE TRUNCATE ON audit_log
26
+ FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
27
+ --> statement-breakpoint
28
+
29
+ -- Two logins, provisioned outside coffre (their passwords are never ours),
30
+ -- each with only the rights of its group:
31
+ --
32
+ -- coffre_runtime the server, through coffre_app: read and insert,
33
+ -- UPDATE on named columns only, and never DELETE. A
34
+ -- revoked session, a used device code and a removed
35
+ -- sync key are rows that say so, not gaps.
36
+ -- coffre_vault_runtime the vault, through coffre_vault: its members and
37
+ -- grants, and reading what it decides on.
38
+ --
39
+ -- Both append to the audit log, each only as its own author (row-level
40
+ -- security, below), and neither may change or delete an entry.
41
+ DO $$
42
+ DECLARE
43
+ name text;
44
+ BEGIN
45
+ FOREACH name IN ARRAY ARRAY['coffre_app', 'coffre_vault'] LOOP
46
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = name) THEN
47
+ EXECUTE format('CREATE ROLE %I', name);
48
+ END IF;
49
+ END LOOP;
50
+ END
51
+ $$;
52
+ --> statement-breakpoint
53
+
54
+ ALTER ROLE coffre_app
55
+ NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
56
+ --> statement-breakpoint
57
+ ALTER ROLE coffre_vault
58
+ NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
59
+ --> statement-breakpoint
60
+
61
+ DO $$
62
+ DECLARE
63
+ name text;
64
+ runtime_role oid;
65
+ BEGIN
66
+ FOREACH name IN ARRAY ARRAY['coffre_runtime', 'coffre_vault_runtime'] LOOP
67
+ SELECT oid INTO runtime_role FROM pg_roles WHERE rolname = name;
68
+ IF runtime_role IS NULL THEN
69
+ RAISE EXCEPTION '% must be provisioned before migrations', name;
70
+ END IF;
71
+
72
+ IF EXISTS (
73
+ SELECT 1
74
+ FROM pg_roles
75
+ WHERE oid = runtime_role
76
+ AND (rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls)
77
+ ) THEN
78
+ RAISE EXCEPTION '% has unsafe role attributes', name;
79
+ END IF;
80
+
81
+ IF EXISTS (
82
+ SELECT 1
83
+ FROM pg_shdepend
84
+ WHERE refclassid = 'pg_authid'::regclass
85
+ AND refobjid = runtime_role
86
+ AND deptype = 'o'
87
+ ) THEN
88
+ RAISE EXCEPTION '% owns database objects', name;
89
+ END IF;
90
+ END LOOP;
91
+ END
92
+ $$;
93
+ --> statement-breakpoint
94
+
95
+ REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public
96
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
97
+ --> statement-breakpoint
98
+ REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public
99
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
100
+ --> statement-breakpoint
101
+ REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA public
102
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
103
+ --> statement-breakpoint
104
+ REVOKE ALL PRIVILEGES ON SCHEMA public
105
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
106
+ --> statement-breakpoint
107
+ GRANT USAGE ON SCHEMA public TO coffre_app, coffre_vault;
108
+ --> statement-breakpoint
109
+ GRANT USAGE ON SCHEMA drizzle TO coffre_app, coffre_vault;
110
+ --> statement-breakpoint
111
+ GRANT SELECT ON drizzle.__drizzle_migrations TO coffre_app, coffre_vault;
112
+ --> statement-breakpoint
113
+
114
+ GRANT SELECT, INSERT ON
115
+ projects,
116
+ environments,
117
+ secrets,
118
+ secret_versions,
119
+ audit_log,
120
+ identities,
121
+ credentials,
122
+ device_authorizations,
123
+ syncs,
124
+ sync_keys
125
+ TO coffre_app;
126
+ --> statement-breakpoint
127
+
128
+ GRANT SELECT ON
129
+ audit_chain_head,
130
+ vault_members,
131
+ vault_grants
132
+ TO coffre_app;
133
+ --> statement-breakpoint
134
+
135
+ GRANT UPDATE (slug, name, archived_at) ON projects TO coffre_app;
136
+ --> statement-breakpoint
137
+ GRANT UPDATE (slug, name, archived_at) ON environments TO coffre_app;
138
+ --> statement-breakpoint
139
+ GRANT UPDATE (key, current_version_id, current_version, updated_at, archived_at) ON secrets TO coffre_app;
140
+ --> statement-breakpoint
141
+ GRANT UPDATE (next_seq, head_hash) ON audit_chain_head TO coffre_app;
142
+ --> statement-breakpoint
143
+ GRANT UPDATE (email, last_sign_in_at, revoked_at, revoked_by, auth_mac) ON identities TO coffre_app;
144
+ --> statement-breakpoint
145
+ GRANT UPDATE (last_used_at, last_used_ip, revoked_at, revoked_by, auth_mac) ON credentials TO coffre_app;
146
+ --> statement-breakpoint
147
+ GRANT UPDATE (decided_at, decision, generation, principal, consumed_at, auth_mac)
148
+ ON device_authorizations TO coffre_app;
149
+ --> statement-breakpoint
150
+ GRANT UPDATE (config, credential_secret_id, paused_at, archived_at, lease_until,
151
+ last_run_at, last_status, last_error)
152
+ ON syncs TO coffre_app;
153
+ --> statement-breakpoint
154
+ GRANT UPDATE (secret_version_id, pushed_at, removed_at) ON sync_keys TO coffre_app;
155
+ --> statement-breakpoint
156
+
157
+ -- The vault reads what it decides on and the log it chains to, and writes
158
+ -- its members and grants. A revoked grant is deleted: the log keeps it.
159
+ GRANT SELECT ON
160
+ projects,
161
+ environments,
162
+ secrets,
163
+ secret_versions,
164
+ audit_chain_head
165
+ TO coffre_vault;
166
+ --> statement-breakpoint
167
+ GRANT SELECT, INSERT ON audit_log TO coffre_vault;
168
+ --> statement-breakpoint
169
+ GRANT SELECT, INSERT, UPDATE (status, owner, generation, created_at, created_by, status_changed_at, status_changed_by, access_seq, mac)
170
+ ON vault_members TO coffre_vault;
171
+ --> statement-breakpoint
172
+ GRANT SELECT, INSERT, DELETE ON vault_grants TO coffre_vault;
173
+ --> statement-breakpoint
174
+ GRANT UPDATE (next_seq, head_hash) ON audit_chain_head TO coffre_vault;
175
+ --> statement-breakpoint
176
+
177
+ REVOKE DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM coffre_app;
178
+ --> statement-breakpoint
179
+ REVOKE TRUNCATE ON ALL TABLES IN SCHEMA public FROM coffre_vault;
180
+ --> statement-breakpoint
181
+ REVOKE UPDATE ON audit_log, secret_versions FROM coffre_app, coffre_vault;
182
+ --> statement-breakpoint
183
+ REVOKE CREATE ON SCHEMA public FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
184
+ --> statement-breakpoint
185
+
186
+ -- Each login appends only as its own author. Its MAC is still what proves
187
+ -- who wrote an entry: the table's owner passes these policies, as owners do.
188
+ ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
189
+ --> statement-breakpoint
190
+ CREATE POLICY audit_log_read ON audit_log FOR SELECT TO coffre_app, coffre_vault USING (true);
191
+ --> statement-breakpoint
192
+ CREATE POLICY audit_log_app_appends ON audit_log FOR INSERT TO coffre_app WITH CHECK (author = 'app');
193
+ --> statement-breakpoint
194
+ CREATE POLICY audit_log_vault_appends ON audit_log FOR INSERT TO coffre_vault WITH CHECK (author = 'vault');
195
+ --> statement-breakpoint
196
+
197
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
198
+ REVOKE ALL PRIVILEGES ON TABLES FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
199
+ --> statement-breakpoint
200
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
201
+ REVOKE ALL PRIVILEGES ON SEQUENCES FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
202
+ --> statement-breakpoint
203
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
204
+ REVOKE ALL PRIVILEGES ON ROUTINES FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
205
+ --> statement-breakpoint
206
+
207
+ -- Each login is a member of its own group and of nothing else.
208
+ DO $$
209
+ DECLARE
210
+ login record;
211
+ membership record;
212
+ BEGIN
213
+ EXECUTE format(
214
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
215
+ current_database()
216
+ );
217
+
218
+ FOR login IN
219
+ SELECT * FROM (VALUES ('coffre_runtime', 'coffre_app'), ('coffre_vault_runtime', 'coffre_vault')) AS logins (name, grp)
220
+ LOOP
221
+ FOR membership IN
222
+ SELECT granted.rolname
223
+ FROM pg_auth_members member_of
224
+ JOIN pg_roles member ON member.oid = member_of.member
225
+ JOIN pg_roles granted ON granted.oid = member_of.roleid
226
+ WHERE member.rolname = login.name
227
+ AND granted.rolname <> login.grp
228
+ LOOP
229
+ EXECUTE format('REVOKE %I FROM %I', membership.rolname, login.name);
230
+ END LOOP;
231
+ END LOOP;
232
+ END
233
+ $$;
234
+ --> statement-breakpoint
235
+
236
+ GRANT coffre_app TO coffre_runtime WITH ADMIN FALSE, INHERIT TRUE, SET FALSE;
237
+ --> statement-breakpoint
238
+ GRANT coffre_vault TO coffre_vault_runtime WITH ADMIN FALSE, INHERIT TRUE, SET FALSE;
@@ -0,0 +1,27 @@
1
+ -- The schema on SQLite. Generated by `pnpm db:generate` from
2
+ -- baseline/sqlite.sql and src/schema.sqlite.ts; edit those, not this.
3
+
4
+ -- @schema
5
+
6
+ -- The chain starts at sequence 0 from 32 zero bytes.
7
+ INSERT INTO `audit_chain_head` (`only_row`, `next_seq`, `head_hash`) VALUES (true, 0, zeroblob(32));
8
+ --> statement-breakpoint
9
+
10
+ -- The audit log only grows: no statement may change or delete an entry.
11
+ -- What gets past this anyway, the entries' MACs and chain show.
12
+ -- REPLACE can delete without firing DELETE triggers, so refuse it before insert.
13
+ CREATE TRIGGER `audit_log_no_replace` BEFORE INSERT ON `audit_log`
14
+ WHEN EXISTS (SELECT 1 FROM `audit_log` WHERE `seq` = NEW.`seq`)
15
+ BEGIN
16
+ SELECT RAISE(ABORT, 'audit_log is append-only');
17
+ END;
18
+ --> statement-breakpoint
19
+ CREATE TRIGGER `audit_log_no_update` BEFORE UPDATE ON `audit_log`
20
+ BEGIN
21
+ SELECT RAISE(ABORT, 'audit_log is append-only');
22
+ END;
23
+ --> statement-breakpoint
24
+ CREATE TRIGGER `audit_log_no_delete` BEFORE DELETE ON `audit_log`
25
+ BEGIN
26
+ SELECT RAISE(ABORT, 'audit_log is append-only');
27
+ END;
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import {
6
+ generateDrizzleJson,
7
+ generateMigration,
8
+ generateSQLiteDrizzleJson,
9
+ generateSQLiteMigration,
10
+ } from 'drizzle-kit/api';
11
+
12
+ import type { Engine } from './dialect.ts';
13
+ import { migrationsFolder } from './migrate.ts';
14
+ import * as postgres from './schema.ts';
15
+ import * as sqlite from './schema.sqlite.ts';
16
+
17
+ /**
18
+ * coffre has never been deployed, so there is no database to upgrade: each
19
+ * engine has one migration, its baseline, and a schema change regenerates it
20
+ * rather than adding a second. After the first deployment, changes become new
21
+ * migrations and this goes.
22
+ *
23
+ * A baseline is baseline/<engine>.sql with its `-- @schema` line replaced by
24
+ * the tables drizzle-kit generates from that engine's schema. The template
25
+ * holds what a schema cannot say: the audit chain's first rows, and the
26
+ * Postgres runtime role and its grants.
27
+ *
28
+ * `pnpm db:generate` runs this file and rewrites the baselines that are stale.
29
+ */
30
+
31
+ export const ENGINES: readonly Engine[] = ['postgres', 'sqlite'];
32
+
33
+ const TAG = '0000_baseline';
34
+ const MARKER = '-- @schema\n';
35
+ const here = fileURLToPath(new URL('.', import.meta.url));
36
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url));
37
+
38
+ async function template(engine: Engine): Promise<{ head: string; tail: string }> {
39
+ const text = await readFile(`${here}baseline/${engine}.sql`, 'utf8');
40
+ const [head, tail, ...more] = text.split(MARKER);
41
+ if (tail === undefined || more.length > 0) {
42
+ throw new Error(`baseline/${engine}.sql needs exactly one ${MARKER.trim()} line`);
43
+ }
44
+ return { head, tail };
45
+ }
46
+
47
+ export async function journal(engine: Engine): Promise<{ idx: number; tag: string }[]> {
48
+ const text = await readFile(`${migrationsFolder(engine)}/meta/_journal.json`, 'utf8');
49
+ return (JSON.parse(text) as { entries: { idx: number; tag: string }[] }).entries;
50
+ }
51
+
52
+ /** The statements that would bring the engine's last snapshot to its schema. */
53
+ async function pending(engine: Engine): Promise<string[]> {
54
+ const last = String((await journal(engine)).at(-1)!.idx).padStart(4, '0');
55
+ const snapshot = JSON.parse(
56
+ await readFile(`${migrationsFolder(engine)}/meta/${last}_snapshot.json`, 'utf8'),
57
+ ) as never;
58
+ switch (engine) {
59
+ case 'postgres':
60
+ return generateMigration(snapshot, generateDrizzleJson(postgres) as never);
61
+ case 'sqlite':
62
+ return generateSQLiteMigration(snapshot, (await generateSQLiteDrizzleJson(sqlite)) as never);
63
+ }
64
+ }
65
+
66
+ /** Why the engine's baseline is out of date: empty when it is not. */
67
+ export async function staleness(engine: Engine): Promise<string[]> {
68
+ const tags = await journal(engine).then(
69
+ (entries) => entries.map((entry) => entry.tag),
70
+ () => [],
71
+ );
72
+ if (tags.length !== 1 || tags[0] !== TAG) return [`expected only ${TAG}, found [${tags.join(', ')}]`];
73
+ const reasons = await pending(engine);
74
+ const { head, tail } = await template(engine);
75
+ const baseline = await readFile(`${migrationsFolder(engine)}/${TAG}.sql`, 'utf8');
76
+ if (!baseline.startsWith(head) || !baseline.endsWith(tail)) reasons.push(`baseline/${engine}.sql changed`);
77
+ return reasons;
78
+ }
79
+
80
+ async function regenerate(engine: Engine): Promise<void> {
81
+ const folder = migrationsFolder(engine);
82
+ await rm(folder, { recursive: true, force: true });
83
+ execFileSync(
84
+ `${packageRoot}node_modules/.bin/drizzle-kit`,
85
+ ['generate', '--config', `drizzle.${engine}.config.ts`, '--name', 'baseline'],
86
+ { cwd: here, stdio: 'inherit' },
87
+ );
88
+ const tables = (await readFile(`${folder}/${TAG}.sql`, 'utf8')).trimEnd();
89
+ const breakpoint = tables.endsWith('--> statement-breakpoint') ? '' : '--> statement-breakpoint';
90
+ const { head, tail } = await template(engine);
91
+ await writeFile(`${folder}/${TAG}.sql`, `${head}${tables}${breakpoint}\n${tail}`);
92
+ }
93
+
94
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
95
+ for (const engine of ENGINES) {
96
+ const reasons = await staleness(engine);
97
+ if (reasons.length === 0) {
98
+ console.log(`${engine}: the baseline is up to date`);
99
+ continue;
100
+ }
101
+ console.log(`${engine}: regenerating the baseline (${reasons.length} change${reasons.length === 1 ? '' : 's'})`);
102
+ await regenerate(engine);
103
+ }
104
+ }
package/src/commits.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Work to do once a transaction has committed, and never if it rolls back.
3
+ *
4
+ * Drizzle has no such hook, so every database this package opens has its
5
+ * `transaction` wrapped (`trackCommits`): it gives each transaction a list,
6
+ * runs the list after COMMIT returns, and drops it on a rollback. A nested
7
+ * transaction, a savepoint, hands its list to the one around it when it is
8
+ * released, so nothing runs before the outermost commit.
9
+ *
10
+ * The log's append uses it to remember the head it wrote only once that
11
+ * head is in the database (log.ts).
12
+ */
13
+
14
+ type Pending = (() => void)[];
15
+
16
+ /** What anything that opens transactions looks like, a database or a transaction. */
17
+ type Transactional = { transaction: (...args: never[]) => Promise<unknown> };
18
+
19
+ const pending = new WeakMap<object, Pending>();
20
+
21
+ /**
22
+ * Run `then` once `tx` has committed, all the way out. Returns false, and
23
+ * never runs it, for a transaction this package did not open.
24
+ */
25
+ export function afterCommit(tx: object, then: () => void): boolean {
26
+ const list = pending.get(tx);
27
+ if (list === undefined) return false;
28
+ list.push(then);
29
+ return true;
30
+ }
31
+
32
+ /** `db`, its transactions tracked for `afterCommit`. */
33
+ export function trackCommits<T extends Transactional>(db: T, outer: Pending | null = null): T {
34
+ const run = db.transaction.bind(db) as (work: (tx: Transactional) => Promise<unknown>, ...rest: unknown[]) => Promise<unknown>;
35
+ const transaction = async (work: (tx: Transactional) => Promise<unknown>, ...rest: unknown[]) => {
36
+ const list: Pending = [];
37
+ const result = await run((tx) => {
38
+ pending.set(tx, list);
39
+ return work(trackCommits(tx, list));
40
+ }, ...rest);
41
+ if (outer !== null) outer.push(...list);
42
+ else for (const then of list) then();
43
+ return result;
44
+ };
45
+ db.transaction = transaction as T['transaction'];
46
+ return db;
47
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ import type { Client, ResultSet, Transaction as LibsqlTransaction, TransactionMode } from '@libsql/client';
4
+ import pg from 'pg';
5
+
6
+ import { createDatabase, type Database } from './database.ts';
7
+ import type { Engine } from './dialect.ts';
8
+ import { asPostgresDatabase } from './portable.ts';
9
+
10
+ /**
11
+ * Open a database from its URL, on a Node server:
12
+ *
13
+ * postgres://… or postgresql://… node-postgres
14
+ * file:… or libsql:… @libsql/client (SQLite)
15
+ *
16
+ * The SQLite driver loads only when asked for. The Worker does not
17
+ * come through here; it builds its Postgres database from the Hyperdrive
18
+ * pool with createDatabase.
19
+ */
20
+ export async function openDatabase(url: string): Promise<OpenDatabase> {
21
+ switch (engineOfUrl(url)) {
22
+ case 'postgres': {
23
+ const pool = new pg.Pool({ connectionString: url });
24
+ return { engine: 'postgres', db: createDatabase(pool), close: () => pool.end() };
25
+ }
26
+ case 'sqlite': {
27
+ const [{ createClient }, { drizzle }, schema] = await Promise.all([
28
+ import('@libsql/client'),
29
+ import('drizzle-orm/libsql'),
30
+ import('./schema.sqlite.ts'),
31
+ ]);
32
+ // A lone statement that finds the database locked waits this long.
33
+ const client = createClient({ url, timeout: 5_000, intMode: 'bigint' });
34
+ if (url.startsWith('file:')) await client.execute('PRAGMA journal_mode = WAL');
35
+ const db = drizzle({ client: oneTransactionAtATime(client, writeQueueFor(url)), schema });
36
+ return { engine: 'sqlite', db: asPostgresDatabase(db), close: async () => client.close() };
37
+ }
38
+ }
39
+ }
40
+
41
+ export type OpenDatabase = { engine: Engine; db: Database; close: () => Promise<void> };
42
+
43
+ export function engineOfUrl(url: string): Engine {
44
+ const scheme = url.slice(0, url.indexOf(':'));
45
+ if (scheme === 'postgres' || scheme === 'postgresql') return 'postgres';
46
+ if (scheme === 'mysql') throw new Error('coffre supports Postgres; MySQL support was removed');
47
+ if (scheme === 'file' || scheme === 'libsql') return 'sqlite';
48
+ throw new Error(`unsupported database URL scheme: ${scheme}:`);
49
+ }
50
+
51
+ // --- SQLite: one writer at a time ---------------------------------------------------
52
+
53
+ /**
54
+ * SQLite lets one connection write at a time and fails the others at once
55
+ * with SQLITE_BUSY rather than queueing them; 24 concurrent audit appends were
56
+ * enough to see it. So transactions queue here instead, in the order they
57
+ * asked, one open at a time per database file, however many clients or
58
+ * connections reach it. Each begins IMMEDIATE (libsql's default), taking the
59
+ * write lock with its first statement, which is what makes a read inside it
60
+ * as good as a locked one (see forUpdate).
61
+ */
62
+ class WriteQueue {
63
+ #tail: Promise<void> = Promise.resolve();
64
+
65
+ /** Wait for our turn; the returned function ends it. */
66
+ acquire(): Promise<() => void> {
67
+ let release!: () => void;
68
+ const turn = new Promise<void>((done) => (release = done));
69
+ const ready = this.#tail.then(() => release);
70
+ this.#tail = this.#tail.then(() => turn);
71
+ return ready;
72
+ }
73
+ }
74
+
75
+ const queues = new Map<string, WriteQueue>();
76
+
77
+ function writeQueueFor(url: string): WriteQueue {
78
+ // file:/a.db, file:///a.db and a relative file:a.db from / are one file.
79
+ const key = url.startsWith('file:') ? resolve(url.replace(/^file:(\/\/)?/, '').split('?')[0]) : url;
80
+ let queue = queues.get(key);
81
+ if (queue === undefined) queues.set(key, (queue = new WriteQueue()));
82
+ return queue;
83
+ }
84
+
85
+ function oneTransactionAtATime(client: Client, queue: WriteQueue): Client {
86
+ return new Proxy(client, {
87
+ get(target, key) {
88
+ if (key === 'execute') {
89
+ return async (...args: Parameters<Client['execute']>) => numbers(await target.execute(...args));
90
+ }
91
+ if (key === 'transaction') {
92
+ return async (mode?: TransactionMode) => {
93
+ const release = await queue.acquire();
94
+ try {
95
+ return settling(await target.transaction(mode), release);
96
+ } catch (error) {
97
+ release();
98
+ throw error;
99
+ }
100
+ };
101
+ }
102
+ if (key === 'batch' || key === 'migrate' || key === 'executeMultiple') {
103
+ return async (...args: unknown[]) => {
104
+ const release = await queue.acquire();
105
+ try {
106
+ const result = await (target[key] as (...args: unknown[]) => Promise<ResultSet[] | void>)(...args);
107
+ return result?.map(numbers);
108
+ } finally {
109
+ release();
110
+ }
111
+ };
112
+ }
113
+ const value = Reflect.get(target, key, target);
114
+ return typeof value === 'function' ? value.bind(target) : value;
115
+ },
116
+ });
117
+ }
118
+
119
+ /** Ends the transaction's turn when it commits, rolls back or closes. */
120
+ function settling(tx: LibsqlTransaction, release: () => void): LibsqlTransaction {
121
+ let settled = false;
122
+ const settle = () => {
123
+ if (settled) return;
124
+ settled = true;
125
+ release();
126
+ };
127
+ const { commit, rollback, close, execute, batch } = tx;
128
+ tx.execute = async (...args) => numbers(await execute.apply(tx, args));
129
+ tx.batch = async (...args) => (await batch.apply(tx, args)).map(numbers);
130
+ tx.commit = async () => {
131
+ try {
132
+ return await commit.call(tx);
133
+ } finally {
134
+ settle();
135
+ }
136
+ };
137
+ tx.rollback = async () => {
138
+ try {
139
+ return await rollback.call(tx);
140
+ } finally {
141
+ settle();
142
+ }
143
+ };
144
+ tx.close = () => {
145
+ try {
146
+ close.call(tx);
147
+ } finally {
148
+ settle();
149
+ }
150
+ };
151
+ return tx;
152
+ }
153
+
154
+ /** Keep large counters exact; Drizzle's booleans and dates still need safe numbers. */
155
+ function numbers(result: ResultSet): ResultSet {
156
+ result.rows = result.rows.map((row) => {
157
+ // libSQL exposes both named and immutable indexed values on each row.
158
+ const properties = Object.getOwnPropertyDescriptors(row);
159
+ for (const property of Object.values(properties)) {
160
+ const value = property.value;
161
+ if (typeof value === 'bigint' && value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
162
+ property.value = Number(value);
163
+ }
164
+ }
165
+ return Object.create(Object.getPrototypeOf(row), properties);
166
+ });
167
+ return result;
168
+ }
@@ -0,0 +1,63 @@
1
+ import { getTableName, is, Table } from 'drizzle-orm';
2
+ import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
3
+ import type pg from 'pg';
4
+
5
+ import { trackCommits } from './commits.ts';
6
+ import * as schema from './schema.ts';
7
+
8
+ export { schema };
9
+
10
+ /**
11
+ * A database, typed as Postgres whichever it is; see portable.ts for why
12
+ * that holds. connect.ts opens one of each from a URL.
13
+ */
14
+ export type Database = NodePgDatabase<typeof schema>;
15
+ export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
16
+ /** Anything a read can run on: the database, or an open transaction. */
17
+ export type Queryable = Database | Transaction;
18
+
19
+ /**
20
+ * What Drizzle needs from a connection pool. A `pg.Pool` fits, and so does the
21
+ * per-invocation Hyperdrive pool, whose class name ends in `Pool` for the
22
+ * same reason: that is how Drizzle knows to check out a client for a
23
+ * transaction rather than run BEGIN on a shared connection.
24
+ */
25
+ export type PoolLike = pg.Pool | {
26
+ query: pg.Pool['query'];
27
+ connect: () => Promise<Pick<pg.PoolClient, 'query'> & { release: () => void }>;
28
+ };
29
+
30
+ export function createDatabase(pool: PoolLike): Database {
31
+ return trackCommits(drizzle(pool as pg.Pool, { schema }));
32
+ }
33
+
34
+ /**
35
+ * The tables of the database's own dialect: the Postgres ones on Postgres,
36
+ * the SQLite ones on SQLite. Every query builds on these.
37
+ */
38
+ export function tablesOf(db: Queryable): typeof schema {
39
+ return db._.fullSchema;
40
+ }
41
+
42
+ const byName = new WeakMap<object, Map<string, Table>>();
43
+
44
+ /**
45
+ * The database's own twin of `table`, which may be another dialect's: the
46
+ * server names tables by importing schema.ts, and the database decides
47
+ * which one that means.
48
+ */
49
+ export function own<T extends Table>(db: Queryable, table: T): T {
50
+ const tables = tablesOf(db);
51
+ let named = byName.get(tables);
52
+ if (named === undefined) {
53
+ named = new Map(
54
+ Object.values(tables)
55
+ .filter((value) => is(value, Table))
56
+ .map((value) => [getTableName(value), value]),
57
+ );
58
+ byName.set(tables, named);
59
+ }
60
+ const found = named.get(getTableName(table));
61
+ if (found === undefined) throw new Error(`no table ${getTableName(table)} in this database`);
62
+ return found as T;
63
+ }