@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 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" && /connection terminated|network connection lost|connect(ion)? timeout/i.test(message)) return true;
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
- const head = locked?.head ?? await lockLogHead(tx);
118
+ if (locked === void 0 && !holding.has(tx)) await lockLogHead(tx);
76
119
  await check?.(tx);
77
- const now = locked?.at ?? (await clockNow(tx))[0].now;
78
- const last = locked === void 0 ? (await lastEntry(tx))[0] : locked.last ?? void 0;
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
- const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
146
- const lastEntry = prepared((db) => {
147
- const { auditLog } = tablesOf(db);
148
- return db.select({
149
- seq: auditLog.seq,
150
- hash: auditLog.hash
151
- }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
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 };
@@ -0,0 +1,2 @@
1
+ -- The app key's rotation (T105) signs every enrollment code again under the new key, as it does every other signed row. It only adds a grant: the release before writes no enrollment's MAC but at its insert.
2
+ GRANT UPDATE (auth_mac) ON enrollments TO coffre_app;