@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -2
  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 -3
  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
package/dist/log.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { i as Transaction } from "./database-CWpo5aF0.js";
2
+ import { LogKey } from "@coffre/core/audit";
3
+ //#region src/log.d.ts
4
+ /**
5
+ * Appending to the audit log, the one table both the app and the vault
6
+ * write, each under its own key (@coffre/core/audit says what an entry's MAC
7
+ * and hash cover).
8
+ *
9
+ * Every append locks the log's head first, `audit_chain_head`, so entries
10
+ * from any process take their numbers in one order. It then reads the
11
+ * database clock, after the lock, and the log's last entry, which the head
12
+ * must name: a head that does not was moved, or entries were deleted behind
13
+ * it, and the append refuses.
14
+ *
15
+ * This is deliberately not a queue and not best-effort: the entries go in
16
+ * the caller's transaction, so if the append fails, what it records does
17
+ * not happen either.
18
+ */
19
+ /** An entry to append: what it says. The append numbers, dates, signs and chains it. */
20
+ export type NewEntry = {
21
+ actor: string;
22
+ action: string;
23
+ decision: 'allow' | 'deny';
24
+ code?: string | null;
25
+ subjectPrincipal?: string | null;
26
+ projectId?: string | null;
27
+ environmentId?: string | null;
28
+ secretId?: string | null;
29
+ secretVersionId?: string | null;
30
+ operationId?: string | null;
31
+ requestId?: string | null;
32
+ sourceIp?: string | null;
33
+ relatedSeq?: bigint | null;
34
+ /** JSON text. */
35
+ metadata?: string;
36
+ };
37
+ export type Appended = {
38
+ /** The first entry's number, and the next number after the last. */
39
+ seqStart: bigint;
40
+ nextSeq: bigint;
41
+ headHash: Buffer;
42
+ /** Milliseconds since the epoch, the database's, shared by the batch. */
43
+ occurredAt: number;
44
+ };
45
+ /** The log's head does not name its last entry: something moved it, or cut the log behind it. */
46
+ export declare class LogHeadMismatch extends Error {
47
+ constructor(message: string);
48
+ }
49
+ /**
50
+ * The log's head is behind one this process wrote: the database was rolled
51
+ * back, or entries were cut off its end. That cannot be told from a restore,
52
+ * so the append refuses and someone has to look.
53
+ */
54
+ export declare class LogRewound extends Error {
55
+ constructor(message: string);
56
+ }
57
+ /**
58
+ * Forget every head written so far. For tests that empty the database
59
+ * between cases, which is exactly what the check above refuses.
60
+ */
61
+ export declare function forgetLogHeads(): void;
62
+ /**
63
+ * Lock the log's head, and read it. A transaction that appends takes this
64
+ * before any other row, so the lock order is the same everywhere; taking it
65
+ * again in the same transaction is free.
66
+ */
67
+ export declare function lockLogHead(tx: Transaction): Promise<{
68
+ nextSeq: bigint;
69
+ headHash: Buffer;
70
+ }>;
71
+ export declare function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[]): Promise<Appended>;
72
+ //#endregion
package/dist/log.js ADDED
@@ -0,0 +1,133 @@
1
+ import { t as afterCommit } from "./commits-BuW-NL3n.js";
2
+ import { tablesOf } from "./index.js";
3
+ import { clockMillis, forUpdate } from "./dialect.js";
4
+ import { desc, eq } from "drizzle-orm";
5
+ import { GENESIS_HASH, sealEntry } from "@coffre/core/audit";
6
+ //#region src/log.ts
7
+ /** The log's head does not name its last entry: something moved it, or cut the log behind it. */
8
+ var LogHeadMismatch = class extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "LogHeadMismatch";
12
+ }
13
+ };
14
+ /**
15
+ * The log's head is behind one this process wrote: the database was rolled
16
+ * back, or entries were cut off its end. That cannot be told from a restore,
17
+ * so the append refuses and someone has to look.
18
+ */
19
+ var LogRewound = class extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "LogRewound";
23
+ }
24
+ };
25
+ /**
26
+ * The newest head each writer has written, under each of its keys, while
27
+ * the process lives. A head is remembered only once the transaction that
28
+ * wrote it has committed, all the way out (commits.ts): one that rolls back
29
+ * never puts it ahead of the database, and the last batch committed is
30
+ * covered too. When commits race, the furthest head stays.
31
+ */
32
+ const remembered = /* @__PURE__ */ new WeakMap();
33
+ let epoch = 0;
34
+ function remember(key, head) {
35
+ const before = remembered.get(key);
36
+ if (before !== void 0 && before.epoch === epoch && before.nextSeq >= head.nextSeq) return;
37
+ remembered.set(key, {
38
+ epoch,
39
+ ...head
40
+ });
41
+ }
42
+ /**
43
+ * Forget every head written so far. For tests that empty the database
44
+ * between cases, which is exactly what the check above refuses.
45
+ */
46
+ function forgetLogHeads() {
47
+ epoch += 1;
48
+ }
49
+ /**
50
+ * Lock the log's head, and read it. A transaction that appends takes this
51
+ * before any other row, so the lock order is the same everywhere; taking it
52
+ * again in the same transaction is free.
53
+ */
54
+ async function lockLogHead(tx) {
55
+ const { auditChainHead } = tablesOf(tx);
56
+ const [head] = await forUpdate(tx, tx.select({
57
+ nextSeq: auditChainHead.nextSeq,
58
+ headHash: auditChainHead.headHash
59
+ }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)));
60
+ if (head === void 0) throw new Error("audit_chain_head is missing; refusing to write an unchained entry");
61
+ return head;
62
+ }
63
+ async function appendEntries(tx, key, entries) {
64
+ if (entries.length === 0) throw new Error("appendEntries called with no entries");
65
+ const { auditChainHead, auditLog } = tablesOf(tx);
66
+ const head = await lockLogHead(tx);
67
+ const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
68
+ const [last] = await tx.select({
69
+ seq: auditLog.seq,
70
+ hash: auditLog.hash
71
+ }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
72
+ const expected = last === void 0 ? {
73
+ nextSeq: 0n,
74
+ headHash: GENESIS_HASH
75
+ } : {
76
+ nextSeq: last.seq + 1n,
77
+ headHash: last.hash
78
+ };
79
+ if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) throw new LogHeadMismatch(`the log's head says entry ${head.nextSeq} comes next, but the log ends ${last === void 0 ? "empty" : `at entry ${last.seq}`}, or with another hash`);
80
+ const before = remembered.get(key);
81
+ if (before !== void 0 && before.epoch === epoch && (head.nextSeq < before.nextSeq || head.nextSeq === before.nextSeq && !head.headHash.equals(before.headHash))) throw new LogRewound(`the log ends before entry ${before.nextSeq}, which this process wrote: it was rolled back`);
82
+ let seq = head.nextSeq;
83
+ let prevHash = head.headHash;
84
+ const rows = [];
85
+ for (const entry of entries) {
86
+ const fields = {
87
+ seq,
88
+ author: key.author,
89
+ keyId: key.keyId,
90
+ occurredAt: now,
91
+ actor: entry.actor,
92
+ action: entry.action,
93
+ decision: entry.decision,
94
+ code: entry.code ?? null,
95
+ subjectPrincipal: entry.subjectPrincipal ?? null,
96
+ projectId: entry.projectId ?? null,
97
+ environmentId: entry.environmentId ?? null,
98
+ secretId: entry.secretId ?? null,
99
+ secretVersionId: entry.secretVersionId ?? null,
100
+ operationId: entry.operationId ?? null,
101
+ requestId: entry.requestId ?? null,
102
+ sourceIp: entry.sourceIp ?? null,
103
+ relatedSeq: entry.relatedSeq ?? null,
104
+ metadata: entry.metadata ?? "{}"
105
+ };
106
+ const { mac, hash } = sealEntry(key, prevHash, fields);
107
+ rows.push({
108
+ ...fields,
109
+ prevHash,
110
+ mac,
111
+ hash
112
+ });
113
+ prevHash = hash;
114
+ seq += 1n;
115
+ }
116
+ await tx.insert(auditLog).values(rows);
117
+ await tx.update(auditChainHead).set({
118
+ nextSeq: seq,
119
+ headHash: prevHash
120
+ }).where(eq(auditChainHead.onlyRow, true));
121
+ const written = {
122
+ nextSeq: seq,
123
+ headHash: prevHash
124
+ };
125
+ afterCommit(tx, () => remember(key, written));
126
+ return {
127
+ seqStart: head.nextSeq,
128
+ ...written,
129
+ occurredAt: now
130
+ };
131
+ }
132
+ //#endregion
133
+ export { LogHeadMismatch, LogRewound, appendEntries, forgetLogHeads, lockLogHead };
@@ -0,0 +1,10 @@
1
+ import { Engine } from "./dialect.js";
2
+ //#region src/migrate.d.ts
3
+ /**
4
+ * Beside this module: src/migrations in the workspace, and dist/migrations
5
+ * in the built package, where the build copies them.
6
+ */
7
+ export declare function migrationsFolder(engine: Engine): string;
8
+ /** Apply missing migrations, check their history and reassert database privileges. */
9
+ export declare function migrateDatabase(url: string): Promise<void>;
10
+ //#endregion
@@ -0,0 +1,129 @@
1
+ import { engineOfUrl } from "./connect.js";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region src/migrate.ts
6
+ /**
7
+ * Bring a database up to date with its engine's migration tree:
8
+ * migrations/postgres or migrations/sqlite. Both trees
9
+ * describe one schema; see schema-parity.test.ts.
10
+ *
11
+ * Applied history must be a prefix of the local journal, byte for byte,
12
+ * before and after. Only one migrator runs at a time: Postgres takes an
13
+ * advisory lock, and SQLite's own write lock covers the
14
+ * whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
15
+ */
16
+ const MIGRATION_LOCK_KEY = "7165058122361679213";
17
+ /**
18
+ * Beside this module: src/migrations in the workspace, and dist/migrations
19
+ * in the built package, where the build copies them.
20
+ */
21
+ function migrationsFolder(engine) {
22
+ return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
23
+ }
24
+ async function expectedMigrations(engine) {
25
+ const folder = migrationsFolder(engine);
26
+ const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
27
+ if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
28
+ const expected = await Promise.all(journal.entries.map(async (entry, position) => {
29
+ if (entry.idx !== position || !Number.isSafeInteger(entry.when) || typeof entry.tag !== "string" || !/^[a-zA-Z0-9_-]+$/.test(entry.tag)) throw new Error(`invalid Drizzle migration journal entry at index ${position}`);
30
+ const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
31
+ return {
32
+ createdAt: String(entry.when),
33
+ hash: createHash("sha256").update(sqlText).digest("hex"),
34
+ tag: entry.tag
35
+ };
36
+ }));
37
+ for (let index = 1; index < expected.length; index += 1) if (BigInt(expected[index - 1].createdAt) >= BigInt(expected[index].createdAt)) throw new Error("Drizzle migration journal timestamps must be strictly increasing");
38
+ return expected;
39
+ }
40
+ function verifyHistory(expected, applied, requireComplete) {
41
+ const rows = applied ?? [];
42
+ if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
43
+ if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
44
+ rows.forEach((row, index) => {
45
+ const local = expected[index];
46
+ if (row.created_at !== local.createdAt || row.hash !== local.hash) throw new Error(`migration history diverged at ${local.tag}; applied migrations must never be edited`);
47
+ });
48
+ }
49
+ async function postgresMigrator(url) {
50
+ const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
51
+ import("pg"),
52
+ import("drizzle-orm/node-postgres"),
53
+ import("drizzle-orm/node-postgres/migrator")
54
+ ]);
55
+ const pool = new pg.Pool({
56
+ application_name: "coffre-migrations",
57
+ connectionString: url,
58
+ max: 1
59
+ });
60
+ const client = await pool.connect();
61
+ return {
62
+ async applied() {
63
+ if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
64
+ return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
65
+ },
66
+ async lock() {
67
+ await client.query("SET lock_timeout = '5min'");
68
+ await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
69
+ },
70
+ async unlock() {
71
+ await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
72
+ },
73
+ migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
74
+ async restrict() {
75
+ await client.query(`DO $$ BEGIN
76
+ EXECUTE format(
77
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
78
+ current_database()
79
+ );
80
+ END $$`);
81
+ },
82
+ async close() {
83
+ client.release();
84
+ await pool.end();
85
+ }
86
+ };
87
+ }
88
+ async function sqliteMigrator(url) {
89
+ const [{ openDatabase }, { migrate }] = await Promise.all([import("./connect.js"), import("drizzle-orm/libsql/migrator")]);
90
+ const { db, close } = await openDatabase(url);
91
+ const sqlite = db;
92
+ return {
93
+ async applied() {
94
+ if ((await sqlite.all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")).length === 0) return null;
95
+ return sqlite.all("SELECT hash, CAST(created_at AS TEXT) AS created_at FROM __drizzle_migrations ORDER BY created_at, id");
96
+ },
97
+ async lock() {},
98
+ async unlock() {},
99
+ migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
100
+ async restrict() {},
101
+ close
102
+ };
103
+ }
104
+ /** Apply missing migrations, check their history and reassert database privileges. */
105
+ async function migrateDatabase(url) {
106
+ const engine = engineOfUrl(url);
107
+ const expected = await expectedMigrations(engine);
108
+ const migrator = await {
109
+ postgres: postgresMigrator,
110
+ sqlite: sqliteMigrator
111
+ }[engine](url);
112
+ let locked = false;
113
+ try {
114
+ await migrator.lock();
115
+ locked = true;
116
+ verifyHistory(expected, await migrator.applied(), false);
117
+ await migrator.migrate(migrationsFolder(engine));
118
+ verifyHistory(expected, await migrator.applied(), true);
119
+ await migrator.restrict();
120
+ } finally {
121
+ try {
122
+ if (locked) await migrator.unlock();
123
+ } finally {
124
+ await migrator.close();
125
+ }
126
+ }
127
+ }
128
+ //#endregion
129
+ export { migrateDatabase, migrationsFolder };