@coffre/db 0.0.0 → 0.1.1

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -3
  3. package/dist/commits-BuW-NL3n.js +30 -0
  4. package/dist/connect.d.ts +26 -0
  5. package/dist/connect.js +156 -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 +54 -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 +130 -0
  17. package/dist/migrations/postgres/0000_baseline.sql +505 -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/postgres-Bmar8y0q.js +29 -0
  26. package/dist/relations-DqgxtlKu.js +58 -0
  27. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  28. package/dist/schema-Cd4WOnB5.d.ts +2503 -0
  29. package/dist/schema-sqlite.d.ts +2587 -0
  30. package/dist/schema-sqlite.js +401 -0
  31. package/dist/schema-version.d.ts +11 -0
  32. package/dist/schema-version.js +16 -0
  33. package/dist/schema.d.ts +2 -0
  34. package/dist/schema.js +448 -0
  35. package/package.json +92 -5
  36. package/src/baseline/postgres.sql +241 -0
  37. package/src/baseline/sqlite.sql +27 -0
  38. package/src/baseline.ts +104 -0
  39. package/src/commits.ts +47 -0
  40. package/src/connect.ts +171 -0
  41. package/src/database.ts +63 -0
  42. package/src/dialect.ts +174 -0
  43. package/src/drizzle.postgres.config.ts +7 -0
  44. package/src/drizzle.sqlite.config.ts +7 -0
  45. package/src/hyperdrive.ts +63 -0
  46. package/src/log.ts +177 -0
  47. package/src/migrate-main.ts +8 -0
  48. package/src/migrate.ts +207 -0
  49. package/src/migrations/postgres/0000_baseline.sql +505 -0
  50. package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
  51. package/src/migrations/postgres/meta/_journal.json +13 -0
  52. package/src/migrations/sqlite/0000_baseline.sql +293 -0
  53. package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  54. package/src/migrations/sqlite/meta/_journal.json +13 -0
  55. package/src/portable.ts +54 -0
  56. package/src/postgres.ts +35 -0
  57. package/src/relations.ts +53 -0
  58. package/src/schema-version.ts +13 -0
  59. package/src/schema.sqlite.ts +511 -0
  60. package/src/schema.ts +548 -0
  61. package/src/verify-runtime.ts +122 -0
