@coffre/db 0.10.0 → 0.10.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/connect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as Database } from "./database-BFnO2b2P.js";
1
+ import { t as Database } from "./database-DBtn3jqp.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  import { ClientConfig } from "pg";
4
4
  //#region src/postgres.d.ts
package/dist/connect.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createDatabase } from "./index.js";
1
+ import { n as createDatabase } from "./database-BadNeeN-.js";
2
2
  import { asPostgresDatabase } from "./portable.js";
3
3
  import { t as postgresConnection } from "./postgres-Bmar8y0q.js";
4
4
  import pg from "pg";
@@ -0,0 +1,146 @@
1
+ import { n as trackCommits } from "./commits-BuW-NL3n.js";
2
+ import { t as schema_exports } from "./schema.js";
3
+ import { engineOf } from "./dialect.js";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { Table, createTableRelationsHelpers, extractTablesRelationalConfig, getTableName, is } from "drizzle-orm";
6
+ import { NodePgDatabase, NodePgDriver } from "drizzle-orm/node-postgres";
7
+ import { PgDialect } from "drizzle-orm/pg-core";
8
+ //#region src/prepared.ts
9
+ /**
10
+ * A select built once per process, or once per isolate on Workers, and run
11
+ * with new values each time. Drizzle rebuilds a query's SQL from its
12
+ * builder at every run, and that was most of a short query's CPU; this
13
+ * builds it from `build` the first time a database of its dialect asks,
14
+ * then hands the SQL and its columns to that call's own session
15
+ * (`session.prepareQuery`), which binds `values` to the placeholders
16
+ * (`sql.placeholder`) and maps the rows as the builder would.
17
+ *
18
+ * `build` must not depend on anything but the database's dialect, and the
19
+ * values go to the driver as they are, not through a column's encoding:
20
+ * strings, numbers, bigints and bytes.
21
+ *
22
+ * const member = prepared((db) => db.select().from(tablesOf(db).vaultMembers).where(eq(…, sql.placeholder('principal'))));
23
+ * const [row] = await member(db, { principal });
24
+ */
25
+ function prepared(build) {
26
+ const builds = /* @__PURE__ */ new WeakMap();
27
+ return (db, values = {}) => {
28
+ const tables = tablesOf(db);
29
+ let built = builds.get(tables);
30
+ if (built === void 0) {
31
+ const builder = build(db);
32
+ const { fields, joinsNotNullableMap } = builder.prepare();
33
+ built = {
34
+ query: builder.toSQL(),
35
+ fields,
36
+ joinsNotNullableMap,
37
+ engine: engineOf(db)
38
+ };
39
+ builds.set(tables, built);
40
+ }
41
+ const session = db.session;
42
+ const query = built.engine === "sqlite" ? session.prepareQuery(built.query, built.fields, "all", true) : session.prepareQuery(built.query, built.fields, void 0, true);
43
+ query.joinsNotNullableMap = built.joinsNotNullableMap;
44
+ return built.engine === "sqlite" ? query.all(values) : query.execute(values);
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region src/database.ts
49
+ /**
50
+ * A query made on the database from inside one of its own transactions,
51
+ * rather than through the transaction. With one connection per call, as on
52
+ * Workers, it would wait for the transaction, which waits for it: a call
53
+ * Cloudflare cancels as hung, and nothing says why. So it is refused at
54
+ * once, with this, wherever the database runs.
55
+ */
56
+ var QueryOutsideTransaction = class extends Error {
57
+ constructor() {
58
+ super("query outside its transaction: use tx. A query on the database inside one of its own transactions waits for that transaction, which waits for it");
59
+ this.name = "QueryOutsideTransaction";
60
+ }
61
+ };
62
+ /** The transaction a flow of work runs inside, with the pool it holds, while it is open. */
63
+ const inside = new AsyncLocalStorage();
64
+ /**
65
+ * `pool`, refusing a query or a transaction asked from inside a transaction
66
+ * it is already running. Work from any other flow, a concurrent request's,
67
+ * or what the transaction left running once its work was done, goes on as before.
68
+ */
69
+ function guarded(pool) {
70
+ return new Proxy(pool, { get(target, key) {
71
+ const value = Reflect.get(target, key, target);
72
+ if (typeof value !== "function") return value;
73
+ if (key !== "query" && key !== "connect") return value.bind(target);
74
+ return (...args) => {
75
+ const open = inside.getStore();
76
+ if (open?.open === true && open.pool === target) throw new QueryOutsideTransaction();
77
+ return value.apply(target, args);
78
+ };
79
+ } });
80
+ }
81
+ /** The refusal `error` is, or was caused by. */
82
+ function brokenRule(error) {
83
+ for (let cause = error; cause instanceof Error; cause = cause.cause) if (cause instanceof QueryOutsideTransaction) return cause;
84
+ }
85
+ let relational;
86
+ function relationalSchema() {
87
+ if (relational === void 0) {
88
+ const { tables, tableNamesMap } = extractTablesRelationalConfig(schema_exports, createTableRelationsHelpers);
89
+ relational = {
90
+ fullSchema: schema_exports,
91
+ schema: tables,
92
+ tableNamesMap
93
+ };
94
+ }
95
+ return relational;
96
+ }
97
+ function createDatabase(pool) {
98
+ const dialect = new PgDialect();
99
+ const config = relationalSchema();
100
+ const db = new NodePgDatabase(dialect, new NodePgDriver(guarded(pool), dialect).createSession(config), config);
101
+ const run = db.transaction.bind(db);
102
+ db.transaction = (async (work, config) => {
103
+ const open = {
104
+ pool,
105
+ open: true
106
+ };
107
+ try {
108
+ return await run((tx) => inside.run(open, async () => {
109
+ try {
110
+ return await work(tx);
111
+ } finally {
112
+ open.open = false;
113
+ }
114
+ }), config);
115
+ } catch (error) {
116
+ throw brokenRule(error) ?? error;
117
+ }
118
+ });
119
+ return trackCommits(db);
120
+ }
121
+ /**
122
+ * The tables of the database's own dialect: the Postgres ones on Postgres,
123
+ * the SQLite ones on SQLite. Every query builds on these.
124
+ */
125
+ function tablesOf(db) {
126
+ return db._.fullSchema;
127
+ }
128
+ const byName = /* @__PURE__ */ new WeakMap();
129
+ /**
130
+ * The database's own twin of `table`, which may be another dialect's: the
131
+ * server names tables by importing schema.ts, and the database decides
132
+ * which one that means.
133
+ */
134
+ function own(db, table) {
135
+ const tables = tablesOf(db);
136
+ let named = byName.get(tables);
137
+ if (named === void 0) {
138
+ named = new Map(Object.values(tables).filter((value) => is(value, Table)).map((value) => [getTableName(value), value]));
139
+ byName.set(tables, named);
140
+ }
141
+ const found = named.get(getTableName(table));
142
+ if (found === void 0) throw new Error(`no table ${getTableName(table)} in this database`);
143
+ return found;
144
+ }
145
+ //#endregion
146
+ export { prepared as a, tablesOf as i, createDatabase as n, own as r, QueryOutsideTransaction as t };
@@ -1,7 +1,31 @@
1
1
  import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
2
- import { Table } from "drizzle-orm";
2
+ import { Query, Table } from "drizzle-orm";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import pg from "pg";
5
+ //#region src/prepared.d.ts
6
+ /** The part of a select builder `prepared` reads, once. */
7
+ type Select = PromiseLike<unknown> & {
8
+ toSQL(): Query;
9
+ prepare(...name: never[]): unknown;
10
+ };
11
+ /**
12
+ * A select built once per process, or once per isolate on Workers, and run
13
+ * with new values each time. Drizzle rebuilds a query's SQL from its
14
+ * builder at every run, and that was most of a short query's CPU; this
15
+ * builds it from `build` the first time a database of its dialect asks,
16
+ * then hands the SQL and its columns to that call's own session
17
+ * (`session.prepareQuery`), which binds `values` to the placeholders
18
+ * (`sql.placeholder`) and maps the rows as the builder would.
19
+ *
20
+ * `build` must not depend on anything but the database's dialect, and the
21
+ * values go to the driver as they are, not through a column's encoding:
22
+ * strings, numbers, bigints and bytes.
23
+ *
24
+ * const member = prepared((db) => db.select().from(tablesOf(db).vaultMembers).where(eq(…, sql.placeholder('principal'))));
25
+ * const [row] = await member(db, { principal });
26
+ */
27
+ declare function prepared<Q extends Select>(build: (db: Queryable) => Q): (db: Queryable, values?: Record<string, unknown>) => Promise<Awaited<Q>>;
28
+ //#endregion
5
29
  //#region src/database.d.ts
6
30
  /**
7
31
  * A database, typed as Postgres whichever it is; see portable.ts for why
@@ -46,4 +70,4 @@ declare function tablesOf(db: Queryable): typeof schema_d_exports;
46
70
  */
47
71
  declare function own<T extends Table>(db: Queryable, table: T): T;
48
72
  //#endregion
49
- export { Transaction as a, tablesOf as c, Queryable as i, PoolLike as n, createDatabase as o, QueryOutsideTransaction as r, own as s, Database as t };
73
+ export { Transaction as a, tablesOf as c, Queryable as i, prepared as l, PoolLike as n, createDatabase as o, QueryOutsideTransaction as r, own as s, Database as t };
package/dist/dialect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-BFnO2b2P.js";
1
+ import { i as Queryable } from "./database-DBtn3jqp.js";
2
2
  import { SQL, SQLWrapper } from "drizzle-orm";
3
3
  import { PgTable } from "drizzle-orm/pg-core";
4
4
  //#region src/dialect.d.ts
package/dist/grants.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-BFnO2b2P.js";
1
+ import { i as Queryable } from "./database-DBtn3jqp.js";
2
2
  import { Authenticity, Newest } from "./log.js";
3
3
  import { StoredEntry } from "@coffre/core/audit";
4
4
  import { Conversion, ConversionInput, InstanceRole, Scope } from "@coffre/core/access";
package/dist/grants.js CHANGED
@@ -1,21 +1,25 @@
1
- import { tablesOf } from "./index.js";
2
1
  import { tombstone, truth } from "./dialect.js";
3
- import { LOOK_BACK, entryColumns, newestAuthentic } from "./log.js";
2
+ import { a as prepared, i as tablesOf } from "./database-BadNeeN-.js";
3
+ import { entryColumns, newestAuthentic } from "./log.js";
4
4
  import { and, asc, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm";
5
5
  import { EVERYWHERE, administers, convertEveryProjectGrants, isInstanceRole, isScope, normalScope, unscoped } from "@coffre/core/access";
6
6
  import { ACCESS_ACTIONS } from "@coffre/core/vault";
7
7
  //#region src/grants.ts
8
8
  /** Grants, lapsed ones too unless `liveAt` says when to judge them: one member's, or everyone's. */
9
9
  async function readGrants(db, filter = {}) {
10
- const { vaultGrants, environments } = tablesOf(db);
11
- return (await db.select({
12
- grant: vaultGrants,
13
- environmentProjectId: environments.projectId
14
- }).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(and(filter.principal === void 0 ? void 0 : eq(vaultGrants.principal, filter.principal), filter.liveAt === void 0 ? void 0 : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt))))).map(({ grant, environmentProjectId }) => ({
10
+ return (filter.principal !== void 0 && filter.liveAt === void 0 ? await grantsOf(db, { principal: filter.principal }) : await grantRows(db, filter.principal === void 0 ? void 0 : eq(tablesOf(db).vaultGrants.principal, filter.principal), filter.liveAt)).map(({ grant, environmentProjectId }) => ({
15
11
  ...grant,
16
12
  projectId: grant.projectId ?? environmentProjectId
17
13
  }));
18
14
  }
15
+ function grantRows(db, principal, liveAt) {
16
+ const { vaultGrants, environments } = tablesOf(db);
17
+ return db.select({
18
+ grant: vaultGrants,
19
+ environmentProjectId: environments.projectId
20
+ }).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(and(principal, liveAt === void 0 ? void 0 : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, liveAt))));
21
+ }
22
+ const grantsOf = prepared((db) => grantRows(db, eq(tablesOf(db).vaultGrants.principal, sql.placeholder("principal"))));
19
23
  /** Add a grant; an environment's row names only the environment. */
20
24
  async function insertGrant(db, grant) {
21
25
  const { vaultGrants } = tablesOf(db);
@@ -226,18 +230,30 @@ async function reportStands(db, principal, check) {
226
230
  const access = await newestAccess(db, principal, check);
227
231
  if (access === "unreached") return true;
228
232
  const after = typeof access === "string" ? -1n : access.seq;
229
- const report = await newestAuthentic((before) => tamperReportsAbout(db, principal, LOOK_BACK.page, after, before), check);
233
+ const report = await newestAuthentic((limit, before) => tamperReportsAbout(db, principal, limit, after, before), check);
230
234
  return report === "unreached" || typeof report !== "string";
231
235
  }
232
236
  /** The newest access entry about `principal` that `check` finds the vault's own. */
233
237
  function newestAccess(db, principal, check) {
234
- return newestAuthentic((before) => accessEntriesAbout(db, principal, LOOK_BACK.page, before), check);
238
+ return newestAuthentic((limit, before) => accessEntriesAbout(db, principal, limit, before), check);
235
239
  }
236
240
  /** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
237
241
  async function accessEntriesAbout(db, principal, limit, before) {
242
+ return before === void 0 ? await newestAccessEntries(db, {
243
+ principal,
244
+ limit
245
+ }) : await accessEntriesBefore(db, {
246
+ principal,
247
+ limit,
248
+ before
249
+ });
250
+ }
251
+ function accessEntries(db, before) {
238
252
  const { auditLog } = tablesOf(db);
239
- return await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, principal), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit);
253
+ return db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, sql.placeholder("principal")), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"), before)).orderBy(desc(auditLog.seq)).limit(sql.placeholder("limit"));
240
254
  }
255
+ const newestAccessEntries = prepared((db) => accessEntries(db));
256
+ const accessEntriesBefore = prepared((db) => accessEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder("before"))));
241
257
  /**
242
258
  * Up to `limit` of the vault's reports that `principal`'s row was changed
243
259
  * around it (`TAMPER_CODES`), after `after` and before `before` (the newest
@@ -1,4 +1,4 @@
1
- import { n as PoolLike } from "./database-BFnO2b2P.js";
1
+ import { n as PoolLike } from "./database-DBtn3jqp.js";
2
2
  import pg from "pg";
3
3
  //#region src/hyperdrive.d.ts
4
4
  /** Postgres through a Hyperdrive binding: `postgres(env.HYPERDRIVE)`. */
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
2
- import { a as Transaction, c as tablesOf, i as Queryable, n as PoolLike, o as createDatabase, r as QueryOutsideTransaction, s as own, t as Database } from "./database-BFnO2b2P.js";
3
- export { Database, PoolLike, QueryOutsideTransaction, Queryable, Transaction, createDatabase, own, schema_d_exports as schema, tablesOf };
2
+ import { a as Transaction, c as tablesOf, i as Queryable, l as prepared, n as PoolLike, o as createDatabase, r as QueryOutsideTransaction, s as own, t as Database } from "./database-DBtn3jqp.js";
3
+ export { Database, PoolLike, QueryOutsideTransaction, Queryable, Transaction, createDatabase, own, prepared, schema_d_exports as schema, tablesOf };
package/dist/index.js CHANGED
@@ -1,90 +1,3 @@
1
- import { n as trackCommits } from "./commits-BuW-NL3n.js";
2
1
  import { t as schema_exports } from "./schema.js";
3
- import { AsyncLocalStorage } from "node:async_hooks";
4
- import { Table, getTableName, is } from "drizzle-orm";
5
- import { drizzle } from "drizzle-orm/node-postgres";
6
- //#region src/database.ts
7
- /**
8
- * A query made on the database from inside one of its own transactions,
9
- * rather than through the transaction. With one connection per call, as on
10
- * Workers, it would wait for the transaction, which waits for it: a call
11
- * Cloudflare cancels as hung, and nothing says why. So it is refused at
12
- * once, with this, wherever the database runs.
13
- */
14
- var QueryOutsideTransaction = class extends Error {
15
- constructor() {
16
- super("query outside its transaction: use tx. A query on the database inside one of its own transactions waits for that transaction, which waits for it");
17
- this.name = "QueryOutsideTransaction";
18
- }
19
- };
20
- /** The transaction a flow of work runs inside, with the pool it holds, while it is open. */
21
- const inside = new AsyncLocalStorage();
22
- /**
23
- * `pool`, refusing a query or a transaction asked from inside a transaction
24
- * it is already running. Work from any other flow, a concurrent request's,
25
- * or what the transaction left running once its work was done, goes on as before.
26
- */
27
- function guarded(pool) {
28
- return new Proxy(pool, { get(target, key) {
29
- const value = Reflect.get(target, key, target);
30
- if (typeof value !== "function") return value;
31
- if (key !== "query" && key !== "connect") return value.bind(target);
32
- return (...args) => {
33
- const open = inside.getStore();
34
- if (open?.open === true && open.pool === target) throw new QueryOutsideTransaction();
35
- return value.apply(target, args);
36
- };
37
- } });
38
- }
39
- /** The refusal `error` is, or was caused by. */
40
- function brokenRule(error) {
41
- for (let cause = error; cause instanceof Error; cause = cause.cause) if (cause instanceof QueryOutsideTransaction) return cause;
42
- }
43
- function createDatabase(pool) {
44
- const db = drizzle(guarded(pool), { schema: schema_exports });
45
- const run = db.transaction.bind(db);
46
- db.transaction = (async (work, config) => {
47
- const open = {
48
- pool,
49
- open: true
50
- };
51
- try {
52
- return await run((tx) => inside.run(open, async () => {
53
- try {
54
- return await work(tx);
55
- } finally {
56
- open.open = false;
57
- }
58
- }), config);
59
- } catch (error) {
60
- throw brokenRule(error) ?? error;
61
- }
62
- });
63
- return trackCommits(db);
64
- }
65
- /**
66
- * The tables of the database's own dialect: the Postgres ones on Postgres,
67
- * the SQLite ones on SQLite. Every query builds on these.
68
- */
69
- function tablesOf(db) {
70
- return db._.fullSchema;
71
- }
72
- const byName = /* @__PURE__ */ new WeakMap();
73
- /**
74
- * The database's own twin of `table`, which may be another dialect's: the
75
- * server names tables by importing schema.ts, and the database decides
76
- * which one that means.
77
- */
78
- function own(db, table) {
79
- const tables = tablesOf(db);
80
- let named = byName.get(tables);
81
- if (named === void 0) {
82
- named = new Map(Object.values(tables).filter((value) => is(value, Table)).map((value) => [getTableName(value), value]));
83
- byName.set(tables, named);
84
- }
85
- const found = named.get(getTableName(table));
86
- if (found === void 0) throw new Error(`no table ${getTableName(table)} in this database`);
87
- return found;
88
- }
89
- //#endregion
90
- export { QueryOutsideTransaction, createDatabase, own, schema_exports as schema, tablesOf };
2
+ import { a as prepared, i as tablesOf, n as createDatabase, r as own, t as QueryOutsideTransaction } from "./database-BadNeeN-.js";
3
+ export { QueryOutsideTransaction, createDatabase, own, prepared, schema_exports as schema, tablesOf };
package/dist/log.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as Transaction, i as Queryable } from "./database-BFnO2b2P.js";
1
+ import { a as Transaction, i as Queryable } from "./database-DBtn3jqp.js";
2
2
  import { SQL } from "drizzle-orm";
3
3
  import { LogKey, StoredEntry } from "@coffre/core/audit";
4
4
  //#region src/log.d.ts
@@ -462,6 +462,19 @@ export declare function withinBytes(db: Queryable, where: SQL | undefined, limit
462
462
  cut: boolean;
463
463
  count: number;
464
464
  } | null>;
465
+ /**
466
+ * The first entry missing from the log up to `seq`, and the one found in
467
+ * its place, or null when every one is there. Seqs are the primary key, so
468
+ * a stretch is whole exactly when its count is its length: one pass over
469
+ * the key's index, never the entries. Only a log with a gap pays for more,
470
+ * a count per halving to find where.
471
+ *
472
+ * entries 0..9, 4 cut: { expected: 4n, found: 5n }
473
+ */
474
+ export declare function missingEntry(db: Queryable, seq: bigint): Promise<{
475
+ expected: bigint;
476
+ found: bigint | null;
477
+ } | null>;
465
478
  /**
466
479
  * How a reader tells the vault's own entries from those forged in its name:
467
480
  * by their MAC, which only the vault holds the key to check. Each it finds
@@ -488,10 +501,11 @@ export declare const LOOK_BACK: {
488
501
  export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
489
502
  /**
490
503
  * The newest of the entries `page` reads, newest first and before a seq,
491
- * that `check` finds authentic, read back a page at a time: 'none' when
492
- * there are none at all, 'forged' when every one is in the vault's name but
493
- * not its own, and 'unreached' once it has read LOOK_BACK such entries
494
- * (1024), whether or not more are left.
504
+ * that `check` finds authentic: the newest alone first, which it is unless
505
+ * entries were forged in the vault's name since, then back a page at a
506
+ * time. 'none' when there are none at all, 'forged' when every one is in
507
+ * the vault's name but not its own, and 'unreached' once it has read
508
+ * LOOK_BACK such entries (1024), whether or not more are left.
495
509
  */
496
- export declare function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest>;
510
+ export declare function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest>;
497
511
  //#endregion
package/dist/log.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as afterCommit } from "./commits-BuW-NL3n.js";
2
- import { tablesOf } from "./index.js";
3
2
  import { clockMillis, forUpdate } from "./dialect.js";
4
- import { and, asc, desc, eq, lt, sql } from "drizzle-orm";
3
+ import { a as prepared, i as tablesOf } from "./database-BadNeeN-.js";
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";
6
6
  //#region src/log.ts
7
7
  /** The log's head does not name its last entry: something moved it, or cut the log behind it. */
@@ -52,14 +52,17 @@ function forgetLogHeads() {
52
52
  * again in the same transaction is free.
53
53
  */
54
54
  async function lockLogHead(tx) {
55
- const { auditChainHead } = tablesOf(tx);
56
- const [head] = await forUpdate(tx, tx.select({
57
- nextSeq: auditChainHead.nextSeq,
58
- headHash: auditChainHead.headHash
59
- }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)));
55
+ const [head] = await lockedHead(tx);
60
56
  if (head === void 0) throw new Error("audit_chain_head is missing; refusing to write an unchained entry");
61
57
  return head;
62
58
  }
59
+ const lockedHead = prepared((db) => {
60
+ const { auditChainHead } = tablesOf(db);
61
+ return forUpdate(db, db.select({
62
+ nextSeq: auditChainHead.nextSeq,
63
+ headHash: auditChainHead.headHash
64
+ }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)));
65
+ });
63
66
  /**
64
67
  * Append `entries` under `key`. `check`, when given, runs once the head is
65
68
  * locked and before anything is written: a writer's own condition on the
@@ -71,11 +74,8 @@ async function appendEntries(tx, key, entries, check) {
71
74
  const { auditChainHead, auditLog } = tablesOf(tx);
72
75
  const head = await lockLogHead(tx);
73
76
  await check?.(tx);
74
- const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
75
- const [last] = await tx.select({
76
- seq: auditLog.seq,
77
- hash: auditLog.hash
78
- }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
77
+ const [{ now }] = await clockNow(tx);
78
+ const [last] = await lastEntry(tx);
79
79
  const expected = last === void 0 ? {
80
80
  nextSeq: 0n,
81
81
  headHash: GENESIS_HASH
@@ -136,6 +136,14 @@ async function appendEntries(tx, key, entries, check) {
136
136
  occurredAt: now
137
137
  };
138
138
  }
139
+ const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
140
+ const lastEntry = prepared((db) => {
141
+ const { auditLog } = tablesOf(db);
142
+ return db.select({
143
+ seq: auditLog.seq,
144
+ hash: auditLog.hash
145
+ }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
146
+ });
139
147
  /** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
140
148
  function entryColumns(db) {
141
149
  const { auditLog } = tablesOf(db);
@@ -198,21 +206,64 @@ async function withinBytes(db, where, limit, order = "asc") {
198
206
  count
199
207
  };
200
208
  }
209
+ /**
210
+ * The first entry missing from the log up to `seq`, and the one found in
211
+ * its place, or null when every one is there. Seqs are the primary key, so
212
+ * a stretch is whole exactly when its count is its length: one pass over
213
+ * the key's index, never the entries. Only a log with a gap pays for more,
214
+ * a count per halving to find where.
215
+ *
216
+ * entries 0..9, 4 cut: { expected: 4n, found: 5n }
217
+ */
218
+ async function missingEntry(db, seq) {
219
+ const { auditLog } = tablesOf(db);
220
+ const whole = async (to) => {
221
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
222
+ return BigInt(n) === to + 1n;
223
+ };
224
+ if (seq < 0n || await whole(seq)) return null;
225
+ let [low, high] = [0n, seq];
226
+ while (low < high) {
227
+ const middle = (low + high) / 2n;
228
+ if (await whole(middle)) low = middle + 1n;
229
+ else high = middle;
230
+ }
231
+ const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
232
+ return {
233
+ expected: low,
234
+ found: next?.seq ?? null
235
+ };
236
+ }
201
237
  /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
