SkateSpots/scripts/restore-relay.mjs
SkateSpots c01521ea3f feat(backup): scoped weekly archive of the community relay dataset
scripts/archive-relay.mjs snapshots the app's own content off
goskatespots.nostr1.com to gzipped JSONL of the raw signed events, so a
restore can republish them verbatim to any relay. scripts/restore-relay.mjs
re-verifies signatures and publishes to an explicit --to (no default, to
avoid replaying a snapshot onto the live relay). scripts/backup-relay.sh
wraps it and pushes with restic when a repo is configured; a weekly systemd
user timer runs it.

Scoped on purpose: that relay is PUBLIC and carries general traffic (15.7k
kind-0 profiles, 2k kind-30078 app-data from other clients, 408 foreign
NIP-99 listings), so the archive keeps only the app's own kinds/tags plus
contributor profiles, SkateSpots labels, and reports that reference archived
content. ~114 events / 63 KiB.

Not configured yet: offsite (restic.env) and a full restore drill.
2026-09-28 12:41:50 +07:00

86 lines
2.8 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Restore a SkateSpots archive (from scripts/archive-relay.mjs) to a relay.
//
// Publishes the RAW archived events verbatim — signatures are re-verified first,
// so anything tampered with is dropped. Addressable events are safe to republish:
// the newest `created_at` wins, so replaying a snapshot converges to the same
// state. This is the "prove you can get it back" half of the backup.
//
// node scripts/restore-relay.mjs <relay-*.jsonl.gz> --dry-run
// node scripts/restore-relay.mjs <relay-*.jsonl.gz> --to wss://your.relay
//
// There is deliberately NO default target: restoring is a deliberate act, and
// the common mistake is publishing a snapshot back to the live relay. Use
// --dry-run to preview, then point --to at the relay you actually want.
import { readFileSync } from 'node:fs';
import { gunzipSync } from 'node:zlib';
import { SimplePool, verifyEvent } from 'nostr-tools';
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
const toIndex = args.indexOf('--to');
const TO = toIndex >= 0 ? args[toIndex + 1] : null;
const DRY = args.includes('--dry-run');
if (!file) {
console.error('usage: node scripts/restore-relay.mjs <archive.jsonl.gz> [--dry-run | --to wss://relay]');
process.exit(2);
}
if (!TO && !DRY) {
console.error('[restore] refusing to publish without an explicit --to <relay> (use --dry-run to preview)');
process.exit(2);
}
const lines = gunzipSync(readFileSync(file)).toString('utf8').split('\n').filter(Boolean);
const events = [];
let invalid = 0;
for (const line of lines) {
try {
const ev = JSON.parse(line);
if (verifyEvent(ev)) events.push(ev);
else invalid += 1;
} catch {
invalid += 1;
}
}
const byKind = {};
for (const e of events) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
console.log(`[restore] ${events.length} valid events (${invalid} invalid/ignored) from ${file}`);
console.log(
'[restore] kinds:',
Object.entries(byKind)
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => `${k}:${n}`)
.join(' '),
);
if (DRY) {
console.log('[restore] dry run — nothing published');
process.exit(0);
}
const pool = new SimplePool();
let ok = 0;
let fail = 0;
let index = 0;
const CONCURRENCY = 10;
async function worker() {
while (index < events.length) {
const ev = events[index++];
try {
const results = await Promise.allSettled(pool.publish([TO], ev));
if (results.some((r) => r.status === 'fulfilled')) ok += 1;
else fail += 1;
} catch {
fail += 1;
}
if ((ok + fail) % 100 === 0) process.stdout.write(`\r[restore] ${ok + fail}/${events.length}…`);
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, events.length) }, worker));
pool.close([TO]);
process.stdout.write(`\r[restore] done — ${ok} accepted, ${fail} failed \n`);
if (fail) process.exit(1);