Files
eranos/scripts/migrate-posts.mjs
T
2ro f1bd64d28e
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
scripts: add migrate-posts.mjs (nrelay.us-ea.st -> relay.floonet.dev)
Fetches all events by the three configured npubs from the source relay and
republishes kinds 0/1/3/7/10002/30023 verbatim (original signatures, no key
material) to the target. Dry run by default with a per-kind per-author count
table incl. out-of-scope kinds; --execute publishes, logging per-event
OK/blocked from the target write policy. Idempotent (relays dedupe by id).
Replaceable kinds published oldest-first so the newest version wins.
2026-07-04 20:00:25 -04:00

230 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/**
* migrate-posts.mjs — one-shot event migration between relays.
*
* Fetches all events authored by the configured pubkeys from the SOURCE relay
* and republishes the whitelisted kinds VERBATIM to the TARGET relay. Events
* keep their original signatures — no re-signing, no key material needed.
*
* Idempotent by nature: relays deduplicate by event id, so re-running the
* migration (or running it after a partial failure) is always safe.
*
* Default mode is a DRY RUN: connects to the source, fetches, prints a
* per-kind per-author count table (including out-of-scope kinds that will be
* skipped) and publishes nothing.
*
* Usage:
* node scripts/migrate-posts.mjs # dry run (read-only)
* node scripts/migrate-posts.mjs --execute # actually publish to target
*/
import { NRelay1 } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
// ─── Config ───────────────────────────────────────────────────────────────────
const SOURCE_RELAY = 'wss://nrelay.us-ea.st/';
const TARGET_RELAY = 'wss://relay.floonet.dev/';
/** Authors to migrate (npub form; decoded to hex below). */
const AUTHOR_NPUBS = [
'npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely',
'npub12tuz8sva4r832xh2axwt0myf33ygpnc9huvzhxe8y6jkvq2f3l2s9ye4k7',
'npub1m049skfequeelxy032555eg7w47ff7qvzfc2cahym7xkrsgvmtqsnm9ny6',
];
/**
* Kinds to migrate. Anything else found on the source is reported but skipped
* — the target relay's write policy whitelist rejects unknown kinds anyway.
*/
const MIGRATE_KINDS = [0, 1, 3, 7, 10002, 30023];
/** Page size for source pagination (stay under typical relay limit caps). */
const PAGE_LIMIT = 500;
/** Per-query timeout (ms). */
const QUERY_TIMEOUT = 15_000;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function npubToHex(npub) {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error(`Not an npub: ${npub}`);
}
return decoded.data;
}
function shortNpub(npub) {
return `${npub.slice(0, 12)}${npub.slice(-6)}`;
}
/**
* Fetch ALL events authored by `authors` from `relay`, paginating with
* `until`. No kinds filter — we want the full picture so out-of-scope kinds
* can be reported. Uses an inclusive `until` (the oldest seen timestamp) and
* dedupes by id, so same-second events at page boundaries are not dropped;
* the loop stops when a page yields no new ids.
*/
async function fetchAllByAuthors(relay, authors) {
const events = new Map(); // id -> event
let until = undefined;
for (;;) {
const filter = { authors, limit: PAGE_LIMIT };
if (until !== undefined) filter.until = until;
const page = await relay.query([filter], {
signal: AbortSignal.timeout(QUERY_TIMEOUT),
});
if (page.length === 0) break;
let newCount = 0;
let oldest = Infinity;
for (const evt of page) {
if (evt.created_at < oldest) oldest = evt.created_at;
if (!events.has(evt.id)) {
events.set(evt.id, evt);
newCount++;
}
}
process.stderr.write(
` fetched page: ${page.length} events (${newCount} new, total ${events.size})\n`,
);
if (newCount === 0) break; // page was entirely duplicates — done
until = oldest; // inclusive; dedupe handles the overlap
}
return [...events.values()];
}
/** Print a per-kind (rows) per-author (columns) count table. */
function printCountTable(events, authorsHex, npubs) {
const kinds = [...new Set(events.map((e) => e.kind))].sort((a, b) => a - b);
const colHeads = npubs.map(shortNpub);
const counts = new Map(); // `${kind}:${pubkey}` -> n
for (const evt of events) {
const key = `${evt.kind}:${evt.pubkey}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const kindCol = 8;
const scopeCol = 9;
const cols = colHeads.map((h) => h.length + 2);
const pad = (s, w) => String(s).padStart(w);
const header =
pad('kind', kindCol) +
pad('scope', scopeCol) +
colHeads.map((h, i) => pad(h, cols[i])).join('') +
pad('total', 8);
console.log(header);
console.log('-'.repeat(header.length));
let grandTotal = 0;
let migrateTotal = 0;
for (const kind of kinds) {
const inScope = MIGRATE_KINDS.includes(kind);
let rowTotal = 0;
const cells = authorsHex.map((pk, i) => {
const n = counts.get(`${kind}:${pk}`) ?? 0;
rowTotal += n;
return pad(n, cols[i]);
});
grandTotal += rowTotal;
if (inScope) migrateTotal += rowTotal;
console.log(
pad(kind, kindCol) +
pad(inScope ? 'migrate' : 'SKIP', scopeCol) +
cells.join('') +
pad(rowTotal, 8),
);
}
console.log('-'.repeat(header.length));
console.log(
pad('all', kindCol) +
pad('', scopeCol) +
authorsHex
.map((pk, i) =>
pad(events.filter((e) => e.pubkey === pk).length, cols[i]),
)
.join('') +
pad(grandTotal, 8),
);
console.log(
`\nTotal on source: ${grandTotal} | to migrate: ${migrateTotal} | skipped (out-of-scope kinds): ${grandTotal - migrateTotal}`,
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const execute = process.argv.includes('--execute');
const authorsHex = AUTHOR_NPUBS.map(npubToHex);
console.log(`Source relay: ${SOURCE_RELAY}`);
console.log(`Target relay: ${TARGET_RELAY}`);
console.log(`Mode: ${execute ? 'EXECUTE (publishing to target)' : 'dry run (read-only)'}`);
console.log(`Kinds: ${MIGRATE_KINDS.join(', ')}`);
console.log('Authors:');
for (const npub of AUTHOR_NPUBS) console.log(` ${npub}`);
console.log('');
const source = new NRelay1(SOURCE_RELAY);
console.log('Fetching all events by the configured authors from the source…');
const all = await fetchAllByAuthors(source, authorsHex);
console.log('');
printCountTable(all, authorsHex, AUTHOR_NPUBS);
const toMigrate = all
.filter((e) => MIGRATE_KINDS.includes(e.kind))
// Oldest first: replaceable kinds (0, 3, 10002, 30023) land in
// chronological order so the newest version wins on the target.
.sort((a, b) => a.created_at - b.created_at);
if (!execute) {
console.log('\nDry run — nothing published. Re-run with --execute to migrate.');
await source.close();
return;
}
console.log(`\nPublishing ${toMigrate.length} events to ${TARGET_RELAY} …\n`);
const target = new NRelay1(TARGET_RELAY);
let ok = 0;
let blocked = 0;
for (const evt of toMigrate) {
const label = `kind ${String(evt.kind).padStart(5)} ${evt.id.slice(0, 12)}… (${new Date(evt.created_at * 1000).toISOString()})`;
try {
// Republish verbatim — original id + signature remain valid.
await target.event(evt, { signal: AbortSignal.timeout(QUERY_TIMEOUT) });
ok++;
console.log(`OK ${label}`);
} catch (err) {
// The target has a write policy; blocked/rejected responses MUST be
// visible, never swallowed.
blocked++;
const reason = err instanceof Error ? err.message : String(err);
console.log(`BLOCKED ${label}${reason}`);
}
}
console.log(`\nSummary: ${ok} accepted, ${blocked} blocked/rejected, of ${toMigrate.length} attempted.`);
if (blocked > 0) process.exitCode = 1;
await source.close();
await target.close();
}
main().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});