slopkit 0.3.0 → 0.4.0-rc.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.
@@ -22,7 +22,10 @@ export type WebConfig = {
22
22
  scopes: string[];
23
23
  subjectClaim: string;
24
24
  } | undefined;
25
- /** `sub:<subject>`, `email:<address>`, or `service:<client id>` for a proxy's service token. */
25
+ /**
26
+ * `sub:<subject>`, `email:<address>`, `service:<client id>` for a proxy's service token, or `key:<name>` for an
27
+ * agent's key (`slopkit/server/keys`).
28
+ */
26
29
  allow: string[];
27
30
  };
28
31
  export type Fields = Record<string, unknown>;
@@ -107,10 +107,10 @@ export function parseWebConfig(value, warnings) {
107
107
  }
108
108
  const listed = web.allow ?? [];
109
109
  const allow = Array.isArray(listed)
110
- ? listed.filter((entry) => typeof entry === "string" && /^(sub|email|service):.+$/.test(entry))
110
+ ? listed.filter((entry) => typeof entry === "string" && /^(sub|email|service|key):.+$/.test(entry))
111
111
  : [];
112
112
  if (!Array.isArray(listed) || allow.length !== listed.length) {
113
- throw new Error("web.allow must list sub:<subject>, email:<address> or service:<client id>");
113
+ throw new Error("web.allow must list sub:<subject>, email:<address>, service:<client id> or key:<name>");
114
114
  }