202
238
  async function newestVaultEntries(db, action, limit, before) {
239
+ return before === void 0 ? await newestEntries(db, {
240
+ action,
241
+ limit
242
+ }) : await entriesBefore(db, {
243
+ action,
244
+ limit,
245
+ before
246
+ });
247
+ }
248
+ function vaultEntries(db, before) {
203
249
  const { auditLog } = tablesOf(db);
204
- return await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), eq(auditLog.decision, "allow"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit);
250
+ return db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, sql.placeholder("action")), eq(auditLog.decision, "allow"), before)).orderBy(desc(auditLog.seq)).limit(sql.placeholder("limit"));
205
251
  }
252
+ const newestEntries = prepared((db) => vaultEntries(db));
253
+ const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder("before"))));
206
254
  /**
207
255
  * The number of the newest entry in the vault's name, allowed, of one
208
256
  * `action`, or null for none: whether one was appended since, and nothing
209
257
  * of what it says, so its MAC is not checked. One indexed read.
210
258
  */
211
259
  async function newestVaultSeq(db, action) {
212
- const { auditLog } = tablesOf(db);
213
- const [row] = await 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);
260
+ const [row] = await newestSeq(db, { action });
214
261
  return row?.seq ?? null;
215
262
  }
263
+ const newestSeq = prepared((db) => {
264
+ 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
+ });
216
267
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
217
268
  const LOOK_BACK = {
218
269
  page: 32,
@@ -220,23 +271,27 @@ const LOOK_BACK = {
220
271
  };
221
272
  /**
222
273
  * The newest of the entries `page` reads, newest first and before a seq,
223
- * that `check` finds authentic, read back a page at a time: 'none' when
224
- * there are none at all, 'forged' when every one is in the vault's name but
225
- * not its own, and 'unreached' once it has read LOOK_BACK such entries
226
- * (1024), whether or not more are left.
274
+ * that `check` finds authentic: the newest alone first, which it is unless
275
+ * entries were forged in the vault's name since, then back a page at a
276
+ * time. 'none' when there are none at all, 'forged' when every one is in
277
+ * the vault's name but not its own, and 'unreached' once it has read
278
+ * LOOK_BACK such entries (1024), whether or not more are left.
227
279
  */
228
280
  async function newestAuthentic(page, check) {
281
+ const most = LOOK_BACK.page * LOOK_BACK.pages;
282
+ let read = 0;
229
283
  let before;
230
- for (let read = 0; read < LOOK_BACK.pages; read += 1) {
231
- const entries = await page(before);
284
+ for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
285
+ const entries = await page(limit, before);
232
286
  for (const entry of entries) {
233
287
  if (check.authentic(entry)) return entry;
234
288
  check.forged(entry);
235
289
  }
236
- if (entries.length < LOOK_BACK.page) return before === void 0 && entries.length === 0 ? "none" : "forged";
290
+ read += entries.length;
291
+ if (entries.length < limit) return read === 0 ? "none" : "forged";
237
292
  before = entries.at(-1).seq;
238
293
  }
239
294
  return "unreached";
240
295
  }
241
296
  //#endregion
242
- export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes };
297
+ export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes };
@@ -1,5 +1,5 @@
1
1
  import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
