@coffre/db 0.10.1 → 0.10.2

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
@@ -32,6 +32,12 @@ export declare function engineOf(db: Queryable): Engine;
32
32
  export declare function forUpdate<Query extends {
33
33
  for: (strength: 'update' | 'no key update') => unknown;
34
34
  }>(db: Queryable, query: Query, strength?: 'update' | 'no key update'): ReturnType<Query['for']>;
35
+ /**
36
+ * The rows a hand-written query answers, as the driver gives them: what
37
+ * Postgres's `execute` and SQLite's `all` each call a result. Column names
38
+ * are the query's own, unmapped.
39
+ */
40
+ export declare function rowsOf<Row>(db: Queryable, query: SQL): Promise<Row[]>;
35
41
  /** Insert the rows whose unique keys are free; returns how many that was. */
36
42
  export declare function insertIfAbsent(db: Queryable, table: Table, rows: object[]): Promise<number>;
37
43
  /**
package/dist/dialect.js CHANGED
@@ -20,6 +20,15 @@ function forUpdate(db, query, strength = "update") {
20
20
  if (engineOf(db) === "sqlite") return query;
21
21
  return query.for(strength);
22
22
  }
23
+ /**
24
+ * The rows a hand-written query answers, as the driver gives them: what
25
+ * Postgres's `execute` and SQLite's `all` each call a result. Column names
26
+ * are the query's own, unmapped.
27
+ */
28
+ async function rowsOf(db, query) {
29
+ if (engineOf(db) === "postgres") return (await db.execute(query)).rows;
30
+ return db.all(query);
31
+ }
23
32
  /** Insert the rows whose unique keys are free; returns how many that was. */
24
33
  async function insertIfAbsent(db, table, rows) {
25
34
  return changedRows(await db.insert(table).values(rows).onConflictDoNothing());
@@ -169,4 +178,4 @@ function canonicalTimestamp(raw) {
169
178
  return `${(/* @__PURE__ */ new Date(Date.UTC(+year, +month - 1, +day, +hour, +minute, +second) - offsetMinutes * 6e4)).toISOString().slice(0, 19)}.${fraction.padEnd(6, "0")}Z`;
170
179
  }
171
180
  //#endregion
172
- export { SNAPSHOT, ahead, canonicalTimestamp, changedRows, clock, clockMillis, engineOf, forUpdate, insertIfAbsent, isUniqueViolation, isUnreachable, migrationLedger, tombstone, truth, upsert };
181
+ export { SNAPSHOT, ahead, canonicalTimestamp, changedRows, clock, clockMillis, engineOf, forUpdate, insertIfAbsent, isUniqueViolation, isUnreachable, migrationLedger, rowsOf, tombstone, truth, upsert };
package/dist/log.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as Transaction, i as Queryable } from "./database-DBtn3jqp.js";
2
- import { SQL } from "drizzle-orm";
2
+ import { Placeholder, SQL } from "drizzle-orm";
3
3
  import { LogKey, StoredEntry } from "@coffre/core/audit";
4
4
  //#region src/log.d.ts
5
5
  /**
@@ -75,7 +75,25 @@ export declare function lockLogHead(tx: Transaction): Promise<{
75
75
  * log as it stands, which no other append can change until this one
76
76
  * commits. It throws to refuse.
77
77
  */
78
- export declare function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[], check?: (tx: Transaction) => Promise<void>): Promise<Appended>;
78
+ export declare function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[], check?: (tx: Transaction) => Promise<void>,
79
+ /**
80
+ * The head as a statement of the caller's locked it, with the log's last
81
+ * entry and the clock as that statement read them once it held the lock:
82
+ * nothing to read again.
83
+ */
84
+ locked?: HeldHead): Promise<Appended>;
85
+ /** A head locked by a statement of the caller's, and what it read under the lock (`appendEntries`). */
86
+ export type HeldHead = {
87
+ head: {
88
+ nextSeq: bigint;
89
+ headHash: Buffer;
90
+ };
91
+ last: {
92
+ seq: bigint;
93
+ hash: Buffer;
94
+ } | null;
95
+ at: number;
96
+ };
79
97
  /** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
80
98
  export declare function entryColumns(db: Queryable): {
81
99
  seq: import("drizzle-orm/pg-core").PgColumn<{
@@ -492,6 +510,50 @@ export declare function newestVaultEntries(db: Queryable, action: string, limit:
492
510
  * of what it says, so its MAC is not checked. One indexed read.
493
511
  */
