@coffre/db 0.13.0 → 0.14.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/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/schema-version.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.14.1",
|
|
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.
|
|
82
|
+
"@coffre/core": "0.14.1"
|
|
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
|
-
|
|
128
|
-
if (typeof
|
|
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
|
-
|
|
188
|
+
if (locked === undefined && !holding.has(tx)) await lockLogHead(tx);
|
|
139
189
|
await check?.(tx);
|
|
140
|
-
const
|
|
141
|
-
const
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 ---------------------------------------------
|