• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cameri / nostream / 24937797860

25 Apr 2026 06:35PM UTC coverage: 63.498% (-12.0%) from 75.491%
24937797860

Pull #574

github

web-flow
Merge b4c964365 into c0c1c35b8
Pull Request #574: feat: migrate nostream scripts to unified CLI/TUI

1619 of 2880 branches covered (56.22%)

Branch coverage included in aggregate %.

735 of 1701 new or added lines in 29 files covered. (43.21%)

3838 of 5714 relevant lines covered (67.17%)

16.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.47
/src/scripts/export-events.ts
1
import 'pg-query-stream'
4✔
2

3
import fs from 'fs'
4✔
4
import path, { extname } from 'path'
4✔
5
import { pipeline } from 'stream/promises'
4✔
6
import { Transform } from 'stream'
4✔
7

8
import {
4✔
9
  CompressionFormat,
10
  createCompressionStream,
11
  getCompressionFormatFromExtension,
12
  parseCompressionFormat,
13
} from '../utils/compression'
14
import { getMasterDbClient } from '../database/client'
4✔
15

16
type ExportCliOptions = {
17
  compress: boolean
18
  format?: CompressionFormat
19
  outputFilePath: string
20
  showHelp: boolean
21
}
22

23
type ExportOptions = {
24
  json?: boolean
25
  format?: 'jsonl' | 'json'
26
}
27

28
const DEFAULT_OUTPUT_FILE_PATH = 'events.jsonl'
4✔
29
const MIN_ELAPSED_SECONDS = 0.001
4✔
30

31
export const formatBytes = (bytes: number): string => {
4✔
32
  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
13✔
33

34
  if (!Number.isFinite(bytes) || bytes <= 0) {
13✔
35
    return '0 B'
1✔
36
  }
37

38
  let unitIndex = 0
12✔
39
  let value = bytes
12✔
40

41
  while (value >= 1024 && unitIndex < units.length - 1) {
12✔
42
    value /= 1024
10✔
43
    unitIndex += 1
10✔
44
  }
45

46
  const rounded = Math.round(value * 100) / 100
12✔
47
  const formatted = String(rounded)
12✔
48

49
  return `${formatted} ${units[unitIndex]}`
12✔
50
}
51

52
export const formatCompressionDelta = (rawBytes: number, outputBytes: number): string | undefined => {
4✔
53
  if (rawBytes <= 0) {
5✔
54
    return undefined
1✔
55
  }
56

57
  const deltaPercent = ((rawBytes - outputBytes) / rawBytes) * 100
4✔
58
  const rounded = Math.round(Math.abs(deltaPercent) * 100) / 100
4✔
59
  const formattedPercent = String(rounded)
4✔
60

61
  if (deltaPercent >= 0) {
4✔
62
    return `${formattedPercent}% smaller`
3✔
63
  }
64

65
  return `${formattedPercent}% larger`
1✔
66
}
67

68
export const getRatePerSecond = (value: number, elapsedMs: number): number => {
4✔
69
  if (!Number.isFinite(value) || value <= 0) {
9✔
70
    return 0
1✔
71
  }
72

73
  const elapsedSeconds = Math.max(elapsedMs / 1000, MIN_ELAPSED_SECONDS)
8✔
74

75
  return value / elapsedSeconds
8✔
76
}
77

78
const formatCount = (value: number): string => {
4✔
79
  const rounded = Math.round(value * 100) / 100
2✔
80

81
  return Number.isInteger(rounded)
2!
82
    ? rounded.toLocaleString('en-US')
83
    : rounded.toLocaleString('en-US', {
84
        maximumFractionDigits: 2,
85
        minimumFractionDigits: 2,
86
      })
87
}
88

89
const getOptionValue = (option: string, args: string[], index: number): [string, number] => {
4✔
90
  const inlineSeparator = `${option}=`
7✔
91
  if (args[index].startsWith(inlineSeparator)) {
7✔
92
    const value = args[index].slice(inlineSeparator.length)
3✔
93
    if (!value) {
3✔
94
      throw new Error(`Missing value for ${option}`)
1✔
95
    }
96

97
    return [value, index]
2✔
98
  }
99

100
  const nextIndex = index + 1
4✔
101
  const nextArg = args[nextIndex]
4✔
102
  if (typeof nextArg !== 'string' || nextArg.startsWith('-')) {
4✔
103
    throw new Error(`Missing value for ${option}`)
1✔
104
  }
105

106
  return [nextArg, nextIndex]
3✔
107
}
108

