@coffre/db 0.12.1 → 0.14.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/dist/dialect.d.ts +19 -1
- package/dist/dialect.js +33 -5
- package/dist/log.d.ts +24 -1
- package/dist/log.js +93 -11
- package/dist/migrations/postgres/0018_rotate_app_key.sql +2 -0
- package/dist/migrations/postgres/meta/0018_snapshot.json +3295 -0
- package/dist/migrations/postgres/meta/_journal.json +7 -0
- package/dist/schema-version.js +7 -0
- package/package.json +2 -2
- package/src/dialect.ts +41 -5
- package/src/log.ts +98 -9
- package/src/migrations/postgres/0018_rotate_app_key.sql +2 -0
- package/src/migrations/postgres/meta/0018_snapshot.json +3295 -0
- package/src/migrations/postgres/meta/_journal.json +7 -0
package/dist/dialect.d.ts
CHANGED
|
@@ -56,9 +56,27 @@ export declare function isUniqueViolation(error: unknown): boolean;
|
|
|
56
56
|
/**
|
|
57
57
|
* Whether a query failed because the database could not be reached or
|
|
58
58
|
* dropped the connection: an outage, not an answer. Postgres's connection
|
|
59
|
-
* exceptions are class 08; pg and Workers' sockets say so in words
|
|
59
|
+
* exceptions are class 08; pg and Workers' sockets say so in words, and so
|
|
60
|
+
* does Hyperdrive when it has no connection to the database to give: every
|
|
61
|
+
* one of its pool busy for 15 s ("Timed out while waiting for an open slot
|
|
62
|
+
* in the pool", FATAL 58000; "Failed to acquire a connection from the
|
|
63
|
+
* pool"), or none to be made ("Server connection attempt failed:
|
|
64
|
+
* destination_unavailable").
|
|
60
65
|
*/
|
|
61
66
|
export declare function isUnreachable(error: unknown): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Whether a statement gave up waiting, on a lock or past its bound: the
|
|
69
|
+
* database too busy to answer in time, or a transaction held too long,
|
|
70
|
+
* which a bug may be (one held across a vault call waits on the vault's
|
|
71
|
+
* decision, which waits on it).
|
|
72
|
+
*/
|
|
73
|
+
export declare function gaveUp(error: unknown): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Whether a query failed for want of the database, not for anything the
|
|
76
|
+
* request asked: it could not be reached (`isUnreachable`), or a statement
|
|
77
|
+
* gave up waiting (`gaveUp`). The same request may well succeed in a moment.
|
|
78
|
+
*/
|
|
79
|
+
export declare function isUnavailable(error: unknown): boolean;
|
|
62
80
|
/**
|
|
63
81
|
* A consistent read of several statements that cannot write, such as
|
|
64
82
|
* verifying the chain. SQLite ignores it: its transactions already see one
|
package/dist/dialect.js
CHANGED
|
@@ -73,7 +73,7 @@ function isUniqueViolation(error) {
|
|
|
73
73
|
}
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
|
-
/** The network's codes for a server that is not there, and Postgres's for one that will not take the connection. */
|
|
76
|
+
/** The network's codes for a server that is not there, and Postgres's for one that will not take the connection, or has none left (53300). */
|
|
77
77
|
const UNREACHABLE = /* @__PURE__ */ new Set([
|
|
78
78
|
"ECONNREFUSED",
|
|
79
79
|
"ECONNRESET",
|
|
@@ -83,24 +83,52 @@ const UNREACHABLE = /* @__PURE__ */ new Set([
|
|
|
83
83
|
"EHOSTUNREACH",
|
|
84
84
|
"ENETUNREACH",
|
|
85
85
|
"EPIPE",
|
|
86
|
+
"53300",
|
|
86
87
|
"57P01",
|
|
87
88
|
"57P02",
|
|
88
89
|
"57P03"
|
|
89
90
|
]);
|
|
91
|
+
/** Hyperdrive's words for having no connection to give, beside pg's and Workers' for one lost. */
|
|
92
|
+
const UNREACHABLE_WORDS = /connection terminated|network connection lost|connect(ion)? timeout|open slot in the pool|acquire a connection from the pool|server connection attempt failed/i;
|
|
90
93
|
/**
|
|
91
94
|
* Whether a query failed because the database could not be reached or
|
|
92
95
|
* dropped the connection: an outage, not an answer. Postgres's connection
|
|
93
|
-
* exceptions are class 08; pg and Workers' sockets say so in words
|
|
96
|
+
* exceptions are class 08; pg and Workers' sockets say so in words, and so
|
|
97
|
+
* does Hyperdrive when it has no connection to the database to give: every
|
|
98
|
+
* one of its pool busy for 15 s ("Timed out while waiting for an open slot
|
|
99
|
+
* in the pool", FATAL 58000; "Failed to acquire a connection from the
|
|
100
|
+
* pool"), or none to be made ("Server connection attempt failed:
|
|
101
|
+
* destination_unavailable").
|
|
94
102
|
*/
|
|
95
103
|
function isUnreachable(error) {
|
|
96
104
|
for (let cause = error; typeof cause === "object" && cause !== null;) {
|
|
97
105
|
const { code, message } = cause;
|
|
98
|
-
if (typeof code === "string" && (UNREACHABLE.has(code) || /^08[0-9A-Z]{3}$/.test(code))) return true;
|
|
99
|
-
if (typeof message === "string" &&
|
|
106
|
+
if (typeof code === "string" && (UNREACHABLE.has(code) || /^08[0-9A-Z]{3}$/.test(code) && code !== "08P01")) return true;
|
|
107
|
+
if (typeof message === "string" && (typeof code !== "string" || code === "58000") && !message.includes("Failed query: ") && UNREACHABLE_WORDS.test(message)) return true;
|
|
100
108
|
cause = cause.cause;
|
|
101
109
|
}
|
|
102
110
|
return false;
|
|
103
111
|
}
|
|
112
|
+
/** Postgres's codes for a statement that gave up waiting: on a lock (55P03), or past its own bound (57014). */
|
|
113
|
+
const GAVE_UP = /* @__PURE__ */ new Set(["55P03", "57014"]);
|
|
114
|
+
/**
|
|
115
|
+
* Whether a statement gave up waiting, on a lock or past its bound: the
|
|
116
|
+
* database too busy to answer in time, or a transaction held too long,
|
|
117
|
+
* which a bug may be (one held across a vault call waits on the vault's
|
|
118
|
+
* decision, which waits on it).
|
|
119
|
+
*/
|
|
120
|
+
function gaveUp(error) {
|
|
121
|
+
for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (GAVE_UP.has(cause.code)) return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a query failed for want of the database, not for anything the
|
|
126
|
+
* request asked: it could not be reached (`isUnreachable`), or a statement
|
|
127
|
+
* gave up waiting (`gaveUp`). The same request may well succeed in a moment.
|
|
128
|
+
*/
|
|
129
|
+
function isUnavailable(error) {
|
|
130
|
+
return isUnreachable(error) || gaveUp(error);
|
|
131
|
+
}
|
|
104
132
|
/**
|
|
105
133
|
* A consistent read of several statements that cannot write, such as
|
|
106
134
|
* verifying the chain. SQLite ignores it: its transactions already see one
|
|
@@ -178,4 +206,4 @@ function canonicalTimestamp(raw) {
|
|
|
178
206
|
return `${(/* @__PURE__ */ new Date(Date.UTC(+year, +month - 1, +day, +hour, +minute, +second) - offsetMinutes * 6e4)).toISOString().slice(0, 19)}.${fraction.padEnd(6, "0")}Z`;
|
|
179
207
|
}
|
|
180
208
|
//#endregion
|
|
181
|
-
export { SNAPSHOT, ahead, canonicalTimestamp, changedRows, clock, clockMillis, engineOf, forUpdate, insertIfAbsent, isUniqueViolation, isUnreachable, migrationLedger, rowsOf, tombstone, truth, upsert };
|
|
209
|
+
export { SNAPSHOT, ahead, canonicalTimestamp, changedRows, clock, clockMillis, engineOf, forUpdate, gaveUp, insertIfAbsent, isUnavailable, isUniqueViolation, isUnreachable, migrationLedger, rowsOf, tombstone, truth, upsert };
|
package/dist/log.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { LogKey, StoredEntry } from "@coffre/core/audit";
|
|
|
11
11
|
* from any process take their numbers in one order. It then reads the
|
|
12
12
|
* database clock, after the lock, and the log's last entry, which the head
|
|
13
13
|
* must name: a head that does not was moved, or entries were deleted behind
|
|
14
|
-
* it, and the append refuses.
|
|
14
|
+
* it, and the append refuses. The same statement says whether the log has
|
|
15
|
+
* moved on from the key it appends under (`KeyRetired`).
|
|
15
16
|
*
|
|
16
17
|
* This is deliberately not a queue and not best-effort: the entries go in
|
|
17
18
|
* the caller's transaction, so if the append fails, what it records does
|
|
@@ -55,6 +56,21 @@ export declare class LogHeadMismatch extends Error {
|
|
|
55
56
|
export declare class LogRewound extends Error {
|
|
56
57
|
constructor(message: string);
|
|
57
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* An append under a key the log has moved on from: an entry of its
|
|
61
|
+
* author's, `key.reseal` or `key.rotate`, names it as the key it moves
|
|
62
|
+
* from (`MOVES_FROM`), and a key moved from writes no more.
|
|
63
|
+
*/
|
|
64
|
+
export declare class KeyRetired extends Error {
|
|
65
|
+
constructor(message: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The actions by which an author moves to a new key, each under the new one
|
|
69
|
+
* and naming the key it moves from as its subject: from the first, that key
|
|
70
|
+
* writes no more (`KeyRetired`). The app's key rotation writes them
|
|
71
|
+
* (packages/server/src/key-rotation.ts).
|
|
72
|
+
*/
|
|
73
|
+
export declare const MOVES_FROM: readonly ["key.reseal", "key.rotate"];
|
|
58
74
|
/**
|
|
59
75
|
* Forget every head written so far. For tests that empty the database
|
|
60
76
|
* between cases, which is exactly what the check above refuses.
|
|
@@ -69,6 +85,13 @@ export declare function lockLogHead(tx: Transaction): Promise<{
|
|
|
69
85
|
nextSeq: bigint;
|
|
70
86
|
headHash: Buffer;
|
|
71
87
|
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Lock the log's head if no one holds it, without waiting: whether it is
|
|
90
|
+
* held now. For work any caller may do, of which one at a time is enough,
|
|
91
|
+
* which a request that finds the head taken leaves to the one that has it.
|
|
92
|
+
* SQLite's transactions hold the whole file, so the head is always free.
|
|
93
|
+
*/
|
|
94
|
+
export declare function lockLogHeadIfFree(tx: Transaction): Promise<boolean>;
|
|
72
95
|
/**
|
|
73
96
|
* Append `entries` under `key`. `check`, when given, runs once the head is
|
|
74
97
|
* locked and before anything is written: a writer's own condition on the
|
package/dist/log.js
CHANGED
|
@@ -23,6 +23,24 @@ var LogRewound = class extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
/**
|
|
26
|
+
* An append under a key the log has moved on from: an entry of its
|
|
27
|
+
* author's, `key.reseal` or `key.rotate`, names it as the key it moves
|
|
28
|
+
* from (`MOVES_FROM`), and a key moved from writes no more.
|
|
29
|
+
*/
|
|
30
|
+
var KeyRetired = class extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "KeyRetired";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The actions by which an author moves to a new key, each under the new one
|
|
38
|
+
* and naming the key it moves from as its subject: from the first, that key
|
|
39
|
+
* writes no more (`KeyRetired`). The app's key rotation writes them
|
|
40
|
+
* (packages/server/src/key-rotation.ts).
|
|
41
|
+
*/
|
|
42
|
+
const MOVES_FROM = ["key.reseal", "key.rotate"];
|
|
43
|
+
/**
|
|
26
44
|
* The newest head each writer has written, under each of its keys, while
|
|
27
45
|
* the process lives. A head is remembered only once the transaction that
|
|
28
46
|
* wrote it has committed, all the way out (commits.ts): one that rolls back
|
|
@@ -47,6 +65,14 @@ function forgetLogHeads() {
|
|
|
47
65
|
epoch += 1;
|
|
48
66
|
}
|
|
49
67
|
/**
|
|
68
|
+
* The transactions that hold the head: an append in one reads what it
|
|
69
|
+
* needs under the lock in one statement, where one that does not first
|
|
70
|
+
* takes the lock in another. A savepoint's transaction is another to this
|
|
71
|
+
* set, so a lock is marked on the object that took it, and an append made
|
|
72
|
+
* through another object locks again, which is free.
|
|
73
|
+
*/
|
|
74
|
+
const holding = /* @__PURE__ */ new WeakSet();
|
|
75
|
+
/**
|
|
50
76
|
* Lock the log's head, and read it. A transaction that appends takes this
|
|
51
77
|
* before any other row, so the lock order is the same everywhere; taking it
|
|
52
78
|
* again in the same transaction is free.
|
|
@@ -54,8 +80,25 @@ function forgetLogHeads() {
|
|
|
54
80
|
async function lockLogHead(tx) {
|
|
55
81
|
const [head] = await lockedHead(tx);
|
|
56
82
|
if (head === void 0) throw new Error("audit_chain_head is missing; refusing to write an unchained entry");
|
|
83
|
+
holding.add(tx);
|
|
57
84
|
return head;
|
|
58
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Lock the log's head if no one holds it, without waiting: whether it is
|
|
88
|
+
* held now. For work any caller may do, of which one at a time is enough,
|
|
89
|
+
* which a request that finds the head taken leaves to the one that has it.
|
|
90
|
+
* SQLite's transactions hold the whole file, so the head is always free.
|
|
91
|
+
*/
|
|
92
|
+
async function lockLogHeadIfFree(tx) {
|
|
93
|
+
if (engineOf(tx) === "sqlite") {
|
|
94
|
+
const held = (await lockedHead(tx)).length > 0;
|
|
95
|
+
if (held) holding.add(tx);
|
|
96
|
+
return held;
|
|
97
|
+
}
|
|
98
|
+
const { rows } = await tx.execute(sql`SELECT next_seq FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED`);
|
|
99
|
+
if (rows.length > 0) holding.add(tx);
|
|
100
|
+
return rows.length > 0;
|
|
101
|
+
}
|
|
59
102
|
const lockedHead = prepared((db) => {
|
|
60
103
|
const { auditChainHead } = tablesOf(db);
|
|
61
104
|
return forUpdate(db, db.select({
|
|
@@ -72,10 +115,11 @@ const lockedHead = prepared((db) => {
|
|
|
72
115
|
async function appendEntries(tx, key, entries, check, locked) {
|
|
73
116
|
if (entries.length === 0) throw new Error("appendEntries called with no entries");
|
|
74
117
|
const { auditChainHead, auditLog } = tablesOf(tx);
|
|
75
|
-
|
|
118
|
+
if (locked === void 0 && !holding.has(tx)) await lockLogHead(tx);
|
|
76
119
|
await check?.(tx);
|
|
77
|
-
const
|
|
78
|
-
const
|
|
120
|
+
const held = locked ?? await readUnderHead(tx, key);
|
|
121
|
+
const { head, at: now } = held;
|
|
122
|
+
const last = held.last ?? void 0;
|
|
79
123
|
const expected = last === void 0 ? {
|
|
80
124
|
nextSeq: 0n,
|
|
81
125
|
headHash: GENESIS_HASH
|
|
@@ -140,15 +184,53 @@ async function appendEntries(tx, key, entries, check, locked) {
|
|
|
140
184
|
occurredAt: now
|
|
141
185
|
};
|
|
142
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* What an append reads under the head its transaction holds, in one
|
|
189
|
+
* statement: the head, the clock and the log's last entry, all as of the
|
|
190
|
+
* lock, since locking a head the transaction holds waits for nothing, and
|
|
191
|
+
* after the append's `check`. The log's own last row says whether the log
|
|
192
|
+
* has moved on from `key`.
|
|
193
|
+
*/
|
|
194
|
+
async function readUnderHead(tx, key) {
|
|
195
|
+
const [read] = await underHead(tx, {
|
|
196
|
+
author: key.author,
|
|
197
|
+
keyId: key.keyId
|
|
198
|
+
});
|
|
199
|
+
if (read === void 0) throw new Error("audit_chain_head is missing; refusing to write an unchained entry");
|
|
200
|
+
if (read.retired) throw new KeyRetired(`the log moved on from ${key.keyId}: a key the ${key.author} rotated from writes no more`);
|
|
201
|
+
return {
|
|
202
|
+
head: {
|
|
203
|
+
nextSeq: read.nextSeq,
|
|
204
|
+
headHash: read.headHash
|
|
205
|
+
},
|
|
206
|
+
last: read.lastSeq === null || read.lastHash === null ? null : {
|
|
207
|
+
seq: read.lastSeq,
|
|
208
|
+
hash: read.lastHash
|
|
209
|
+
},
|
|
210
|
+
at: read.now
|
|
211
|
+
};
|
|
212
|
+
}
|
|
143
213
|
/** Entries one statement inserts at most, at 21 parameters each: Postgres takes 65,535. */
|
|
144
214
|
const ROWS_AT_ONCE = 3e3;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
215
|
+
/**
|
|
216
|
+
* `readUnderHead`'s statement. Its `FOR UPDATE` waits for nothing in a
|
|
217
|
+
* transaction that holds the head, and stays for one that would not: there
|
|
218
|
+
* it waits, and if an append committed meanwhile, its head, read again
|
|
219
|
+
* after the wait, disagrees with the last entry its snapshot saw before it,
|
|
220
|
+
* so the append refuses (`LogHeadMismatch`) rather than writing unlocked.
|
|
221
|
+
*/
|
|
222
|
+
const underHead = prepared((db) => {
|
|
223
|
+
const { auditChainHead, auditLog } = tablesOf(db);
|
|
224
|
+
const moved = sql.raw(MOVES_FROM.map((action) => `'${action}'`).join(", "));
|
|
225
|
+
const newest = (column) => sql`(SELECT ${column} FROM audit_log ORDER BY seq DESC LIMIT 1)`;
|
|
226
|
+
return forUpdate(db, db.select({
|
|
227
|
+
nextSeq: auditChainHead.nextSeq,
|
|
228
|
+
headHash: auditChainHead.headHash,
|
|
229
|
+
now: clockMillis(db),
|
|
230
|
+
lastSeq: newest(sql`seq`).mapWith(auditLog.seq),
|
|
231
|
+
lastHash: newest(sql`hash`).mapWith(auditLog.hash),
|
|
232
|
+
retired: sql`EXISTS (SELECT 1 FROM audit_log moved WHERE moved.author = ${sql.placeholder("author")} AND moved.subject_principal = ${sql.placeholder("keyId")} AND moved.action IN (${moved}))`
|
|
233
|
+
}).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)));
|
|
152
234
|
});
|
|
153
235
|
/** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
|
|
154
236
|
function entryColumns(db) {
|
|
@@ -306,4 +388,4 @@ async function newestAuthentic(page, check) {
|
|
|
306
388
|
return "unreached";
|
|
307
389
|
}
|
|
308
390
|
//#endregion
|
|
309
|
-
export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, missingEntry, newestAuthentic, newestSeqQuery, newestVaultEntries, newestVaultSeq, withinBytes };
|
|
391
|
+
export { BATCH_BYTES, KeyRetired, LOOK_BACK, LogHeadMismatch, LogRewound, MOVES_FROM, appendEntries, entryColumns, forgetLogHeads, lockLogHead, lockLogHeadIfFree, missingEntry, newestAuthentic, newestSeqQuery, newestVaultEntries, newestVaultSeq, withinBytes };
|