aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
Diffstat (limited to 'names')
-rw-r--r--names/server.js33
-rw-r--r--names/store.js111
-rw-r--r--names/store.test.js118
3 files changed, 242 insertions, 20 deletions
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: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile,
+// { port, domains: { '<domain>': { 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: '[email protected]', memo: 'payouts', created: 1 } },
+ invoices: {}, // an empty map must survive a reload
+ pending: [{ name: '[email protected]', 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['[email protected]'] = { pubkey: hex(5000) };
+ delete st.names['[email protected]'];
+ st.names['[email protected]'].uri = 'bitcoin:?changed';
+ st.pending.push({ name: '[email protected]', 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['[email protected]'].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['[email protected]'] = { 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['[email protected]'].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['[email protected]'].updated).toBe(s.state.lastPayIndex);
+ s.close();
+}, 10_000);