Skip to content

Commit 79117cf

Browse files
feat: Export events to JSON Lines (#451)
* feat: add CLI script to export events to JSON Lines * chore: add export npm script and ignore .jsonl output * feat: add streaming export of events to JSON Lines * fix: polish export-events review feedback
1 parent ef9209c commit 79117cf

4 files changed

Lines changed: 140 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,6 @@ dist
4343
.nostr
4444

4545
# Docker Compose overrides
46-
docker-compose.overrides.yml
46+
docker-compose.overrides.yml
47+
# Export output
48+
*.jsonl

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,16 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep
570570
open .coverage/integration/lcov-report/index.html
571571
```
572572
573+
## Export Events
574+
575+
Export all stored events to a [JSON Lines](https://jsonlines.org/) (`.jsonl`) file. Each line is a valid NIP-01 Nostr event JSON object. The export streams rows from the database using cursors, so it works safely on relays with millions of events without loading them into memory.
576+
577+
```
578+
npm run export # writes to events.jsonl
579+
npm run export -- backup-2024-01-01.jsonl # custom filename
580+
```
581+
582+
The script reads the same `DB_*` environment variables used by the relay (see [CONFIGURATION.md](CONFIGURATION.md)).
573583
## Relay Maintenance
574584
575585
Use `clean-db` to wipe or prune `events` table data. This also removes

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"pretest:integration": "mkdir -p .test-reports/integration",
4949
"test:integration": "cucumber-js",
5050
"cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover",
51+
"export": "node -r ts-node/register src/scripts/export-events.ts",
5152
"docker:compose:start": "./scripts/start",
5253
"docker:compose:stop": "./scripts/stop",
5354
"docker:compose:clean": "./scripts/clean",

src/scripts/export-events.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import 'pg-query-stream'
2+
import dotenv from 'dotenv'
3+
dotenv.config()
4+
5+
import fs from 'fs'
6+
import path from 'path'
7+
import { pipeline } from 'stream/promises'
8+
import { Transform } from 'stream'
9+
10+
import { getMasterDbClient } from '../database/client'
11+
12+
type EventRow = {
13+
event_id: Buffer
14+
event_pubkey: Buffer
15+
event_kind: number
16+
event_created_at: number
17+
event_content: string
18+
event_tags: unknown[] | null
19+
event_signature: Buffer
20+
}
21+
22+
async function exportEvents(): Promise<void> {
23+
const filename = process.argv[2] || 'events.jsonl'
24+
const outputPath = path.resolve(filename)
25+
const db = getMasterDbClient()
26+
const abortController = new AbortController()
27+
let interruptedBySignal: NodeJS.Signals | undefined
28+
29+
const onSignal = (signal: NodeJS.Signals) => {
30+
if (abortController.signal.aborted) {
31+
return
32+
}
33+
34+
interruptedBySignal = signal
35+
process.exitCode = 130
36+
console.log(`${signal} received. Stopping export...`)
37+
abortController.abort()
38+
}
39+
40+
process
41+
.on('SIGINT', onSignal)
42+
.on('SIGTERM', onSignal)
43+
44+
try {
45+
const firstEvent = await db('events')
46+
.select('event_id')
47+
.whereNull('deleted_at')
48+
.first()
49+
50+
if (abortController.signal.aborted) {
51+
return
52+
}
53+
54+
if (!firstEvent) {
55+
console.log('No events to export.')
56+
return
57+
}
58+
59+
console.log(`Exporting events to ${outputPath}`)
60+
61+
const output = fs.createWriteStream(outputPath)
62+
let exported = 0
63+
64+
const dbStream = db('events')
65+
.select(
66+
'event_id',
67+
'event_pubkey',
68+
'event_kind',
69+
'event_created_at',
70+
'event_content',
71+
'event_tags',
72+
'event_signature',
73+
)
74+
.whereNull('deleted_at')
75+
.orderBy('event_created_at', 'asc')
76+
.orderBy('event_id', 'asc')
77+
.stream()
78+
79+
const toJsonLine = new Transform({
80+
objectMode: true,
81+
transform(row: EventRow, _encoding, callback) {
82+
const event = {
83+
id: row.event_id.toString('hex'),
84+
pubkey: row.event_pubkey.toString('hex'),
85+
created_at: row.event_created_at,
86+
kind: row.event_kind,
87+
tags: Array.isArray(row.event_tags) ? row.event_tags : [],
88+
content: row.event_content,
89+
sig: row.event_signature.toString('hex'),
90+
}
91+
92+
exported++
93+
if (exported % 10000 === 0) {
94+
console.log(`Exported ${exported} events...`)
95+
}
96+
97+
callback(null, JSON.stringify(event) + '\n')
98+
},
99+
})
100+
101+
await pipeline(dbStream, toJsonLine, output, {
102+
signal: abortController.signal,
103+
})
104+
105+
console.log(`Export complete: ${exported} events written to ${outputPath}`)
106+
} catch (error) {
107+
if (abortController.signal.aborted) {
108+
console.log(`Export interrupted by ${interruptedBySignal ?? 'signal'}.`)
109+
process.exitCode = 130
110+
return
111+
}
112+
113+
throw error
114+
} finally {
115+
process
116+
.off('SIGINT', onSignal)
117+
.off('SIGTERM', onSignal)
118+
119+
await db.destroy()
120+
}
121+
}
122+
123+
exportEvents().catch((error) => {
124+
console.error('Export failed:', error.message)
125+
process.exit(1)
126+
})

0 commit comments

Comments
 (0)