109
const printUsage = (): void => {
4✔
110
  console.log('Usage: pnpm run export [output-file] [--compress|-z] [--format gzip|gz|xz]')
×
111
  console.log('Example: pnpm run export ./events.jsonl')
×
112
  console.log('Example: pnpm run export ./events.jsonl.gz --compress --format gzip')
×
113
  console.log('Example: pnpm run export ./events.jsonl.xz -z --format xz')
×
114
}
115

116
const getCompressionLabel = (format: CompressionFormat): string => {
4✔
117
  switch (format) {
2!
118
    case CompressionFormat.GZIP:
119
      return 'gzip'
1✔
120
    case CompressionFormat.XZ:
121
      return 'xz'
1✔
122
    default:
123
      return String(format)
×
124
  }
125
}
126

127
export const parseCliArgs = (args: string[]): ExportCliOptions => {
4✔
128
  let compress = false
15✔
129
  let format: CompressionFormat | undefined
130
  let outputFilePath: string | undefined
131

132
  if (args.includes('--help') || args.includes('-h')) {
15✔
133
    return {
1✔
134
      compress,
135
      format,
136
      outputFilePath: DEFAULT_OUTPUT_FILE_PATH,
137
      showHelp: true,
138
    }
139
  }
140

141
  for (let index = 0; index < args.length; index++) {
14✔
142
    const arg = args[index]
28✔
143

144
    if (arg === '--compress' || arg === '-z') {
28✔
145
      compress = true
9✔
146
      continue
9✔
147
    }
148

149
    if (arg === '--format' || arg.startsWith('--format=')) {
19✔
150
      const [rawFormat, nextIndex] = getOptionValue('--format', args, index)
7✔
151
      format = parseCompressionFormat(rawFormat)
5✔
152
      index = nextIndex
5✔
153
      continue
5✔
154
    }
155

156
    if (arg.startsWith('-')) {
12✔
157
      throw new Error(`Unknown option: ${arg}`)
1✔
158
    }
159

160
    if (outputFilePath) {
11✔
161
      throw new Error(`Unexpected extra argument: ${arg}`)
1✔
162
    }
163

164
    outputFilePath = arg
10✔
165
  }
166

167
  if (!compress && format) {
10✔
168
    throw new Error('--format requires --compress')
1✔
169
  }
170

171
  outputFilePath = outputFilePath ?? DEFAULT_OUTPUT_FILE_PATH
9✔
172

173
  if (compress && !format) {
9✔
174
    format = getCompressionFormatFromExtension(outputFilePath) ?? CompressionFormat.GZIP
3✔
175
  }
176

177
  return {
9✔
178
    compress,
179
    format,
180
    outputFilePath,
181
    showHelp: false,
182
  }
183
}
184

185
type EventRow = {
186
  event_id: Buffer
187
  event_pubkey: Buffer
188
  event_kind: number
189
  event_created_at: number
190
  event_content: string
191
  event_tags: unknown[] | null
192
  event_signature: Buffer
193
}
194

195
const resolveExportFormat = (format?: string): 'jsonl' | 'json' => {
4✔
196
  if (!format) {
5✔
197
    return 'jsonl'
2✔
198
  }
199

200
  if (format === 'jsonl' || format === 'json') {
3!
201
    return format
3✔
202
  }
203

NEW
204
  throw new Error(`Unsupported format: ${format}. Supported values: json, jsonl`)
×
205
}
206

207
const resolveOutputPath = (filename: string | undefined, format: 'jsonl' | 'json'): string => {
4✔
208
  const fallback = format === 'json' ? 'events.json' : 'events.jsonl'
3✔
209
  const outputPath = path.resolve(filename || fallback)
3!
210
  const expectedExtension = format === 'json' ? '.json' : '.jsonl'
3✔
211

212
  if (extname(outputPath).toLowerCase() !== expectedExtension) {
3✔
213
    throw new Error(`Output file extension must be ${expectedExtension} when using --format ${format}`)
1✔
214
  }
215

216
  return outputPath
2✔
217
}
218

