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/store.test.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 names/store.test.js (limited to 'names/store.test.js') 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