@coffre/db 0.14.4 → 0.14.5

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.
@@ -148,6 +148,13 @@
148
148
  "when": 1793017780802,
149
149
  "tag": "0020_vault_releases",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "7",
155
+ "when": 1793035191911,
156
+ "tag": "0021_prune_vault_releases",
157
+ "breakpoints": true
151
158
  }
152
159
  ]
153
160
  }
@@ -1,5 +1,5 @@
1
- import { g as schema_d_exports } from "./schema-z8GcN_Xu.js";
2
- import { t as Database } from "./database-CFhTnnzM.js";
1
+ import { g as schema_d_exports } from "./schema-BfQEBqFv.js";
2
+ import { t as Database } from "./database-wq1AVRAP.js";
3
3
  import { t as schema_sqlite_d_exports } from "./schema-sqlite.js";
4
4
  import { LibSQLDatabase } from "drizzle-orm/libsql";
5
5
  //#region src/portable.d.ts
@@ -1247,8 +1247,9 @@ declare const vaultGrants: import("drizzle-orm/pg-core").PgTableWithColumns<{
1247
1247
  * read them from the entries before (an entry that names none, which only
1248
1248
  * a forged one is, counts as a key of its own, `entry:<seq>`). No login
1249
1249
  * writes it: the vault and the backup read it. A row a window old counts
1250
- * for nothing, so nothing prunes them: there are at most as many as
1251
- * principals times keys, and a count reads one principal's.
1250
+ * for nothing: the vault's checkpoint forgets those a day and an hour old,
1251
+ * longer than any window (`prune_vault_releases`), so a count reads about a
1252
+ * day of keys.
1252
1253
  */
1253
1254
  declare const vaultReleases: import("drizzle-orm/pg-core").PgTableWithColumns<{
1254
1255
  name: "vault_releases";
@@ -1301,8 +1301,8 @@ export declare const vaultGrants: import("drizzle-orm/sqlite-core").SQLiteTableW
1301
1301
  * read them from the entries before (an entry that names none, which only
1302
1302
  * a forged one is, counts as a key of its own, `entry:<seq>`). No login
1303
1303
  * writes it: the vault and the backup read it. A row a window old counts
1304
- * for nothing, so nothing prunes them: there are at most as many as
1305
- * principals times keys, and a count reads one principal's.
1304
+ * for nothing: the vault's checkpoint forgets those a day and an hour old,
1305
+ * longer than any window, so a count reads about a day of keys.
1306
1306
  */
1307
1307
  export declare const vaultReleases: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
1308
1308
  name: "vault_releases";
@@ -223,8 +223,8 @@ const vaultGrants = sqliteTable("vault_grants", {
223
223
  * read them from the entries before (an entry that names none, which only
224
224
  * a forged one is, counts as a key of its own, `entry:<seq>`). No login
225
225
  * writes it: the vault and the backup read it. A row a window old counts
226
- * for nothing, so nothing prunes them: there are at most as many as
227
- * principals times keys, and a count reads one principal's.
226
+ * for nothing: the vault's checkpoint forgets those a day and an hour old,
227
+ * longer than any window, so a count reads about a day of keys.
228
228
  */
229
229
  const vaultReleases = sqliteTable("vault_releases", {
230
230
  principal: text().notNull(),
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-CFhTnnzM.js";
1
+ import { i as Queryable } from "./database-wq1AVRAP.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  //#region src/schema-version.d.ts
4
4
  /**
@@ -155,6 +155,13 @@ const KNOWN_MIGRATIONS = {
155
155
  "when": 1793017780802,
156
156
  "tag": "0020_vault_releases",
157
157
  "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 21,
161
+ "version": "7",
162
+ "when": 1793035191911,
163
+ "tag": "0021_prune_vault_releases",
164
+ "breakpoints": true
158
165
  }
159
166
  ].map((entry) => entry.tag),
160
167
  sqlite: [
package/dist/schema.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as vaultMembers, S as vaultGrants, _ as secretReferences, a as credentials, b as secretsRelations, c as enrollments, d as identities, f as mcpApprovals, h as projects, i as consumedTokens, l as environments, m as oauthClients, n as auditLog, o as deviceAuthorizations, p as mcpConnections, r as cappedAlerts, s as dismissedKeys, t as auditChainHead, u as environmentsRelations, v as secretVersions, w as vaultReleases, x as serviceBindings, y as secrets } from "./schema-z8GcN_Xu.js";
1
+ import { C as vaultMembers, S as vaultGrants, _ as secretReferences, a as credentials, b as secretsRelations, c as enrollments, d as identities, f as mcpApprovals, h as projects, i as consumedTokens, l as environments, m as oauthClients, n as auditLog, o as deviceAuthorizations, p as mcpConnections, r as cappedAlerts, s as dismissedKeys, t as auditChainHead, u as environmentsRelations, v as secretVersions, w as vaultReleases, x as serviceBindings, y as secrets } from "./schema-BfQEBqFv.js";
2
2
  export { auditChainHead, auditLog, cappedAlerts, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, enrollments, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers, vaultReleases };
package/dist/schema.js CHANGED
@@ -250,8 +250,9 @@ const vaultGrants = pgTable("vault_grants", {
250
250
  * read them from the entries before (an entry that names none, which only
251
251
  * a forged one is, counts as a key of its own, `entry:<seq>`). No login
252
252
  * writes it: the vault and the backup read it. A row a window old counts
253
- * for nothing, so nothing prunes them: there are at most as many as
254
- * principals times keys, and a count reads one principal's.
253
+ * for nothing: the vault's checkpoint forgets those a day and an hour old,
254
+ * longer than any window (`prune_vault_releases`), so a count reads about a
255
+ * day of keys.
255
256
  */
256
257
  const vaultReleases = pgTable("vault_releases", {
257
258
  principal: text().notNull(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
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.14.4"
82
+ "@coffre/core": "0.14.5"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/log.ts CHANGED
@@ -59,6 +59,20 @@ export class LogHeadMismatch extends Error {
59
59
  }
60
60
  }
61
61
 
62
+ /**
63
+ * As many transactions as may already wait for the log's head do
64
+ * (`lockLogHeadQueued`): this one waits for nothing, and holds nothing.
65
+ * Its code is Postgres's for a lock not available, so every caller already
66
+ * answers it as one that gave up waiting: unavailable, ask again shortly.
67
+ */
68
+ export class HeadBusy extends Error {
69
+ readonly code = '55P03';
70
+ constructor() {
71
+ super("too many requests wait for the audit log's head: ask again shortly");
72
+ this.name = 'HeadBusy';
73
+ }
74
+ }
75
+
62
76
  /**
63
77
  * The log's head is behind one this process wrote: the database was rolled
64
78
  * back, or entries were cut off its end. That cannot be told from a restore,
@@ -155,6 +169,44 @@ export async function lockLogHeadIfFree(tx: Transaction): Promise<boolean> {
155
169
  return rows.length > 0;
156
170
  }
157
171
 
172
+ /**
173
+ * Lock the log's head, waiting for it as `lockLogHead` does, if fewer than
174
+ * `waiters` other transactions hold one of its places in line; otherwise
175
+ * refuse at once (`HeadBusy`), having waited for nothing. A place is an
176
+ * advisory lock, tried, never waited for, and held to the end of the
177
+ * transaction, which holds the head by then: so at most `waiters`
178
+ * connections wait for the head, or hold it, through here, and a burst
179
+ * beyond them answers in one round trip rather than seconds later, holding
180
+ * a connection all the while. One statement: the place, then, only if one
181
+ * was free, the head; refused, one more says whether the head is there at
182
+ * all. SQLite's transactions hold the whole file.
183
+ */
184
+ export async function lockLogHeadQueued(tx: Transaction, waiters: number): Promise<{ nextSeq: bigint; headHash: Buffer }> {
185
+ if (engineOf(tx) === 'sqlite') return lockLogHead(tx);
186
+ const { rows } = await (tx as unknown as { execute(query: SQL): Promise<{ rows: { next_seq: string; head_hash: Buffer }[] }> }).execute(sql`
187
+ SELECT next_seq, head_hash FROM audit_chain_head
188
+ WHERE only_row AND (SELECT true FROM generate_series(1, ${waiters}::int) AS place WHERE pg_try_advisory_xact_lock(${HEAD_LINE}, place) LIMIT 1)
189
+ FOR UPDATE`);
190
+ const [head] = rows;
191
+ if (head === undefined) {
192
+ // No place, or no head at all: the second, which no one may write past, said as lockLogHead says it.
193
+ const { rows: [found] } = await (tx as unknown as { execute(query: SQL): Promise<{ rows: { found: boolean }[] }> }).execute(
194
+ sql`SELECT EXISTS (SELECT 1 FROM audit_chain_head WHERE only_row) AS found`,
195
+ );
196
+ if (found?.found !== true) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
197
+ throw new HeadBusy();
198
+ }
199
+ holding.add(tx);
200
+ return { nextSeq: BigInt(head.next_seq), headHash: head.head_hash };
201
+ }
202
+
203
+ /**
204
+ * The first key of the head's places in line, Postgres's advisory locks:
205
+ * the second is the place. A space apart from the members' queues and the
206
+ * migrator's key.
207
+ */
208
+ const HEAD_LINE = 0x636f6668;
209
+
158
210
  const lockedHead = prepared((db) => {
159
211
  const { auditChainHead } = tablesOf(db);
160
212
  return forUpdate(
@@ -0,0 +1,19 @@
1
+ -- Releases pruned (W199, T111): the vault's checkpoint forgets the releases no window can count again, those over a day and an hour old by the database's clock, at most at_most a call. A bulk limit's window is a day at most (the vault's config), and the hour is a margin for the clock stepping back: no caller chooses what is kept, so the vault's login, which still deletes no row itself, cannot forget a release a window counts. The release before never calls it.
2
+ CREATE FUNCTION prune_vault_releases(at_most integer) RETURNS integer
3
+ LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp SET lock_timeout = '3s' AS $$
4
+ -- The bound in the DELETE's own WHERE too: a row a read moved on since the subquery's snapshot is checked again,
5
+ -- as it is now, and kept.
6
+ WITH bound AS (SELECT (extract(epoch from clock_timestamp()) * 1000)::bigint - 25 * 3600000 AS before),
7
+ pruned AS (
8
+ DELETE FROM public.vault_releases
9
+ WHERE released_at < (SELECT before FROM bound)
10
+ AND ctid IN (
11
+ -- Any of them, each past every window; a row an append holds is left to the next run, rather than waited for.
12
+ SELECT ctid FROM public.vault_releases WHERE released_at < (SELECT before FROM bound) LIMIT at_most FOR UPDATE SKIP LOCKED
13
+ )
14
+ RETURNING 1
15
+ )
16
+ SELECT count(*)::integer FROM pruned;
17
+ $$;--> statement-breakpoint
18
+ REVOKE ALL ON FUNCTION prune_vault_releases(integer) FROM PUBLIC;--> statement-breakpoint
19
+ GRANT EXECUTE ON FUNCTION prune_vault_releases(integer) TO coffre_vault;