@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.
- package/LICENSE +21 -0
- package/README.md +19 -3
- package/dist/commits-BuW-NL3n.js +30 -0
- package/dist/connect.d.ts +26 -0
- package/dist/connect.js +156 -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 +54 -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 +130 -0
- package/dist/migrations/postgres/0000_baseline.sql +505 -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/postgres-Bmar8y0q.js +29 -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 +92 -5
- package/src/baseline/postgres.sql +241 -0
- package/src/baseline/sqlite.sql +27 -0
- package/src/baseline.ts +104 -0
- package/src/commits.ts +47 -0
- package/src/connect.ts +171 -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 +63 -0
- package/src/log.ts +177 -0
- package/src/migrate-main.ts +8 -0
- package/src/migrate.ts +207 -0
- package/src/migrations/postgres/0000_baseline.sql +505 -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/postgres.ts +35 -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 +122 -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
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { t as postgresConnection } from "./postgres-Bmar8y0q.js";
|
|
2
|
+
import { engineOfUrl } from "./connect.js";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
//#region src/migrate.ts
|
|
7
|
+
/**
|
|
8
|
+
* Bring a database up to date with its engine's migration tree:
|
|
9
|
+
* migrations/postgres or migrations/sqlite. Both trees
|
|
10
|
+
* describe one schema; see schema-parity.test.ts.
|
|
11
|
+
*
|
|
12
|
+
* Applied history must be a prefix of the local journal, byte for byte,
|
|
13
|
+
* before and after. Only one migrator runs at a time: Postgres takes an
|
|
14
|
+
* advisory lock, and SQLite's own write lock covers the
|
|
15
|
+
* whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
|
|
16
|
+
*/
|
|
17
|
+
const MIGRATION_LOCK_KEY = "7165058122361679213";
|
|
18
|
+
/**
|
|
19
|
+
* Beside this module: src/migrations in the workspace, and dist/migrations
|
|
20
|
+
* in the built package, where the build copies them.
|
|
21
|
+
*/
|
|
22
|
+
function migrationsFolder(engine) {
|
|
23
|
+
return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
|
|
24
|
+
}
|
|
25
|
+
async function expectedMigrations(engine) {
|
|
26
|
+
const folder = migrationsFolder(engine);
|
|
27
|
+
const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
|
|
28
|
+
if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
|
|
29
|
+
const expected = await Promise.all(journal.entries.map(async (entry, position) => {
|
|
30
|
+
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}`);
|
|
31
|
+
const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
|
|
32
|
+
return {
|
|
33
|
+
createdAt: String(entry.when),
|
|
34
|
+
hash: createHash("sha256").update(sqlText).digest("hex"),
|
|
35
|
+
tag: entry.tag
|
|
36
|
+
};
|
|
37
|
+
}));
|
|
38
|
+
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");
|
|
39
|
+
return expected;
|
|
40
|
+
}
|
|
41
|
+
function verifyHistory(expected, applied, requireComplete) {
|
|
42
|
+
const rows = applied ?? [];
|
|
43
|
+
if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
|
|
44
|
+
if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
|
|
45
|
+
rows.forEach((row, index) => {
|
|
46
|
+
const local = expected[index];
|
|
47
|
+
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`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async function postgresMigrator(url) {
|
|
51
|
+
const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
|
|
52
|
+
import("pg"),
|
|
53
|
+
import("drizzle-orm/node-postgres"),
|
|
54
|
+
import("drizzle-orm/node-postgres/migrator")
|
|
55
|
+
]);
|
|
56
|
+
const pool = new pg.Pool({
|
|
57
|
+
...postgresConnection(url),
|
|
58
|
+
application_name: "coffre-migrations",
|
|
59
|
+
max: 1
|
|
60
|
+
});
|
|
61
|
+
const client = await pool.connect();
|
|
62
|
+
return {
|
|
63
|
+
async applied() {
|
|
64
|
+
if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
|
|
65
|
+
return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
|
|
66
|
+
},
|
|
67
|
+
async lock() {
|
|
68
|
+
await client.query("SET lock_timeout = '5min'");
|
|
69
|
+
await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
70
|
+
},
|
|
71
|
+
async unlock() {
|
|
72
|
+
await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
73
|
+
},
|
|
74
|
+
migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
|
|
75
|
+
async restrict() {
|
|
76
|
+
await client.query(`DO $$ BEGIN
|
|
77
|
+
EXECUTE format(
|
|
78
|
+
'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
|
|
79
|
+
current_database()
|
|
80
|
+
);
|
|
81
|
+
END $$`);
|
|
82
|
+
},
|
|
83
|
+
async close() {
|
|
84
|
+
client.release();
|
|
85
|
+
await pool.end();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function sqliteMigrator(url) {
|
|
90
|
+
const [{ openDatabase }, { migrate }] = await Promise.all([import("./connect.js"), import("drizzle-orm/libsql/migrator")]);
|
|
91
|
+
const { db, close } = await openDatabase(url);
|
|
92
|
+
const sqlite = db;
|
|
93
|
+
return {
|
|
94
|
+
async applied() {
|
|
95
|
+
if ((await sqlite.all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")).length === 0) return null;
|
|
96
|
+
return sqlite.all("SELECT hash, CAST(created_at AS TEXT) AS created_at FROM __drizzle_migrations ORDER BY created_at, id");
|
|
97
|
+
},
|
|
98
|
+
async lock() {},
|
|
99
|
+
async unlock() {},
|
|
100
|
+
migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
|
|
101
|
+
async restrict() {},
|
|
102
|
+
close
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Apply missing migrations, check their history and reassert database privileges. */
|
|
106
|
+
async function migrateDatabase(url) {
|
|
107
|
+
const engine = engineOfUrl(url);
|
|
108
|
+
const expected = await expectedMigrations(engine);
|
|
109
|
+
const migrator = await {
|
|
110
|
+
postgres: postgresMigrator,
|
|
111
|
+
sqlite: sqliteMigrator
|
|
112
|
+
}[engine](url);
|
|
113
|
+
let locked = false;
|
|
114
|
+
try {
|
|
115
|
+
await migrator.lock();
|
|
116
|
+
locked = true;
|
|
117
|
+
verifyHistory(expected, await migrator.applied(), false);
|
|
118
|
+
await migrator.migrate(migrationsFolder(engine));
|
|
119
|
+
verifyHistory(expected, await migrator.applied(), true);
|
|
120
|
+
await migrator.restrict();
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
if (locked) await migrator.unlock();
|
|
124
|
+
} finally {
|
|
125
|
+
await migrator.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
export { migrateDatabase, migrationsFolder };
|