@coffre/db 0.13.0 → 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.
@@ -127,6 +127,13 @@
127
127
  "when": 1792835445795,
128
128
  "tag": "0017_capped_alerts",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1792871149580,
135
+ "tag": "0018_rotate_app_key",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
139
  }
@@ -134,6 +134,13 @@ const KNOWN_MIGRATIONS = {
134
134
  "when": 1792835445795,
135
135
  "tag": "0017_capped_alerts",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 18,
140
+ "version": "7",
141
+ "when": 1792871149580,
142
+ "tag": "0018_rotate_app_key",
143
+ "breakpoints": true
137
144
  }
138
145
  ].map((entry) => entry.tag),
139
146
  sqlite: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -79,7 +79,7 @@
79
79
  "drizzle-orm": "0.45.2",
80
80
  "pg": "8.16.3",
81
81
  "pg-connection-string": "2.14.0",
82
- "@coffre/core": "0.13.0"
82
+ "@coffre/core": "0.14.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/dialect.ts CHANGED
@@ -113,24 +113,60 @@ export function isUniqueViolation(error: unknown): boolean {
113
113
  return false;
114
114
  }
115
115
 
116
- /** The network's codes for a server that is not there, and Postgres's for one that will not take the connection. */
117
- const UNREACHABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', '57P01', '57P02', '57P03']);
116
+ /** 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). */
117
+ const UNREACHABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', '53300', '57P01', '57P02', '57P03']);
118
+
119
+ /** Hyperdrive's words for having no connection to give, beside pg's and Workers' for one lost. */
120
+ 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;
118
121
 
119
122
  /**
120
123
  * Whether a query failed because the database could not be reached or
121
124
  * dropped the connection: an outage, not an answer. Postgres's connection
122
- * exceptions are class 08; pg and Workers' sockets say so in words.
125
+ * exceptions are class 08; pg and Workers' sockets say so in words, and so
126
+ * does Hyperdrive when it has no connection to the database to give: every
127
+ * one of its pool busy for 15 s ("Timed out while waiting for an open slot
128
+ * in the pool", FATAL 58000; "Failed to acquire a connection from the
129
+ * pool"), or none to be made ("Server connection attempt failed:
130
+ * destination_unavailable").
123
131
  */
124
132
  export function isUnreachable(error: unknown): boolean {
125
133
  for (let cause = error; typeof cause === 'object' && cause !== null; ) {
126
134
  const { code, message } = cause as { code?: unknown; message?: unknown };
127
- if (typeof code === 'string' && (UNREACHABLE.has(code) || /^08[0-9A-Z]{3}$/.test(code))) return true;
128
- if (typeof message === 'string' && /connection terminated|network connection lost|connect(ion)? timeout/i.test(message)) return true;
135
+ // Class 08 but its protocol violation (08P01), which is the client's bug: asked again, it fails again.
136
+ if (typeof code === 'string' && (UNREACHABLE.has(code) || (/^08[0-9A-Z]{3}$/.test(code) && code !== '08P01'))) return true;
137
+ // The words only of what said no code, or Hyperdrive's 58000, and never a message quoting a query and its
138
+ // parameters (Drizzle's, the Node vault's carrying it): the driver's words are its cause's.
139
+ if (typeof message === 'string' && (typeof code !== 'string' || code === '58000') && !message.includes('Failed query: ') && UNREACHABLE_WORDS.test(message)) return true;
129
140
  cause = (cause as { cause?: unknown }).cause;
130
141
  }
131
142
  return false;
132
143
  }
133
144
 
