From a3d2dc2e05a4f7c5ab0d6e7689498da9ca63de7b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 12:57:19 -0700 Subject: names: keep state in SQLite persist() rewrote the whole state file, close to 10 MB, on every settled payment and every queue tick, so one crash during a write could take every name with it. State now lives in SQLite (bun:sqlite, WAL, synchronous=FULL): one row per name, offer, invoice and so on, with the forwarder wallet's state kept as a single row. Each persist() is one transaction that writes only the rows that changed, so a crash leaves the previous state or the new one, never a torn file. The in-memory state object and every persist() call stay as they were. On the first start the JSON state file is imported, and a corrupt one stops startup. The file is left in place, so a rollback to the JSON build still finds its data. store.snapshot() makes a consistent copy while the service runs, for backups. Co-Authored-By: Claude Opus 5.5 --- names/server.js | 33 ++++++--------- names/store.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ names/store.test.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 names/store.js create mode 100644 names/store.test.js (limited to 'names') diff --git a/names/server.js b/names/server.js index 9d94b51..c4ed650 100644 --- a/names/server.js +++ b/names/server.js @@ -13,10 +13,10 @@ // WHAT THIS SERVICE LEARNS: name ↔ nostr pubkey ↔ payment URI. All of it is // public by construction (DNS is public). It cannot spend anything. // -// Deliberately dumb: one JSON state file, no accounts, no email. +// Deliberately dumb: one SQLite file (store.js), no accounts, no email. -import { readFileSync, writeFileSync, renameSync, mkdirSync, openSync, fsyncSync, closeSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { verifyEvent, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import * as nip44 from 'nostr-tools/nip44'; import { wrapManyEvents } from 'nostr-tools/nip17'; @@ -34,10 +34,12 @@ import { decodeNoffer } from '../coinosv3/src/noffer.js'; import { decodeBolt11 } from '../coinosv3/src/ark/lightning.js'; import { decodeOffer } from '../coinosv3/src/bolt12.js'; import { registrationIdentity } from './ownership.js'; +import { openStore } from './store.js'; const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); -// { port, domains: { '': { zoneId } }, domain (default), cfEmail, cfKey, stateFile, +// { port, domains: { '': { zoneId } }, domain (default), cfEmail, cfKey, +// dbFile (SQLite state), stateFile (the old JSON state, imported once), // ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers, // forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet // that forwards settled offer payments to the name's ark address } @@ -46,6 +48,7 @@ const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims const PUBLIC_BASE = CFG.publicBase || 'https://names.coinos.io'; const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } }; const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json'); +const DB = CFG.dbFile || STATE.replace(/\.json$/, '') + '.db'; const TTL = 300; // record TTL: BIP-353 wallets cache by this const log = (...a) => console.log(new Date().toISOString(), ...a); @@ -65,22 +68,12 @@ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/; const NEW_NAME_RE = /^[a-z0-9]{2,24}$/; const NEW_NAME_ERR = 'names can only have letters and numbers (2–24)'; -// A missing file is a fresh registrar; an unreadable one is not. Starting -// empty over a corrupt file would persist the empty state on top of it. -let state = (() => { - let raw; - try { raw = readFileSync(STATE, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return { names: {} }; throw e; } - return JSON.parse(raw); -})(); -function persist() { - mkdirSync(dirname(STATE), { recursive: true }); - const tmp = STATE + '.tmp'; - writeFileSync(tmp, JSON.stringify(state)); - // flush before the rename, or a crash can leave a renamed-but-empty file - const fd = openSync(tmp, 'r'); fsyncSync(fd); closeSync(fd); - renameSync(tmp, STATE); - const dfd = openSync(dirname(STATE), 'r'); fsyncSync(dfd); closeSync(dfd); -} +// State lives in SQLite: each persist() is one transaction that rewrites only +// the records that changed. On the first start the old JSON state file is +// imported (a corrupt one stops startup rather than importing nothing). +const store = openStore(DB, { legacyJson: STATE }); +const state = store.state; +function persist() { store.persist(); } // --------------------------------------------------------------------------- // Cloudflare DNS 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(), + }; +} diff --git a/names/store.test.js b/names/store.test.js new file mode 100644 index 0000000..13a996e --- /dev/null +++ b/names/store.test.js @@ -0,0 +1,118 @@ +// bun test names/store.test.js +import { test, expect, beforeEach, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openStore } from './store.js'; + +const dir = mkdtempSync(join(tmpdir(), 'names-store-')); +const db = join(dir, 'names.db'); +const legacy = join(dir, 'names.json'); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const hex = (i) => i.toString(16).padStart(64, '0'); +function fixture() { + const names = {}; + for (let i = 0; i < 2000; i++) names[`user${i}@coinos.io`] = { pubkey: hex(i), uri: `bitcoin:?ark=x${i}`, updated: i }; + return { + names, + offers: { [hex(1)]: { key: 'user1@coinos.io', memo: 'payouts', created: 1 } }, + invoices: {}, // an empty map must survive a reload + pending: [{ name: 'user2@coinos.io', sat: 5, hash: hex(2), ts: 1 }], + lastPayIndex: 817, + serviceSk: hex(99), + fwdArk: { vtxos: [{ id: 'a', amountSat: 1000, state: 'spendable' }], _rev: 1 }, + }; +} +const fresh = () => { for (const f of [db, db + '-wal', db + '-shm']) rmSync(f, { force: true }); }; +const plain = (o) => JSON.parse(JSON.stringify(o)); + +beforeEach(() => { fresh(); writeFileSync(legacy, JSON.stringify(fixture())); }); + +test('imports the legacy JSON once and leaves it in place', () => { + let s = openStore(db, { legacyJson: legacy }); + expect(plain(s.state)).toEqual(fixture()); + s.close(); + expect(existsSync(legacy)).toBe(true); + writeFileSync(legacy, 'not json'); // a later start must not read it again + s = openStore(db, { legacyJson: legacy }); + expect(plain(s.state)).toEqual(fixture()); + s.close(); +}); + +test('a corrupt legacy file stops startup instead of importing nothing', () => { + writeFileSync(legacy, '{"names":{"trunc'); + expect(() => openStore(db, { legacyJson: legacy })).toThrow(); +}); + +test('every kind of mutation survives a reopen', () => { + const s = openStore(db, { legacyJson: legacy }); + const st = s.state; + st.names['new@coinos.io'] = { pubkey: hex(5000) }; + delete st.names['user0@coinos.io']; + st.names['user3@coinos.io'].uri = 'bitcoin:?changed'; + st.pending.push({ name: 'user4@coinos.io', sat: 1, hash: hex(4), ts: 2 }); + for (const p of st.pending) p.next = 0; // in-place edits of array items + st.lastPayIndex++; + st.hats = { [hex(7)]: { owned: ['beanie'] } }; + delete st.offers; + st.fwdArk._rev = 2; + s.persist(); + const want = plain(st); + s.close(); + const r = openStore(db); + expect(plain(r.state)).toEqual(want); + r.close(); +}); + +test('one changed record rewrites one row; no change writes nothing', () => { + const s = openStore(db, { legacyJson: legacy }); + const raw = new Database(db, { readonly: true }); + const before = raw.query('SELECT max(updated) m FROM kv').get().m; + Bun.sleepSync(5); + s.state.names['user9@coinos.io'].uri = 'bitcoin:?again'; + s.persist(); + s.persist(); + expect(raw.query('SELECT count(*) c FROM kv WHERE updated > ?').get(before).c).toBe(1); + raw.close(); + s.close(); +}); + +test('a snapshot taken while open equals the live state', () => { + const s = openStore(db, { legacyJson: legacy }); + s.state.names['snap@coinos.io'] = { pubkey: hex(6000) }; + s.persist(); + const snap = join(dir, 'snap.db'); + rmSync(snap, { force: true }); + s.snapshot(snap); + const c = openStore(snap); + expect(plain(c.state)).toEqual(plain(s.state)); + c.close(); + s.close(); +}); + +test('kill -9 mid-persist leaves a consistent database', async () => { + openStore(db, { legacyJson: legacy }).close(); + const child = Bun.spawn(['bun', '-e', ` + import { openStore } from ${JSON.stringify(join(import.meta.dir, 'store.js'))}; + const s = openStore(${JSON.stringify(db)}); + for (let i = 0; ; i++) { + s.state.lastPayIndex = i; + s.state.names['user1@coinos.io'].updated = i; + s.state.pending.push({ i }); + if (s.state.pending.length > 50) s.state.pending.shift(); + s.persist(); + }`]); + await Bun.sleep(1500); + child.kill(9); + await child.exited; + const raw = new Database(db); + expect(Object.values(raw.query('PRAGMA integrity_check').get())[0]).toBe('ok'); + raw.close(); + const s = openStore(db); + // both fields were written in the same transaction, so they always agree + expect(s.state.lastPayIndex).toBeGreaterThan(0); + expect(s.state.names['user1@coinos.io'].updated).toBe(s.state.lastPayIndex); + s.close(); +}, 10_000); -- cgit v1.2.3