219
const toEvent = (row: EventRow) => ({
9✔
220
  id: row.event_id.toString('hex'),
221
  pubkey: row.event_pubkey.toString('hex'),
222
  created_at: row.event_created_at,
223
  kind: row.event_kind,
224
  tags: Array.isArray(row.event_tags) ? row.event_tags : [],
9!
225
  content: row.event_content,
226
  sig: row.event_signature.toString('hex'),
227
})
228

229
const createFormatterTransform = (
4✔
230
  format: 'jsonl' | 'json',
231
  onExported: () => void,
232
): Transform => {
233
  if (format === 'jsonl') {
2✔
234
    return new Transform({
1✔
235
      objectMode: true,
236
      transform(row: EventRow, _encoding, callback) {
237
        onExported()
1✔
238
        callback(null, JSON.stringify(toEvent(row)) + '\n')
1✔
239
      },
240
    })
241
  }
242

243
  let hasRows = false
1✔
244
  return new Transform({
1✔
245
    objectMode: true,
246
    transform(row: EventRow, _encoding, callback) {
247
      const prefix = hasRows ? ',\n' : '[\n'
2✔
248
      hasRows = true
2✔
249
      onExported()
2✔
250
      callback(null, prefix + JSON.stringify(toEvent(row)))
2✔
251
    },
252
    flush(callback) {
253
      callback(null, hasRows ? '\n]\n' : '[]\n')
1!
254
    },
255
  })
256
}
257

