aboutsummaryrefslogtreecommitdiffstats
path: root/names/store.js
diff options
context:
space:
mode:
Diffstat (limited to 'names/store.js')
-rw-r--r--names/store.js111
1 files changed, 111 insertions, 0 deletions
diff --git a/names/store.js b/names/store.js
new file mode 100644
index 0000000..bbfc077
--- /dev/null
+++ b/names/store.js
@@ -0,0 +1,111 @@
+// SQLite-backed state for the mailroom.
+//
+// The service keeps one in-memory `state` object and calls persist() after
+// mutating it. This module keeps that model but makes each persist a single
+// transaction that writes only what changed: every plain-object collection
+// (names, offers, invoices, ...) is one row per entry, and everything else
+// (lastPayIndex, the pending queue, the forwarder wallet) is one row per
+// top-level key. WAL + synchronous=FULL, so a crash leaves either the old
+// rows or the new ones — never a truncated file.
+//
+// When the database has no rows yet, a legacy JSON state file is imported
+// (in one transaction) and left in place, so a rollback to the JSON-file
+// build finds its data instead of starting empty.
+
+import { Database } from 'bun:sqlite';
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+// Top-level keys stored whole even though they are objects: the forwarder
+// wallet's state is one unit and must never be written half-updated.
+const WHOLE = new Set(['fwdArk']);
+const SCALAR = ''; // collection name used for whole-value keys
+
+const isMap = (k, v) => !WHOLE.has(k) && v !== null && typeof v === 'object' && !Array.isArray(v);
+
+export function openStore(path, { legacyJson } = {}) {
+ mkdirSync(dirname(path), { recursive: true });
+ const db = new Database(path, { create: true, strict: true });
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA synchronous = FULL');
+ db.exec('PRAGMA busy_timeout = 5000');
+ db.exec(`CREATE TABLE IF NOT EXISTS kv (
+ coll TEXT NOT NULL, key TEXT NOT NULL, val TEXT NOT NULL, updated INTEGER NOT NULL,
+ PRIMARY KEY (coll, key)) WITHOUT ROWID`);
+ // which top-level keys are maps, so an empty map survives a reload
+ db.exec('CREATE TABLE IF NOT EXISTS colls (coll TEXT PRIMARY KEY)');
+
+ const upsert = db.prepare(`INSERT INTO kv (coll, key, val, updated) VALUES (?, ?, ?, ?)
+ ON CONFLICT (coll, key) DO UPDATE SET val = excluded.val, updated = excluded.updated`);
+ const del = db.prepare('DELETE FROM kv WHERE coll = ? AND key = ?');
+ const addColl = db.prepare('INSERT OR IGNORE INTO colls (coll) VALUES (?)');
+ const dropColl = db.prepare('DELETE FROM colls WHERE coll = ?');
+ const dropRows = db.prepare('DELETE FROM kv WHERE coll = ?');
+
+ // last-written serialization of every row, to diff against
+ const written = new Map(); // `${coll}\0${key}` -> json
+ const maps = new Set();
+
+ function load() {
+ const state = {};
+ for (const { coll } of db.query('SELECT coll FROM colls').all()) { state[coll] = {}; maps.add(coll); }
+ for (const { coll, key, val } of db.query('SELECT coll, key, val FROM kv').all()) {
+ written.set(`${coll}\0${key}`, val);
+ const v = JSON.parse(val);
+ if (coll === SCALAR) state[key] = v;
+ else (state[coll] ||= {})[key] = v;
+ }
+ return state;
+ }
+
+ const persistTx = db.transaction((state) => {
+ const now = Date.now();
+ const seen = new Set();
+ for (const [k, v] of Object.entries(state)) {
+ if (v === undefined) continue;
+ if (isMap(k, v)) {
+ if (!maps.has(k)) {
+ // a key that used to be stored whole is now a map: clear the old row
+ if (written.has(`${SCALAR}\0${k}`)) { del.run(SCALAR, k); written.delete(`${SCALAR}\0${k}`); }
+ addColl.run(k); maps.add(k);
+ }
+ for (const [rk, rv] of Object.entries(v)) {
+ if (rv === undefined) continue;
+ const id = `${k}\0${rk}`, json = JSON.stringify(rv);
+ seen.add(id);
+ if (written.get(id) !== json) { upsert.run(k, rk, json, now); written.set(id, json); }
+ }
+ } else {
+ if (maps.has(k)) { dropRows.run(k); dropColl.run(k); maps.delete(k); }
+ const id = `${SCALAR}\0${k}`, json = JSON.stringify(v);
+ seen.add(id);
+ if (written.get(id) !== json) { upsert.run(SCALAR, k, json, now); written.set(id, json); }
+ }
+ }
+ for (const k of [...maps]) if (!(k in state)) { dropColl.run(k); maps.delete(k); }
+ for (const id of [...written.keys()]) {
+ if (seen.has(id)) continue;
+ const [coll, key] = id.split('\0');
+ del.run(coll, key); written.delete(id);
+ }
+ });
+
+ let state;
+ const empty = !db.query('SELECT 1 FROM kv LIMIT 1').get();
+ if (empty && legacyJson && existsSync(legacyJson)) {
+ // JSON.parse throws on a corrupt file: refuse to start rather than import nothing
+ state = JSON.parse(readFileSync(legacyJson, 'utf8'));
+ persistTx(state);
+ } else {
+ state = load();
+ }
+ state.names ||= {};
+
+ return {
+ state,
+ persist: () => persistTx(state),
+ // a consistent copy for backups, safe while the service runs
+ snapshot: (to) => db.exec(`VACUUM INTO '${to.replace(/'/g, "''")}'`),
+ close: () => db.close(),
+ };
+}