Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,43 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep
open .coverage/integration/lcov-report/index.html
```

## Relay Maintenance

Use `clean-db` to wipe or prune only the `events` table data.
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says clean-db wipes/prunes "only the events table data", but the implementation also removes data from the derived event_tags table (TRUNCATE includes it when present, and selective deletes trigger cleanup). Please clarify the docs to mention event_tags is also affected so operators don’t assume tags remain intact.

Suggested change
Use `clean-db` to wipe or prune only the `events` table data.
Use `clean-db` to wipe or prune `events` table data. This also removes corresponding data from the derived `event_tags` table when present.

Copilot uses AI. Check for mistakes.

Dry run (no deletion):

```
npm run clean-db -- --all --dry-run
```

Full wipe:

```
npm run clean-db -- --all --force
```

Delete events older than N days:

```
npm run clean-db -- --older-than=30 --force
```

Delete only selected kinds:

```
npm run clean-db -- --kinds=1,7,4 --force
```

Delete only selected kinds older than N days:

```
npm run clean-db -- --older-than=30 --kinds=1,7,4 --force
```

By default, the script asks for explicit confirmation (`Type 'DELETE' to confirm`).
Use `--force` to skip the prompt.

## Configuration

You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"main": "src/index.ts",
"scripts": {
"dev": "node -r ts-node/register src/index.ts",
"clean-db": "node -r ts-node/register src/clean-db.ts",
"clean": "rimraf ./{dist,.nyc_output,.test-reports,.coverage}",
"build": "tsc --project tsconfig.build.json",
"prestart": "npm run build",
Expand Down
271 changes: 271 additions & 0 deletions src/clean-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { createInterface } from 'readline'
import dotenv from 'dotenv'
import { Knex } from 'knex'

import { getMasterDbClient } from './database/client'

dotenv.config()

type CleanDbOptions = {
all: boolean
dryRun: boolean
force: boolean
help: boolean
kinds: number[]
olderThanDays?: number
}

const HELP_TEXT = [
'Usage: npm run clean-db -- [options]',
'',
'Options:',
' --all Delete all events.',
' --older-than=<days> Delete events older than the given number of days.',
' --kinds=<1,7,4> Delete events for specific kinds.',
' --dry-run Show how many rows would be deleted without deleting them.',
' --force Skip interactive confirmation prompt.',
' --help Show this help message.',
'',
'Examples:',
' npm run clean-db -- --all --dry-run',
' npm run clean-db -- --all --force',
' npm run clean-db -- --older-than=30 --force',
' npm run clean-db -- --older-than=30 --kinds=1,7,4 --dry-run',
].join('\n')

const getOptionValue = (arg: string, args: string[], index: number): [string, number] => {
const [option, inlineValue] = arg.split('=')

if (inlineValue !== undefined) {
if (!inlineValue.trim()) {
throw new Error(`Missing value for ${option}`)
}

return [inlineValue, index]
}

const nextIndex = index + 1
const nextArg = args[nextIndex]

if (!nextArg || nextArg.startsWith('--')) {
throw new Error(`Missing value for ${option}`)
}

return [nextArg, nextIndex]
}

const parseOlderThanDays = (value: string): number => {
if (!/^\d+$/.test(value)) {
throw new Error('--older-than must be a positive integer')
}

const days = Number(value)
if (!Number.isSafeInteger(days) || days <= 0) {
throw new Error('--older-than must be a positive integer')
}

return days
}

const parseKinds = (value: string): number[] => {
const parts = value
.split(',')
.map((kind) => kind.trim())
.filter(Boolean)

if (!parts.length) {
throw new Error('--kinds requires at least one kind')
}

const kinds = parts.map((kind) => {
if (!/^\d+$/.test(kind)) {
throw new Error('--kinds must be a comma-separated list of non-negative integers')
}

const parsed = Number(kind)
if (!Number.isSafeInteger(parsed)) {
throw new Error('--kinds must contain valid integers')
}

return parsed
})

return Array.from(new Set(kinds))
}

export const parseCleanDbOptions = (args: string[]): CleanDbOptions => {
const options: CleanDbOptions = {
all: false,
dryRun: false,
force: false,
help: false,
kinds: [],
}

for (let index = 0; index < args.length; index++) {
const arg = args[index]

if (arg === '--all') {
options.all = true
continue
}

if (arg === '--dry-run') {
options.dryRun = true
continue
}

if (arg === '--force') {
options.force = true
continue
}

if (arg === '--help' || arg === '-h') {
options.help = true
continue
}

if (arg.startsWith('--older-than')) {
const [value, nextIndex] = getOptionValue(arg, args, index)
options.olderThanDays = parseOlderThanDays(value)
index = nextIndex
continue
}

if (arg.startsWith('--kinds')) {
const [value, nextIndex] = getOptionValue(arg, args, index)
options.kinds = parseKinds(value)
index = nextIndex
continue
}
Comment on lines +128 to +140
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseCleanDbOptions matches --older-than / --kinds using startsWith, which means options like --older-than-days or --kinds-extra will be treated as valid and parsed instead of raising Unknown option. Tighten the check to only accept the exact flag (--older-than/--kinds) or an inline-value form (--older-than=/--kinds=).

Copilot uses AI. Check for mistakes.

throw new Error(`Unknown option: ${arg}`)
}

if (options.help) {
return options
}

if (!options.all && options.olderThanDays === undefined && !options.kinds.length) {
throw new Error('Select a target with --all, --older-than, or --kinds')
}

if (options.all && (options.olderThanDays !== undefined || options.kinds.length)) {
throw new Error('--all cannot be combined with --older-than or --kinds')
}

return options
}

const applySelectiveFilters = (query: Knex.QueryBuilder, options: CleanDbOptions): Knex.QueryBuilder => {
if (options.olderThanDays !== undefined) {
const olderThanSeconds = options.olderThanDays * 24 * 60 * 60
const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds
query.where('event_created_at', '<', cutoff)
}

if (options.kinds.length) {
query.whereIn('event_kind', options.kinds)
}

return query
}

const getMatchingEventsCount = async (dbClient: Knex, options: CleanDbOptions): Promise<number> => {
const query = dbClient('events')

if (!options.all) {
applySelectiveFilters(query, options)
}

const result = await query.count<{ count: string | number }>('* as count').first()
return Number(result?.count ?? 0)
}

const askForConfirmation = async (): Promise<boolean> => {
const readline = createInterface({
input: process.stdin,
output: process.stdout,
})

const answer = await new Promise<string>((resolve) => {
readline.question("Type 'DELETE' to confirm: ", (input) => resolve(input))
})

readline.close()
return answer.trim() === 'DELETE'
}

const runAllDelete = async (dbClient: Knex): Promise<void> => {
const hasEventTagsTable = await dbClient.schema.hasTable('event_tags')
if (hasEventTagsTable) {
await dbClient.raw('TRUNCATE TABLE events, event_tags RESTART IDENTITY CASCADE;')
return
}

await dbClient.raw('TRUNCATE TABLE events RESTART IDENTITY CASCADE;')
}

const runSelectiveDelete = async (dbClient: Knex, options: CleanDbOptions): Promise<number> => {
const deleteQuery = applySelectiveFilters(dbClient('events'), options)
const deletedRows = await deleteQuery.del()
await dbClient.raw('VACUUM ANALYZE events;')
return Number(deletedRows)
}

export const runCleanDb = async (args: string[] = process.argv.slice(2)): Promise<number> => {
const options = parseCleanDbOptions(args)

if (options.help) {
console.log(HELP_TEXT)
return 0
}

if (process.env.NODE_ENV === 'production') {
console.warn('WARNING: NODE_ENV=production detected. This operation permanently deletes data.')
}

const dbClient = getMasterDbClient()

try {
if (options.dryRun) {
const matchingEvents = await getMatchingEventsCount(dbClient, options)
console.log(`Dry run: ${matchingEvents} events would be deleted.`)
return 0
}

if (!options.force) {
if (!process.stdin.isTTY) {
throw new Error('Interactive confirmation is unavailable. Re-run with --force.')
}

const confirmed = await askForConfirmation()
if (!confirmed) {
console.log('Aborted. Confirmation text did not match DELETE.')
return 1
}
}

if (options.all) {
await runAllDelete(dbClient)
console.log('Deleted all events with TRUNCATE.')
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --all is used and event_tags exists, runAllDelete truncates both events and event_tags, but the user-facing message says "Deleted all events with TRUNCATE." Consider updating the output to reflect both tables (or otherwise make the scope explicit) to avoid misleading operators.

Suggested change
console.log('Deleted all events with TRUNCATE.')
console.log('Deleted all rows from events and event_tags with TRUNCATE.')

Copilot uses AI. Check for mistakes.
return 0
}

const deletedRows = await runSelectiveDelete(dbClient, options)
console.log(`Deleted ${deletedRows} events. VACUUM ANALYZE completed.`)
return 0
} finally {
await dbClient.destroy()
}
}

if (require.main === module) {
runCleanDb().then((exitCode) => {
process.exitCode = exitCode
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
console.error(message)
process.exitCode = 1
})
}
Loading
Loading