2
- import { t as Database } from "./database-BFnO2b2P.js";
2
+ import { t as Database } from "./database-DBtn3jqp.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
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-BFnO2b2P.js";
1
+ import { i as Queryable } from "./database-DBtn3jqp.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  //#region src/schema-version.d.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.10.0",
3
+ "version": "0.10.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.10.0"
82
+ "@coffre/core": "0.10.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/database.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
 
3
- import { getTableName, is, Table } from 'drizzle-orm';
4
- import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
3
+ import {
4
+ createTableRelationsHelpers,
5
+ extractTablesRelationalConfig,
6
+ getTableName,
7
+ is,
8
+ Table,
9
+ type ExtractTablesWithRelations,
10
+ type RelationalSchemaConfig,
11
+ } from 'drizzle-orm';
12
+ import { NodePgDatabase, NodePgDriver } from 'drizzle-orm/node-postgres';
13
+ import { PgDialect } from 'drizzle-orm/pg-core';
5
14
  import type pg from 'pg';
6
15
 
7
16
  import { trackCommits } from './commits.ts';
@@ -74,8 +83,29 @@ function brokenRule(error: unknown): QueryOutsideTransaction | undefined {
74
83
  return undefined;
75
84
  }
76
85
 
86
+ /**
87
+ * What Drizzle makes of the schema, which `drizzle(pool, { schema })` works
88
+ * out again from every table at each call: on Workers, a database is made
89
+ * per call, and this was a fifth of a vault call's CPU. The schema never
90
+ * changes, so it is worked out once per process or isolate, a settled value.
91
+ */
92
+ type Relational = RelationalSchemaConfig<ExtractTablesWithRelations<typeof schema>>;
93
+ let relational: Relational | undefined;
94
+
95
+ function relationalSchema(): Relational {
96
+ if (relational === undefined) {
97
+ const { tables, tableNamesMap } = extractTablesRelationalConfig(schema, createTableRelationsHelpers);
98
+ // Drizzle's own construct passes the same untyped tables on as these.
99
+ relational = { fullSchema: schema, schema: tables as Relational['schema'], tableNamesMap };
100
+ }
101
+ return relational;
102
+ }
103
+
77
104
  export function createDatabase(pool: PoolLike): Database {
78
- const db = drizzle(guarded(pool) as pg.Pool, { schema });
105
+ // What `drizzle(pool, { schema })` builds, but for the schema's config, made once.
106
+ const dialect = new PgDialect();
107
+ const config = relationalSchema();
108
+ const db: Database = new NodePgDatabase(dialect, new NodePgDriver(guarded(pool) as pg.Pool, dialect).createSession(config), config);
79
109
  // Each transaction's work runs knowing which one it is in, until that work is done. Not until
80
110
  // the COMMIT comes back: the transaction waits on nothing then, so what the work left running
81
111
  // may ask for a query, which waits for the transaction to end rather than for itself.
@@ -132,3 +162,5 @@ export function own<T extends Table>(db: Queryable, table: T): T {
132
162
  if (found === undefined) throw new Error(`no table ${getTableName(table)} in this database`);
133
163
  return found as T;
134
164
  }
165
+
166
+ export { prepared } from './prepared.ts';
package/src/grants.ts CHANGED
@@ -18,8 +18,9 @@ import { ACCESS_ACTIONS } from '@coffre/core/vault';
18
18
  import { and, asc, desc, eq, gt, inArray, isNull, like, lt, or, sql, type SQL } from 'drizzle-orm';
19
19
 
20
20
  import { tablesOf, type Queryable } from './database.ts';
21
+ import { prepared } from './prepared.ts';
21
22
  import { tombstone, truth } from './dialect.ts';
22
- import { entryColumns, LOOK_BACK, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
23
+ import { entryColumns, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
23
24
 
24
25
  /**
25
26
  * One grant, as `vault_grants` holds it: on a project (`environmentId`
@@ -46,20 +47,24 @@ export async function readGrants(
46
47
  db: Queryable,
47
48
  filter: { principal?: string; liveAt?: number } = {},
48
49
  ): Promise<GrantRow[]> {
50
+ // A member's grants, which every check reads, built once (`prepared`); any other filter, as asked.
51
+ const rows = filter.principal !== undefined && filter.liveAt === undefined
52
+ ? await grantsOf(db, { principal: filter.principal })
53
+ : await grantRows(db, filter.principal === undefined ? undefined : eq(tablesOf(db).vaultGrants.principal, filter.principal), filter.liveAt);
54
+ return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
55
+ }
56
+
57
+ function grantRows(db: Queryable, principal: SQL | undefined, liveAt?: number) {
49
58
  const { vaultGrants, environments } = tablesOf(db);
50
- const rows = await db
59
+ return db
51
60
  .select({ grant: vaultGrants, environmentProjectId: environments.projectId })
52
61
  .from(vaultGrants)
53
62
  .leftJoin(environments, eq(environments.id, vaultGrants.environmentId))
54
- .where(
55
- and(
56
- filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
57
- filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
58
- ),
59
- );
60
- return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
63
+ .where(and(principal, liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, liveAt))));
61
64
  }
62
65
 
66
+ const grantsOf = prepared((db) => grantRows(db, eq(tablesOf(db).vaultGrants.principal, sql.placeholder('principal'))));
67
+
63
68
  /** Add a grant; an environment's row names only the environment. */
64
69
  export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void> {
65
70
  const { vaultGrants } = tablesOf(db);
@@ -289,35 +294,43 @@ export async function reportStands(db: Queryable, principal: string, check: Auth
289
294
  const access = await newestAccess(db, principal, check);
290
295
  if (access === 'unreached') return true;
291
296
  const after = typeof access === 'string' ? -1n : access.seq;
292
- const report = await newestAuthentic((before) => tamperReportsAbout(db, principal, LOOK_BACK.page, after, before), check);
297
+ const report = await newestAuthentic((limit, before) => tamperReportsAbout(db, principal, limit, after, before), check);
293
298
  return report === 'unreached' || typeof report !== 'string';
294
299
  }
295
300
 
296
301
  /** The newest access entry about `principal` that `check` finds the vault's own. */
297
302
  export function newestAccess(db: Queryable, principal: string, check: Authenticity): Promise<Newest> {
298
- return newestAuthentic((before) => accessEntriesAbout(db, principal, LOOK_BACK.page, before), check);
303
+ return newestAuthentic((limit, before) => accessEntriesAbout(db, principal, limit, before), check);
299
304
  }
300
305
 
301
306
  /** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
302
307
  export async function accessEntriesAbout(db: Queryable, principal: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
308
+ const rows = before === undefined ? await newestAccessEntries(db, { principal, limit }) : await accessEntriesBefore(db, { principal, limit, before });
309
+ return rows as StoredEntry[];
310
+ }
311
+
312
+ function accessEntries(db: Queryable, before?: SQL) {
303
313
  const { auditLog } = tablesOf(db);
304
- const rows = await db
314
+ return db
305
315
  .select(entryColumns(db))
306
316
  .from(auditLog)
307
317
  .where(
308
318
  and(
309
319
  eq(auditLog.author, 'vault'),
310
- eq(auditLog.subjectPrincipal, principal),
320
+ eq(auditLog.subjectPrincipal, sql.placeholder('principal')),
311
321
  inArray(auditLog.action, [...ACCESS_ACTIONS]),
312
322
  eq(auditLog.decision, 'allow'),
313
- before === undefined ? undefined : lt(auditLog.seq, before),
323
+ before,
314
324
  ),
315
325
  )
316
326
  .orderBy(desc(auditLog.seq))
317
- .limit(limit);
318
- return rows as StoredEntry[];
327
+ .limit(sql.placeholder('limit'));
319
328
  }
320
329
 
330
+ // What every check of a member reads first, built once (`prepared`).
331
+ const newestAccessEntries = prepared((db) => accessEntries(db));
332
+ const accessEntriesBefore = prepared((db) => accessEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
333
+
321
334
  /**
322
335
  * Up to `limit` of the vault's reports that `principal`'s row was changed
323
336
  * around it (`TAMPER_CODES`), after `after` and before `before` (the newest
package/src/log.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
- import { and, asc, desc, eq, lt, sql, type SQL } from 'drizzle-orm';
2
+ import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, 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
+ import { prepared } from './prepared.ts';
6
7
  import { clockMillis, forUpdate } from './dialect.ts';
7
8
 
8
9
  /**
@@ -99,18 +100,19 @@ export function forgetLogHeads(): void {
99
100
  * again in the same transaction is free.
100
101
  */
101
102
  export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; headHash: Buffer }> {
102
- const { auditChainHead } = tablesOf(tx);
103
- const [head] = await forUpdate(
104
- tx,
105
- tx
106
- .select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash })
107
- .from(auditChainHead)
108
- .where(eq(auditChainHead.onlyRow, true)),
109
- );
103
+ const [head] = await lockedHead(tx);
110
104
  if (head === undefined) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
111
105
  return head;
112
106
  }
113
107
 
108
+ const lockedHead = prepared((db) => {
109
+ const { auditChainHead } = tablesOf(db);
110
+ return forUpdate(
111
+ db,
112
+ db.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)),
113
+ );
114
+ });
115
+
114
116
  /**
115
117
  * Append `entries` under `key`. `check`, when given, runs once the head is
116
118
  * locked and before anything is written: a writer's own condition on the
@@ -129,12 +131,8 @@ export async function appendEntries(
129
131
  // Every append queues here, if its transaction has not already.
130
132
  const head = await lockLogHead(tx);
131
133
  await check?.(tx);
132
- const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
133
- const [last] = await tx
134
- .select({ seq: auditLog.seq, hash: auditLog.hash })
135
- .from(auditLog)
136
- .orderBy(desc(auditLog.seq))
137
- .limit(1);
134
+ const [{ now }] = await clockNow(tx);
135
+ const [last] = await lastEntry(tx);
138
136
 
139
137
  const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
140
138
  if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
@@ -188,6 +186,13 @@ export async function appendEntries(
188
186
  return { seqStart: head.nextSeq, ...written, occurredAt: now };
189
187
  }
190
188
 
189
+ const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
190
+
191
+ const lastEntry = prepared((db) => {
192
+ const { auditLog } = tablesOf(db);
193
+ return db.select({ seq: auditLog.seq, hash: auditLog.hash }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
194
+ });
195
+
191
196
  // --- reading the vault's entries back ---------------------------------------------
192
197
 
193
198
  /** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
@@ -257,6 +262,33 @@ export async function withinBytes(
257
262
  return { from: sizes[0]!.seq, to: sizes[count - 1]!.seq, cut: count < sizes.length, count };
258
263
  }
259
264
 
265
+ /**
266
+ * The first entry missing from the log up to `seq`, and the one found in
267
+ * its place, or null when every one is there. Seqs are the primary key, so
268
+ * a stretch is whole exactly when its count is its length: one pass over
269
+ * the key's index, never the entries. Only a log with a gap pays for more,
270
+ * a count per halving to find where.
271
+ *
272
+ * entries 0..9, 4 cut: { expected: 4n, found: 5n }
273
+ */
274
+ export async function missingEntry(db: Queryable, seq: bigint): Promise<{ expected: bigint; found: bigint | null } | null> {
275
+ const { auditLog } = tablesOf(db);
276
+ const whole = async (to: bigint) => {
277
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
278
+ return BigInt(n) === to + 1n;
279
+ };
280
+ if (seq < 0n || (await whole(seq))) return null;
281
+ // The first stretch from 0 that is not whole ends at the missing entry.
282
+ let [low, high] = [0n, seq];
283
+ while (low < high) {
284
+ const middle = (low + high) / 2n;
285
+ if (await whole(middle)) low = middle + 1n;
286
+ else high = middle;
287
+ }
288
+ const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
289
+ return { expected: low, found: (next?.seq as bigint | undefined) ?? null };
290
+ }
291
+
260
292
  /**
261
293
  * How a reader tells the vault's own entries from those forged in its name:
262
294
  * by their MAC, which only the vault holds the key to check. Each it finds
@@ -266,38 +298,42 @@ export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged:
266
298
 
267
299
  /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
268
300
  export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
301
+ const rows = before === undefined ? await newestEntries(db, { action, limit }) : await entriesBefore(db, { action, limit, before });
302
+ return rows as StoredEntry[];
303
+ }
304
+
305
+ function vaultEntries(db: Queryable, before?: SQL) {
269
306
  const { auditLog } = tablesOf(db);
270
- const rows = await db
307
+ return db
271
308
  .select(entryColumns(db))
272
309
  .from(auditLog)
273
- .where(
274
- and(
275
- eq(auditLog.author, 'vault'),
276
- eq(auditLog.action, action),
277
- eq(auditLog.decision, 'allow'),
278
- before === undefined ? undefined : lt(auditLog.seq, before),
279
- ),
280
- )
310
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow'), before))
281
311
  .orderBy(desc(auditLog.seq))
282
- .limit(limit);
283
- return rows as StoredEntry[];
312
+ .limit(sql.placeholder('limit'));
284
313
  }
285
314
 
315
+ const newestEntries = prepared((db) => vaultEntries(db));
316
+ const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
317
+
286
318
  /**
287
319
  * The number of the newest entry in the vault's name, allowed, of one
288
320
  * `action`, or null for none: whether one was appended since, and nothing
289
321
  * of what it says, so its MAC is not checked. One indexed read.
290
322
  */