258
export async function runExportEvents(args: string[] = process.argv.slice(2), options: ExportOptions = {}): Promise<number> {
4✔
259
  const useStructuredFormat = Boolean(options.format)
5✔
260
  const structuredFormat = resolveExportFormat(options.format)
5✔
261
  const cliOptions = useStructuredFormat ? undefined : parseCliArgs(args)
5✔
262

263
  if (!useStructuredFormat && cliOptions?.showHelp) {
5!
264
    printUsage()
×
NEW
265
    return 0
×
266
  }
267

268
  const outputPath = useStructuredFormat
5✔
269
    ? resolveOutputPath(args[0], structuredFormat)
270
    : path.resolve(cliOptions?.outputFilePath ?? DEFAULT_OUTPUT_FILE_PATH)
2!
271

272
  const db = getMasterDbClient()
4✔
273
  const abortController = new AbortController()
4✔
274
  let interruptedBySignal: NodeJS.Signals | undefined
275

276
  const onSignal = (signal: NodeJS.Signals) => {
4✔
277
    if (abortController.signal.aborted) {
×
278
      return
×
279
    }
280

281
    interruptedBySignal = signal
×
282
    process.exitCode = 130
×
283
    console.log(`${signal} received. Stopping export...`)
×
284
    abortController.abort()
×
285
  }
286

287
  process.on('SIGINT', onSignal).on('SIGTERM', onSignal)
4✔
288

289
  try {
4✔
290
    const firstEvent = await db('events').select('event_id').whereNull('deleted_at').first()
4✔
291

292
    if (abortController.signal.aborted) {
4!
NEW
293
      return 130
×
294
    }
295

296
    if (!firstEvent) {
4!
NEW
297
      if (options.json) {
×
NEW
298
        console.log(JSON.stringify({ exported: 0, outputPath, empty: true }, null, 2))
×
299
      } else {
NEW
300
        console.log('No events to export.')
×
301
      }
NEW
302
      return 0
×
303
    }
304

305
    if (useStructuredFormat) {
4✔
306
      console.log(`Exporting events to ${outputPath}`)
2✔
307
    } else if (cliOptions?.format) {
2!
308
      console.log(`Exporting events to ${outputPath} using ${getCompressionLabel(cliOptions.format)} compression`)
2✔
309
    } else {
310
      console.log(`Exporting events to ${outputPath}`)
×
311
    }
312

313
    const startedAt = Date.now()
4✔
314
    const output = fs.createWriteStream(outputPath)
4✔
315

316
    const dbStream = db('events')
4✔
317
      .select(
318
        'event_id',
319
        'event_pubkey',
320
        'event_kind',
321
        'event_created_at',
322
        'event_content',
323
        'event_tags',
324
        'event_signature',
325
      )
326
      .whereNull('deleted_at')
327
      .orderBy('event_created_at', 'asc')
328
      .orderBy('event_id', 'asc')
329
      .stream()
330

331
    let exported = 0
4✔
332

333
    if (useStructuredFormat) {
4✔
334
      const formatter = createFormatterTransform(structuredFormat, () => {
2✔
335
        exported += 1
3✔
336
        if (exported % 10000 === 0) {
3!
NEW
337
          console.log(`Exported ${exported} events...`)
×
338
        }
339
      })
340

341
      await pipeline(dbStream, formatter, output, {
2✔
342
        signal: abortController.signal,
343
      })
344

345
      if (options.json) {
2!
NEW
346
        console.log(
×
347
          JSON.stringify(
348
            {
349
              exported,
350
              outputPath,
351
              format: structuredFormat,
352
            },
353
            null,
354
            2,
355
          ),
356
        )
357
      } else {
358
        console.log(`Export complete: ${exported} events written to ${outputPath} (${structuredFormat})`)
2✔
359
      }
360

361
      return 0
2✔
362
    }
363

364
    const compressionFormat = cliOptions?.format
2✔
365
    const compressionStream = createCompressionStream(compressionFormat)
2✔
366
    let rawBytes = 0
2✔
367

368
    const toJsonLine = new Transform({
2✔
369
      objectMode: true,
370
      transform(row: EventRow, _encoding, callback) {
371
        exported += 1
6✔
372
        if (exported % 10000 === 0) {
6!
373
          console.log(`Exported ${exported} events...`)
×
374
        }
375

376
        const line = JSON.stringify(toEvent(row)) + '\n'
6✔
377
        rawBytes += Buffer.byteLength(line)
6✔
378
        callback(null, line)
6✔
379
      },
380
    })
381

382
    await pipeline(dbStream, toJsonLine, compressionStream, output, {
2✔
383
      signal: abortController.signal,
384
    })
385

386
    const elapsedMs = Date.now() - startedAt
2✔
387
    const outputBytes = output.bytesWritten
2✔
388
    const compressionDelta = formatCompressionDelta(rawBytes, outputBytes)
2✔
389
    const eventRate = getRatePerSecond(exported, elapsedMs)
2✔
390
    const rawRate = getRatePerSecond(rawBytes, elapsedMs)
2✔
391
    const outputRate = getRatePerSecond(outputBytes, elapsedMs)
2✔
392

393
    console.log(`Export complete: ${exported} events written to ${outputPath}`)
2✔
394
    if (compressionDelta) {
2!
395
      console.log(`Size: ${formatBytes(rawBytes)} raw -> ${formatBytes(outputBytes)} on disk (${compressionDelta})`)
2✔
396
    } else {
397
      console.log(`Size: ${formatBytes(outputBytes)} on disk`)
×
398
    }
399

400
    console.log(
2✔
401
      `Throughput: ${formatCount(eventRate)} events/s | ${formatBytes(rawRate)}/s raw | ${formatBytes(outputRate)}/s output`,
402
    )
403

404
    return 0
2✔
405
  } catch (error) {
406
    if (abortController.signal.aborted) {
×
407
      console.log(`Export interrupted by ${interruptedBySignal ?? 'signal'}.`)
×
408
      process.exitCode = 130
×
NEW
409
      return 130
×
410
    }
411

412
    throw error
×
413
  } finally {
414
    process.off('SIGINT', onSignal).off('SIGTERM', onSignal)
4✔
415

416
    await db.destroy()
4✔
417
  }
418
}
419

420
if (require.main === module) {
4✔
421
  runExportEvents().catch((error) => {
2✔
422
    console.error('Export failed:', error.message)
×
423
    process.exit(1)
×
424
  })
425
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc