@coffre/db 0.10.0 → 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/connect.d.ts +1 -1
- package/dist/connect.js +1 -1
- package/dist/database-BadNeeN-.js +146 -0
- package/dist/{database-BFnO2b2P.d.ts → database-DBtn3jqp.d.ts} +26 -2
- package/dist/dialect.d.ts +7 -1
- package/dist/dialect.js +10 -1
- package/dist/grants.d.ts +1 -1
- package/dist/grants.js +26 -10
- package/dist/hyperdrive.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -89
- package/dist/log.d.ts +84 -8
- package/dist/log.js +98 -31
- package/dist/portable.d.ts +1 -1
- package/dist/schema-version.d.ts +1 -1
- package/package.json +2 -2
- package/src/database.ts +35 -3
- package/src/dialect.ts +10 -0
- package/src/grants.ts +29 -16
- package/src/log.ts +105 -40
- package/src/prepared.ts +60 -0
package/dist/connect.d.ts
CHANGED
package/dist/connect.js
CHANGED
|
@@ -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-
|
|
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
|
|
@@ -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/grants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as Queryable } from "./database-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
package/dist/hyperdrive.d.ts
CHANGED
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-
|
|
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 {
|
|
4
|
-
|
|
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,5 +1,5 @@
|
|
|
1
|
-
import { a as Transaction, i as Queryable } from "./database-
|
|
2
|
-
import { SQL } from "drizzle-orm";
|
|
1
|
+
import { a as Transaction, i as Queryable } from "./database-DBtn3jqp.js";
|
|
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
|
|
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<{
|
|
@@ -462,6 +480,19 @@ export declare function withinBytes(db: Queryable, where: SQL | undefined, limit
|
|
|
462
480
|
cut: boolean;
|
|
463
481
|
count: number;
|
|
464
482
|
} | null>;
|
|
483
|
+
/**
|
|
484
|
+
* The first entry missing from the log up to `seq`, and the one found in
|
|
485
|
+
* its place, or null when every one is there. Seqs are the primary key, so
|
|
486
|
+
* a stretch is whole exactly when its count is its length: one pass over
|
|
487
|
+
* the key's index, never the entries. Only a log with a gap pays for more,
|
|
488
|
+
* a count per halving to find where.
|
|
489
|
+
*
|
|
490
|
+
* entries 0..9, 4 cut: { expected: 4n, found: 5n }
|
|
491
|
+
*/
|
|
492
|
+
export declare function missingEntry(db: Queryable, seq: bigint): Promise<{
|
|
493
|
+
expected: bigint;
|
|
494
|
+
found: bigint | null;
|
|
495
|
+
} | null>;
|
|
465
496
|
/**
|
|
466
497
|
* How a reader tells the vault's own entries from those forged in its name:
|
|
467
498
|
* by their MAC, which only the vault holds the key to check. Each it finds
|
|
@@ -479,6 +510,50 @@ export declare function newestVaultEntries(db: Queryable, action: string, limit:
|
|
|
479
510
|
* of what it says, so its MAC is not checked. One indexed read.
|
|
480
511
|
*/
|
|
481
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">;
|
|
482
557
|
/** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
|
|
483
558
|
export declare const LOOK_BACK: {
|
|
484
559
|
page: number;
|
|
@@ -488,10 +563,11 @@ export declare const LOOK_BACK: {
|
|
|
488
563
|
export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
|
|
489
564
|
/**
|
|
490
565
|
* The newest of the entries `page` reads, newest first and before a seq,
|
|
491
|
-
* that `check` finds authentic
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
566
|
+
* that `check` finds authentic: the newest alone first, which it is unless
|
|
567
|
+
* entries were forged in the vault's name since, then back a page at a
|
|
568
|
+
* time. 'none' when there are none at all, 'forged' when every one is in
|
|
569
|
+
* the vault's name but not its own, and 'unreached' once it has read
|
|
570
|
+
* LOOK_BACK such entries (1024), whether or not more are left.
|
|
495
571
|
*/
|
|
496
|
-
export declare function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest>;
|
|
572
|
+
export declare function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest>;
|
|
497
573
|
//#endregion
|
package/dist/log.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as afterCommit } from "./commits-BuW-NL3n.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { and, asc, desc, eq, lt, sql } from "drizzle-orm";
|
|
2
|
+
import { clockMillis, engineOf, forUpdate } from "./dialect.js";
|
|
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,30 +52,30 @@ function forgetLogHeads() {
|
|
|
52
52
|
* again in the same transaction is free.
|
|
53
53
|
*/
|
|
54
54
|
async function lockLogHead(tx) {
|
|
55
|
-
const
|
|
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
|
|
66
69
|
* log as it stands, which no other append can change until this one
|
|
67
70
|
* commits. It throws to refuse.
|
|
68
71
|
*/
|
|
69
|
-
async function appendEntries(tx, key, entries, check) {
|
|
72
|
+
async function appendEntries(tx, key, entries, check, locked) {
|
|
70
73
|
if (entries.length === 0) throw new Error("appendEntries called with no entries");
|
|
71
74
|
const { auditChainHead, auditLog } = tablesOf(tx);
|
|
72
|
-
const head = await lockLogHead(tx);
|
|
75
|
+
const head = locked?.head ?? await lockLogHead(tx);
|
|
73
76
|
await check?.(tx);
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
seq: auditLog.seq,
|
|
77
|
-
hash: auditLog.hash
|
|
78
|
-
}).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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,16 @@ 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;
|
|
145
|
+
const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
146
|
+
const lastEntry = prepared((db) => {
|
|
147
|
+
const { auditLog } = tablesOf(db);
|
|
148
|
+
return db.select({
|
|
149
|
+
seq: auditLog.seq,
|
|
150
|
+
hash: auditLog.hash
|
|
151
|
+
}).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
|
|
152
|
+
});
|
|
139
153
|
/** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
|
|
140
154
|
function entryColumns(db) {
|
|
141
155
|
const { auditLog } = tablesOf(db);
|
|
@@ -198,21 +212,70 @@ async function withinBytes(db, where, limit, order = "asc") {
|
|
|
198
212
|
count
|
|
199
213
|
};
|
|
200
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* The first entry missing from the log up to `seq`, and the one found in
|
|
217
|
+
* its place, or null when every one is there. Seqs are the primary key, so
|
|
218
|
+
* a stretch is whole exactly when its count is its length: one pass over
|
|
219
|
+
* the key's index, never the entries. Only a log with a gap pays for more,
|
|
220
|
+
* a count per halving to find where.
|
|
221
|
+
*
|
|
222
|
+
* entries 0..9, 4 cut: { expected: 4n, found: 5n }
|
|
223
|
+
*/
|
|
224
|
+
async function missingEntry(db, seq) {
|
|
225
|
+
const { auditLog } = tablesOf(db);
|
|
226
|
+
const whole = async (to) => {
|
|
227
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
|
|
228
|
+
return BigInt(n) === to + 1n;
|
|
229
|
+
};
|
|
230
|
+
if (seq < 0n || await whole(seq)) return null;
|
|
231
|
+
let [low, high] = [0n, seq];
|
|
232
|
+
while (low < high) {
|
|
233
|
+
const middle = (low + high) / 2n;
|
|
234
|
+
if (await whole(middle)) low = middle + 1n;
|
|
235
|
+
else high = middle;
|
|
236
|
+
}
|
|
237
|
+
const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
|
|
238
|
+
return {
|
|
239
|
+
expected: low,
|
|
240
|
+
found: next?.seq ?? null
|
|
241
|
+
};
|
|
242
|
+
}
|
|
201
243
|
/** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
|
|
202
244
|
async function newestVaultEntries(db, action, limit, before) {
|
|
245
|
+
return before === void 0 ? await newestEntries(db, {
|
|
246
|
+
action,
|
|
247
|
+
limit
|
|
248
|
+
}) : await entriesBefore(db, {
|
|
249
|
+
action,
|
|
250
|
+
limit,
|
|
251
|
+
before
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function vaultEntries(db, before) {
|
|
203
255
|
const { auditLog } = tablesOf(db);
|
|
204
|
-
return
|
|
256
|
+
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
257
|
}
|
|
258
|
+
const newestEntries = prepared((db) => vaultEntries(db));
|
|
259
|
+
const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder("before"))));
|
|
206
260
|
/**
|
|
207
261
|
* The number of the newest entry in the vault's name, allowed, of one
|
|
208
262
|
* `action`, or null for none: whether one was appended since, and nothing
|
|
209
263
|
* of what it says, so its MAC is not checked. One indexed read.
|
|
210
264
|
*/
|
|
211
265
|
async function newestVaultSeq(db, action) {
|
|
212
|
-
const
|
|
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);
|
|
266
|
+
const [row] = await newestSeq(db, { action });
|
|
214
267
|
return row?.seq ?? null;
|
|
215
268
|
}
|
|
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) {
|
|
276
|
+
const { auditLog } = tablesOf(db);
|
|
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
|
+
}
|
|
216
279
|
/** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
|
|
217
280
|
const LOOK_BACK = {
|
|
218
281
|
page: 32,
|
|
@@ -220,23 +283,27 @@ const LOOK_BACK = {
|
|
|
220
283
|
};
|
|
221
284
|
/**
|
|
222
285
|
* The newest of the entries `page` reads, newest first and before a seq,
|
|
223
|
-
* that `check` finds authentic
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
286
|
+
* that `check` finds authentic: the newest alone first, which it is unless
|
|
287
|
+
* entries were forged in the vault's name since, then back a page at a
|
|
288
|
+
* time. 'none' when there are none at all, 'forged' when every one is in
|
|
289
|
+
* the vault's name but not its own, and 'unreached' once it has read
|
|
290
|
+
* LOOK_BACK such entries (1024), whether or not more are left.
|
|
227
291
|
*/
|
|
228
292
|
async function newestAuthentic(page, check) {
|
|
293
|
+
const most = LOOK_BACK.page * LOOK_BACK.pages;
|
|
294
|
+
let read = 0;
|
|
229
295
|
let before;
|
|
230
|
-
for (let
|
|
231
|
-
const entries = await page(before);
|
|
296
|
+
for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
|
|
297
|
+
const entries = await page(limit, before);
|
|
232
298
|
for (const entry of entries) {
|
|
233
299
|
if (check.authentic(entry)) return entry;
|
|
234
300
|
check.forged(entry);
|
|
235
301
|
}
|
|
236
|
-
|
|
302
|
+
read += entries.length;
|
|
303
|
+
if (entries.length < limit) return read === 0 ? "none" : "forged";
|
|
237
304
|
before = entries.at(-1).seq;
|
|
238
305
|
}
|
|
239
306
|
return "unreached";
|
|
240
307
|
}
|
|
241
308
|
//#endregion
|
|
242
|
-
export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes };
|
|
309
|
+
export { BATCH_BYTES, LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, missingEntry, newestAuthentic, newestSeqQuery, newestVaultEntries, newestVaultSeq, withinBytes };
|
package/dist/portable.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
|
|
2
|
-
import { t as Database } from "./database-
|
|
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
|
package/dist/schema-version.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/db",
|
|
3
|
-
"version": "0.10.
|
|
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.
|
|
82
|
+
"@coffre/core": "0.10.2"
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
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/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/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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,9 +1,10 @@
|
|
|
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 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
|
-
import {
|
|
6
|
+
import { prepared } from './prepared.ts';
|
|
7
|
+
import { clockMillis, engineOf, forUpdate } from './dialect.ts';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Appending to the audit log, the one table both the app and the vault
|
|
@@ -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
|
|
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
|
|
@@ -122,19 +124,21 @@ export async function appendEntries(
|
|
|
122
124
|
key: LogKey,
|
|
123
125
|
entries: readonly NewEntry[],
|
|
124
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,
|
|
125
133
|
): Promise<Appended> {
|
|
126
134
|
if (entries.length === 0) throw new Error('appendEntries called with no entries');
|
|
127
135
|
const { auditChainHead, auditLog } = tablesOf(tx);
|
|
128
136
|
|
|
129
137
|
// Every append queues here, if its transaction has not already.
|
|
130
|
-
const head = await lockLogHead(tx);
|
|
138
|
+
const head = locked?.head ?? (await lockLogHead(tx));
|
|
131
139
|
await check?.(tx);
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
.select({ seq: auditLog.seq, hash: auditLog.hash })
|
|
135
|
-
.from(auditLog)
|
|
136
|
-
.orderBy(desc(auditLog.seq))
|
|
137
|
-
.limit(1);
|
|
140
|
+
const now = locked?.at ?? (await clockNow(tx))[0]!.now;
|
|
141
|
+
const last = locked === undefined ? (await lastEntry(tx))[0] : locked.last ?? undefined;
|
|
138
142
|
|
|
139
143
|
const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
|
|
140
144
|
if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
|
|
@@ -181,13 +185,32 @@ export async function appendEntries(
|
|
|
181
185
|
seq += 1n;
|
|
182
186
|
}
|
|
183
187
|
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
|
186
196
|
const written = { nextSeq: seq, headHash: prevHash };
|
|
187
197
|
afterCommit(tx, () => remember(key, written));
|
|
188
198
|
return { seqStart: head.nextSeq, ...written, occurredAt: now };
|
|
189
199
|
}
|
|
190
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
|
+
|
|
207
|
+
const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
208
|
+
|
|
209
|
+
const lastEntry = prepared((db) => {
|
|
210
|
+
const { auditLog } = tablesOf(db);
|
|
211
|
+
return db.select({ seq: auditLog.seq, hash: auditLog.hash }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
191
214
|
// --- reading the vault's entries back ---------------------------------------------
|
|
192
215
|
|
|
193
216
|
/** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
|
|
@@ -257,6 +280,33 @@ export async function withinBytes(
|
|
|
257
280
|
return { from: sizes[0]!.seq, to: sizes[count - 1]!.seq, cut: count < sizes.length, count };
|
|
258
281
|
}
|
|
259
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The first entry missing from the log up to `seq`, and the one found in
|
|
285
|
+
* its place, or null when every one is there. Seqs are the primary key, so
|
|
286
|
+
* a stretch is whole exactly when its count is its length: one pass over
|
|
287
|
+
* the key's index, never the entries. Only a log with a gap pays for more,
|
|
288
|
+
* a count per halving to find where.
|
|
289
|
+
*
|
|
290
|
+
* entries 0..9, 4 cut: { expected: 4n, found: 5n }
|
|
291
|
+
*/
|
|
292
|
+
export async function missingEntry(db: Queryable, seq: bigint): Promise<{ expected: bigint; found: bigint | null } | null> {
|
|
293
|
+
const { auditLog } = tablesOf(db);
|
|
294
|
+
const whole = async (to: bigint) => {
|
|
295
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
|
|
296
|
+
return BigInt(n) === to + 1n;
|
|
297
|
+
};
|
|
298
|
+
if (seq < 0n || (await whole(seq))) return null;
|
|
299
|
+
// The first stretch from 0 that is not whole ends at the missing entry.
|
|
300
|
+
let [low, high] = [0n, seq];
|
|
301
|
+
while (low < high) {
|
|
302
|
+
const middle = (low + high) / 2n;
|
|
303
|
+
if (await whole(middle)) low = middle + 1n;
|
|
304
|
+
else high = middle;
|
|
305
|
+
}
|
|
306
|
+
const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
|
|
307
|
+
return { expected: low, found: (next?.seq as bigint | undefined) ?? null };
|
|
308
|
+
}
|
|
309
|
+
|
|
260
310
|
/**
|
|
261
311
|
* How a reader tells the vault's own entries from those forged in its name:
|
|
262
312
|
* by their MAC, which only the vault holds the key to check. Each it finds
|
|
@@ -266,37 +316,48 @@ export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged:
|
|
|
266
316
|
|
|
267
317
|
/** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
|
|
268
318
|
export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
|
|
319
|
+
const rows = before === undefined ? await newestEntries(db, { action, limit }) : await entriesBefore(db, { action, limit, before });
|
|
320
|
+
return rows as StoredEntry[];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function vaultEntries(db: Queryable, before?: SQL) {
|
|
269
324
|
const { auditLog } = tablesOf(db);
|
|
270
|
-
|
|
325
|
+
return db
|
|
271
326
|
.select(entryColumns(db))
|
|
272
327
|
.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
|
-
)
|
|
328
|
+
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow'), before))
|
|
281
329
|
.orderBy(desc(auditLog.seq))
|
|
282
|
-
.limit(limit);
|
|
283
|
-
return rows as StoredEntry[];
|
|
330
|
+
.limit(sql.placeholder('limit'));
|
|
284
331
|
}
|
|
285
332
|
|
|
333
|
+
const newestEntries = prepared((db) => vaultEntries(db));
|
|
334
|
+
const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
|
|
335
|
+
|
|
286
336
|
/**
|
|
287
337
|
* The number of the newest entry in the vault's name, allowed, of one
|
|
288
338
|
* `action`, or null for none: whether one was appended since, and nothing
|
|
289
339
|
* of what it says, so its MAC is not checked. One indexed read.
|
|
290
340
|
*/
|
|
291
341
|
export async function newestVaultSeq(db: Queryable, action: string): Promise<bigint | null> {
|
|
342
|
+
const [row] = await newestSeq(db, { action });
|
|
343
|
+
return (row?.seq as bigint | undefined) ?? null;
|
|
344
|
+
}
|
|
345
|
+
|
|
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) {
|
|
292
354
|
const { auditLog } = tablesOf(db);
|
|
293
|
-
|
|
355
|
+
return db
|
|
294
356
|
.select({ seq: auditLog.seq })
|
|
295
357
|
.from(auditLog)
|
|
296
358
|
.where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), eq(auditLog.decision, 'allow')))
|
|
297
359
|
.orderBy(desc(auditLog.seq))
|
|
298
360
|
.limit(1);
|
|
299
|
-
return (row?.seq as bigint | undefined) ?? null;
|
|
300
361
|
}
|
|
301
362
|
|
|
302
363
|
/** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
|
|
@@ -307,20 +368,24 @@ export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
|
|
|
307
368
|
|
|
308
369
|
/**
|
|
309
370
|
* The newest of the entries `page` reads, newest first and before a seq,
|
|
310
|
-
* that `check` finds authentic
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
371
|
+
* that `check` finds authentic: the newest alone first, which it is unless
|
|
372
|
+
* entries were forged in the vault's name since, then back a page at a
|
|
373
|
+
* time. 'none' when there are none at all, 'forged' when every one is in
|
|
374
|
+
* the vault's name but not its own, and 'unreached' once it has read
|
|
375
|
+
* LOOK_BACK such entries (1024), whether or not more are left.
|
|
314
376
|
*/
|
|
315
|
-
export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
|
|
377
|
+
export async function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
|
|
378
|
+
const most = LOOK_BACK.page * LOOK_BACK.pages;
|
|
379
|
+
let read = 0;
|
|
316
380
|
let before: bigint | undefined;
|
|
317
|
-
for (let
|
|
318
|
-
const entries = await page(before);
|
|
381
|
+
for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
|
|
382
|
+
const entries = await page(limit, before);
|
|
319
383
|
for (const entry of entries) {
|
|
320
384
|
if (check.authentic(entry)) return entry;
|
|
321
385
|
check.forged(entry);
|
|
322
386
|
}
|
|
323
|
-
|
|
387
|
+
read += entries.length;
|
|
388
|
+
if (entries.length < limit) return read === 0 ? 'none' : 'forged';
|
|
324
389
|
before = entries.at(-1)!.seq;
|
|
325
390
|
}
|
|
326
391
|
return 'unreached';
|
package/src/prepared.ts
ADDED
|
@@ -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
|
+
};
|