291
323
  export async function newestVaultSeq(db: Queryable, action: string): Promise<bigint | null> {
324
+ const [row] = await newestSeq(db, { action });
325
+ return (row?.seq as bigint | undefined) ?? null;
326
+ }
327
+
328
+ const newestSeq = prepared((db) => {
292
329
  const { auditLog } = tablesOf(db);
293
- const [row] = await db
330
+ return db
294
331
  .select({ seq: auditLog.seq })
295
332
  .from(auditLog)
296
- .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), eq(auditLog.decision, 'allow')))
333
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow')))
297
334
  .orderBy(desc(auditLog.seq))
298
335
  .limit(1);
299
- return (row?.seq as bigint | undefined) ?? null;
300
- }
336
+ });
301
337
 
302
338
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
303
339
  export const LOOK_BACK = { page: 32, pages: 32 };
@@ -307,20 +343,24 @@ export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
307
343
 
308
344
  /**
309
345
  * The newest of the entries `page` reads, newest first and before a seq,
310
- * that `check` finds authentic, read back a page at a time: 'none' when
311
- * there are none at all, 'forged' when every one is in the vault's name but
312
- * not its own, and 'unreached' once it has read LOOK_BACK such entries
313
- * (1024), whether or not more are left.
346
+ * that `check` finds authentic: the newest alone first, which it is unless
347
+ * entries were forged in the vault's name since, then back a page at a
348
+ * time. 'none' when there are none at all, 'forged' when every one is in
349
+ * the vault's name but not its own, and 'unreached' once it has read
350
+ * LOOK_BACK such entries (1024), whether or not more are left.
314
351
  */
