@coffre/vault 0.14.5 → 0.15.0
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/README.md +6 -4
- package/dist/cloudflare.d.ts +341 -4
- package/dist/cloudflare.js +802 -6
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +181 -6
- package/dist/node.js +358 -16
- package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +3 -2
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +64 -10
- package/src/store.ts +832 -36
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1366 -220
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-Cbi45kLB.js +0 -5197
package/src/log-store.ts
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { entryLeaf, parseCheckpoint, splitNote, tileLeaves, type LogEntry, type PairNotes, type StoredPair, type Tile, type TileKey, type TreeName } from '@coffre/core/merkle';
|
|
2
|
+
import type { Database } from '@coffre/db';
|
|
3
|
+
import { engineOf, rowsOf } from '@coffre/db/dialect';
|
|
4
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
import { misplaced, Refused, type CheckpointStore, type Locating } from './checkpointer.ts';
|
|
7
|
+
import { logObject } from './inputs.ts';
|
|
8
|
+
import { changeEntry, RETENTION_ACTOR, RETENTION_OBJECT, useEntry } from './log-entry.ts';
|
|
9
|
+
import { CursorGone, type ReadRow, type ReadsCursor, type ReadsStore } from './reads.ts';
|
|
10
|
+
import type { RetentionStore } from './retention.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The checkpointer's database (checkpointer.ts, `CheckpointStore`): 0.15's
|
|
14
|
+
* log, migration 0022 on Postgres and 0018 on SQLite. Every write goes
|
|
15
|
+
* through 0022's functions, which run as their owner: the vault's login
|
|
16
|
+
* reads `log_*` and writes none of it.
|
|
17
|
+
*
|
|
18
|
+
* - The barrier is `close_one`, under the fence exclusive; the window is
|
|
19
|
+
* read in the statement after it, in a snapshot that sees every append
|
|
20
|
+
* the barrier waited for (a snapshot from before its lock would miss
|
|
21
|
+
* those). Changes by number, their key; uses through their one index,
|
|
22
|
+
* `(at, request_id)`, from the floor the signer holds, and kept by
|
|
23
|
+
* number.
|
|
24
|
+
* - A checkpoint is `write_checkpoint`: both trees' tiles and the pair, one
|
|
25
|
+
* call (PairWrittenTogether). Each range of the uses tree's leaf tiles is
|
|
26
|
+
* made (`make_tiles`) before a tile lands in it, the next one with it.
|
|
27
|
+
* - Every read of uses is bounded by `at`, from the floor of the window it
|
|
28
|
+
* reads: none scans the history (R48).
|
|
29
|
+
* - Retention (retention.ts, migration 0027): days made ahead, the
|
|
30
|
+
* retention change, and a day's partition and the tiles below a cut
|
|
31
|
+
* dropped, each through its function, which holds the vault's login to
|
|
32
|
+
* what a sealed retention change cuts.
|
|
33
|
+
*
|
|
34
|
+
* On SQLite one transaction runs at a time: the fence is implicit, every
|
|
35
|
+
* number committed is final, and the barrier only moves the floor. The
|
|
36
|
+
* writes are the same statements, in one transaction.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** How many leaves of the uses tree one range of its leaf tiles holds: 2^24, about 4.7 hours at 1,000 uses a second, 512 MB of tiles. */
|
|
40
|
+
export const TILE_RANGE = 2 ** 24;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How far past its window's barrier floor a use's `at` may lie: the deadline (`deadline_max()`, 3 s) and the minute
|
|
44
|
+
* a Worker's clock may run ahead of Postgres's, which `use_credential` holds a use to (it answers `ahead`, and writes
|
|
45
|
+
* nothing, past it). `locate` reads a use's window through the uses' one index within it.
|
|
46
|
+
*/
|
|
47
|
+
const AT_SLACK_MS = 63_000;
|
|
48
|
+
|
|
49
|
+
type Options = {
|
|
50
|
+
/** Leaves per range of the uses tree's leaf tiles (Postgres): `TILE_RANGE` unless set, smaller in tests. */
|
|
51
|
+
tileRange?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The checkpointer's database, with retention's part. */
|
|
55
|
+
export type LogStore = CheckpointStore & RetentionStore & ReadsStore;
|
|
56
|
+
|
|
57
|
+
export function logStore(db: Database, options: Options = {}): LogStore {
|
|
58
|
+
const range = options.tileRange ?? TILE_RANGE;
|
|
59
|
+
// A range holds whole leaf tiles: 256 leaves each.
|
|
60
|
+
if (!Number.isSafeInteger(range) || range <= 0 || range % 256 !== 0) throw new Error(`tileRange must be a positive multiple of 256, not ${range}`);
|
|
61
|
+
const base = engineOf(db) === 'postgres' ? postgresStore(db, range) : sqliteStore(db);
|
|
62
|
+
const reads = readsStore(db);
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
readsCursor: reads.readsCursor,
|
|
66
|
+
objects: reads.objects,
|
|
67
|
+
writeReads: reads.writeReads,
|
|
68
|
+
listing: reads.listing,
|
|
69
|
+
// The reads' days with the uses' (T166): made beside them, and gone before the uses' last step, so that a day is
|
|
70
|
+
// found (`days()` reads the uses') until both are: a reads' drop that gave up, past its lock timeout, is tried again
|
|
71
|
+
// by the next run, never forgotten with its reads listed (review r10). On Postgres the reads' day goes once the uses'
|
|
72
|
+
// is detached (`drop_reads_day` keeps it while the log holds it); on SQLite, before the uses' rows.
|
|
73
|
+
async makeDays(days) {
|
|
74
|
+
await base.makeDays(days);
|
|
75
|
+
await reads.makeDays(days);
|
|
76
|
+
},
|
|
77
|
+
async dropDay(day) {
|
|
78
|
+
await reads.dropDay(day);
|
|
79
|
+
const state = await base.dropDay(day);
|
|
80
|
+
if (state === 'detached') await reads.dropDay(day);
|
|
81
|
+
return state;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The pair's fields as `write_checkpoint` takes them, from its notes (the checkpointer signed them; nothing is checked here). */
|
|
87
|
+
function heads(notes: PairNotes) {
|
|
88
|
+
const changes = parseCheckpoint(splitNote(notes.changes).text);
|
|
89
|
+
const uses = parseCheckpoint(splitNote(notes.uses).text);
|
|
90
|
+
return { upto: changes.upto, at: changes.at, floor: changes.floor, changes, uses };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type RawEntry = {
|
|
94
|
+
tree: TreeName; number: string; at: string; requestId: string; mac: string;
|
|
95
|
+
object?: string; actor?: string; payload?: string; seal?: string | null;
|
|
96
|
+
credential?: string; secret?: string; purpose?: number; place?: string | null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const hex = (value: string | null | undefined) => (value === null || value === undefined ? null : Buffer.from(value, 'hex'));
|
|
100
|
+
|
|
101
|
+
function toEntry(r: RawEntry): LogEntry {
|
|
102
|
+
if (r.tree === 'changes') {
|
|
103
|
+
return changeEntry({
|
|
104
|
+
number: BigInt(r.number), at: Number(r.at), requestId: r.requestId, object: r.object!, actor: r.actor!, payload: r.payload!, seal: hex(r.seal), mac: hex(r.mac)!,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return useEntry({
|
|
108
|
+
number: BigInt(r.number), at: Number(r.at), requestId: BigInt(r.requestId), credential: r.credential!, secret: r.secret!, purpose: Number(r.purpose), mac: hex(r.mac)!, place: r.place ?? null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A window read's first `limit` entries, by number, when the caller bounds it (`LogReader.entries`). */
|
|
113
|
+
function limitOf(limit: number | undefined): SQL {
|
|
114
|
+
return limit === undefined ? sql`` : sql` LIMIT ${limit}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One tree's half of a bounded read, its first `limit` in its index's
|
|
119
|
+
* order, so that the database too stops there rather than sort every entry
|
|
120
|
+
* above the cursor (PR 8 reviews r2, r3): the changes by number, their key;
|
|
121
|
+
* the uses by `at`, their one index (`log_uses_request_idx`). By number, a
|
|
122
|
+
* use may lie past one of them later by `at`: a read that fills its limit
|
|
123
|
+
* says so, and is not taken as all there is (`verifyLog`'s `above`).
|
|
124
|
+
*/
|
|
125
|
+
function first(half: SQL, order: SQL, limit: number | undefined): SQL {
|
|
126
|
+
return limit === undefined ? half : sql`SELECT * FROM (${half} ORDER BY ${order} LIMIT ${limit}) f`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The `at` a read of the entries in (after, upto] needs: from the floor of the pair at or below `after`, to the slack
|
|
131
|
+
* past the floor of the first pair at or above `upto`, the barrier that sealed it; without one, no upper bound. A
|
|
132
|
+
* verifier gives the floor its pair signed (`floor`): the row's own is a column any writer may raise, which would hide
|
|
133
|
+
* every entry above the newest pair from it (review r9).
|
|
134
|
+
*/
|
|
135
|
+
function bounds(after: bigint, upto: bigint, floor?: number): [SQL, SQL] {
|
|
136
|
+
return [
|
|
137
|
+
floor === undefined ? sql`coalesce((SELECT k.at_floor FROM log_checkpoints k WHERE k.upto <= ${after} ORDER BY k.upto DESC LIMIT 1), 0)` : sql`${floor}`,
|
|
138
|
+
sql`coalesce((SELECT k.at_floor FROM log_checkpoints k WHERE k.upto >= ${upto} ORDER BY k.upto LIMIT 1) + ${AT_SLACK_MS}, 9223372036854775807)`,
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The tiles asked, as constants in the statement, `(level = 0 AND leaf = 256) OR …`: Postgres then prunes the leaf
|
|
144
|
+
* ranges as it plans. Passed as an array, every range was planned, 8.7 ms at 150 ranges, a month at 1,000 uses a
|
|
145
|
+
* second, against 0.4 ms (Fable's review). Each is a whole number the code computed, checked before it is written.
|
|
146
|
+
*/
|
|
147
|
+
export function tileKeys(keys: readonly TileKey[]): SQL {
|
|
148
|
+
return sql.raw(keys.map((k) => {
|
|
149
|
+
const leaf = leafOf(k);
|
|
150
|
+
if (!Number.isSafeInteger(k.level) || !Number.isSafeInteger(leaf) || k.level < 0 || leaf < 0) throw new Error(`not a tile: ${k.level}/${k.index}`);
|
|
151
|
+
return `(t.level = ${k.level} AND t.leaf = ${leaf})`;
|
|
152
|
+
}).join(' OR '));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type WindowRow = {
|
|
156
|
+
tree: TreeName; n: string; at: string; request_id: string; mac: Buffer;
|
|
157
|
+
object: string | null; actor: string | null; payload: string | null; seal: Buffer | null;
|
|
158
|
+
credential: string | null; secret: string | null; purpose: number | null; place: string | null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function fromRow(r: WindowRow): LogEntry {
|
|
162
|
+
if (r.tree === 'changes') {
|
|
163
|
+
return changeEntry({ number: BigInt(r.n), at: Number(r.at), requestId: r.request_id, object: r.object!, actor: r.actor!, payload: r.payload!, seal: r.seal, mac: r.mac });
|
|
164
|
+
}
|
|
165
|
+
return useEntry({ number: BigInt(r.n), at: Number(r.at), requestId: BigInt(r.request_id), credential: r.credential!, secret: r.secret!, purpose: r.purpose!, mac: r.mac, place: r.place });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type RawPair = { upto: string; changes: string; uses: string };
|
|
169
|
+
const toPair = (r: RawPair | null | undefined): StoredPair | null => (r === null || r === undefined ? null : { upto: BigInt(r.upto), notes: { changes: r.changes, uses: r.uses } });
|
|
170
|
+
|
|
171
|
+
const leafOf = (t: TileKey) => tileLeaves(t).first;
|
|
172
|
+
/** A UTC day, YYYY-MM-DD, as ms since the epoch at its start. */
|
|
173
|
+
const dayStart = (day: string) => Date.parse(`${day}T00:00:00Z`);
|
|
174
|
+
const DAY_MS = 86_400_000;
|
|
175
|
+
const indexOf = (level: number, leaf: number) => leaf / 2 ** (8 * (level + 1));
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A window's first `limit` entries by number, of the `limit + 1` read (T169): the one past them kept too when it holds
|
|
179
|
+
* the last one's number (review r2), so that the tick's check sees two entries with one number and refuses, rather
|
|
180
|
+
* than seal through a number whose second entry a writer put past the cut.
|
|
181
|
+
*/
|
|
182
|
+
function capped<E extends { number: bigint }>(read: E[], limit: number | undefined): E[] {
|
|
183
|
+
if (limit === undefined || read.length <= limit) return read;
|
|
184
|
+
return read[limit]!.number === read[limit - 1]!.number ? read : read.slice(0, limit);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One row, one JSON column `v`, as both dialects answer the reads below. */
|
|
188
|
+
async function one<T>(db: Database, query: SQL): Promise<T> {
|
|
189
|
+
const [row] = await rowsOf<{ v: unknown }>(db, query);
|
|
190
|
+
const v = row?.v;
|
|
191
|
+
return (typeof v === 'string' ? JSON.parse(v) : v) as T;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The widest window of uses a proof reads the leaves of: 4,096 leaf tiles, some 17 minutes at 1,000 uses a second. */
|
|
195
|
+
const PROOF_WINDOW = 2 ** 20;
|
|
196
|
+
|
|
197
|
+
/** What `locate` reads in one statement: the entry, a change's place among its window's changes, and the uses tree's size at each end of the window. */
|
|
198
|
+
type Located = { entry: RawEntry | null; before: string | null; from: string | null; to: string | null };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The use numbered as asked, in its window: at its time, when the caller
|
|
202
|
+
* knows it (one probe of the uses' index), else anywhere in the `at` the
|
|
203
|
+
* window's uses may hold, from the prior floor to the sealing barrier's slack.
|
|
204
|
+
*/
|
|
205
|
+
function useAt(at: number | undefined): SQL {
|
|
206
|
+
return at === undefined ? sql`u.at >= p.at_floor AND u.at <= s.at_floor + ${AT_SLACK_MS}` : sql`u.at = ${at}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The entry `locate` found, and its index in its tree. A change's is the
|
|
211
|
+
* changes before it in its window, counted by number, their key. A use's
|
|
212
|
+
* is where its leaf sits among the window's leaves, read from the uses
|
|
213
|
+
* tree's leaf tiles (a tick's worth, two or three tiles at 1,000 uses a
|
|
214
|
+
* second): counting the uses before it by `at` read every use the window's
|
|
215
|
+
* slack holds, about 63,000 index entries at that rate (T119: 9.6 ms and
|
|
216
|
+
* 1,397 buffers a proof, measured at 1M uses). A use whose leaf is not
|
|
217
|
+
* there was changed since its tick. The window's bounds are its rows'
|
|
218
|
+
* sizes, the database's: one wider than `PROOF_WINDOW` leaves is refused,
|
|
219
|
+
* never scanned (PR 8 review r2).
|
|
220
|
+
*/
|
|
221
|
+
async function located(found: Located | null | undefined, tiles: (keys: TileKey[]) => Promise<Tile[]>, tree?: TreeName): Promise<{ entry: LogEntry; index: number } | null> {
|
|
222
|
+
if (found === null || found === undefined || found.entry === null) return null;
|
|
223
|
+
const entry = toEntry(found.entry);
|
|
224
|
+
if (tree !== undefined && entry.tree !== tree) throw misplaced(entry, tree);
|
|
225
|
+
if (entry.tree === 'changes') return found.before === null ? null : { entry, index: Number(found.before) };
|
|
226
|
+
if (found.from === null || found.to === null) return null;
|
|
227
|
+
const [from, to] = [Number(found.from), Number(found.to)];
|
|
228
|
+
if (!(from <= to && to - from <= PROOF_WINDOW)) {
|
|
229
|
+
throw new Refused(`use ${entry.number}'s window is ${from} to ${to} in the uses tree: no window of ${PROOF_WINDOW.toLocaleString('en')} leaves or fewer, so the database's checkpoint sizes are not the signer's, or a tick sealed a stall longer than a proof reads`);
|
|
230
|
+
}
|
|
231
|
+
const leaf = entryLeaf('uses', entry.number, entry.columns);
|
|
232
|
+
const wanted: TileKey[] = [];
|
|
233
|
+
for (let index = Math.floor(from / 256); index * 256 < to; index += 1) wanted.push({ level: 0, index });
|
|
234
|
+
for (const tile of await tiles(wanted)) {
|
|
235
|
+
for (let i = Math.max(from, tile.index * 256); i < Math.min(to, tile.index * 256 + tile.hashes.length / 32); i += 1) {
|
|
236
|
+
const offset = (i - tile.index * 256) * 32;
|
|
237
|
+
if (tile.hashes.subarray(offset, offset + 32).equals(leaf)) return { entry, index: i };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw new Refused(`use ${entry.number} is in none of its window's leaves: the database changed it since its tick`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Postgres's read of the entries in (after, upto], by number, uses from `floor` to `ceiling`: the window's rows (`postgresStore`). */
|
|
244
|
+
export function postgresWindow(after: bigint, upto: bigint, floor: SQL, ceiling: SQL = sql`9223372036854775807`, limit?: number, byNumber = false): SQL {
|
|
245
|
+
return sql`
|
|
246
|
+
${first(sql`SELECT 'changes' AS tree, c.number AS n, c.at::text AS at, c.request_id, c.object::text AS object, c.actor, c.payload, c.seal, c.mac,
|
|
247
|
+
NULL::text AS credential, NULL::text AS secret, NULL::smallint AS purpose, NULL::text AS place
|
|
248
|
+
FROM log_changes c WHERE c.number > ${after} AND c.number <= ${upto}`, sql`c.number`, limit)}
|
|
249
|
+
UNION ALL
|
|
250
|
+
${first(sql`SELECT 'uses', u.number, u.at::text, u.request_id::text, NULL::text, NULL::text, NULL::text, NULL::bytea, u.mac, u.credential::text, u.secret::text, u.purpose, u.place::text
|
|
251
|
+
FROM log_uses u WHERE u.at >= ${floor} AND u.at <= ${ceiling} AND u.number > ${after} AND u.number <= ${upto}`, byNumber ? sql`u.number` : sql`u.at`, limit)}
|
|
252
|
+
ORDER BY n${limitOf(limit)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function postgresStore(db: Database, range: number): CheckpointStore & Omit<RetentionStore, 'readsCursor'> {
|
|
256
|
+
const CHANGE = sql.raw(`json_build_object('tree', 'changes', 'number', c.number::text, 'at', c.at::text, 'requestId', c.request_id,
|
|
257
|
+
'object', c.object::text, 'actor', c.actor, 'payload', c.payload, 'seal', encode(c.seal, 'hex'), 'mac', encode(c.mac, 'hex'))`);
|
|
258
|
+
const USE = sql.raw(`json_build_object('tree', 'uses', 'number', u.number::text, 'at', u.at::text, 'requestId', u.request_id::text,
|
|
259
|
+
'credential', u.credential::text, 'secret', u.secret::text, 'purpose', u.purpose, 'mac', encode(u.mac, 'hex'), 'place', u.place::text)`);
|
|
260
|
+
const PAIR = sql.raw(`json_build_object('upto', k.upto::text, 'changes', k.changes_note, 'uses', k.uses_note)`);
|
|
261
|
+
/** The entries in (after, upto], uses read from `floor` on: one JSON array, by number. */
|
|
262
|
+
/**
|
|
263
|
+
* The window as rows, by number: the tick's read, 500 entries every 500 ms at R44's rate, so no JSON is built
|
|
264
|
+
* for it (measured: the window read 6.0 ms p50 as JSON).
|
|
265
|
+
*/
|
|
266
|
+
const rows = (after: bigint, upto: bigint, floor: SQL, ceiling?: SQL, limit?: number, byNumber = false) =>
|
|
267
|
+
rowsOf<WindowRow>(db, postgresWindow(after, upto, floor, ceiling, limit, byNumber));
|
|
268
|
+
// The ranges this store made: each made once, not one statement more a tick. Retention (plan PR 7), which drops
|
|
269
|
+
// ranges, forgets the ones it drops here (T119's item 2); and the days it made, which it forgets as it drops them.
|
|
270
|
+
const made = new Set<number>();
|
|
271
|
+
const madeDays = new Set<string>();
|
|
272
|
+
/** The lowest `at` of the uses in (after, upto] from `from` on, null for none: the index by `at`, to the first in range. */
|
|
273
|
+
const lowestAbove = async (after: bigint, upto: bigint, from: number) => {
|
|
274
|
+
const at = await one<string | null>(db, sql`SELECT min(u.at)::text AS v FROM log_uses u WHERE u.at >= ${from}::bigint AND u.number > ${after} AND u.number <= ${upto}`);
|
|
275
|
+
return at === null ? null : Number(at);
|
|
276
|
+
};
|
|
277
|
+
const tiles: LogStore['tiles'] = async (tree, keys) => {
|
|
278
|
+
if (keys.length === 0) return [];
|
|
279
|
+
const rows = await rowsOf<{ level: number; leaf: string; hashes: Buffer }>(db, sql`
|
|
280
|
+
SELECT t.level, t.leaf::text, t.hashes FROM log_tiles t WHERE t.tree = ${tree} AND (${tileKeys(keys)})`);
|
|
281
|
+
return rows.map((r) => ({ level: r.level, index: indexOf(r.level, Number(r.leaf)), hashes: Buffer.from(r.hashes) }));
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
async close(after, from, limit, told) {
|
|
285
|
+
// The barrier, and the newest pair with it: only the checkpointer writes that row, so a snapshot from before the
|
|
286
|
+
// barrier's lock holds it. The window comes in the next statement, a snapshot after the lock.
|
|
287
|
+
// The ceiling is the clock less the deadline once close_one has run, as close_one raises the floor to it: no
|
|
288
|
+
// barrier before this one can have left the floor above it (T169, a late one included).
|
|
289
|
+
const [barrier] = await rowsOf<{ upto: string | null; at_floor: string | null; floor_found: string | null; ceiling: string; newest: RawPair | null }>(db, sql`
|
|
290
|
+
SELECT b.upto::text, b.at_floor::text, b.floor_found::text,
|
|
291
|
+
((extract(epoch FROM clock_timestamp()) * 1000)::bigint - deadline_max())::text AS ceiling,
|
|
292
|
+
(SELECT ${PAIR} FROM log_checkpoints k ORDER BY k.upto DESC LIMIT 1) AS newest
|
|
293
|
+
FROM close_one() b`);
|
|
294
|
+
const upto = BigInt(barrier?.upto ?? '0');
|
|
295
|
+
told?.(upto);
|
|
296
|
+
// The window by number, its first `limit` (T169): every use of the window read, sorted by number, which costs the
|
|
297
|
+
// window's size; past the limit, the lowest `at` of the uses left above it, which the index finds past those read.
|
|
298
|
+
const read = (await rows(after, upto, sql`${from}::bigint`, undefined, limit === undefined ? undefined : limit + 1, true)).map(fromRow);
|
|
299
|
+
const entries = capped(read, limit);
|
|
300
|
+
const more = entries === read ? null : { above: await lowestAbove(entries.at(-1)!.number, upto, from) };
|
|
301
|
+
return {
|
|
302
|
+
upto,
|
|
303
|
+
newest: toPair(barrier?.newest),
|
|
304
|
+
entries,
|
|
305
|
+
floor: { found: barrier?.floor_found == null ? null : Number(barrier.floor_found), set: Number(barrier?.at_floor ?? 0), ceiling: Number(barrier?.ceiling) },
|
|
306
|
+
more,
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
async newest() {
|
|
310
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k ORDER BY k.upto DESC LIMIT 1`));
|
|
311
|
+
},
|
|
312
|
+
async pairs(after, limit) {
|
|
313
|
+
const rows = await one<RawPair[]>(db, sql`SELECT coalesce(json_agg(p.v ORDER BY p.upto), '[]') AS v FROM (
|
|
314
|
+
SELECT k.upto, ${PAIR} AS v FROM log_checkpoints k WHERE k.upto > ${after} ORDER BY k.upto LIMIT ${limit}) p`);
|
|
315
|
+
return rows.map((r) => toPair(r)!);
|
|
316
|
+
},
|
|
317
|
+
async pair(upto) {
|
|
318
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k WHERE k.upto = ${upto}`));
|
|
319
|
+
},
|
|
320
|
+
async entries(after, upto, options = {}) {
|
|
321
|
+
// The uses after `after` were admitted at or above the floor its barrier set (FloorChecked): read from there, up to
|
|
322
|
+
// the slack past the floor of the barrier that sealed `upto`. Past the newest pair, unbounded: the head is near.
|
|
323
|
+
const [floor, ceiling] = bounds(after, upto, options.floor);
|
|
324
|
+
return (await rows(after, upto, floor, ceiling, options.limit)).map(fromRow);
|
|
325
|
+
},
|
|
326
|
+
tiles,
|
|
327
|
+
async write(row, tiles) {
|
|
328
|
+
// Each range a uses leaf tile falls in, and the one after it, made before the write needs them.
|
|
329
|
+
for (const t of tiles.uses) {
|
|
330
|
+
if (t.level !== 0) continue;
|
|
331
|
+
const start = Math.floor(leafOf(t) / range) * range;
|
|
332
|
+
for (const r of [start, start + range]) {
|
|
333
|
+
if (made.has(r)) continue;
|
|
334
|
+
await rowsOf(db, sql`SELECT make_tiles(${r}::bigint, ${r + range}::bigint)`);
|
|
335
|
+
made.add(r);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const all = [...tiles.changes.map((t) => ['changes', t] as const), ...tiles.uses.map((t) => ['uses', t] as const)];
|
|
339
|
+
const h = heads(row.notes);
|
|
340
|
+
await rowsOf(db, sql`SELECT write_checkpoint(
|
|
341
|
+
${row.upto}::bigint, ${h.at}::bigint, ${h.floor}::bigint,
|
|
342
|
+
${h.changes.size}::bigint, ${h.changes.root}::bytea, ${row.notes.changes},
|
|
343
|
+
${h.uses.size}::bigint, ${h.uses.root}::bytea, ${row.notes.uses},
|
|
344
|
+
${sql.param(all.map(([tree]) => tree))}::text[], ${sql.param(all.map(([, t]) => t.level))}::smallint[],
|
|
345
|
+
${sql.param(all.map(([, t]) => String(leafOf(t))))}::bigint[], ${sql.param(all.map(([, t]) => t.hashes))}::bytea[])`);
|
|
346
|
+
},
|
|
347
|
+
async locate(number, { at, tree }: Locating = {}) {
|
|
348
|
+
const found = await one<Located | null>(db, sql`
|
|
349
|
+
WITH sealing AS (SELECT upto, at_floor, uses_size FROM log_checkpoints WHERE upto >= ${number} ORDER BY upto LIMIT 1),
|
|
350
|
+
prior AS (SELECT upto, at_floor, changes_size, uses_size FROM log_checkpoints WHERE upto < ${number} ORDER BY upto DESC LIMIT 1)
|
|
351
|
+
SELECT json_build_object(
|
|
352
|
+
'entry', coalesce(
|
|
353
|
+
(SELECT ${CHANGE} FROM log_changes c WHERE c.number = ${number}),
|
|
354
|
+
(SELECT ${USE} FROM log_uses u, prior p, sealing s WHERE ${useAt(at)} AND u.number = ${number} LIMIT 1)),
|
|
355
|
+
'before', (SELECT p.changes_size + (SELECT count(*) FROM log_changes x WHERE x.number > p.upto AND x.number < ${number}) FROM prior p)::text,
|
|
356
|
+
'from', (SELECT uses_size FROM prior)::text,
|
|
357
|
+
'to', (SELECT uses_size FROM sealing)::text) AS v
|
|
358
|
+
WHERE EXISTS (SELECT 1 FROM sealing)`);
|
|
359
|
+
return located(found, (keys) => tiles('uses', keys), tree);
|
|
360
|
+
},
|
|
361
|
+
async retention() {
|
|
362
|
+
// The newest at or below the newest checkpoint (sealed, as far as the database says: log_cut's), by its object's
|
|
363
|
+
// index; then its place, as a proof reads it.
|
|
364
|
+
const [r] = await rowsOf<{ n: string | null }>(db, sql`SELECT (SELECT c.number FROM log_changes c
|
|
365
|
+
WHERE c.object = ${RETENTION_OBJECT}::uuid AND c.number <= (SELECT coalesce(max(k.upto), -1) FROM log_checkpoints k)
|
|
366
|
+
ORDER BY c.number DESC LIMIT 1)::text AS n`);
|
|
367
|
+
return r?.n == null ? null : this.locate(BigInt(r.n), { tree: 'changes' });
|
|
368
|
+
},
|
|
369
|
+
async days() {
|
|
370
|
+
// Every day of uses there is, attached or left detached by a drop that stopped between its two steps: the
|
|
371
|
+
// catalog, not the uses, so that a quiet day, with none, is found too.
|
|
372
|
+
const names = await rowsOf<{ name: string }>(db, sql`
|
|
373
|
+
SELECT c.relname AS name FROM pg_class c WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND c.relname ~ '^log_uses_[0-9]{8}$'`);
|
|
374
|
+
return names.map((n) => `${n.name.slice(9, 13)}-${n.name.slice(13, 15)}-${n.name.slice(15, 17)}`).sort();
|
|
375
|
+
},
|
|
376
|
+
async makeDays(days) {
|
|
377
|
+
for (const day of days) {
|
|
378
|
+
if (madeDays.has(day)) continue;
|
|
379
|
+
await rowsOf(db, sql`SELECT make_day(${day}::date)`);
|
|
380
|
+
madeDays.add(day);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
async cutAt(ms) {
|
|
384
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k WHERE k.at_floor >= ${ms} ORDER BY k.at_floor, k.upto LIMIT 1`));
|
|
385
|
+
},
|
|
386
|
+
async appendRetention(change) {
|
|
387
|
+
const [row] = await rowsOf<{ n: string }>(db, sql`SELECT log_retention(${change.requestId}, ${change.at}::bigint, ${change.payload}, ${change.mac}::bytea)::text AS n`);
|
|
388
|
+
return BigInt(row!.n);
|
|
389
|
+
},
|
|
390
|
+
async dropDay(day) {
|
|
391
|
+
const [row] = await rowsOf<{ state: 'detached' | 'dropped' | 'gone' }>(db, sql`SELECT drop_day(${day}::date) AS state`);
|
|
392
|
+
if (row!.state !== 'detached') madeDays.delete(day);
|
|
393
|
+
return row!.state;
|
|
394
|
+
},
|
|
395
|
+
async dropTiles(below) {
|
|
396
|
+
// A step a call; each leaf range detached is forgotten, so that a write would make it again (T119's item 2): none does,
|
|
397
|
+
// a write's tiles being at the edge, at or above every cut.
|
|
398
|
+
let deleted = 0;
|
|
399
|
+
for (let step = 0; step < 10_000; step += 1) {
|
|
400
|
+
const [row] = await rowsOf<{ done: string }>(db, sql`SELECT drop_tiles(${below}::bigint) AS done`);
|
|
401
|
+
const [what, name] = row!.done.split(' ') as [string, string];
|
|
402
|
+
if (what === 'done') return deleted + Number(name);
|
|
403
|
+
// Forgotten whichever step this call took: a DETACH whose answer was lost shows next as the range dropped.
|
|
404
|
+
made.delete(Number(name.slice('log_tiles_uses_'.length)));
|
|
405
|
+
if (what === 'detached') deleted += 1;
|
|
406
|
+
}
|
|
407
|
+
throw new Error('the tiles below the cut were not all dropped in 10,000 steps');
|
|
408
|
+
},
|
|
409
|
+
async pruneBulk(limit) {
|
|
410
|
+
const [row] = await rowsOf<{ n: number }>(db, sql`SELECT prune_bulk(${limit}::int) AS n`);
|
|
411
|
+
return Number(row!.n);
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sqliteStore(db: Database): CheckpointStore & Omit<RetentionStore, 'readsCursor'> {
|
|
417
|
+
const CHANGE = sql.raw(`json_object('tree', 'changes', 'number', CAST(c.number AS TEXT), 'at', CAST(c.at AS TEXT), 'requestId', c.request_id,
|
|
418
|
+
'object', c.object, 'actor', c.actor, 'payload', c.payload, 'seal', CASE WHEN c.seal IS NULL THEN NULL ELSE hex(c.seal) END, 'mac', hex(c.mac))`);
|
|
419
|
+
const USE = sql.raw(`json_object('tree', 'uses', 'number', CAST(u.number AS TEXT), 'at', CAST(u.at AS TEXT), 'requestId', CAST(u.request_id AS TEXT),
|
|
420
|
+
'credential', u.credential, 'secret', u.secret, 'purpose', u.purpose, 'mac', hex(u.mac), 'place', u.place)`);
|
|
421
|
+
const PAIR = sql.raw(`json_object('upto', CAST(k.upto AS TEXT), 'changes', k.changes_note, 'uses', k.uses_note)`);
|
|
422
|
+
const window = (after: bigint, upto: bigint, floor: SQL, ceiling: SQL = sql`9223372036854775807`, limit?: number, byNumber = false) => sql`(SELECT coalesce(json_group_array(json(e.v)), '[]') FROM (
|
|
423
|
+
${first(sql`SELECT c.number AS n, ${CHANGE} AS v FROM log_changes c WHERE c.number > ${after} AND c.number <= ${upto}`, sql`c.number`, limit)}
|
|
424
|
+
UNION ALL
|
|
425
|
+
${first(sql`SELECT u.number, ${USE} AS v FROM log_uses u WHERE u.at >= ${floor} AND u.at <= ${ceiling} AND u.number > ${after} AND u.number <= ${upto}`, byNumber ? sql`u.number` : sql`u.at`, limit)}
|
|
426
|
+
ORDER BY 1${limitOf(limit)}
|
|
427
|
+
) e)`;
|
|
428
|
+
const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
|
|
429
|
+
const tiles: LogStore['tiles'] = async (tree, keys) => {
|
|
430
|
+
if (keys.length === 0) return [];
|
|
431
|
+
const wanted = JSON.stringify(keys.map((k) => [k.level, leafOf(k)]));
|
|
432
|
+
const rows = await rowsOf<{ level: number; leaf: number; hashes: Uint8Array }>(db, sql`
|
|
433
|
+
SELECT t.level, t.leaf, t.hashes FROM log_tiles t
|
|
434
|
+
WHERE t.tree = ${tree} AND (t.level, t.leaf) IN (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(${wanted}))`);
|
|
435
|
+
return rows.map((r) => ({ level: Number(r.level), index: indexOf(Number(r.level), Number(r.leaf)), hashes: Buffer.from(r.hashes) }));
|
|
436
|
+
};
|
|
437
|
+
return {
|
|
438
|
+
async close(after, from, limit, told) {
|
|
439
|
+
// One writer: every number committed is final. The barrier only moves the floor, as close_one does.
|
|
440
|
+
const barrier = await db.transaction(async (tx) => {
|
|
441
|
+
const [fence] = await rowsOf<{ at_floor: number }>(tx, sql`SELECT at_floor FROM log_fence WHERE only_row`);
|
|
442
|
+
await rowsOf(tx, sql`UPDATE log_fence SET at_floor = max(at_floor, ${NOW} - 3000) WHERE only_row`);
|
|
443
|
+
const [now] = await rowsOf<{ at_floor: number; ceiling: number; upto: number | null }>(tx, sql`
|
|
444
|
+
SELECT (SELECT at_floor FROM log_fence WHERE only_row) AS at_floor, ${NOW} - 3000 AS ceiling,
|
|
445
|
+
max(coalesce((SELECT max(number) FROM log_changes), 0), coalesce((SELECT max(number) FROM log_uses), 0)) AS upto`);
|
|
446
|
+
return { found: fence?.at_floor ?? null, set: Number(now!.at_floor), ceiling: Number(now!.ceiling), upto: BigInt(now!.upto ?? 0) };
|
|
447
|
+
});
|
|
448
|
+
told?.(barrier.upto);
|
|
449
|
+
const read = await one<{ newest: RawPair | null; entries: RawEntry[] }>(db, sql`SELECT json_object(
|
|
450
|
+
'newest', json((SELECT ${PAIR} FROM log_checkpoints k ORDER BY k.upto DESC LIMIT 1)),
|
|
451
|
+
'entries', json(${window(after, barrier.upto, sql`${from}`, undefined, limit === undefined ? undefined : limit + 1, true)})) AS v`);
|
|
452
|
+
const all = read.entries.map(toEntry);
|
|
453
|
+
const entries = capped(all, limit);
|
|
454
|
+
let more: { above: number | null } | null = null;
|
|
455
|
+
if (entries !== all) {
|
|
456
|
+
const at = await one<number | null>(db, sql`SELECT min(u.at) AS v FROM log_uses u WHERE u.at >= ${from} AND u.number > ${entries.at(-1)!.number} AND u.number <= ${barrier.upto}`);
|
|
457
|
+
more = { above: at === null ? null : Number(at) };
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
upto: barrier.upto,
|
|
461
|
+
newest: toPair(read.newest),
|
|
462
|
+
entries,
|
|
463
|
+
floor: { found: barrier.found === null ? null : Number(barrier.found), set: barrier.set, ceiling: barrier.ceiling },
|
|
464
|
+
more,
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
async newest() {
|
|
468
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k ORDER BY k.upto DESC LIMIT 1`));
|
|
469
|
+
},
|
|
470
|
+
async pairs(after, limit) {
|
|
471
|
+
const rows = await one<RawPair[]>(db, sql`SELECT coalesce(json_group_array(json(p.v)), '[]') AS v FROM (
|
|
472
|
+
SELECT ${PAIR} AS v FROM log_checkpoints k WHERE k.upto > ${after} ORDER BY k.upto LIMIT ${limit}) p`);
|
|
473
|
+
return rows.map((r) => toPair(r)!);
|
|
474
|
+
},
|
|
475
|
+
async pair(upto) {
|
|
476
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k WHERE k.upto = ${upto}`));
|
|
477
|
+
},
|
|
478
|
+
async entries(after, upto, options = {}) {
|
|
479
|
+
const [floor, ceiling] = bounds(after, upto, options.floor);
|
|
480
|
+
return (await one<RawEntry[]>(db, sql`SELECT ${window(after, upto, floor, ceiling, options.limit)} AS v`)).map(toEntry);
|
|
481
|
+
},
|
|
482
|
+
tiles,
|
|
483
|
+
async write(row, tiles) {
|
|
484
|
+
const h = heads(row.notes);
|
|
485
|
+
await db.transaction(async (tx) => {
|
|
486
|
+
for (const [tree, t] of [...tiles.changes.map((t) => ['changes', t] as const), ...tiles.uses.map((t) => ['uses', t] as const)]) {
|
|
487
|
+
// A tile only grows: written again with the same bytes, or wider, never anything else (write_checkpoint's rule).
|
|
488
|
+
const [kept] = await rowsOf<{ n: number }>(tx, sql`
|
|
489
|
+
INSERT INTO log_tiles (tree, level, leaf, hashes) VALUES (${tree}, ${t.level}, ${leafOf(t)}, ${t.hashes})
|
|
490
|
+
ON CONFLICT (tree, level, leaf) DO UPDATE SET hashes = excluded.hashes
|
|
491
|
+
WHERE substr(excluded.hashes, 1, length(log_tiles.hashes)) = log_tiles.hashes
|
|
492
|
+
RETURNING 1 AS n`);
|
|
493
|
+
if (kept === undefined) throw new Error(`a tile would shrink or change: ${tree} ${t.level}/${t.index}`);
|
|
494
|
+
}
|
|
495
|
+
await rowsOf(tx, sql`INSERT INTO log_checkpoints (upto, at, at_floor, changes_size, changes_root, changes_note, uses_size, uses_root, uses_note)
|
|
496
|
+
VALUES (${row.upto}, ${h.at}, ${h.floor}, ${h.changes.size}, ${h.changes.root}, ${row.notes.changes}, ${h.uses.size}, ${h.uses.root}, ${row.notes.uses})`);
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
async locate(number, { at, tree }: Locating = {}) {
|
|
500
|
+
const found = await one<Located | null>(db, sql`
|
|
501
|
+
WITH sealing AS (SELECT upto, at_floor, uses_size FROM log_checkpoints WHERE upto >= ${number} ORDER BY upto LIMIT 1),
|
|
502
|
+
prior AS (SELECT upto, at_floor, changes_size, uses_size FROM log_checkpoints WHERE upto < ${number} ORDER BY upto DESC LIMIT 1)
|
|
503
|
+
SELECT json_object(
|
|
504
|
+
'entry', json(coalesce(
|
|
505
|
+
(SELECT ${CHANGE} FROM log_changes c WHERE c.number = ${number}),
|
|
506
|
+
(SELECT ${USE} FROM log_uses u, prior p, sealing s WHERE ${useAt(at)} AND u.number = ${number} LIMIT 1))),
|
|
507
|
+
'before', CAST((SELECT p.changes_size + (SELECT count(*) FROM log_changes x WHERE x.number > p.upto AND x.number < ${number}) FROM prior p) AS TEXT),
|
|
508
|
+
'from', CAST((SELECT uses_size FROM prior) AS TEXT),
|
|
509
|
+
'to', CAST((SELECT uses_size FROM sealing) AS TEXT)) AS v
|
|
510
|
+
WHERE EXISTS (SELECT 1 FROM sealing)`);
|
|
511
|
+
return located(found, (keys) => tiles('uses', keys), tree);
|
|
512
|
+
},
|
|
513
|
+
// Retention on SQLite (tests and local development): no partitions, so a day's uses and the tiles below a cut are
|
|
514
|
+
// deleted rows, in one statement each; no functions, so no checks but the vault's own.
|
|
515
|
+
async retention() {
|
|
516
|
+
const [r] = await rowsOf<{ n: string | null }>(db, sql`SELECT CAST((SELECT c.number FROM log_changes c
|
|
517
|
+
WHERE c.object = ${RETENTION_OBJECT} AND c.number <= (SELECT coalesce(max(k.upto), -1) FROM log_checkpoints k)
|
|
518
|
+
ORDER BY c.number DESC LIMIT 1) AS TEXT) AS n`);
|
|
519
|
+
return r?.n == null ? null : this.locate(BigInt(r.n), { tree: 'changes' });
|
|
520
|
+
},
|
|
521
|
+
async days() {
|
|
522
|
+
// Each day that holds a use: the first use at or past the end of the one before, by the uses' index, a probe a day.
|
|
523
|
+
const days: string[] = [];
|
|
524
|
+
for (let from = Number.MIN_SAFE_INTEGER; ;) {
|
|
525
|
+
const [r] = await rowsOf<{ at: number | null }>(db, sql`SELECT min(at) AS at FROM log_uses WHERE at >= ${from}`);
|
|
526
|
+
if (r?.at == null) return days;
|
|
527
|
+
const start = Math.floor(Number(r.at) / DAY_MS) * DAY_MS;
|
|
528
|
+
days.push(new Date(start).toISOString().slice(0, 10));
|
|
529
|
+
from = start + DAY_MS;
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
async makeDays() {},
|
|
533
|
+
async cutAt(ms) {
|
|
534
|
+
return toPair(await one<RawPair | null>(db, sql`SELECT ${PAIR} AS v FROM log_checkpoints k WHERE k.at_floor >= ${ms} ORDER BY k.at_floor, k.upto LIMIT 1`));
|
|
535
|
+
},
|
|
536
|
+
async appendRetention(change) {
|
|
537
|
+
return db.transaction(async (tx) => {
|
|
538
|
+
const [found] = await rowsOf<{ n: string }>(tx, sql`SELECT CAST(number AS TEXT) AS n FROM log_changes WHERE request_id = ${change.requestId}`);
|
|
539
|
+
if (found !== undefined) return BigInt(found.n);
|
|
540
|
+
const [next] = await rowsOf<{ n: string }>(tx, sql`SELECT CAST(max(coalesce((SELECT max(number) FROM log_changes), 0), coalesce((SELECT max(number) FROM log_uses), 0)) + 1 AS TEXT) AS n`);
|
|
541
|
+
await rowsOf(tx, sql`INSERT INTO log_changes (number, at, request_id, object, actor, payload, seal, mac)
|
|
542
|
+
VALUES (${BigInt(next!.n)}, ${change.at}, ${change.requestId}, ${RETENTION_OBJECT}, ${RETENTION_ACTOR}, ${change.payload}, NULL, ${change.mac})`);
|
|
543
|
+
return BigInt(next!.n);
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
async dropDay(day) {
|
|
547
|
+
await rowsOf(db, sql`DELETE FROM log_uses WHERE at >= ${dayStart(day)} AND at < ${dayStart(day) + DAY_MS}`);
|
|
548
|
+
return 'gone';
|
|
549
|
+
},
|
|
550
|
+
async dropTiles(below) {
|
|
551
|
+
// A tile goes once every leaf it stands for lies below the cut; level 7's one tile spans past any (2^64 leaves).
|
|
552
|
+
const deleted = await rowsOf(db, sql`DELETE FROM log_tiles WHERE tree = 'uses' AND level < 7 AND leaf + (1 << (8 * (level + 1))) <= ${below} RETURNING 1 AS n`);
|
|
553
|
+
return deleted.length;
|
|
554
|
+
},
|
|
555
|
+
async pruneBulk(limit) {
|
|
556
|
+
// prune_bulk's bound (migration 0022): the policy's window, its refresh, the deadline and an hour.
|
|
557
|
+
const pruned = await rowsOf(db, sql`DELETE FROM log_bulk WHERE rowid IN (
|
|
558
|
+
SELECT b.rowid FROM log_bulk b, (
|
|
559
|
+
SELECT CAST(json_extract(x.payload, '$.value.bulk.windowMs') AS INTEGER) AS w FROM log_changes x
|
|
560
|
+
WHERE x.object = ${logObject('policy', 'instance')} ORDER BY x.number DESC LIMIT 1) p
|
|
561
|
+
WHERE p.w IS NOT NULL AND b.last < ${NOW} - p.w - min(60000, p.w / 10) - 3000 - 3600000 LIMIT ${limit})
|
|
562
|
+
RETURNING 1 AS n`);
|
|
563
|
+
return pruned.length;
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The projection of reads (reads.ts, T166), on either engine: its cursor,
|
|
570
|
+
* the objects a projection reads, its write, and its days. On Postgres the
|
|
571
|
+
* write is `project_reads`, which takes the rows, each once, and moves the
|
|
572
|
+
* cursor on after them, never past the newest pair; SQLite, one writer and
|
|
573
|
+
* no partitions, writes them in a transaction, and drops a day's by `at`.
|
|
574
|
+
*/
|
|
575
|
+
/** How many objects one statement of `objects` asks for. */
|
|
576
|
+
const OBJECTS_AT_ONCE = 200;
|
|
577
|
+
|
|
578
|
+
/** How many rows one statement of the projector's write on SQLite inserts: 14 parameters a read, well under SQLite's 32,766. */
|
|
579
|
+
const SQLITE_ROWS = 500;
|
|
580
|
+
|
|
581
|
+
function readsStore(db: Database): Pick<ReadsStore, 'readsCursor' | 'objects' | 'writeReads' | 'listing'> & { makeDays(days: readonly string[]): Promise<void>; dropDay(day: string): Promise<void> } {
|
|
582
|
+
const postgres = engineOf(db) === 'postgres';
|
|
583
|
+
const made = new Set<string>();
|
|
584
|
+
const id = (value: string | null) => (value === null ? sql`NULL` : postgres ? sql`CAST(${value} AS uuid)` : sql`${value}`);
|
|
585
|
+
const text = (column: string) => sql.raw(postgres ? `CAST(${column} AS text)` : column);
|
|
586
|
+
return {
|
|
587
|
+
async readsCursor() {
|
|
588
|
+
const [row] = await rowsOf<{ through: string | number; pair: string | number; at: string | number; mac: Uint8Array | null }>(db, sql`SELECT through, pair, at, mac FROM log_reads_cursor WHERE only_row`);
|
|
589
|
+
// The migration's one row: gone, nothing is projected and no check passes (review r6), never a fresh start.
|
|
590
|
+
if (row === undefined) throw new CursorGone();
|
|
591
|
+
return { through: BigInt(row.through), pair: BigInt(row.pair), at: Number(row.at), mac: row.mac === null ? null : Buffer.from(row.mac) };
|
|
592
|
+
},
|
|
593
|
+
async objects(asked) {
|
|
594
|
+
const found: (LogEntry | null)[] = [];
|
|
595
|
+
// A few hundred a statement: SQLite takes 500 terms of a compound SELECT at most.
|
|
596
|
+
for (let at = 0; at < asked.length; at += OBJECTS_AT_ONCE) {
|
|
597
|
+
const batch = asked.slice(at, at + OBJECTS_AT_ONCE);
|
|
598
|
+
// Each asked's place and whether it wants the first change are the code's own whole numbers, written as such.
|
|
599
|
+
const rows = sql.join(batch.map(({ object, below, first }, i) => (i === 0
|
|
600
|
+
? sql`SELECT ${sql.raw(String(i))} AS i, ${id(object)} AS object, CAST(${String(below)} AS ${sql.raw(postgres ? 'bigint' : 'INTEGER')}) AS below, ${sql.raw(first ? '1' : '0')} AS first`
|
|
601
|
+
: sql`SELECT ${sql.raw(String(i))}, ${id(object)}, CAST(${String(below)} AS ${sql.raw(postgres ? 'bigint' : 'INTEGER')}), ${sql.raw(first ? '1' : '0')}`)), sql` UNION ALL `);
|
|
602
|
+
// One probe of `log_changes_object_idx` each: the object's first change, or its newest below the number; whole,
|
|
603
|
+
// for its MAC.
|
|
604
|
+
const read = await rowsOf<{ i: number | string; n: string | number; at: string | number; request_id: string; object: string; actor: string; payload: string; seal: Uint8Array | null; mac: Uint8Array }>(db, sql`
|
|
605
|
+
SELECT a.i, c.number AS n, ${text('c.at')} AS at, ${text('c.request_id')} AS request_id, ${text('c.object')} AS object, c.actor, c.payload, c.seal, c.mac FROM (${rows}) a
|
|
606
|
+
JOIN log_changes c ON c.number = CASE WHEN a.first = 1
|
|
607
|
+
THEN (SELECT min(b.number) FROM log_changes b WHERE b.object = a.object)
|
|
608
|
+
ELSE (SELECT max(b.number) FROM log_changes b WHERE b.object = a.object AND b.number < a.below) END`);
|
|
609
|
+
const byIndex = new Map(read.map((r) => [Number(r.i), r]));
|
|
610
|
+
for (let i = 0; i < batch.length; i += 1) {
|
|
611
|
+
const r = byIndex.get(i);
|
|
612
|
+
found.push(r === undefined ? null : changeEntry({
|
|
613
|
+
number: BigInt(r.n), at: Number(r.at), requestId: r.request_id, object: r.object, actor: r.actor, payload: r.payload,
|
|
614
|
+
seal: r.seal === null ? null : Buffer.from(r.seal), mac: Buffer.from(r.mac),
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return found;
|
|
619
|
+
},
|
|
620
|
+
async writeReads(reads, from, cursor) {
|
|
621
|
+
if (postgres) {
|
|
622
|
+
const json = (rows: readonly object[]) => JSON.stringify(rows, (_, value: unknown) => (typeof value === 'bigint' ? String(value) : Buffer.isBuffer(value) ? value.toString('hex') : value));
|
|
623
|
+
const wire = reads.map((row) => ({ ...row, mac: row.mac.toString('hex') }));
|
|
624
|
+
const [row] = await rowsOf<{ wrote: boolean }>(db, sql`SELECT project_reads(${json(wire)}::jsonb, ${String(from)}::bigint, ${String(cursor.through)}::bigint,
|
|
625
|
+
${String(cursor.pair)}::bigint, ${cursor.at}::bigint, ${cursor.mac}::bytea) AS wrote`);
|
|
626
|
+
return row!.wrote === true;
|
|
627
|
+
}
|
|
628
|
+
// A few statements however many reads, rows by the hundred: the transaction holds the file's one writer as briefly as
|
|
629
|
+
// it can, so that neither the vault's own transactions, queued behind it, nor the app's, waiting on its lock, are kept.
|
|
630
|
+
// A read was a statement (review r5's CI: a run of a thousand held the writer for seconds).
|
|
631
|
+
const values = <T>(rows: readonly T[], size: number) => Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => rows.slice(i * size, (i + 1) * size));
|
|
632
|
+
return db.transaction(async (raw) => {
|
|
633
|
+
const tx = raw as unknown as Database;
|
|
634
|
+
const [cut] = await rowsOf<{ sealed: string | number | null }>(tx, sql`SELECT max(upto) AS sealed FROM log_checkpoints`);
|
|
635
|
+
if (cut?.sealed == null || cursor.through > BigInt(cut.sealed)) throw new Error(`reads through ${cursor.through} are past the newest pair`);
|
|
636
|
+
const [stands] = await rowsOf<{ through: string | number }>(tx, sql`SELECT through FROM log_reads_cursor WHERE only_row`);
|
|
637
|
+
if (stands === undefined) throw new CursorGone();
|
|
638
|
+
// Overtaken (FromChecked): nothing written.
|
|
639
|
+
if (BigInt(stands.through) !== from) return false;
|
|
640
|
+
// The projector's row over any other at its number (ReplaceOnConflict).
|
|
641
|
+
for (const rows of values(reads, SQLITE_ROWS)) {
|
|
642
|
+
await rowsOf(tx, sql`INSERT INTO log_reads (number, at, credential, purpose, principal, secret, environment, project, place, source, source_environment, source_project, version, mac)
|
|
643
|
+
VALUES ${sql.join(rows.map((row) => sql`(${row.number}, ${row.at}, ${row.credential}, ${row.purpose}, ${row.principal}, ${row.secret}, ${row.environment}, ${row.project},
|
|
644
|
+
${row.place}, ${row.source}, ${row.sourceEnvironment}, ${row.sourceProject}, ${row.version}, ${row.mac})`), sql`, `)}
|
|
645
|
+
ON CONFLICT (at, number) DO UPDATE SET credential = excluded.credential, purpose = excluded.purpose, principal = excluded.principal, secret = excluded.secret,
|
|
646
|
+
environment = excluded.environment, project = excluded.project, place = excluded.place, source = excluded.source, source_environment = excluded.source_environment,
|
|
647
|
+
source_project = excluded.source_project, version = excluded.version, mac = excluded.mac`);
|
|
648
|
+
}
|
|
649
|
+
const days = [...new Set(reads.map((row) => `${row.principal}\0${Math.floor(row.at / 86_400_000)}`))].map((key) => key.split('\0') as [string, string]);
|
|
650
|
+
for (const rows of values(days, SQLITE_ROWS)) {
|
|
651
|
+
await rowsOf(tx, sql`INSERT INTO log_read_days (principal, day) VALUES ${sql.join(rows.map(([principal, day]) => sql`(${principal}, ${Number(day)})`), sql`, `)} ON CONFLICT DO NOTHING`);
|
|
652
|
+
}
|
|
653
|
+
await rowsOf(tx, sql`UPDATE log_reads_cursor SET through = ${cursor.through}, pair = ${cursor.pair}, at = ${cursor.at}, mac = ${cursor.mac} WHERE only_row AND through < ${cursor.through}`);
|
|
654
|
+
return true;
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
async listing({ after, upto, from, to }) {
|
|
658
|
+
const big = (value: bigint | number) => (postgres ? sql`CAST(${String(value)} AS bigint)` : sql`${value}`);
|
|
659
|
+
const found = await rowsOf<{
|
|
660
|
+
number: string | number; at: string | number; credential: string; purpose: string | number; principal: string; secret: string; environment: string; project: string;
|
|
661
|
+
place: string | null; source: string | null; source_environment: string | null; source_project: string | null; version: string | null; mac: Uint8Array;
|
|
662
|
+
dayed: boolean | number;
|
|
663
|
+
}>(db, sql`SELECT r.number, r.at, ${text('r.credential')} AS credential, r.purpose, r.principal, ${text('r.secret')} AS secret, ${text('r.environment')} AS environment,
|
|
664
|
+
${text('r.project')} AS project, ${text('r.place')} AS place, ${text('r.source')} AS source, ${text('r.source_environment')} AS source_environment,
|
|
665
|
+
${text('r.source_project')} AS source_project, ${text('r.version')} AS version, r.mac,
|
|
666
|
+
EXISTS (SELECT 1 FROM log_read_days d WHERE d.principal = r.principal AND d.day = CAST(r.at / 86400000 AS integer)) AS dayed
|
|
667
|
+
FROM log_reads r WHERE r.at >= ${big(from)} AND r.at <= ${big(to)} AND r.number > ${big(after)} AND r.number <= ${big(upto)}`);
|
|
668
|
+
return found.map((row): ReadRow & { dayed: boolean } => ({
|
|
669
|
+
number: BigInt(row.number), at: Number(row.at), credential: row.credential, purpose: Number(row.purpose), principal: row.principal, secret: row.secret,
|
|
670
|
+
environment: row.environment, project: row.project, place: row.place, source: row.source, sourceEnvironment: row.source_environment,
|
|
671
|
+
sourceProject: row.source_project, version: row.version, mac: Buffer.from(row.mac), dayed: row.dayed === true || row.dayed === 1,
|
|
672
|
+
}));
|
|
673
|
+
},
|
|
674
|
+
async makeDays(days) {
|
|
675
|
+
if (!postgres) return;
|
|
676
|
+
for (const day of days) {
|
|
677
|
+
if (made.has(day)) continue;
|
|
678
|
+
await rowsOf(db, sql`SELECT make_reads_day(${day}::date)`);
|
|
679
|
+
made.add(day);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
async dropDay(day) {
|
|
683
|
+
if (postgres) {
|
|
684
|
+
const [row] = await rowsOf<{ state: string }>(db, sql`SELECT drop_reads_day(${day}::date) AS state`);
|
|
685
|
+
if (row?.state !== 'kept') made.delete(day);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const from = Date.parse(`${day}T00:00:00Z`);
|
|
689
|
+
await rowsOf(db, sql`DELETE FROM log_reads WHERE at >= ${from} AND at < ${from + 86_400_000}`);
|
|
690
|
+
await rowsOf(db, sql`DELETE FROM log_read_days WHERE day = ${from / 86_400_000}`);
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export type { ReadRow, ReadsCursor };
|