145
+ /** Postgres's codes for a statement that gave up waiting: on a lock (55P03), or past its own bound (57014). */
146
+ const GAVE_UP = new Set(['55P03', '57014']);
147
+
148
+ /**
149
+ * Whether a statement gave up waiting, on a lock or past its bound: the
150
+ * database too busy to answer in time, or a transaction held too long,
151
+ * which a bug may be (one held across a vault call waits on the vault's
152
+ * decision, which waits on it).
153
+ */
154
+ export function gaveUp(error: unknown): boolean {
155
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
156
+ if (GAVE_UP.has((cause as { code?: unknown }).code as string)) return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * Whether a query failed for want of the database, not for anything the
163
+ * request asked: it could not be reached (`isUnreachable`), or a statement
164
+ * gave up waiting (`gaveUp`). The same request may well succeed in a moment.
165
+ */
166
+ export function isUnavailable(error: unknown): boolean {
167
+ return isUnreachable(error) || gaveUp(error);
168
+ }
169
+
134
170
  /**
135
171
  * A consistent read of several statements that cannot write, such as
136
172
  * verifying the chain. SQLite ignores it: its transactions already see one
package/src/log.ts CHANGED
@@ -15,7 +15,8 @@ import { clockMillis, engineOf, forUpdate } from './dialect.ts';
15
15
  * from any process take their numbers in one order. It then reads the
16
16
  * database clock, after the lock, and the log's last entry, which the head
17
17
  * must name: a head that does not was moved, or entries were deleted behind
18
- * it, and the append refuses.
18
+ * it, and the append refuses. The same statement says whether the log has
19
+ * moved on from the key it appends under (`KeyRetired`).
19
20
  *
20
21
  * This is deliberately not a queue and not best-effort: the entries go in
21
22
  * the caller's transaction, so if the append fails, what it records does
@@ -70,6 +71,26 @@ export class LogRewound extends Error {
70
71
  }
71
72
  }
72
73
 
74
+ /**
75
+ * An append under a key the log has moved on from: an entry of its
76
+ * author's, `key.reseal` or `key.rotate`, names it as the key it moves
77
+ * from (`MOVES_FROM`), and a key moved from writes no more.
78
+ */
79
+ export class KeyRetired extends Error {
80
+ constructor(message: string) {
81
+ super(message);
82
+ this.name = 'KeyRetired';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The actions by which an author moves to a new key, each under the new one
88
+ * and naming the key it moves from as its subject: from the first, that key
89
+ * writes no more (`KeyRetired`). The app's key rotation writes them
90
+ * (packages/server/src/key-rotation.ts).
91
+ */
92
+ export const MOVES_FROM = ['key.reseal', 'key.rotate'] as const;
93
+
73
94
  /**
74
95
  * The newest head each writer has written, under each of its keys, while
75
96
  * the process lives. A head is remembered only once the transaction that
@@ -94,6 +115,15 @@ export function forgetLogHeads(): void {
94
115
  epoch += 1;
95
116
  }
96
117
 
118
+ /**
119
+ * The transactions that hold the head: an append in one reads what it
120
+ * needs under the lock in one statement, where one that does not first
121
+ * takes the lock in another. A savepoint's transaction is another to this
122
+ * set, so a lock is marked on the object that took it, and an append made
123
+ * through another object locks again, which is free.
124
+ */
125
+ const holding = new WeakSet<Transaction>();
126
+
97
127
  /**
98
128
  * Lock the log's head, and read it. A transaction that appends takes this
99
129
  * before any other row, so the lock order is the same everywhere; taking it
@@ -102,9 +132,29 @@ export function forgetLogHeads(): void {
102
132
  export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; headHash: Buffer }> {
103
133
  const [head] = await lockedHead(tx);
104
134
  if (head === undefined) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
135
+ holding.add(tx);
105
136
  return head;
106
137
  }
107
138
 
139
+ /**
140
+ * Lock the log's head if no one holds it, without waiting: whether it is
141
+ * held now. For work any caller may do, of which one at a time is enough,
142
+ * which a request that finds the head taken leaves to the one that has it.
143
+ * SQLite's transactions hold the whole file, so the head is always free.
144
+ */
145
+ export async function lockLogHeadIfFree(tx: Transaction): Promise<boolean> {
146
+ if (engineOf(tx) === 'sqlite') {
147
+ const held = (await lockedHead(tx)).length > 0;
148
+ if (held) holding.add(tx);
149
+ return held;
150
+ }
151
+ const { rows } = await (tx as unknown as { execute(query: SQL): Promise<{ rows: unknown[] }> }).execute(
152
+ sql`SELECT next_seq FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED`,
153
+ );
154
+ if (rows.length > 0) holding.add(tx);
155
+ return rows.length > 0;
156
+ }
157
+
108
158
  const lockedHead = prepared((db) => {
109
159
  const { auditChainHead } = tablesOf(db);
110
160
  return forUpdate(
@@ -135,10 +185,11 @@ export async function appendEntries(
135
185
  const { auditChainHead, auditLog } = tablesOf(tx);
136
186
 
137
187
  // Every append queues here, if its transaction has not already.
138
- const head = locked?.head ?? (await lockLogHead(tx));
188
+ if (locked === undefined && !holding.has(tx)) await lockLogHead(tx);
139
189
  await check?.(tx);
140
- const now = locked?.at ?? (await clockNow(tx))[0]!.now;
141
- const last = locked === undefined ? (await lastEntry(tx))[0] : locked.last ?? undefined;
190
+ const held = locked ?? (await readUnderHead(tx, key));
191
+ const { head, at: now } = held;
192
+ const last = held.last ?? undefined;
142
193
 
143
194
  const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
144
195
  if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
@@ -201,14 +252,52 @@ export async function appendEntries(
201
252
  /** A head locked by a statement of the caller's, and what it read under the lock (`appendEntries`). */
202
253
  export type HeldHead = { head: { nextSeq: bigint; headHash: Buffer }; last: { seq: bigint; hash: Buffer } | null; at: number };
203
254
 
255
+ /**
256
+ * What an append reads under the head its transaction holds, in one
257
+ * statement: the head, the clock and the log's last entry, all as of the
258
+ * lock, since locking a head the transaction holds waits for nothing, and
259
+ * after the append's `check`. The log's own last row says whether the log
260
+ * has moved on from `key`.
261
+ */
262
+ async function readUnderHead(tx: Transaction, key: LogKey): Promise<HeldHead> {
263
+ const [read] = await underHead(tx, { author: key.author, keyId: key.keyId });
264
+ if (read === undefined) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
265
+ if (read.retired) throw new KeyRetired(`the log moved on from ${key.keyId}: a key the ${key.author} rotated from writes no more`);
266
+ return {
267
+ head: { nextSeq: read.nextSeq, headHash: read.headHash },
268
+ last: read.lastSeq === null || read.lastHash === null ? null : { seq: read.lastSeq, hash: read.lastHash },
269
+ at: read.now,
270
+ };
271
+ }
272
+
204
273
  /** Entries one statement inserts at most, at 21 parameters each: Postgres takes 65,535. */
205
274
  const ROWS_AT_ONCE = 3000;
206
275
 
207
- const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
208
-
209
- const lastEntry = prepared((db) => {
210
- const { auditLog } = tablesOf(db);
211
- return db.select({ seq: auditLog.seq, hash: auditLog.hash }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
276
+ /**
277
+ * `readUnderHead`'s statement. Its `FOR UPDATE` waits for nothing in a
278
+ * transaction that holds the head, and stays for one that would not: there
279
+ * it waits, and if an append committed meanwhile, its head, read again
280
+ * after the wait, disagrees with the last entry its snapshot saw before it,
281
+ * so the append refuses (`LogHeadMismatch`) rather than writing unlocked.
282
+ */
283
+ const underHead = prepared((db) => {
284
+ const { auditChainHead, auditLog } = tablesOf(db);
285
+ const moved = sql.raw(MOVES_FROM.map((action) => `'${action}'`).join(', '));
286
+ const newest = (column: SQL) => sql`(SELECT ${column} FROM audit_log ORDER BY seq DESC LIMIT 1)`;
287
+ return forUpdate(
288
+ db,
289
+ db
290
+ .select({
291
+ nextSeq: auditChainHead.nextSeq,
292
+ headHash: auditChainHead.headHash,
293
+ now: clockMillis(db),
294
+ lastSeq: newest(sql`seq`).mapWith(auditLog.seq) as SQL<bigint | null>,
295
+ lastHash: newest(sql`hash`).mapWith(auditLog.hash) as SQL<Buffer | null>,
296
+ retired: sql<boolean | number>`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}))`,
297
+ })
298
+ .from(auditChainHead)
299
+ .where(eq(auditChainHead.onlyRow, true)),
300
+ );
212
301
  });
213
302
 
214
303
  // --- reading the vault's entries back ---------------------------------------------
@@ -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;