115
115
  if (access === undefined && oidc === undefined) {
116
116
  throw new Error("web.json needs a login: web.access (an identity-aware proxy) or web.oidc");
@@ -0,0 +1,17 @@
1
+ /** A new key named `name`: returns its token, the only time it exists outside the agent that keeps it. */
2
+ export declare function addKey(home: string, name: string): Promise<string>;
3
+ /** Removes the key named `name`; whether there was one. Its token stops working at the next request. */
4
+ export declare function revokeKey(home: string, name: string): Promise<boolean>;
5
+ /**
6
+ * The keys a server checks, reread whenever the file changes (its size, time or inode), so a revocation holds from the
7
+ * next request. A file it can't read refuses every key until it can.
8
+ */
9
+ export declare class Keys {
10
+ private readonly path;
11
+ private seen;
12
+ private entries;
13
+ constructor(home: string);
14
+ /** The key's name for a token, or undefined when it isn't one of ours. */
15
+ verify(token: string): Promise<string | undefined>;
16
+ private load;
17
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Agent keys: bearer tokens an agent or a check sends as `Authorization: Bearer <token>`, for an app's API on its own
3
+ * binding without a person's login or a proxy's service token. The home keeps only each key's SHA-256
4
+ * (`<home>/web-keys.json`, mode 0600); the token is shown once, by `addKey`. A key's identity is `key:<name>`, allowed
5
+ * only when `web.allow` lists it, and it is never a person.
6
+ */
7
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
8
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ const FILE = "web-keys.json";
11
+ const NAME = /^[a-z0-9-]{1,32}$/;
12
+ const TOKEN = /^([a-z0-9-]{1,32})\.([A-Za-z0-9_-]{43})$/;
13
+ const digest = (token) => createHash("sha256").update(token).digest();
14
+ function parse(raw) {
15
+ const value = JSON.parse(raw);
16
+ if (!Array.isArray(value)) {
17
+ throw new TypeError(`${FILE} must be a list`);
18
+ }
19
+ return value.map((entry) => {
20
+ if (typeof entry !== "object" || entry === null) {
21
+ throw new TypeError(`${FILE} holds an entry that isn't an object`);
22
+ }
23
+ const { name, sha256 } = Object.fromEntries(Object.entries(entry));
24
+ if (typeof name !== "string" || !NAME.test(name) || typeof sha256 !== "string") {
25
+ throw new TypeError(`${FILE} holds an entry without a name and a sha256`);
26
+ }
27
+ return { name, sha256 };
28
+ });
29
+ }
30
+ async function read(home) {
31
+ const raw = await readFile(join(home, FILE), "utf-8").catch((error) => {
32
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
33
+ return "[]";
34
+ }
35
+ throw error;
36
+ });
37
+ return parse(raw);
38
+ }
39
+ async function write(home, entries) {
40
+ const path = join(home, FILE);
41
+ const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`;
42
+ await writeFile(temporary, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
43
+ await rename(temporary, path);
44
+ }
45
+ /** Changes to one home's keys, one at a time in this process: each reads the file its predecessor wrote. */
46
+ const writing = new Map();
47
+ function serialized(home, change) {
48
+ const next = (writing.get(home) ?? Promise.resolve()).then(change, change);
49
+ writing.set(home, next.catch(() => undefined));
50
+ return next;
51
+ }
52
+ /** A new key named `name`: returns its token, the only time it exists outside the agent that keeps it. */
53
+ export function addKey(home, name) {
54
+ if (!NAME.test(name)) {
55
+ return Promise.reject(new Error("a key's name is 1–32 of a-z, 0-9 and -"));
56
+ }
57
+ return serialized(home, async () => {
58
+ const entries = await read(home);
59
+ if (entries.some((entry) => entry.name === name)) {
60
+ throw new Error(`a key named ${name} exists; revoke it first`);
61
+ }
62
+ const token = `${name}.${randomBytes(32).toString("base64url")}`;
63
+ await write(home, [...entries, { name, sha256: digest(token).toString("base64url") }]);
64
+ return token;
65
+ });
66
+ }
67
+ /** Removes the key named `name`; whether there was one. Its token stops working at the next request. */
68
+ export function revokeKey(home, name) {
69
+ return serialized(home, async () => {
70
+ const entries = await read(home);
71
+ const kept = entries.filter((entry) => entry.name !== name);
72
+ if (kept.length === entries.length) {
73
+ return false;
74
+ }
75
+ await write(home, kept);
76
+ return true;
77
+ });
78
+ }
79
+ /**
80
+ * The keys a server checks, reread whenever the file changes (its size, time or inode), so a revocation holds from the
81
+ * next request. A file it can't read refuses every key until it can.
82
+ */
83
+ export class Keys {
84
+ path;
85
+ seen = "";
86
+ entries;
87
+ constructor(home) {
88
+ this.path = join(home, FILE);
89
+ }
90
+ /** The key's name for a token, or undefined when it isn't one of ours. */
91
+ async verify(token) {
92
+ const shape = TOKEN.exec(token);
93
+ if (shape?.[1] === undefined) {
94
+ return undefined;
95
+ }
96
+ const name = shape[1];
97
+ const entries = await this.load();
98
+ const entry = entries?.find((each) => each.name === name);
99
+ if (entry === undefined) {
100
+ return undefined;
101
+ }
102
+ const expected = Buffer.from(entry.sha256, "base64url");
103
+ const actual = digest(token);
104
+ return expected.length === actual.length && timingSafeEqual(expected, actual)
105
+ ? name
106
+ : undefined;
107
+ }
108
+ async load() {
109
+ const found = await stat(this.path).catch(() => undefined);
110
+ if (found === undefined) {
111
+ this.seen = "";
112
+ this.entries = [];
113
+ return this.entries;
114
+ }
115
+ const version = `${found.ino}:${found.size}:${found.mtimeMs}`;
116
+ if (version !== this.seen) {
117
+ try {
118
+ this.entries = parse(await readFile(this.path, "utf-8"));
119
+ }
120
+ catch {
121
+ this.entries = undefined;
122
+ }
123
+ this.seen = version;
124
+ }
125
+ return this.entries;
126
+ }
127
+ }
@@ -0,0 +1,101 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { Jobs } from "./jobs.ts";
3
+ import { Ledger } from "./ledger.ts";
4
+ /**
5
+ * The core tables every replicated store has, before the app's own: `meta`, the command ledger's `commands`
6
+ * (`slopkit/server/ledger`), `jobs` (`slopkit/server/jobs`) and the `revisions` log. Never edited once shipped.
7
+ */
8
+ export declare const CORE_TABLES = "create table meta(key text primary key, value text not null);\n create table commands(\n principal text not null check (length(principal) > 0),\n client text not null, id integer not null check (id > 0),\n name text not null, args text not null,\n state text not null check (state in ('accepted', 'done', 'retired')),\n outcome text check ((state = 'done') = (outcome is not null)),\n revision integer check ((state = 'accepted') = (revision is null)),\n created_at text not null,\n primary key (principal, client, id)) strict;\n create index commands_revision on commands(principal, client, revision);\n create trigger commands_never_deleted before delete on commands\n begin select raise(abort, 'a command key is kept forever: retire it'); end;\n create table jobs(\n id integer primary key,\n kind text not null check (length(kind) > 0),\n key text not null,\n state text not null check (state in ('due', 'running', 'done', 'failed')),\n due_at text not null, deadline_at text not null,\n attempts integer not null default 0 check (attempts >= 0),\n note text,\n result text check ((state = 'done') = (result is not null)),\n who text check ((state = 'failed') = (who is not null) and (who is null or who in ('you', 'us', 'gone'))),\n reason text check ((state = 'failed') = (reason is not null)),\n asked_by text not null default '[]',\n created_at text not null, updated_at text not null);\n create unique index jobs_open on jobs(kind, key) where state in ('due', 'running');\n create table revisions(revision integer primary key, at text not null) strict;\n insert into meta(key, value) values ('revision', '1'), ('history_floor', '1'),\n ('dataset', lower(hex(randomblob(12))));";
9
+ /** An app's store schema: its own tables at `baseVersion`, and the migrations since, one version each. */
10
+ export type StoreSchema = {
11
+ /** The app's tables as a new store starts them (the core tables come first, made by the kit). */
12
+ base: string;
13
+ /** Tables a replica holds: stamped and never deleted, by trigger. */
14
+ replicated: readonly string[];
15
+ baseVersion: number;
16
+ /** Each moves the schema one version up from `baseVersion`; never edit a shipped one, append. */
17
+ migrations: readonly string[];
18
+ /** Indexes that only speed reads, made at every open: no row's meaning depends on one. */
19
+ indexes?: string;
20
+ };
21
+ /** The store schema a release writes: what `version --json` reports and a rollback compares. */
22
+ export declare function schemaVersion(schema: StoreSchema): number;
23
+ export type StoreOptions = {
24
+ /** The app's name, as a refusal says it ("update <name>"). */
25
+ name: string;
26
+ schema: StoreSchema;
27
+ now?: () => number;
28
+ };
29
+ export declare class SyncStore {
30
+ readonly db: DatabaseSync;
31
+ readonly path: string;
32
+ readonly now: () => number;
33
+ /**
34
+ * Set when the store opened older than what it had already served (restored from a backup), or a caller proved it
35
+ * went back: the dataset and revision it had served, and its revision then. It took a new dataset, so every replica
36
+ * starts over and no intent made against the lost history runs here.
37
+ */
38
+ restored: {
39
+ dataset: string;
40
+ served: number;
41
+ revision: number;
42
+ } | undefined;
43
+ private readonly head;
44
+ private servedHead;
45
+ private depth;
46
+ constructor(path: string, options: StoreOptions);
47
+ private userVersion;
48
+ private migrate;
49
+ /** The head beside the store: a store that opens behind what it served went back, and takes a new dataset. */
50
+ private checkHead;
51
+ /** A caller proved this store went back: it holds `seen` of this very dataset, more than the store has. */
52
+ wentBack(seen: number): boolean;
53
+ /** Record that `revision` of this dataset left the server (a pull or push reply), before the reply does. */
54
+ served(revision: number): void;
55
+ close(): void;
56
+ /** All of `body` or none of it. Nested calls join the outer transaction: a failure anywhere undoes all of it. */
57
+ transaction<T>(body: () => T): T;
58
+ meta(key: string): string | undefined;
59
+ setMeta(key: string, value: string): void;
60
+ /** This store's identity: a replica of another store starts over. */
61
+ dataset(): string;
62
+ revision(): number;
63
+ /** The store's clock as stored: now, or `at` (milliseconds). */
64
+ iso(at?: number): string;
65
+ /**
66
+ * Whether `revision` is one of this history: none (0), the head, at or before the floor (the revisions before they
67
+ * were logged), or one this store stamped and still remembers. A page holding another came from a history this store
68
+ * doesn't have (a restore's lost future), or is older than the log keeps: it starts over, and its taps don't run.
69
+ */
70
+ known(revision: number): boolean;
71
+ /**
72
+ * The next revision, for rows written in the current transaction: past the last, and at least the clock in
73
+ * microseconds (plus a random part below a millisecond), so two histories forked from one copy never stamp the same
74
+ * number while the clock moves forward. Each is logged (`known`).
75
+ */
76
+ stamp(): number;
77
+ /** The command ledger (`slopkit/server/ledger`) on this store: its transaction and its stamp. */
78
+ createLedger<K>(codec: {
79
+ encode: (receipt: K) => string;
80
+ decode: (text: string) => K;
81
+ recover?: (command: {
82
+ name: string;
83
+ args: string;
84
+ }) => K;
85
+ }): Ledger<K>;
86
+ /** Jobs (`slopkit/server/jobs`) on this store. */
87
+ createJobs<R>(codec: {
88
+ encode: (result: R) => string;
89
+ decode: (text: string) => R;
90
+ }): Jobs<R>;
91
+ /**
92
+ * Retention, which never deletes a key: receipts accepted more than `receiptsMs` ago retire (their outcome dropped, never acting
93
+ * again), logged revisions older than `revisionsMs` are forgotten except the head (a page holding one starts over),
94
+ * and finished jobs beyond the newest `jobsKept` of each kind are pruned.
95
+ */
96
+ retain(options?: {
97
+ receiptsMs?: number;
98
+ revisionsMs?: number;
99
+ jobsKept?: number;
100
+ }): void;
101
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The replicated store's core: a home's SQLite file (Node's built-in SQLite, WAL), owned by the one server under the
3
+ * home's lock, that a replica pulls from. An app's store extends `SyncStore` (or holds one) and adds its own tables and
4
+ * queries; the mechanics every replicated store needs are here.
5
+ *
6
+ * - **Nothing opens a store it can't read.** A store from a newer release, or older than the schema this release
7
+ * starts from, is refused before anything is written to it.
8
+ * - **Migrations are append only** and each runs in its own transaction, foreign keys checked at its end; a store with
9
+ * data is first copied whole (`<path>.schema-<n>.bak`, the oldest kept), so a rollback can put it back.
10
+ * - **Every write is stamped:** the rows a transaction writes to a replicated table carry the revision `stamp()` made
11
+ * in it (a trigger refuses any other), and nothing replicated is deleted (tombstones). Each revision is logged, so a
12
+ * page holding one this history never had (a restore's lost future) is told apart from one that is behind.
13
+ * - **A restore is told apart:** the newest revision served is kept beside the store (`<path>.head`), written before
14
+ * the reply carrying it leaves; a store that opens behind it went back, and takes a new dataset.
15
+ * - **Retention never deletes a key:** receipts retire, old revisions are forgotten (a page holding one starts over),
16
+ * finished jobs beyond the newest are pruned.
17
+ */
18
+ import { randomBytes } from "node:crypto";
19
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, writeSync, } from "node:fs";
20
+ import { dirname } from "node:path";
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { check, number, object, string } from "../schema.js";
23
+ import { Jobs } from "./jobs.js";
24
+ import { Ledger } from "./ledger.js";
25
+ /**
26
+ * The core tables every replicated store has, before the app's own: `meta`, the command ledger's `commands`
27
+ * (`slopkit/server/ledger`), `jobs` (`slopkit/server/jobs`) and the `revisions` log. Never edited once shipped.
28
+ */
29
+ export const CORE_TABLES = `create table meta(key text primary key, value text not null);
30
+ create table commands(
31
+ principal text not null check (length(principal) > 0),
32
+ client text not null, id integer not null check (id > 0),
33
+ name text not null, args text not null,
34
+ state text not null check (state in ('accepted', 'done', 'retired')),
35
+ outcome text check ((state = 'done') = (outcome is not null)),
36
+ revision integer check ((state = 'accepted') = (revision is null)),
37
+ created_at text not null,
38
+ primary key (principal, client, id)) strict;
39
+ create index commands_revision on commands(principal, client, revision);
40
+ create trigger commands_never_deleted before delete on commands
41
+ begin select raise(abort, 'a command key is kept forever: retire it'); end;
42
+ create table jobs(
43
+ id integer primary key,
44
+ kind text not null check (length(kind) > 0),
45
+ key text not null,
46
+ state text not null check (state in ('due', 'running', 'done', 'failed')),
47
+ due_at text not null, deadline_at text not null,
48
+ attempts integer not null default 0 check (attempts >= 0),
49
+ note text,
50
+ result text check ((state = 'done') = (result is not null)),
51
+ who text check ((state = 'failed') = (who is not null) and (who is null or who in ('you', 'us', 'gone'))),
52
+ reason text check ((state = 'failed') = (reason is not null)),
53
+ asked_by text not null default '[]',
54
+ created_at text not null, updated_at text not null);
55
+ create unique index jobs_open on jobs(kind, key) where state in ('due', 'running');
56
+ create table revisions(revision integer primary key, at text not null) strict;
57
+ insert into meta(key, value) values ('revision', '1'), ('history_floor', '1'),
58
+ ('dataset', lower(hex(randomblob(12))));`;
59
+ /** A replicated table's triggers: every write carries the revision stamped in its own transaction; no delete. */
60
+ function stamped(table) {
61
+ const revision = "(select cast(value as integer) from meta where key = 'revision')";
62
+ const stamping = "(select cast(value as integer) from meta where key = 'stamping')";
63
+ return [
64
+ ...["insert", "update"].map((event) => `create trigger ${table}_stamped_${event} before ${event} on ${table}
65
+ when new.revision is not ${revision} or ${revision} is not ${stamping}
66
+ begin select raise(abort, 'a replicated row must carry a revision stamped in its own transaction'); end;`),
67
+ `create trigger ${table}_never_deleted before delete on ${table}
68
+ begin select raise(abort, 'a replicated row is never deleted: tombstone it'); end;`,
69
+ ].join("\n");
70
+ }
71
+ /** The store schema a release writes: what `version --json` reports and a rollback compares. */
72
+ export function schemaVersion(schema) {
73
+ return schema.baseVersion + schema.migrations.length;
74
+ }
75
+ const DAY = 86_400_000;
76
+ /** What `<path>.head` holds: the newest revision of a dataset that left the server. */
77
+ const HEAD = object({ dataset: string(), revision: number() });
78
+ /** Retention reads no receipt or result: these stand in for an app's own codecs. */
79
+ const OPAQUE = {
80
+ encode: (value) => JSON.stringify(value),
81
+ decode: (text) => text,
82
+ };
83
+ /** A safe name for a table: it is spliced into the trigger SQL. */
84
+ const TABLE = /^[a-z_][a-z0-9_]*$/;
85
+ export class SyncStore {
86
+ db;
87
+ path;
88
+ now;
89
+ /**
90
+ * Set when the store opened older than what it had already served (restored from a backup), or a caller proved it
91
+ * went back: the dataset and revision it had served, and its revision then. It took a new dataset, so every replica
92
+ * starts over and no intent made against the lost history runs here.
93
+ */
94
+ restored;
95
+ head;
96
+ servedHead;
97
+ depth = 0;
98
+ constructor(path, options) {
99
+ const { schema } = options;
100
+ for (const table of schema.replicated) {
101
+ if (!TABLE.test(table)) {
102
+ throw new Error(`not a table name: ${table}`);
103
+ }
104
+ }
105
+ this.path = path;
106
+ this.now = options.now ?? Date.now;
107
+ this.db = new DatabaseSync(path);
108
+ const version = this.userVersion();
109
+ const latest = schemaVersion(schema);
110
+ // A file at version 0 is new only while it holds nothing: one with tables is from before any release could say.
111
+ const empty = this.db.prepare("select count(*) as n from sqlite_master").get()?.n === 0;
112
+ const refused = version > latest
113
+ ? `The store is from a newer version (schema ${version}); update ${options.name}`
114
+ : (version > 0 || !empty) && version < schema.baseVersion
115
+ ? `The store is at schema ${version}, from before ${schema.baseVersion}, the schema this release starts from: it can't be opened`
116
+ : undefined;
117
+ if (refused !== undefined) {
118
+ this.db.close();
119
+ throw new Error(refused);
120
+ }
121
+ // FULL: a commit is on the disk before it is acknowledged.
122
+ this.db.exec("pragma journal_mode = wal; pragma synchronous = full; pragma foreign_keys = on;");
123
+ this.migrate(schema, version);
124
+ this.head = `${path}.head`;
125
+ this.restored = this.checkHead();
126
+ }
127
+ userVersion() {
128
+ return Number(this.db.prepare("pragma user_version").get()?.user_version ?? 0);
129
+ }
130
+ migrate(schema, version) {
131
+ if (version === 0) {
132
+ this.transaction(() => {
133
+ this.db.exec(CORE_TABLES);
134
+ this.db.exec(schema.base);
135
+ for (const table of schema.replicated) {
136
+ this.db.exec(stamped(table));
137
+ }
138
+ this.db.exec(`pragma user_version = ${schema.baseVersion}`);
139
+ });
140
+ }
141
+ const from = Math.max(version, schema.baseVersion);
142
+ const latest = schemaVersion(schema);
143
+ const backup = `${this.path}.schema-${from}.bak`;
144
+ if (version > 0 && version < latest && !existsSync(backup)) {
145
+ this.db.prepare("vacuum into ?").run(backup);
146
+ }
147
+ for (let next = from; next < latest; next++) {
148
+ // A rebuild drops tables other tables reference; foreign keys are checked once it is done.
149
+ this.db.exec("pragma foreign_keys = off");
150
+ try {
151
+ this.transaction(() => {
152
+ this.db.exec(schema.migrations[next - schema.baseVersion] ?? "");
153
+ this.db.exec(`pragma user_version = ${next + 1}`);
154
+ const broken = this.db.prepare("pragma foreign_key_check").all();
155
+ if (broken.length > 0) {
156
+ throw new Error(`Migration ${next + 1} leaves ${broken.length} broken references`);
157
+ }
158
+ });
159
+ }
160
+ finally {
161
+ this.db.exec("pragma foreign_keys = on");
162
+ }
163
+ }
164
+ if (schema.indexes !== undefined) {
165
+ this.db.exec(schema.indexes);
166
+ }
167
+ }
168
+ /** The head beside the store: a store that opens behind what it served went back, and takes a new dataset. */
169
+ checkHead() {
170
+ let head;
171
+ try {
172
+ head = JSON.parse(readFileSync(this.head, "utf-8"));
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ const read = check(HEAD, head);
178
+ if (!read.ok) {
179
+ return undefined;
180
+ }
181
+ const { dataset, revision: served } = read.value;
182
+ if (dataset !== this.dataset() || this.revision() >= served) {
183
+ return undefined;
184
+ }
185
+ const replaced = { dataset, served, revision: this.revision() };
186
+ this.setMeta("dataset", randomBytes(12).toString("hex"));
187
+ return replaced;
188
+ }
189
+ /** A caller proved this store went back: it holds `seen` of this very dataset, more than the store has. */
190
+ wentBack(seen) {
191
+ if (seen <= this.revision()) {
192
+ return false;
193
+ }
194
+ this.restored = { dataset: this.dataset(), served: seen, revision: this.revision() };
195
+ this.setMeta("dataset", randomBytes(12).toString("hex"));
196
+ return true;
197
+ }
198
+ /** Record that `revision` of this dataset left the server (a pull or push reply), before the reply does. */
199
+ served(revision) {
200
+ const dataset = this.dataset();
201
+ const last = this.servedHead;
202
+ if (last !== undefined && last.dataset === dataset && last.revision >= revision) {
203
+ return;
204
+ }
205
+ const temporary = `${this.head}.tmp`;
206
+ const fd = openSync(temporary, "w", 0o600);
207
+ try {
208
+ const bytes = Buffer.from(JSON.stringify({ dataset, revision }), "utf-8");
209
+ for (let at = 0; at < bytes.length;) {
210
+ at += writeSync(fd, bytes, at, bytes.length - at);
211
+ }
212
+ fsyncSync(fd);
213
+ }
214
+ finally {
215
+ closeSync(fd);
216
+ }
217
+ renameSync(temporary, this.head);
218
+ // The rename itself on the disk too.
219
+ const dir = openSync(dirname(this.head), "r");
220
+ try {
221
+ fsyncSync(dir);
222
+ }
223
+ finally {
224
+ closeSync(dir);
225
+ }
226
+ this.servedHead = { dataset, revision };
227
+ }
228
+ close() {
229
+ if (this.db.isOpen) {
230
+ this.db.close();
231
+ }
232
+ }
233
+ /** All of `body` or none of it. Nested calls join the outer transaction: a failure anywhere undoes all of it. */
234
+ transaction(body) {
235
+ if (this.depth > 0) {
236
+ return body();
237
+ }
238
+ this.db.exec("begin immediate");
239
+ this.depth++;
240
+ try {
241
+ const result = body();
242
+ this.db.exec("delete from meta where key = 'stamping'");
243
+ this.db.exec("commit");
244
+ return result;
245
+ }
246
+ catch (error) {
247
+ this.db.exec("rollback");
248
+ throw error;
249
+ }
250
+ finally {
251
+ this.depth--;
252
+ }
253
+ }
254
+ meta(key) {
255
+ const value = this.db.prepare("select value from meta where key = ?").get(key)?.value;
256
+ return typeof value === "string" ? value : undefined;
257
+ }
258
+ setMeta(key, value) {
259
+ this.db
260
+ .prepare("insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value")
261
+ .run(key, value);
262
+ }
263
+ /** This store's identity: a replica of another store starts over. */
264
+ dataset() {
265
+ let dataset = this.meta("dataset");
266
+ if (dataset === undefined) {
267
+ dataset = randomBytes(12).toString("hex");
268
+ this.setMeta("dataset", dataset);
269
+ }
270
+ return dataset;
271
+ }
272
+ revision() {
273
+ return Number(this.meta("revision") ?? 0);
274
+ }
275
+ /** The store's clock as stored: now, or `at` (milliseconds). */
276
+ iso(at) {
277
+ return new Date(at ?? this.now()).toISOString();
278
+ }
279
+ /**
280
+ * Whether `revision` is one of this history: none (0), the head, at or before the floor (the revisions before they
281
+ * were logged), or one this store stamped and still remembers. A page holding another came from a history this store
282
+ * doesn't have (a restore's lost future), or is older than the log keeps: it starts over, and its taps don't run.
283
+ */
284
+ known(revision) {
285
+ if (revision === 0 || revision === this.revision()) {
286
+ return true;
287
+ }
288
+ if (revision <= Number(this.meta("history_floor") ?? 0)) {
289
+ return true;
290
+ }
291
+ return (this.db.prepare("select 1 from revisions where revision = ?").get(revision) !== undefined);
292
+ }
293
+ /**
294
+ * The next revision, for rows written in the current transaction: past the last, and at least the clock in
295
+ * microseconds (plus a random part below a millisecond), so two histories forked from one copy never stamp the same
296
+ * number while the clock moves forward. Each is logged (`known`).
297
+ */
298
+ stamp() {
299
+ if (!this.db.isTransaction) {
300
+ throw new Error("A revision is stamped inside the transaction that writes its rows");
301
+ }
302
+ const next = Math.max(this.revision() + 1, this.now() * 1000 + Math.floor(Math.random() * 1000));
303
+ if (!Number.isSafeInteger(next)) {
304
+ throw new TypeError(`Revision ${next} is past a safe integer`);
305
+ }
306
+ this.db.prepare("insert into revisions(revision, at) values (?, ?)").run(next, this.iso());
307
+ this.setMeta("revision", String(next));
308
+ this.setMeta("stamping", String(next));
309
+ return next;
310
+ }
311
+ /** The command ledger (`slopkit/server/ledger`) on this store: its transaction and its stamp. */
312
+ createLedger(codec) {
313
+ return new Ledger({
314
+ db: this.db,
315
+ transaction: (body) => this.transaction(body),
316
+ stamp: () => this.stamp(),
317
+ encode: codec.encode,
318
+ decode: codec.decode,
319
+ ...(codec.recover === undefined ? {} : { recover: codec.recover }),
320
+ });
321
+ }
322
+ /** Jobs (`slopkit/server/jobs`) on this store. */
323
+ createJobs(codec) {
324
+ return new Jobs({
325
+ db: this.db,
326
+ transaction: (body) => this.transaction(body),
327
+ encode: codec.encode,
328
+ decode: codec.decode,
329
+ now: this.now,
330
+ });
331
+ }
332
+ /**
333
+ * Retention, which never deletes a key: receipts accepted more than `receiptsMs` ago retire (their outcome dropped, never acting
334
+ * again), logged revisions older than `revisionsMs` are forgotten except the head (a page holding one starts over),
335
+ * and finished jobs beyond the newest `jobsKept` of each kind are pruned.
336
+ */
337
+ retain(options = {}) {
338
+ const now = this.now();
339
+ this.transaction(() => {
340
+ this.createLedger(OPAQUE).retire(this.iso(now - (options.receiptsMs ?? 90 * DAY)));
341
+ this.db
342
+ .prepare("delete from revisions where at < ? and revision <> ?")
343
+ .run(this.iso(now - (options.revisionsMs ?? 400 * DAY)), this.revision());
344
+ });
345
+ this.createJobs(OPAQUE).prune(options.jobsKept ?? 200);
346
+ }
347
+ }