aboutsummaryrefslogtreecommitdiffstats
path: root/names/store.test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 12:57:19 -0700
committerAdam Soltys <[email protected]>2026-10-16 12:57:19 -0700
commita3d2dc2e05a4f7c5ab0d6e7689498da9ca63de7b (patch)
treeb1c9d687e1cf0cced71bb2460a69ccdcb512f0dc /names/store.test.js
parent1dd70cc2c2b9b0a9f65a704ded9e322afabf736f (diff)
downloadcoinosv3-server-a3d2dc2e05a4f7c5ab0d6e7689498da9ca63de7b.tar.gz
coinosv3-server-a3d2dc2e05a4f7c5ab0d6e7689498da9ca63de7b.zip
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 <[email protected]>
Diffstat (limited to 'names/store.test.js')
-rw-r--r--names/store.test.js118
1 files changed, 118 insertions, 0 deletions
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);