494
512
  export declare function newestVaultSeq(db: Queryable, action: string): Promise<bigint | null>;
513
+ /**
514
+ * `newestVaultSeq`'s query, to run or to read inside another, as a
515
+ * credential's read takes the newest `policy.change` with it: one rule for
516
+ * what the newest is, wherever it is read.
517
+ */
518
+ export declare function newestSeqQuery(db: Queryable, action: string | Placeholder): Omit<import("drizzle-orm/pg-core").PgSelectBase<"audit_log", {
519
+ seq: import("drizzle-orm/pg-core").PgColumn<{
520
+ name: "seq";
521
+ tableName: "audit_log";
522
+ dataType: "bigint";
523
+ columnType: "PgBigInt64";
524
+ data: bigint;
525
+ driverParam: string;
526
+ notNull: true;
527
+ hasDefault: false;
528
+ isPrimaryKey: true;
529
+ isAutoincrement: false;
530
+ hasRuntimeDefault: false;
531
+ enumValues: undefined;
532
+ baseColumn: never;
533
+ identity: undefined;
534
+ generated: undefined;
535
+ }, {}, {}>;
536
+ }, "partial", Record<"audit_log", "not-null">, false, "where" | "orderBy" | "limit", {
537
+ seq: bigint;
538
+ }[], {
539
+ seq: import("drizzle-orm/pg-core").PgColumn<{
540
+ name: "seq";
541
+ tableName: "audit_log";
542
+ dataType: "bigint";
543
+ columnType: "PgBigInt64";
544
+ data: bigint;
545
+ driverParam: string;
546
+ notNull: true;
547
+ hasDefault: false;
548
+ isPrimaryKey: true;
549
+ isAutoincrement: false;
550
+ hasRuntimeDefault: false;
551
+ enumValues: undefined;
552
+ baseColumn: never;
553
+ identity: undefined;
554
+ generated: undefined;
555
+ }, {}, {}>;
556
+ }>, "where" | "orderBy" | "limit">;
495
557
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
496
558
  export declare const LOOK_BACK: {
497
559
  page: number;
package/dist/log.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as afterCommit } from "./commits-BuW-NL3n.js";
2
- import { clockMillis, forUpdate } from "./dialect.js";
2
+ import { clockMillis, engineOf, forUpdate } from "./dialect.js";
3
3
  import { a as prepared, i as tablesOf } from "./database-BadNeeN-.js";
