beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// journal-store.mjs — the durable document journal, answered by the HOST.
|
|
2
|
+
//
|
|
3
|
+
// A transcription of spike/journal.R. The page's history engine
|
|
4
|
+
// (lib/history/transaction-engine.js) accepts edits in memory;
|
|
5
|
+
// lib/history/journal.js turns each accepted transaction into a RECORD and
|
|
6
|
+
// hands ordered batches here. This is the store: an append-only NDJSON journal
|
|
7
|
+
// plus a full checkpoint per document, under the user's own app-data directory,
|
|
8
|
+
// 0700/0600. It never evaluates anything and never reads a document's text for
|
|
9
|
+
// any purpose but writing it back.
|
|
10
|
+
//
|
|
11
|
+
// WHY THE HOST AND NOT A CHILD. The supervisor evaluates no user code, it is
|
|
12
|
+
// never behind a running cell, and a journal append is a few hundred bytes.
|
|
13
|
+
// Spawning a process to write a kilobyte would buy nothing but a queue. Every
|
|
14
|
+
// function here takes a request, touches disk and RETURNS the frame, so if a
|
|
15
|
+
// measurement ever says otherwise, moving them is a routing change.
|
|
16
|
+
//
|
|
17
|
+
// ── the on-disk shape ──────────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// <root>/<key>/ key = the document's durable key, ':' → '__'
|
|
20
|
+
// journal.ndjson one record per line: "<bytes>\t<json>\n"
|
|
21
|
+
// checkpoint.json the newest checkpoint envelope (a JSON string
|
|
22
|
+
// the page's checkpoint codec wrote)
|
|
23
|
+
// checkpoint.rev the revision of that envelope
|
|
24
|
+
// pins/<rev>.json a named version, with its content
|
|
25
|
+
// meta.json display name, file binding, gaps, updated
|
|
26
|
+
// journal.corrupt-<time> bytes cut from a journal that did not end at
|
|
27
|
+
// a line boundary — kept as evidence
|
|
28
|
+
//
|
|
29
|
+
// The record line carries its own BYTE length so a torn write is detected at
|
|
30
|
+
// the next open: the tail is cut at the last complete, contiguous line and the
|
|
31
|
+
// cut bytes are moved aside rather than deleted. Contiguity (each record's
|
|
32
|
+
// baseRevision equals the previous record's revision) is checked on every
|
|
33
|
+
// append; a mismatch is refused as `discontiguous` and the page re-bases with a
|
|
34
|
+
// full checkpoint. Nothing here rewrites an accepted record.
|
|
35
|
+
//
|
|
36
|
+
// What is NOT promised, in words: there is no fsync. The bytes are handed to
|
|
37
|
+
// the operating system; a power cut before it writes them loses the tail, and
|
|
38
|
+
// the next open says so. The store is safe against the process dying, not
|
|
39
|
+
// against the machine dying.
|
|
40
|
+
//
|
|
41
|
+
// ── the encoding rule, which is the whole reason journalFrame() exists ──────
|
|
42
|
+
//
|
|
43
|
+
// Records travel as VERBATIM JSON STRINGS in both directions: the page
|
|
44
|
+
// serialised each one and signed it (sha), and re-encoding would be the
|
|
45
|
+
// fidelity bug relay_frame exists to prevent. `journalFrame()` splices their
|
|
46
|
+
// bytes into the reply by hand. JSON.stringify would ALSO be faithful here —
|
|
47
|
+
// but the page's checkpoint codec verifies a SHA-256 over the exact bytes it
|
|
48
|
+
// wrote, and "faithful enough today" is not a property a signature check can
|
|
49
|
+
// rely on. The bytes go through untouched.
|
|
50
|
+
|
|
51
|
+
import crypto from "node:crypto";
|
|
52
|
+
import fs from "node:fs";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
import { privateModeHeld, stateDir } from "./user-dirs.mjs";
|
|
55
|
+
|
|
56
|
+
const TAB = 0x09;
|
|
57
|
+
const NL = 0x0a;
|
|
58
|
+
export const JOURNAL_KEY_RE = /^[a-z]+:[A-Za-z0-9._~-]{1,200}$/;
|
|
59
|
+
export const JOURNAL_MAX_REPLY_BYTES = 6e6; // under the 8 MB frame cap, with room
|
|
60
|
+
const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
|
|
61
|
+
|
|
62
|
+
/** A UTC stamp in the journal's evidence-filename shape: %Y%m%d-%H%M%S. */
|
|
63
|
+
const stamp = () => {
|
|
64
|
+
const d = new Date();
|
|
65
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
66
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
67
|
+
};
|
|
68
|
+
const bytesOf = (text) => Buffer.byteLength(text, "utf8");
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The journal store. One per host; the cache of scanned documents lives on it
|
|
72
|
+
* rather than in module state, so a second store (a test's) cannot inherit the
|
|
73
|
+
* first's idea of where a tail is.
|
|
74
|
+
*/
|
|
75
|
+
export function createJournalStore() {
|
|
76
|
+
const enabled = envOf("CARMAR_NO_JOURNAL") !== "1";
|
|
77
|
+
const cache = new Map();
|
|
78
|
+
|
|
79
|
+
const root = () => {
|
|
80
|
+
const explicit = envOf("CARMAR_HISTORY_DIR");
|
|
81
|
+
if (explicit) return explicit;
|
|
82
|
+
return path.join(stateDir(), "history");
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Per-document byte budget. Above it, records at or below the newest
|
|
86
|
+
* checkpoint are compacted away (the checkpoint holds the state and both Undo
|
|
87
|
+
* stacks; pins hold their own content), never the tail.
|
|
88
|
+
*/
|
|
89
|
+
const budget = () => {
|
|
90
|
+
const v = Number(envOf("CARMAR_HISTORY_BUDGET"));
|
|
91
|
+
return !Number.isFinite(v) || v < 65536 ? 64 * 1024 * 1024 : v;
|
|
92
|
+
};
|
|
93
|
+
const validKey = (key) => typeof key === "string" && JOURNAL_KEY_RE.test(key);
|
|
94
|
+
const dirOf = (key) => path.join(root(), key.replace(":", "__"));
|
|
95
|
+
|
|
96
|
+
/** A directory only this user may enter; created on first use. */
|
|
97
|
+
const ensureDir = (p) => {
|
|
98
|
+
if (!fs.existsSync(p)) {
|
|
99
|
+
fs.mkdirSync(p, { recursive: true });
|
|
100
|
+
if (!fs.existsSync(p)) throw new Error("could not create the history directory");
|
|
101
|
+
}
|
|
102
|
+
fs.chmodSync(p, 0o700);
|
|
103
|
+
return p;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const err = (type, id, code, message, extra = {}) => ({ type, id, error: message, code, ...extra });
|
|
107
|
+
|
|
108
|
+
// ── the journal file ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every complete, contiguous record line, with the byte count where the
|
|
112
|
+
* verified prefix ends. Byte scanning on purpose: the length prefix counts
|
|
113
|
+
* BYTES (that is what a torn write leaves), so a character-indexed walk would
|
|
114
|
+
* drift on the first multi-byte record.
|
|
115
|
+
*/
|
|
116
|
+
const scan = (file) => {
|
|
117
|
+
const empty = { records: [], revisions: [], offsets: [], end: 0, last: null, torn: false };
|
|
118
|
+
if (!fs.existsSync(file)) return empty;
|
|
119
|
+
let raw;
|
|
120
|
+
try { raw = fs.readFileSync(file); } catch { return empty; }
|
|
121
|
+
const total = raw.length;
|
|
122
|
+
if (!total) return empty;
|
|
123
|
+
const records = []; const revisions = []; const offsets = [];
|
|
124
|
+
let pos = 0; let torn = false; let last = null;
|
|
125
|
+
while (pos < total) {
|
|
126
|
+
let t = pos;
|
|
127
|
+
while (t < total && t < pos + 12 && raw[t] !== TAB) t += 1;
|
|
128
|
+
if (t >= total || raw[t] !== TAB) { torn = true; break; }
|
|
129
|
+
const len = Number.parseInt(raw.subarray(pos, t).toString("latin1"), 10);
|
|
130
|
+
if (!Number.isInteger(len) || len < 2) { torn = true; break; }
|
|
131
|
+
const start = t + 1;
|
|
132
|
+
const stop = start + len; // exclusive
|
|
133
|
+
if (stop >= total || raw[stop] !== NL) { torn = true; break; }
|
|
134
|
+
const slice = raw.subarray(start, stop);
|
|
135
|
+
// An embedded NUL (a zeroed sector reads as one) is corruption, and
|
|
136
|
+
// corruption ends the verified prefix rather than the scan. R's
|
|
137
|
+
// rawToChar refuses it; Buffer.toString would not, so it is checked.
|
|
138
|
+
if (slice.includes(0)) { torn = true; break; }
|
|
139
|
+
const json = slice.toString("utf8");
|
|
140
|
+
let parsed = null;
|
|
141
|
+
try { parsed = JSON.parse(json); } catch { parsed = null; }
|
|
142
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.revision !== "number"
|
|
143
|
+
|| typeof parsed.baseRevision !== "number"
|
|
144
|
+
|| (last != null && parsed.baseRevision !== last)) { torn = true; break; }
|
|
145
|
+
records.push(json);
|
|
146
|
+
revisions.push(Math.trunc(parsed.revision));
|
|
147
|
+
offsets.push(pos);
|
|
148
|
+
last = Math.trunc(parsed.revision);
|
|
149
|
+
pos = stop + 1;
|
|
150
|
+
}
|
|
151
|
+
return { records, revisions, offsets, end: pos, last, torn };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Cut a torn tail at the last complete record, keeping the cut bytes. */
|
|
155
|
+
const repair = (file, s) => {
|
|
156
|
+
if (!s.torn) return false;
|
|
157
|
+
let size;
|
|
158
|
+
try { size = fs.statSync(file).size; } catch { return false; }
|
|
159
|
+
if (!Number.isFinite(size) || s.end >= size) return false;
|
|
160
|
+
const raw = fs.readFileSync(file);
|
|
161
|
+
const evidence = `${file}.corrupt-${stamp()}`;
|
|
162
|
+
fs.writeFileSync(evidence, raw.subarray(s.end), { mode: 0o600 });
|
|
163
|
+
fs.chmodSync(evidence, 0o600);
|
|
164
|
+
fs.writeFileSync(file, raw.subarray(0, s.end));
|
|
165
|
+
fs.chmodSync(file, 0o600);
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const writePrivate = (file, text) => {
|
|
170
|
+
fs.writeFileSync(file, Buffer.from(text, "utf8"));
|
|
171
|
+
fs.chmodSync(file, 0o600);
|
|
172
|
+
if (!privateModeHeld(file)) throw new Error("could not make the history file private");
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Atomic replace within the same directory: write a sibling, rename over. */
|
|
176
|
+
const replace = (file, text) => {
|
|
177
|
+
const temp = path.join(path.dirname(file), `.carmar-journal-${crypto.randomBytes(8).toString("hex")}`);
|
|
178
|
+
writePrivate(temp, text);
|
|
179
|
+
try { fs.renameSync(temp, file); } catch (e) {
|
|
180
|
+
try { fs.unlinkSync(temp); } catch { /* gone */ }
|
|
181
|
+
throw new Error("could not replace the history file atomically; the previous one was left unchanged");
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const readMeta = (dir) => {
|
|
186
|
+
const file = path.join(dir, "meta.json");
|
|
187
|
+
if (!fs.existsSync(file)) return { schema: 1, gaps: [], pins: [] };
|
|
188
|
+
try {
|
|
189
|
+
const meta = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
190
|
+
return meta && typeof meta === "object" && !Array.isArray(meta) ? meta : { schema: 1, gaps: [], pins: [] };
|
|
191
|
+
} catch { return { schema: 1, gaps: [], pins: [] }; }
|
|
192
|
+
};
|
|
193
|
+
const writeMeta = (dir, meta) => {
|
|
194
|
+
meta.updated = Date.now();
|
|
195
|
+
replace(path.join(dir, "meta.json"), JSON.stringify(meta));
|
|
196
|
+
};
|
|
197
|
+
const checkpointRevisionOf = (dir) => {
|
|
198
|
+
const file = path.join(dir, "checkpoint.rev");
|
|
199
|
+
if (!fs.existsSync(file)) return null;
|
|
200
|
+
const v = Number.parseInt(String(fs.readFileSync(file, "utf8")).split("\n")[0], 10);
|
|
201
|
+
return Number.isInteger(v) ? v : null;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The store's view of one document: scanned, repaired if torn, cached. */
|
|
205
|
+
const stateOf = (key) => {
|
|
206
|
+
const dir = dirOf(key);
|
|
207
|
+
const cached = cache.get(key);
|
|
208
|
+
if (cached && cached.dir === dir) return cached;
|
|
209
|
+
ensureDir(dir);
|
|
210
|
+
const file = path.join(dir, "journal.ndjson");
|
|
211
|
+
const s = scan(file);
|
|
212
|
+
const repaired = repair(file, s);
|
|
213
|
+
const cp = checkpointRevisionOf(dir);
|
|
214
|
+
const st = {
|
|
215
|
+
dir, path: file,
|
|
216
|
+
last: s.last != null ? s.last : cp,
|
|
217
|
+
checkpoint: cp,
|
|
218
|
+
repaired,
|
|
219
|
+
bytes: fs.existsSync(file) ? fs.statSync(file).size : 0,
|
|
220
|
+
};
|
|
221
|
+
cache.set(key, st);
|
|
222
|
+
return st;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const pinsOf = (dir) => {
|
|
226
|
+
const p = path.join(dir, "pins");
|
|
227
|
+
if (!fs.existsSync(p)) return [];
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const f of fs.readdirSync(p).filter((n) => /^[0-9]+\.json$/.test(n))) {
|
|
230
|
+
try {
|
|
231
|
+
const pin = JSON.parse(fs.readFileSync(path.join(p, f), "utf8"));
|
|
232
|
+
if (pin && typeof pin === "object") out.push({ revision: pin.revision, name: pin.name, at: pin.at });
|
|
233
|
+
} catch { /* an unreadable pin is not a pin; the list goes on */ }
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// ── the writer lease: WHO may append, decided on disk, fenced by an epoch ──
|
|
239
|
+
//
|
|
240
|
+
// `open` issues a lease {pid, port, epoch, at} and returns the epoch; every
|
|
241
|
+
// append and checkpoint must carry it, and a write whose epoch is not the
|
|
242
|
+
// current one is refused as `fenced` — the old writer learns it lost the
|
|
243
|
+
// document rather than interleaving into it. A LIVE other holder is reported,
|
|
244
|
+
// and the caller gets no epoch unless it asks for a `takeover`, which bumps
|
|
245
|
+
// the epoch. A dead holder is simply replaced. The epoch is monotone per key
|
|
246
|
+
// and comes from the lease FILE, so a restarted supervisor cannot reissue one.
|
|
247
|
+
const leaseRead = (dir) => {
|
|
248
|
+
const file = path.join(dir, "lease.json");
|
|
249
|
+
if (!fs.existsSync(file)) return null;
|
|
250
|
+
try {
|
|
251
|
+
const v = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
252
|
+
return v && typeof v === "object" ? v : null;
|
|
253
|
+
} catch { return null; }
|
|
254
|
+
};
|
|
255
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
256
|
+
|
|
257
|
+
const lease = (dir, takeover) => {
|
|
258
|
+
const file = path.join(dir, "lease.json");
|
|
259
|
+
const prior = leaseRead(dir);
|
|
260
|
+
const mePid = process.pid;
|
|
261
|
+
const alive = Boolean(prior && typeof prior.pid === "number" && prior.pid !== mePid && pidAlive(prior.pid));
|
|
262
|
+
if (alive && takeover !== true) {
|
|
263
|
+
return { held: { pid: Math.trunc(prior.pid), port: prior.port, epoch: prior.epoch }, epoch: null };
|
|
264
|
+
}
|
|
265
|
+
const epoch = prior && typeof prior.epoch === "number" ? Math.trunc(prior.epoch) + 1 : 1;
|
|
266
|
+
const me = { pid: mePid, port: Number.parseInt(envOf("CARMAR_PORT", "0"), 10) || 0, epoch, at: Date.now() };
|
|
267
|
+
try { replace(file, JSON.stringify(me)); } catch {
|
|
268
|
+
return { held: null, epoch: null, error: "The writer lease could not be written." };
|
|
269
|
+
}
|
|
270
|
+
return { held: null, epoch, takenOver: alive };
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const leaseCurrent = (dir, epoch) => {
|
|
274
|
+
if (typeof epoch !== "number" || !Number.isFinite(epoch)) return false;
|
|
275
|
+
const prior = leaseRead(dir);
|
|
276
|
+
return Boolean(prior && typeof prior.epoch === "number" && Math.trunc(prior.epoch) === Math.trunc(epoch));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Why a write is refused, or null when the lease is current. Two answers,
|
|
281
|
+
* because they ask for different things: `unleased` (no epoch at all — this
|
|
282
|
+
* window never opened the key) is cured by `journal_open`; `fenced` (an epoch
|
|
283
|
+
* no longer current) is another window's takeover and nothing here cures it.
|
|
284
|
+
*/
|
|
285
|
+
const leaseRefusal = (type, id, dir, epoch) => {
|
|
286
|
+
if (typeof epoch !== "number" || !Number.isFinite(epoch) || leaseRead(dir) == null) {
|
|
287
|
+
return err(type, id, "unleased", "This window holds no writer lease for this document; open it first.");
|
|
288
|
+
}
|
|
289
|
+
if (!leaseCurrent(dir, epoch)) {
|
|
290
|
+
return err(type, id, "fenced", "Another window took over this document's history. This window no longer writes it.");
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ── the ops ───────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
const open = (id, key, meta, takeover) => {
|
|
298
|
+
if (!enabled) return err("journal_open", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1).");
|
|
299
|
+
if (!validKey(key)) return err("journal_open", id, "invalid_key", "A journal needs a durable document key.");
|
|
300
|
+
let st;
|
|
301
|
+
try { st = stateOf(key); } catch (e) { return err("journal_open", id, "io", e.message); }
|
|
302
|
+
const stored = readMeta(st.dir);
|
|
303
|
+
if (meta && typeof meta === "object" && Object.keys(meta).length) {
|
|
304
|
+
stored.display = meta;
|
|
305
|
+
try { writeMeta(st.dir, stored); } catch { /* the open still stands */ }
|
|
306
|
+
}
|
|
307
|
+
const l = lease(st.dir, takeover);
|
|
308
|
+
if (l.error) return err("journal_open", id, "io", l.error);
|
|
309
|
+
return { type: "journal_open", id, key, heldBy: l.held ?? null, epoch: l.epoch ?? null,
|
|
310
|
+
takenOver: l.takenOver === true,
|
|
311
|
+
exists: st.last != null || st.checkpoint != null,
|
|
312
|
+
checkpointRevision: st.checkpoint,
|
|
313
|
+
durableRevision: st.last,
|
|
314
|
+
repaired: st.repaired === true, bytes: st.bytes, budget: budget(),
|
|
315
|
+
pins: pinsOf(st.dir), meta: stored.display ?? null, gaps: stored.gaps ?? [] };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* READ-ONLY: does the store keep a history for `key`, from what PATH was it
|
|
320
|
+
* last saved, and does THAT path still exist? The page asks this to tell a
|
|
321
|
+
* copied file from a moved one. Unlike `open` this takes no lease and CREATES
|
|
322
|
+
* NOTHING — a probe of an unknown key must not leave an empty history
|
|
323
|
+
* directory behind, so it never calls stateOf().
|
|
324
|
+
*/
|
|
325
|
+
const probe = (id, key) => {
|
|
326
|
+
if (!enabled) return err("journal_probe", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1).");
|
|
327
|
+
if (!validKey(key)) return err("journal_probe", id, "invalid_key", "A journal needs a durable document key.");
|
|
328
|
+
const dir = dirOf(key);
|
|
329
|
+
if (!fs.existsSync(dir)) return { type: "journal_probe", id, key, exists: false, path: null, pathExists: false };
|
|
330
|
+
const stored = readMeta(dir);
|
|
331
|
+
const cp = checkpointRevisionOf(dir);
|
|
332
|
+
const exists = fs.existsSync(path.join(dir, "journal.ndjson")) || cp != null;
|
|
333
|
+
const p = stored.display && stored.display.path;
|
|
334
|
+
const okPath = typeof p === "string" && p.length > 0;
|
|
335
|
+
return { type: "journal_probe", id, key, exists: Boolean(exists),
|
|
336
|
+
path: okPath ? p : null, pathExists: Boolean(okPath && fs.existsSync(p)) };
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** The JSON of the last record, for the exact-retry rule. */
|
|
340
|
+
const tailJson = (st) => {
|
|
341
|
+
const s = scan(st.path);
|
|
342
|
+
return s.records.length ? s.records[s.records.length - 1] : null;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const append = (id, key, records, epoch) => {
|
|
346
|
+
if (!enabled) return err("journal_append", id, "unavailable", "The document journal is disabled.");
|
|
347
|
+
if (!validKey(key)) return err("journal_append", id, "invalid_key", "A journal needs a durable document key.");
|
|
348
|
+
const refused = leaseRefusal("journal_append", id, dirOf(key), epoch);
|
|
349
|
+
if (refused) return refused;
|
|
350
|
+
const list = Array.isArray(records) ? records : (records == null ? [] : [records]);
|
|
351
|
+
if (!list.length || !list.every((r) => typeof r === "string" && r.length > 0)) {
|
|
352
|
+
return err("journal_append", id, "invalid_record", "Append needs one or more serialised records.");
|
|
353
|
+
}
|
|
354
|
+
let st;
|
|
355
|
+
try { st = stateOf(key); } catch (e) { return err("journal_append", id, "io", e.message); }
|
|
356
|
+
// Validate the WHOLE batch before writing a byte of it.
|
|
357
|
+
let last = st.last;
|
|
358
|
+
const lines = new Array(list.length);
|
|
359
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
360
|
+
const json = list[i];
|
|
361
|
+
let parsed = null;
|
|
362
|
+
try { parsed = JSON.parse(json); } catch { parsed = null; }
|
|
363
|
+
if (!parsed || typeof parsed !== "object" || parsed.key !== key
|
|
364
|
+
|| typeof parsed.revision !== "number" || typeof parsed.baseRevision !== "number"
|
|
365
|
+
|| parsed.revision !== parsed.baseRevision + 1
|
|
366
|
+
|| typeof parsed.sha !== "string" || !/^[a-f0-9]{64}$/.test(parsed.sha)) {
|
|
367
|
+
return err("journal_append", id, "invalid_record", "A record is malformed or belongs to another document.");
|
|
368
|
+
}
|
|
369
|
+
if (last != null && parsed.baseRevision !== last) {
|
|
370
|
+
// An exact retry of the record already at the tail is acknowledged, not
|
|
371
|
+
// refused: the ack may have been lost, and the page must be able to ask
|
|
372
|
+
// again with the same bytes.
|
|
373
|
+
if (parsed.revision === last && i === 0 && list.length === 1 && tailJson(st) === json) {
|
|
374
|
+
return { type: "journal_append", id, key, durableRevision: last, retried: true };
|
|
375
|
+
}
|
|
376
|
+
return err("journal_append", id, "discontiguous",
|
|
377
|
+
`The journal ends at revision ${last}, not ${Math.trunc(parsed.baseRevision)}.`, { lastRevision: last });
|
|
378
|
+
}
|
|
379
|
+
if (last == null && parsed.baseRevision !== 0 && st.checkpoint == null) {
|
|
380
|
+
return err("journal_append", id, "discontiguous", "An empty journal starts at revision 0.", { lastRevision: 0 });
|
|
381
|
+
}
|
|
382
|
+
lines[i] = `${bytesOf(json)}\t${json}\n`;
|
|
383
|
+
last = Math.trunc(parsed.revision);
|
|
384
|
+
}
|
|
385
|
+
const added = lines.reduce((sum, l) => sum + bytesOf(l), 0);
|
|
386
|
+
if (st.bytes + added > budget() * 2) {
|
|
387
|
+
return err("journal_append", id, "quota",
|
|
388
|
+
`This document's history has reached its ${Math.trunc(budget() / 1048576)} MB budget; checkpoint to compact it.`);
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
fs.appendFileSync(st.path, Buffer.from(lines.join(""), "utf8"));
|
|
392
|
+
fs.chmodSync(st.path, 0o600);
|
|
393
|
+
} catch (e) { return err("journal_append", id, "io", e.message); }
|
|
394
|
+
st.last = last;
|
|
395
|
+
st.bytes += added;
|
|
396
|
+
return { type: "journal_append", id, key, durableRevision: last };
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** Move every record above `revision` into a superseded-evidence file. */
|
|
400
|
+
const supersede = (st, revision) => {
|
|
401
|
+
const s = scan(st.path);
|
|
402
|
+
const keep = s.revisions.map((r) => r <= revision);
|
|
403
|
+
if (keep.every(Boolean)) return false;
|
|
404
|
+
const gone = s.records.filter((_, i) => !keep[i]);
|
|
405
|
+
const evidence = `${st.path}.superseded-${stamp()}`;
|
|
406
|
+
writePrivate(evidence, gone.map((r) => `${bytesOf(r)}\t${r}\n`).join(""));
|
|
407
|
+
const kept = s.records.filter((_, i) => keep[i]);
|
|
408
|
+
replace(st.path, kept.map((r) => `${bytesOf(r)}\t${r}\n`).join(""));
|
|
409
|
+
st.bytes = fs.statSync(st.path).size;
|
|
410
|
+
const keptRevs = s.revisions.filter((_, i) => keep[i]);
|
|
411
|
+
st.last = keptRevs.length ? Math.max(...keptRevs) : null;
|
|
412
|
+
return true;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Drop records at or below the checkpoint once the file is over budget.
|
|
417
|
+
* Never a byte of the tail; pins carry their own content.
|
|
418
|
+
*/
|
|
419
|
+
const compact = (st, checkpoint) => {
|
|
420
|
+
if (st.bytes <= budget()) return 0;
|
|
421
|
+
const s = scan(st.path);
|
|
422
|
+
const kept = s.records.filter((_, i) => s.revisions[i] > checkpoint);
|
|
423
|
+
const before = st.bytes;
|
|
424
|
+
const text = kept.map((r) => `${bytesOf(r)}\t${r}\n`).join("");
|
|
425
|
+
try { replace(st.path, text); } catch { return 0; }
|
|
426
|
+
st.bytes = bytesOf(text);
|
|
427
|
+
return before - st.bytes;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const checkpoint = (id, key, revision, generation, envelope, meta, rebase, epoch) => {
|
|
431
|
+
if (!enabled) return err("journal_checkpoint", id, "unavailable", "The document journal is disabled.");
|
|
432
|
+
if (!validKey(key)) return err("journal_checkpoint", id, "invalid_key", "A journal needs a durable document key.");
|
|
433
|
+
const refused = leaseRefusal("journal_checkpoint", id, dirOf(key), epoch);
|
|
434
|
+
if (refused) return refused;
|
|
435
|
+
if (typeof revision !== "number" || !Number.isFinite(revision) || revision < 0
|
|
436
|
+
|| typeof envelope !== "string" || !envelope.length) {
|
|
437
|
+
return err("journal_checkpoint", id, "invalid_checkpoint", "A checkpoint needs a revision and its envelope.");
|
|
438
|
+
}
|
|
439
|
+
let st;
|
|
440
|
+
try { st = stateOf(key); } catch (e) { return err("journal_checkpoint", id, "io", e.message); }
|
|
441
|
+
const rev = Math.trunc(revision);
|
|
442
|
+
const tailEnd = st.last != null ? st.last : (st.checkpoint != null ? st.checkpoint : 0);
|
|
443
|
+
if (rev < tailEnd && rebase !== true) {
|
|
444
|
+
return err("journal_checkpoint", id, "stale_checkpoint",
|
|
445
|
+
`The journal is already at revision ${tailEnd}.`, { lastRevision: tailEnd });
|
|
446
|
+
}
|
|
447
|
+
if (rev < tailEnd) {
|
|
448
|
+
// A RE-BASE: the page's history no longer follows this tail. The records
|
|
449
|
+
// above `revision` are not deleted — they are moved aside as evidence,
|
|
450
|
+
// exactly like a torn tail — and the journal continues from the checkpoint.
|
|
451
|
+
try { supersede(st, rev); } catch (e) { return err("journal_checkpoint", id, "io", e.message); }
|
|
452
|
+
}
|
|
453
|
+
const stored = readMeta(st.dir);
|
|
454
|
+
try {
|
|
455
|
+
replace(path.join(st.dir, "checkpoint.json"), envelope);
|
|
456
|
+
replace(path.join(st.dir, "checkpoint.rev"), `${rev}\n`);
|
|
457
|
+
} catch (e) { return err("journal_checkpoint", id, "io", e.message); }
|
|
458
|
+
let gap = null;
|
|
459
|
+
if (rev > tailEnd) {
|
|
460
|
+
// Records between the tail and this checkpoint never reached the store
|
|
461
|
+
// (an outage, an overflow, another opening). Say so, durably.
|
|
462
|
+
gap = { from: tailEnd, to: rev, at: Date.now() };
|
|
463
|
+
stored.gaps = [...(stored.gaps ?? []), gap];
|
|
464
|
+
}
|
|
465
|
+
if (meta && typeof meta === "object" && Object.keys(meta).length) stored.display = meta;
|
|
466
|
+
try { writeMeta(st.dir, stored); } catch { /* the checkpoint still stands */ }
|
|
467
|
+
st.checkpoint = rev;
|
|
468
|
+
st.last = rev;
|
|
469
|
+
const pruned = compact(st, rev);
|
|
470
|
+
return { type: "journal_checkpoint", id, key, checkpointRevision: rev, prunedBytes: pruned, gap };
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const readEnvelope = (dir) => {
|
|
474
|
+
const file = path.join(dir, "checkpoint.json");
|
|
475
|
+
if (!fs.existsSync(file)) return null;
|
|
476
|
+
return fs.readFileSync(file, "utf8");
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Everything recovery needs: the checkpoint envelope and the tail after it,
|
|
481
|
+
* page by page when the tail is long.
|
|
482
|
+
*/
|
|
483
|
+
const load = (id, key, afterRevision) => {
|
|
484
|
+
if (!enabled) return err("journal_load", id, "unavailable", "The document journal is disabled.");
|
|
485
|
+
if (!validKey(key)) return err("journal_load", id, "invalid_key", "A journal needs a durable document key.");
|
|
486
|
+
let st;
|
|
487
|
+
try { st = stateOf(key); } catch (e) { return err("journal_load", id, "io", e.message); }
|
|
488
|
+
const wantEnvelope = afterRevision == null;
|
|
489
|
+
const envelope = wantEnvelope ? readEnvelope(st.dir) : null;
|
|
490
|
+
const from = typeof afterRevision === "number" ? Math.trunc(afterRevision)
|
|
491
|
+
: (st.checkpoint == null ? -1 : st.checkpoint);
|
|
492
|
+
const s = scan(st.path);
|
|
493
|
+
let records = s.records.filter((_, i) => s.revisions[i] > from);
|
|
494
|
+
const cap = Math.max(JOURNAL_MAX_REPLY_BYTES - bytesOf(envelope ?? "") - 4096, 0);
|
|
495
|
+
let running = 0;
|
|
496
|
+
const fits = records.map((r) => { running += bytesOf(r) + 2; return running <= cap; });
|
|
497
|
+
const more = fits.some((f) => !f);
|
|
498
|
+
records = records.filter((_, i) => fits[i]);
|
|
499
|
+
const meta = readMeta(st.dir);
|
|
500
|
+
return { type: "journal_load", id, key, aliases: meta.aliases ?? [],
|
|
501
|
+
checkpointRevision: st.checkpoint, durableRevision: st.last,
|
|
502
|
+
more, repaired: st.repaired === true, gaps: meta.gaps ?? [], pins: pinsOf(st.dir),
|
|
503
|
+
__raw: { records, checkpoint: envelope } };
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/** Records in a revision range, verbatim, for the time machine's walk-back. */
|
|
507
|
+
const read = (id, key, fromRevision, toRevision) => {
|
|
508
|
+
if (!validKey(key)) return err("journal_read", id, "invalid_key", "A journal needs a durable document key.");
|
|
509
|
+
let st;
|
|
510
|
+
try { st = stateOf(key); } catch (e) { return err("journal_read", id, "io", e.message); }
|
|
511
|
+
const s = scan(st.path);
|
|
512
|
+
const lo = typeof fromRevision === "number" ? Math.trunc(fromRevision) : 0;
|
|
513
|
+
const hi = typeof toRevision === "number" ? Math.trunc(toRevision) : 2147483647;
|
|
514
|
+
let records = s.records.filter((_, i) => s.revisions[i] >= lo && s.revisions[i] <= hi);
|
|
515
|
+
let running = 0;
|
|
516
|
+
const fits = records.map((r) => { running += bytesOf(r) + 2; return running <= JOURNAL_MAX_REPLY_BYTES; });
|
|
517
|
+
const more = fits.some((f) => !f);
|
|
518
|
+
records = records.filter((_, i) => fits[i]);
|
|
519
|
+
return { type: "journal_read", id, key, more, __raw: { records } };
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/** The time-machine list: one row per record, newest first, bounded. */
|
|
523
|
+
const versions = (id, key, beforeRevision, limit) => {
|
|
524
|
+
if (!validKey(key)) return err("journal_versions", id, "invalid_key", "A journal needs a durable document key.");
|
|
525
|
+
let st;
|
|
526
|
+
try { st = stateOf(key); } catch (e) { return err("journal_versions", id, "io", e.message); }
|
|
527
|
+
const s = scan(st.path);
|
|
528
|
+
const cap = Math.max(1, Math.min(Math.trunc(Number(limit ?? 200)) || 200, 1000));
|
|
529
|
+
const before = typeof beforeRevision === "number" ? Math.trunc(beforeRevision) : 2147483647;
|
|
530
|
+
let idx = [];
|
|
531
|
+
for (let i = s.revisions.length - 1; i >= 0; i -= 1) if (s.revisions[i] < before) idx.push(i);
|
|
532
|
+
idx = idx.slice(0, cap);
|
|
533
|
+
const rows = idx.map((i) => {
|
|
534
|
+
const r = JSON.parse(s.records[i]);
|
|
535
|
+
const edit = r.kind === "edit";
|
|
536
|
+
return { revision: r.revision, kind: r.kind, id: r.id, at: r.at,
|
|
537
|
+
label: (edit ? (r.entry && r.entry.label) : r.label) ?? null,
|
|
538
|
+
origin: edit ? ((r.entry && r.entry.origin) ?? null) : "user",
|
|
539
|
+
generation: r.generation ?? null,
|
|
540
|
+
abandoned: edit ? (Array.isArray(r.abandoned) ? r.abandoned.length : 0) : 0 };
|
|
541
|
+
});
|
|
542
|
+
return { type: "journal_versions", id, key, rows,
|
|
543
|
+
more: idx.length === cap && idx.length < s.revisions.length,
|
|
544
|
+
pins: pinsOf(st.dir), gaps: readMeta(st.dir).gaps ?? [] };
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* A named version: the page sends the content it reconstructed for that
|
|
549
|
+
* revision, so a pin outlives compaction of the records that led to it.
|
|
550
|
+
*/
|
|
551
|
+
const pin = (id, key, revision, name, state) => {
|
|
552
|
+
if (!validKey(key)) return err("journal_pin", id, "invalid_key", "A journal needs a durable document key.");
|
|
553
|
+
if (typeof revision !== "number" || !Number.isFinite(revision) || revision < 0
|
|
554
|
+
|| typeof name !== "string" || !name.trim().length
|
|
555
|
+
|| typeof state !== "string" || !state.length) {
|
|
556
|
+
return err("journal_pin", id, "invalid_pin", "A pin needs a revision, a name and the content at that revision.");
|
|
557
|
+
}
|
|
558
|
+
let st;
|
|
559
|
+
try { st = stateOf(key); } catch (e) { return err("journal_pin", id, "io", e.message); }
|
|
560
|
+
const dir = path.join(st.dir, "pins");
|
|
561
|
+
try {
|
|
562
|
+
ensureDir(dir);
|
|
563
|
+
const head = JSON.stringify({ schema: 1, revision: Math.trunc(revision),
|
|
564
|
+
name: name.trim().slice(0, 120), at: Date.now() });
|
|
565
|
+
// `state` is the page's JSON, spliced verbatim for the record's reason.
|
|
566
|
+
replace(path.join(dir, `${Math.trunc(revision)}.json`),
|
|
567
|
+
`${head.slice(0, head.length - 1)},"state":${state}}`);
|
|
568
|
+
} catch (e) { return err("journal_pin", id, "io", e.message); }
|
|
569
|
+
return { type: "journal_pin", id, key, revision: Math.trunc(revision), pins: pinsOf(st.dir) };
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const unpin = (id, key, revision) => {
|
|
573
|
+
if (!validKey(key) || typeof revision !== "number" || !Number.isFinite(revision)) {
|
|
574
|
+
return err("journal_unpin", id, "invalid_pin", "Unpin needs a document key and a revision.");
|
|
575
|
+
}
|
|
576
|
+
let st;
|
|
577
|
+
try { st = stateOf(key); } catch (e) { return err("journal_unpin", id, "io", e.message); }
|
|
578
|
+
try { fs.unlinkSync(path.join(st.dir, "pins", `${Math.trunc(revision)}.json`)); } catch { /* already gone */ }
|
|
579
|
+
return { type: "journal_unpin", id, key, pins: pinsOf(st.dir) };
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** The content of one pin, verbatim. */
|
|
583
|
+
const pinRead = (id, key, revision) => {
|
|
584
|
+
if (!validKey(key) || typeof revision !== "number" || !Number.isFinite(revision)) {
|
|
585
|
+
return err("journal_pin_read", id, "invalid_pin", "Reading a pin needs a document key and a revision.");
|
|
586
|
+
}
|
|
587
|
+
let st;
|
|
588
|
+
try { st = stateOf(key); } catch (e) { return err("journal_pin_read", id, "io", e.message); }
|
|
589
|
+
const file = path.join(st.dir, "pins", `${Math.trunc(revision)}.json`);
|
|
590
|
+
if (!fs.existsSync(file)) return err("journal_pin_read", id, "missing_pin", "No such named version.");
|
|
591
|
+
return { type: "journal_pin_read", id, key, revision: Math.trunc(revision),
|
|
592
|
+
__raw: { pin: fs.readFileSync(file, "utf8") } };
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Save As: the history follows the document to its new key. The old key's
|
|
597
|
+
* directory is renamed; the old key is kept in `meta.aliases` so a checkpoint
|
|
598
|
+
* the engine stamped with it still verifies. A history already under the new
|
|
599
|
+
* key is set aside as abandoned first — the file the user just wrote there
|
|
600
|
+
* supersedes it.
|
|
601
|
+
*/
|
|
602
|
+
const rename = (id, from, to) => {
|
|
603
|
+
if (!validKey(from) || !validKey(to)) return err("journal_rename", id, "invalid_key", "A rename needs two durable document keys.");
|
|
604
|
+
if (from === to) return { type: "journal_rename", id, from, to, renamed: false };
|
|
605
|
+
const src = dirOf(from); const dst = dirOf(to);
|
|
606
|
+
if (!fs.existsSync(src)) return err("journal_rename", id, "missing", "There is no history under the old key.");
|
|
607
|
+
cache.delete(from); cache.delete(to);
|
|
608
|
+
if (fs.existsSync(dst)) {
|
|
609
|
+
try { fs.renameSync(dst, `${dst}.abandoned-${stamp()}`); } catch {
|
|
610
|
+
return err("journal_rename", id, "io", "The history already at the new key could not be set aside.");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
try { fs.renameSync(src, dst); } catch {
|
|
614
|
+
return err("journal_rename", id, "io", "The history could not be moved to its new key.");
|
|
615
|
+
}
|
|
616
|
+
const meta = readMeta(dst);
|
|
617
|
+
meta.aliases = [...new Set([...(meta.aliases ?? []), from])];
|
|
618
|
+
try { writeMeta(dst, meta); } catch { /* the rename still stands */ }
|
|
619
|
+
return { type: "journal_rename", id, from, to, renamed: true, aliases: meta.aliases };
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Set aside a document's whole history (the user declined to recover it):
|
|
624
|
+
* the directory is renamed, listed as abandoned, deletable from Settings.
|
|
625
|
+
*/
|
|
626
|
+
const archive = (id, key) => {
|
|
627
|
+
if (!validKey(key)) return err("journal_archive", id, "invalid_key", "A journal needs a durable document key.");
|
|
628
|
+
const dir = dirOf(key);
|
|
629
|
+
cache.delete(key);
|
|
630
|
+
if (!fs.existsSync(dir)) return { type: "journal_archive", id, key, archived: false };
|
|
631
|
+
try { fs.renameSync(dir, `${dir}.abandoned-${stamp()}`); } catch {
|
|
632
|
+
return err("journal_archive", id, "io", "The history could not be set aside.");
|
|
633
|
+
}
|
|
634
|
+
return { type: "journal_archive", id, key, archived: true };
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
/** Every document the store knows, for the recovery list and Settings. */
|
|
638
|
+
const list = (id) => {
|
|
639
|
+
if (!enabled) return { type: "journal_list", id, documents: [], enabled: false };
|
|
640
|
+
const r = root();
|
|
641
|
+
const dirs = fs.existsSync(r)
|
|
642
|
+
? fs.readdirSync(r, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(r, d.name))
|
|
643
|
+
: [];
|
|
644
|
+
const documents = [];
|
|
645
|
+
for (const dir of dirs) {
|
|
646
|
+
const name = path.basename(dir);
|
|
647
|
+
const abandoned = /\.abandoned-[0-9-]+$/.test(name);
|
|
648
|
+
const key = name.replace(/\.abandoned-[0-9-]+$/, "").replace("__", ":");
|
|
649
|
+
if (!validKey(key)) continue;
|
|
650
|
+
const meta = readMeta(dir);
|
|
651
|
+
const jf = path.join(dir, "journal.ndjson");
|
|
652
|
+
const cf = path.join(dir, "checkpoint.json");
|
|
653
|
+
documents.push({ key, dir: name, abandoned, meta: meta.display ?? null, updated: meta.updated ?? null,
|
|
654
|
+
checkpointRevision: checkpointRevisionOf(dir),
|
|
655
|
+
bytes: (fs.existsSync(jf) ? fs.statSync(jf).size : 0) + (fs.existsSync(cf) ? fs.statSync(cf).size : 0),
|
|
656
|
+
pins: pinsOf(dir).length });
|
|
657
|
+
}
|
|
658
|
+
return { type: "journal_list", id, enabled: true, root: r, documents };
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/** Deliberate deletion of one document's history. The page confirms first. */
|
|
662
|
+
const remove = (id, key) => {
|
|
663
|
+
if (!validKey(key)) return err("journal_delete", id, "invalid_key", "A journal needs a durable document key.");
|
|
664
|
+
const dir = dirOf(key);
|
|
665
|
+
cache.delete(key);
|
|
666
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
667
|
+
return { type: "journal_delete", id, key, deleted: !fs.existsSync(dir) };
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
/** The whole history of one document as one JSON text, for the user to keep. */
|
|
671
|
+
const exportBundle = (id, key) => {
|
|
672
|
+
if (!validKey(key)) return err("journal_export", id, "invalid_key", "A journal needs a durable document key.");
|
|
673
|
+
let st;
|
|
674
|
+
try { st = stateOf(key); } catch (e) { return err("journal_export", id, "io", e.message); }
|
|
675
|
+
const s = scan(st.path);
|
|
676
|
+
const envelope = readEnvelope(st.dir);
|
|
677
|
+
const head = JSON.stringify({ format: "carmar-history-export", schema: 1, key,
|
|
678
|
+
exported: Date.now(), checkpointRevision: st.checkpoint, meta: readMeta(st.dir) });
|
|
679
|
+
const text = `${head.slice(0, head.length - 1)},"checkpoint":${envelope == null ? "null" : JSON.stringify(envelope)},"records":[${s.records.join(",")}]}`;
|
|
680
|
+
if (bytesOf(text) > JOURNAL_MAX_REPLY_BYTES) {
|
|
681
|
+
return err("journal_export", id, "too_large", "This history is too large to export over the kernel connection.");
|
|
682
|
+
}
|
|
683
|
+
return { type: "journal_export", id, key, __raw: { bundle: text } };
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
/** One frame for one command; the wire vocabulary in one place. */
|
|
687
|
+
const answer = (cmd) => {
|
|
688
|
+
switch (cmd.type) {
|
|
689
|
+
case "journal_open": return open(cmd.id, cmd.key, cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.takeover === true);
|
|
690
|
+
case "journal_probe": return probe(cmd.id, cmd.key);
|
|
691
|
+
case "journal_append": return append(cmd.id, cmd.key, cmd.records, cmd.epoch);
|
|
692
|
+
case "journal_checkpoint": return checkpoint(cmd.id, cmd.key, cmd.revision, cmd.generation, cmd.envelope,
|
|
693
|
+
cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.rebase === true, cmd.epoch);
|
|
694
|
+
case "journal_archive": return archive(cmd.id, cmd.key);
|
|
695
|
+
case "journal_rename": return rename(cmd.id, cmd.from, cmd.to);
|
|
696
|
+
case "journal_load": return load(cmd.id, cmd.key, cmd.afterRevision);
|
|
697
|
+
case "journal_read": return read(cmd.id, cmd.key, cmd.fromRevision, cmd.toRevision);
|
|
698
|
+
case "journal_versions": return versions(cmd.id, cmd.key, cmd.beforeRevision, cmd.limit ?? 200);
|
|
699
|
+
case "journal_pin": return pin(cmd.id, cmd.key, cmd.revision, cmd.name, cmd.state);
|
|
700
|
+
case "journal_unpin": return unpin(cmd.id, cmd.key, cmd.revision);
|
|
701
|
+
case "journal_pin_read": return pinRead(cmd.id, cmd.key, cmd.revision);
|
|
702
|
+
case "journal_list": return list(cmd.id);
|
|
703
|
+
case "journal_delete": return remove(cmd.id, cmd.key);
|
|
704
|
+
case "journal_export": return exportBundle(cmd.id, cmd.key);
|
|
705
|
+
default: return err(cmd.type, cmd.id, "unknown_op", `The document journal has no op named ${cmd.type}.`);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* A guard every answer goes through. The send is best-effort, so a throw
|
|
711
|
+
* while BUILDING the frame means no frame is sent at all — the page's promise
|
|
712
|
+
* never settles and the pane waits out its timeout. A refusal is a reply;
|
|
713
|
+
* silence is a hang.
|
|
714
|
+
*/
|
|
715
|
+
const answerGuarded = (cmd) => {
|
|
716
|
+
try {
|
|
717
|
+
const frame = answer(cmd);
|
|
718
|
+
if (!frame || typeof frame !== "object") return err(cmd.type, cmd.id, "failed", "The store produced no reply.");
|
|
719
|
+
return frame;
|
|
720
|
+
} catch (e) { return err(cmd.type, cmd.id, "failed", e && e.message ? e.message : String(e)); }
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
return { enabled, root, budget, validKey, dirOf, scan, stateOf, pinsOf, leaseRead, cache,
|
|
724
|
+
open, probe, append, checkpoint, load, read, versions, pin, unpin, pinRead,
|
|
725
|
+
rename, archive, list, remove, exportBundle, answer, answerGuarded };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Serialise a journal frame, splicing verbatim JSON fields in by hand.
|
|
730
|
+
* `frame.__raw` holds them: `records` (an array of JSON object texts) and any
|
|
731
|
+
* scalar JSON text (`checkpoint`, `pin`, `bundle`), emitted as JSON STRINGS
|
|
732
|
+
* (quoted) because the page decodes them itself.
|
|
733
|
+
*/
|
|
734
|
+
export function journalFrame(frame) {
|
|
735
|
+
const raw = frame.__raw;
|
|
736
|
+
const body = { ...frame };
|
|
737
|
+
delete body.__raw;
|
|
738
|
+
const head = JSON.stringify(body);
|
|
739
|
+
if (!raw) return head;
|
|
740
|
+
const extra = [];
|
|
741
|
+
if (raw.records) extra.push(`"records":[${raw.records.join(",")}]`);
|
|
742
|
+
for (const name of Object.keys(raw)) {
|
|
743
|
+
if (name === "records") continue;
|
|
744
|
+
const value = raw[name];
|
|
745
|
+
extra.push(`"${name}":${value == null ? "null" : JSON.stringify(value)}`);
|
|
746
|
+
}
|
|
747
|
+
if (!extra.length) return head;
|
|
748
|
+
return `${head.slice(0, head.length - 1)}${head === "{}" ? "" : ","}${extra.join(",")}}`;
|
|
749
|
+
}
|