f1bd64d28e
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.
230 lines
7.7 KiB
JavaScript
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);
|
|
});
|