315
- export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
352
+ export async function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
353
+ const most = LOOK_BACK.page * LOOK_BACK.pages;
354
+ let read = 0;
316
355
  let before: bigint | undefined;
317
- for (let read = 0; read < LOOK_BACK.pages; read += 1) {
318
- const entries = await page(before);
356
+ for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
357
+ const entries = await page(limit, before);
319
358
  for (const entry of entries) {
320
359
  if (check.authentic(entry)) return entry;
321
360
  check.forged(entry);
322
361
  }
323
- if (entries.length < LOOK_BACK.page) return before === undefined && entries.length === 0 ? 'none' : 'forged';
362
+ read += entries.length;
363
+ if (entries.length < limit) return read === 0 ? 'none' : 'forged';
324
364
  before = entries.at(-1)!.seq;
325
365
  }
326
366
  return 'unreached';
@@ -0,0 +1,60 @@
1
+ import type { Query } from 'drizzle-orm';
2
+
3
+ import { tablesOf, type Queryable } from './database.ts';
4
+ import { engineOf } from './dialect.ts';
5
+
6
+ /** What Drizzle works out from a select builder before it can run it, once per dialect. */
7
+ type Built = { query: Query; fields: unknown; joinsNotNullableMap: unknown; engine: 'postgres' | 'sqlite' };
8
+
9
+ /** The part of a select builder `prepared` reads, once. */
10
+ type Select = PromiseLike<unknown> & { toSQL(): Query; prepare(...name: never[]): unknown };
11
+
12
+ /**
13
+ * A select built once per process, or once per isolate on Workers, and run
14
+ * with new values each time. Drizzle rebuilds a query's SQL from its
15
+ * builder at every run, and that was most of a short query's CPU; this
16
+ * builds it from `build` the first time a database of its dialect asks,
17
+ * then hands the SQL and its columns to that call's own session
18
+ * (`session.prepareQuery`), which binds `values` to the placeholders
19
+ * (`sql.placeholder`) and maps the rows as the builder would.
20
+ *
21
+ * `build` must not depend on anything but the database's dialect, and the
22
+ * values go to the driver as they are, not through a column's encoding:
23
+ * strings, numbers, bigints and bytes.
24
+ *
25
+ * const member = prepared((db) => db.select().from(tablesOf(db).vaultMembers).where(eq(…, sql.placeholder('principal'))));
26
+ * const [row] = await member(db, { principal });
27
+ */
28
+ export function prepared<Q extends Select>(
29
+ build: (db: Queryable) => Q,
30
+ ): (db: Queryable, values?: Record<string, unknown>) => Promise<Awaited<Q>> {
31
+ // By the dialect's tables: one Postgres build, one SQLite build.
32
+ const builds = new WeakMap<object, Built>();
33
+ return (db, values = {}) => {
34
+ const tables = tablesOf(db);
35
+ let built = builds.get(tables);
36
+ if (built === undefined) {
37
+ const builder = build(db);
38
+ // Drizzle's own preparation, which runs nothing, says which columns come back and which joins may be null.
39
+ // Unnamed on Postgres: no statement is prepared on the server, whose connections a call does not keep.
40
+ const { fields, joinsNotNullableMap } = (builder as unknown as { prepare(): { fields: unknown; joinsNotNullableMap: unknown } }).prepare();
41
+ built = { query: builder.toSQL(), fields, joinsNotNullableMap, engine: engineOf(db) };
42
+ builds.set(tables, built);
43
+ }
44
+ const session = (db as unknown as { session: Session }).session;
45
+ const query = built.engine === 'sqlite'
46
+ ? session.prepareQuery(built.query, built.fields, 'all', true)
47
+ : session.prepareQuery(built.query, built.fields, undefined, true);
48
+ query.joinsNotNullableMap = built.joinsNotNullableMap;
49
+ return (built.engine === 'sqlite' ? query.all(values) : query.execute(values)) as Promise<Awaited<Q>>;
50
+ };
51
+ }
52
+
53
+ /** Drizzle's session, Postgres's or SQLite's, as `prepared` calls it. */
54
+ type Session = {
55
+ prepareQuery(query: Query, fields: unknown, nameOrMethod: string | undefined, isResponseInArrayMode: boolean): {
56
+ joinsNotNullableMap: unknown;
57
+ execute(values: Record<string, unknown>): Promise<unknown>;
58
+ all(values: Record<string, unknown>): Promise<unknown>;
59
+ };
60
+ };