4
4
  import { and, asc, count, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm";
5
5
  import { GENESIS_HASH, sealEntry } from "@coffre/core/audit";
@@ -69,13 +69,13 @@ const lockedHead = prepared((db) => {
69
69
  * log as it stands, which no other append can change until this one
70
70
  * commits. It throws to refuse.
71
71
  */
72
- async function appendEntries(tx, key, entries, check) {
72
+ async function appendEntries(tx, key, entries, check, locked) {
73
73
  if (entries.length === 0) throw new Error("appendEntries called with no entries");
74
74
  const { auditChainHead, auditLog } = tablesOf(tx);
75
- const head = await lockLogHead(tx);
75
+ const head = locked?.head ?? await lockLogHead(tx);
76
76
  await check?.(tx);
77
- const [{ now }] = await clockNow(tx);
78
- const [last] = await lastEntry(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;
79
79
  const expected = last === void 0 ? {
80
80
  nextSeq: 0n,
81
81
  headHash: GENESIS_HASH
@@ -120,11 +120,15 @@ async function appendEntries(tx, key, entries, check) {
120
120
  prevHash = hash;
121
121
  seq += 1n;
122
122
  }
123
- await tx.insert(auditLog).values(rows);
124
- await tx.update(auditChainHead).set({
125
- nextSeq: seq,
126
- headHash: prevHash
127
- }).where(eq(auditChainHead.onlyRow, true));
123
+ if (engineOf(tx) === "postgres" && rows.length <= ROWS_AT_ONCE) await tx.execute(sql`WITH appended AS (${tx.insert(auditLog).values(rows).getSQL()})
124
+ UPDATE audit_chain_head SET next_seq = ${seq}, head_hash = ${prevHash} WHERE only_row`);
125
+ else {
126
+ await tx.insert(auditLog).values(rows);
127
+ await tx.update(auditChainHead).set({
128
+ nextSeq: seq,
129
+ headHash: prevHash
130
+ }).where(eq(auditChainHead.onlyRow, true));
131
+ }
128
132
  const written = {
129
133
  nextSeq: seq,
130
134
  headHash: prevHash
@@ -136,6 +140,8 @@ async function appendEntries(tx, key, entries, check) {
136
140
  occurredAt: now
137
141
  };
138
142
  }
143
+ /** Entries one statement inserts at most, at 21 parameters each: Postgres takes 65,535. */
144
+ const ROWS_AT_ONCE = 3e3;
139
145
  const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
140
146
  const lastEntry = prepared((db) => {
141
147
  const { auditLog } = tablesOf(db);
@@ -260,10 +266,16 @@ async function newestVaultSeq(db, action) {
260
266
  const [row] = await newestSeq(db, { action });
261
267
  return row?.seq ?? null;
262
268
  }
263
- const newestSeq = prepared((db) => {
269
+ const newestSeq = prepared((db) => newestSeqQuery(db, sql.placeholder("action")));
270
+ /**
271
+ * `newestVaultSeq`'s query, to run or to read inside another, as a
272
+ * credential's read takes the newest `policy.change` with it: one rule for
273
+ * what the newest is, wherever it is read.
274
+ */
275
+ function newestSeqQuery(db, action) {
264
276
  const { auditLog } = tablesOf(db);
265
- return db.select({ seq: auditLog.seq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, sql.placeholder("action")), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
266
- });
277
+ return db.select({ seq: auditLog.seq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
278
+ }
267
279
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
268
280
  const LOOK_BACK = {
269
281
  page: 32,
@@ -294,4 +306,4 @@ async function newestAuthentic(page, check) {
294
306
  return "unreached";
295
307
  }
296
308
  //#endregion
297
- export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes };
309
+ export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, missingEntry, newestAuthentic, newestSeqQuery, newestVaultEntries, newestVaultSeq, withinBytes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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.10.1"
82
+ "@coffre/core": "0.10.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/dialect.ts CHANGED
@@ -49,6 +49,16 @@ export function forUpdate<Query extends { for: (strength: 'update' | 'no key upd
49
49
  return query.for(strength) as ReturnType<Query['for']>;
50
50
  }
51
51
 
52
+ /**
53
+ * The rows a hand-written query answers, as the driver gives them: what
54
+ * Postgres's `execute` and SQLite's `all` each call a result. Column names
55
+ * are the query's own, unmapped.
56
+ */
57
+ export async function rowsOf<Row>(db: Queryable, query: SQL): Promise<Row[]> {
58
+ if (engineOf(db) === 'postgres') return (await (db as unknown as { execute(query: SQL): Promise<{ rows: Row[] }> }).execute(query)).rows;
59
+ return (db as unknown as { all<R>(query: SQL): Promise<R[]> }).all<Row>(query);
60
+ }
61
+
52
62
  /** Insert the rows whose unique keys are free; returns how many that was. */
53
63
  export async function insertIfAbsent(db: Queryable, table: Table, rows: object[]): Promise<number> {
54
64
  return changedRows(await db.insert(table).values(rows as never).onConflictDoNothing());
package/src/log.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
- import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, type SQL } from 'drizzle-orm';
2
+ import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, type Placeholder, type SQL } from 'drizzle-orm';
3
3
 
4
4
  import { afterCommit } from './commits.ts';
5
5
  import { tablesOf, type Queryable, type Transaction } from './database.ts';
6
6
  import { prepared } from './prepared.ts';
7
- import { clockMillis, forUpdate } from './dialect.ts';
7
+ import { clockMillis, engineOf, forUpdate } from './dialect.ts';
8
8
 
9
9
  /**
10
10
  * Appending to the audit log, the one table both the app and the vault
@@ -124,15 +124,21 @@ export async function appendEntries(
124
124
  key: LogKey,
125
125
  entries: readonly NewEntry[],
126
126
  check?: (tx: Transaction) => Promise<void>,
127
+ /**
128
+ * The head as a statement of the caller's locked it, with the log's last
129
+ * entry and the clock as that statement read them once it held the lock:
130
+ * nothing to read again.
131
+ */
132
+ locked?: HeldHead,
127
133
  ): Promise<Appended> {
128
134
  if (entries.length === 0) throw new Error('appendEntries called with no entries');
129
135
  const { auditChainHead, auditLog } = tablesOf(tx);
130
136
 
131
137
  // Every append queues here, if its transaction has not already.
132
- const head = await lockLogHead(tx);
138
+ const head = locked?.head ?? (await lockLogHead(tx));
133
139
  await check?.(tx);
134
- const [{ now }] = await clockNow(tx);
135
- const [last] = await lastEntry(tx);
140
+ const now = locked?.at ?? (await clockNow(tx))[0]!.now;
141
+ const last = locked === undefined ? (await lastEntry(tx))[0] : locked.last ?? undefined;
136
142
 
137
143
  const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
138
144
  if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
@@ -179,13 +185,25 @@ export async function appendEntries(
179
185
  seq += 1n;
180
186
  }
181
187
 
182
- await tx.insert(auditLog).values(rows);
183
- await tx.update(auditChainHead).set({ nextSeq: seq, headHash: prevHash }).where(eq(auditChainHead.onlyRow, true));
188
+ if (engineOf(tx) === 'postgres' && rows.length <= ROWS_AT_ONCE) {
189
+ // The entries and the head that names the last of them, in one statement.
190
+ await tx.execute(sql`WITH appended AS (${tx.insert(auditLog).values(rows).getSQL()})
191
+ UPDATE audit_chain_head SET next_seq = ${seq}, head_hash = ${prevHash} WHERE only_row`);
192
+ } else {
193
+ await tx.insert(auditLog).values(rows);
194
+ await tx.update(auditChainHead).set({ nextSeq: seq, headHash: prevHash }).where(eq(auditChainHead.onlyRow, true));
195
+ }
184
196
  const written = { nextSeq: seq, headHash: prevHash };
185
197
  afterCommit(tx, () => remember(key, written));
186
198
  return { seqStart: head.nextSeq, ...written, occurredAt: now };
187
199
  }
188
200
 
201
+ /** A head locked by a statement of the caller's, and what it read under the lock (`appendEntries`). */
202
+ export type HeldHead = { head: { nextSeq: bigint; headHash: Buffer }; last: { seq: bigint; hash: Buffer } | null; at: number };
203
+
204
+ /** Entries one statement inserts at most, at 21 parameters each: Postgres takes 65,535. */
205
+ const ROWS_AT_ONCE = 3000;
206
+
189
207
  const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
190
208
 
191
209
  const lastEntry = prepared((db) => {
@@ -325,15 +343,22 @@ export async function newestVaultSeq(db: Queryable, action: string): Promise<big
325
343
  return (row?.seq as bigint | undefined) ?? null;
326
344
  }
327
345
 
328
- const newestSeq = prepared((db) => {
346
+ const newestSeq = prepared((db) => newestSeqQuery(db, sql.placeholder('action')));
347
+
348
+ /**
349
+ * `newestVaultSeq`'s query, to run or to read inside another, as a
350
+ * credential's read takes the newest `policy.change` with it: one rule for
351
+ * what the newest is, wherever it is read.
352
+ */
353
+ export function newestSeqQuery(db: Queryable, action: string | Placeholder) {
329
354
  const { auditLog } = tablesOf(db);
330
355
  return db
331
356
  .select({ seq: auditLog.seq })
332
357
  .from(auditLog)
333
- .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow')))
358
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), eq(auditLog.decision, 'allow')))
334
359
  .orderBy(desc(auditLog.seq))
335
360
  .limit(1);
336
- });
361
+ }
337
362
 
338
363
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
339
364
  export const LOOK_BACK = { page: 32, pages: 32 };