@@ -0,0 +1,241 @@
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 NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
48
+ ELSE
49
+ -- Managed owners cannot ALTER the superuser-only attributes, even to turn them off.
50
+ IF EXISTS (
51
+ SELECT 1 FROM pg_roles WHERE rolname = name
52
+ AND (rolsuper OR rolreplication OR rolbypassrls)
53
+ ) THEN
54
+ RAISE EXCEPTION '% has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off', name;
55
+ END IF;
56
+ EXECUTE format('ALTER ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
57
+ END IF;
58
+ END LOOP;
59
+ END
60
+ $$;
61
+ --> statement-breakpoint
62
+
63
+
64
+ DO $$
65
+ DECLARE
66
+ name text;
67
+ runtime_role oid;
68
+ BEGIN
69
+ FOREACH name IN ARRAY ARRAY['coffre_runtime', 'coffre_vault_runtime'] LOOP
70
+ SELECT oid INTO runtime_role FROM pg_roles WHERE rolname = name;
71
+ IF runtime_role IS NULL THEN
72
+ RAISE EXCEPTION '% must be provisioned before migrations', name;
73
+ END IF;
74
+
75
+ IF EXISTS (
76
+ SELECT 1
77
+ FROM pg_roles
78
+ WHERE oid = runtime_role
79
+ AND (rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls)
80
+ ) THEN
81
+ RAISE EXCEPTION '% has unsafe role attributes', name;
82
+ END IF;
83
+
84
+ IF EXISTS (
85
+ SELECT 1
86
+ FROM pg_shdepend
87
+ WHERE refclassid = 'pg_authid'::regclass
88
+ AND refobjid = runtime_role
89
+ AND deptype = 'o'
90
+ ) THEN
91
+ RAISE EXCEPTION '% owns database objects', name;
92
+ END IF;
93
+ END LOOP;
94
+ END
95
+ $$;
96
+ --> statement-breakpoint
97
+
98
+ REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public
99
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
100
+ --> statement-breakpoint
101
+ REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public
102
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
103
+ --> statement-breakpoint
104
+ REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA public
105
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
106
+ --> statement-breakpoint
107
+ REVOKE ALL PRIVILEGES ON SCHEMA public
108
+ FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
109
+ --> statement-breakpoint
110
+ GRANT USAGE ON SCHEMA public TO coffre_app, coffre_vault;
111
+ --> statement-breakpoint
112
+ GRANT USAGE ON SCHEMA drizzle TO coffre_app, coffre_vault;
113
+ --> statement-breakpoint
114
+ GRANT SELECT ON drizzle.__drizzle_migrations TO coffre_app, coffre_vault;
115
+ --> statement-breakpoint
116
+
117
+ GRANT SELECT, INSERT ON
118
+ projects,
119
+ environments,
120
+ secrets,
121
+ secret_versions,
122
+ audit_log,
123
+ identities,
124
+ credentials,
125
+ device_authorizations,
126
+ syncs,
127
+ sync_keys
128
+ TO coffre_app;
129
+ --> statement-breakpoint
130
+
131
+ GRANT SELECT ON
132
+ audit_chain_head,
133
+ vault_members,
134
+ vault_grants
135
+ TO coffre_app;
136
+ --> statement-breakpoint
137
+
138
+ GRANT UPDATE (slug, name, archived_at) ON projects TO coffre_app;
139
+ --> statement-breakpoint
140
+ GRANT UPDATE (slug, name, archived_at) ON environments TO coffre_app;
141
+ --> statement-breakpoint
142
+ GRANT UPDATE (key, current_version_id, current_version, updated_at, archived_at) ON secrets TO coffre_app;
143
+ --> statement-breakpoint
144
+ GRANT UPDATE (next_seq, head_hash) ON audit_chain_head TO coffre_app;
145
+ --> statement-breakpoint
146
+ GRANT UPDATE (email, last_sign_in_at, revoked_at, revoked_by, auth_mac) ON identities TO coffre_app;
147
+ --> statement-breakpoint
148
+ GRANT UPDATE (last_used_at, last_used_ip, revoked_at, revoked_by, auth_mac) ON credentials TO coffre_app;
149
+ --> statement-breakpoint
150
+ GRANT UPDATE (decided_at, decision, generation, principal, consumed_at, auth_mac)
151
+ ON device_authorizations TO coffre_app;
152
+ --> statement-breakpoint
153
+ GRANT UPDATE (config, credential_secret_id, paused_at, archived_at, lease_until,
154
+ last_run_at, last_status, last_error)
155
+ ON syncs TO coffre_app;
156
+ --> statement-breakpoint
157
+ GRANT UPDATE (secret_version_id, pushed_at, removed_at) ON sync_keys TO coffre_app;
158
+ --> statement-breakpoint
159
+
160
+ -- The vault reads what it decides on and the log it chains to, and writes
161
+ -- its members and grants. A revoked grant is deleted: the log keeps it.
162
+ GRANT SELECT ON
163
+ projects,
164
+ environments,
165
+ secrets,
166
+ secret_versions,
167
+ audit_chain_head
168
+ TO coffre_vault;
169
+ --> statement-breakpoint
170
+ GRANT SELECT, INSERT ON audit_log TO coffre_vault;
171
+ --> statement-breakpoint
172
+ GRANT SELECT, INSERT, UPDATE (status, owner, generation, created_at, created_by, status_changed_at, status_changed_by, access_seq, mac)
173
+ ON vault_members TO coffre_vault;
174
+ --> statement-breakpoint
175
+ GRANT SELECT, INSERT, DELETE ON vault_grants TO coffre_vault;
176
+ --> statement-breakpoint
177
+ GRANT UPDATE (next_seq, head_hash) ON audit_chain_head TO coffre_vault;
178
+ --> statement-breakpoint
179
+
180
+ REVOKE DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM coffre_app;
181
+ --> statement-breakpoint
182
+ REVOKE TRUNCATE ON ALL TABLES IN SCHEMA public FROM coffre_vault;
183
+ --> statement-breakpoint
184
+ REVOKE UPDATE ON audit_log, secret_versions FROM coffre_app, coffre_vault;
185
+ --> statement-breakpoint
186
+ REVOKE CREATE ON SCHEMA public FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
187
+ --> statement-breakpoint
188
+
189
+ -- Each login appends only as its own author. Its MAC is still what proves
190
+ -- who wrote an entry: the table's owner passes these policies, as owners do.
191
+ ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
192
+ --> statement-breakpoint
193
+ CREATE POLICY audit_log_read ON audit_log FOR SELECT TO coffre_app, coffre_vault USING (true);
194
+ --> statement-breakpoint
195
+ CREATE POLICY audit_log_app_appends ON audit_log FOR INSERT TO coffre_app WITH CHECK (author = 'app');
196
+ --> statement-breakpoint
197
+ CREATE POLICY audit_log_vault_appends ON audit_log FOR INSERT TO coffre_vault WITH CHECK (author = 'vault');
198
+ --> statement-breakpoint
199
+
200
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
201
+ REVOKE ALL PRIVILEGES ON TABLES 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 SEQUENCES FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
205
+ --> statement-breakpoint
206
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
207
+ REVOKE ALL PRIVILEGES ON ROUTINES FROM coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
208
+ --> statement-breakpoint
209
+
210
+ -- Each login is a member of its own group and of nothing else.
211
+ DO $$
212
+ DECLARE
213
+ login record;
214
+ membership record;
215
+ BEGIN
216
+ EXECUTE format(
217
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
218
+ current_database()
219
+ );
220
+
221
+ FOR login IN
222
+ SELECT * FROM (VALUES ('coffre_runtime', 'coffre_app'), ('coffre_vault_runtime', 'coffre_vault')) AS logins (name, grp)
223
+ LOOP
224
+ FOR membership IN
225
+ SELECT granted.rolname
226
+ FROM pg_auth_members member_of
227
+ JOIN pg_roles member ON member.oid = member_of.member
228
+ JOIN pg_roles granted ON granted.oid = member_of.roleid
229
+ WHERE member.rolname = login.name
230
+ AND granted.rolname <> login.grp
231
+ LOOP
232
+ EXECUTE format('REVOKE %I FROM %I', membership.rolname, login.name);
233
+ END LOOP;
234
+ END LOOP;
235
+ END
236
+ $$;
237
+ --> statement-breakpoint
238
+
239
+ GRANT coffre_app TO coffre_runtime WITH ADMIN FALSE, INHERIT TRUE, SET FALSE;
240
+ --> statement-breakpoint
241
+ 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,171 @@
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
+ import { postgresConnection } from './postgres.ts';
10
+
11
+ export { postgresConnection } from './postgres.ts';
12
+
13
+ /**
14
+ * Open a database from its URL, on a Node server:
15
+ *
16
+ * postgres://… or postgresql://… node-postgres
17
+ * file:… or libsql:… @libsql/client (SQLite)
18
+ *
19
+ * The SQLite driver loads only when asked for. The Worker does not
20
+ * come through here; it builds its Postgres database from the Hyperdrive
21
+ * pool with createDatabase.
22
+ */
23
+ export async function openDatabase(url: string): Promise<OpenDatabase> {
24
+ switch (engineOfUrl(url)) {
25
+ case 'postgres': {
26
+ const pool = new pg.Pool(postgresConnection(url));
27
+ return { engine: 'postgres', db: createDatabase(pool), close: () => pool.end() };
28
+ }
29
+ case 'sqlite': {
30
+ const [{ createClient }, { drizzle }, schema] = await Promise.all([
31
+ import('@libsql/client'),
32
+ import('drizzle-orm/libsql'),
33
+ import('./schema.sqlite.ts'),
34
+ ]);
35
+ // A lone statement that finds the database locked waits this long.
36
+ const client = createClient({ url, timeout: 5_000, intMode: 'bigint' });
37
+ if (url.startsWith('file:')) await client.execute('PRAGMA journal_mode = WAL');
38
+ const db = drizzle({ client: oneTransactionAtATime(client, writeQueueFor(url)), schema });
39
+ return { engine: 'sqlite', db: asPostgresDatabase(db), close: async () => client.close() };
40
+ }
41
+ }
42
+ }
43
+
44
+ export type OpenDatabase = { engine: Engine; db: Database; close: () => Promise<void> };
45
+
46
+ export function engineOfUrl(url: string): Engine {
47
+ const scheme = url.slice(0, url.indexOf(':'));
48
+ if (scheme === 'postgres' || scheme === 'postgresql') return 'postgres';
49
+ if (scheme === 'mysql') throw new Error('coffre supports Postgres; MySQL support was removed');
50
+ if (scheme === 'file' || scheme === 'libsql') return 'sqlite';
51
+ throw new Error(`unsupported database URL scheme: ${scheme}:`);
52
+ }
53
+
54
+ // --- SQLite: one writer at a time ---------------------------------------------------
55
+
56
+ /**
57
+ * SQLite lets one connection write at a time and fails the others at once
58
+ * with SQLITE_BUSY rather than queueing them; 24 concurrent audit appends were
59
+ * enough to see it. So transactions queue here instead, in the order they
60
+ * asked, one open at a time per database file, however many clients or
61
+ * connections reach it. Each begins IMMEDIATE (libsql's default), taking the
62
+ * write lock with its first statement, which is what makes a read inside it
63
+ * as good as a locked one (see forUpdate).
64
+ */
65
+ class WriteQueue {
66
+ #tail: Promise<void> = Promise.resolve();
67
+
68
+ /** Wait for our turn; the returned function ends it. */
69
+ acquire(): Promise<() => void> {
70
+ let release!: () => void;
71
+ const turn = new Promise<void>((done) => (release = done));
72
+ const ready = this.#tail.then(() => release);
73
+ this.#tail = this.#tail.then(() => turn);
74
+ return ready;
75
+ }
76
+ }
77
+
78
+ const queues = new Map<string, WriteQueue>();
79
+
80
+ function writeQueueFor(url: string): WriteQueue {
81
+ // file:/a.db, file:///a.db and a relative file:a.db from / are one file.
82
+ const key = url.startsWith('file:') ? resolve(url.replace(/^file:(\/\/)?/, '').split('?')[0]) : url;
83
+ let queue = queues.get(key);
84
+ if (queue === undefined) queues.set(key, (queue = new WriteQueue()));
85
+ return queue;
86
+ }
87
+
88
+ function oneTransactionAtATime(client: Client, queue: WriteQueue): Client {
89
+ return new Proxy(client, {
90
+ get(target, key) {
91
+ if (key === 'execute') {
92
+ return async (...args: Parameters<Client['execute']>) => numbers(await target.execute(...args));
93
+ }
94
+ if (key === 'transaction') {
95
+ return async (mode?: TransactionMode) => {
96
+ const release = await queue.acquire();
97
+ try {
98
+ return settling(await target.transaction(mode), release);
99
+ } catch (error) {
100
+ release();
101
+ throw error;
102
+ }
103
+ };
104
+ }
105
+ if (key === 'batch' || key === 'migrate' || key === 'executeMultiple') {
106
+ return async (...args: unknown[]) => {
107
+ const release = await queue.acquire();
108
+ try {
109
+ const result = await (target[key] as (...args: unknown[]) => Promise<ResultSet[] | void>)(...args);
110
+ return result?.map(numbers);
111
+ } finally {
112
+ release();
113
+ }
114
+ };
115
+ }
116
+ const value = Reflect.get(target, key, target);
117
+ return typeof value === 'function' ? value.bind(target) : value;
118
+ },
119
+ });
120
+ }
121
+
122
+ /** Ends the transaction's turn when it commits, rolls back or closes. */
123
+ function settling(tx: LibsqlTransaction, release: () => void): LibsqlTransaction {
124
+ let settled = false;
125
+ const settle = () => {
126
+ if (settled) return;
127
+ settled = true;
128
+ release();
129
+ };
130
+ const { commit, rollback, close, execute, batch } = tx;
131
+ tx.execute = async (...args) => numbers(await execute.apply(tx, args));
132
+ tx.batch = async (...args) => (await batch.apply(tx, args)).map(numbers);
133
+ tx.commit = async () => {
134
+ try {
135
+ return await commit.call(tx);
136
+ } finally {
137
+ settle();
138
+ }
139
+ };
140
+ tx.rollback = async () => {
141
+ try {
142
+ return await rollback.call(tx);
143
+ } finally {
144
+ settle();
145
+ }
146
+ };
147
+ tx.close = () => {
148
+ try {
149
+ close.call(tx);
150
+ } finally {
151
+ settle();
152
+ }
153
+ };
154
+ return tx;
155
+ }
156
+
157
+ /** Keep large counters exact; Drizzle's booleans and dates still need safe numbers. */
158
+ function numbers(result: ResultSet): ResultSet {
159
+ result.rows = result.rows.map((row) => {
160
+ // libSQL exposes both named and immutable indexed values on each row.
161
+ const properties = Object.getOwnPropertyDescriptors(row);
162
+ for (const property of Object.values(properties)) {
163
+ const value = property.value;
164
+ if (typeof value === 'bigint' && value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
165
+ property.value = Number(value);
166
+ }
167
+ }
168
+ return Object.create(Object.getPrototypeOf(row), properties);
169
+ });
170
+ return result;
171
+ }
@@ -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
+ }