@coffre/db 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/commits-BuW-NL3n.js +30 -0
- package/dist/connect.d.ts +21 -0
- package/dist/connect.js +155 -0
- package/dist/database-CWpo5aF0.d.ts +39 -0
- package/dist/dialect.d.ts +88 -0
- package/dist/dialect.js +128 -0
- package/dist/hyperdrive.d.ts +32 -0
- package/dist/hyperdrive.js +53 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +34 -0
- package/dist/log.d.ts +72 -0
- package/dist/log.js +133 -0
- package/dist/migrate.d.ts +10 -0
- package/dist/migrate.js +129 -0
- package/dist/migrations/postgres/0000_baseline.sql +502 -0
- package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/dist/migrations/postgres/meta/_journal.json +13 -0
- package/dist/migrations/sqlite/0000_baseline.sql +293 -0
- package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/dist/migrations/sqlite/meta/_journal.json +13 -0
- package/dist/portable.d.ts +33 -0
- package/dist/portable.js +12 -0
- package/dist/relations-DqgxtlKu.js +58 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Cd4WOnB5.d.ts +2503 -0
- package/dist/schema-sqlite.d.ts +2587 -0
- package/dist/schema-sqlite.js +401 -0
- package/dist/schema-version.d.ts +11 -0
- package/dist/schema-version.js +16 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +448 -0
- package/package.json +91 -3
- package/src/baseline/postgres.sql +238 -0
- package/src/baseline/sqlite.sql +27 -0
- package/src/baseline.ts +104 -0
- package/src/commits.ts +47 -0
- package/src/connect.ts +168 -0
- package/src/database.ts +63 -0
- package/src/dialect.ts +174 -0
- package/src/drizzle.postgres.config.ts +7 -0
- package/src/drizzle.sqlite.config.ts +7 -0
- package/src/hyperdrive.ts +62 -0
- package/src/log.ts +177 -0
- package/src/migrate-main.ts +8 -0
- package/src/migrate.ts +206 -0
- package/src/migrations/postgres/0000_baseline.sql +502 -0
- package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
- package/src/migrations/postgres/meta/_journal.json +13 -0
- package/src/migrations/sqlite/0000_baseline.sql +293 -0
- package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
- package/src/migrations/sqlite/meta/_journal.json +13 -0
- package/src/portable.ts +54 -0
- package/src/relations.ts +53 -0
- package/src/schema-version.ts +13 -0
- package/src/schema.sqlite.ts +511 -0
- package/src/schema.ts +548 -0
- 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;
|
package/src/baseline.ts
ADDED
|
@@ -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
|
+
}
|
package/src/database.ts
ADDED
|
@@ -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
|
+
}
|