Skip to content

Commit 678cfe5

Browse files
Priyanshubhartistmgithub-manager
authored andcommitted
feat: wipe events table script (#450)
1 parent 28022cb commit 678cfe5

4 files changed

Lines changed: 419 additions & 0 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,44 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep
570570
open .coverage/integration/lcov-report/index.html
571571
```
572572
573+
## Relay Maintenance
574+
575+
Use `clean-db` to wipe or prune `events` table data. This also removes
576+
corresponding data from the derived `event_tags` table when present.
577+
578+
Dry run (no deletion):
579+
580+
```
581+
npm run clean-db -- --all --dry-run
582+
```
583+
584+
Full wipe:
585+
586+
```
587+
npm run clean-db -- --all --force
588+
```
589+
590+
Delete events older than N days:
591+
592+
```
593+
npm run clean-db -- --older-than=30 --force
594+
```
595+
596+
Delete only selected kinds:
597+
598+
```
599+
npm run clean-db -- --kinds=1,7,4 --force
600+
```
601+
602+
Delete only selected kinds older than N days:
603+
604+
```
605+
npm run clean-db -- --older-than=30 --kinds=1,7,4 --force
606+
```
607+
608+
By default, the script asks for explicit confirmation (`Type 'DELETE' to confirm`).
609+
Use `--force` to skip the prompt.
610+
573611
## Configuration
574612
575613
You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"main": "src/index.ts",
2626
"scripts": {
2727
"dev": "node -r ts-node/register src/index.ts",
28+
"clean-db": "node -r ts-node/register src/clean-db.ts",
2829
"clean": "rimraf ./{dist,.nyc_output,.test-reports,.coverage}",
2930
"build": "tsc --project tsconfig.build.json",
3031
"prestart": "npm run build",

src/clean-db.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { createInterface } from 'readline'
2+
import dotenv from 'dotenv'
3+
import { Knex } from 'knex'
4+
5+
import { getMasterDbClient } from './database/client'
6+
7+
dotenv.config()
8+
9+
type CleanDbOptions = {
10+
all: boolean
11+
dryRun: boolean
12+
force: boolean
13+
help: boolean
14+
kinds: number[]
15+
olderThanDays?: number
16+
}
17+
18+
const HELP_TEXT = [
19+
'Usage: npm run clean-db -- [options]',
20+
'',
21+
'Options:',
22+
' --all Delete all events.',
23+
' --older-than=<days> Delete events older than the given number of days.',
24+
' --kinds=<1,7,4> Delete events for specific kinds.',
25+
' --dry-run Show how many rows would be deleted without deleting them.',
26+
' --force Skip interactive confirmation prompt.',
27+
' --help Show this help message.',
28+
'',
29+
'Examples:',
30+
' npm run clean-db -- --all --dry-run',
31+
' npm run clean-db -- --all --force',
32+
' npm run clean-db -- --older-than=30 --force',
33+
' npm run clean-db -- --older-than=30 --kinds=1,7,4 --dry-run',
34+
].join('\n')
35+
36+
const getOptionValue = (arg: string, args: string[], index: number): [string, number] => {
37+
const [option, inlineValue] = arg.split('=')
38+
39+
if (inlineValue !== undefined) {
40+
if (!inlineValue.trim()) {
41+
throw new Error(`Missing value for ${option}`)
42+
}
43+
44+
return [inlineValue, index]
45+
}
46+
47+
const nextIndex = index + 1
48+
const nextArg = args[nextIndex]
49+
50+
if (!nextArg || nextArg.startsWith('--')) {
51+
throw new Error(`Missing value for ${option}`)
52+
}
53+
54+
return [nextArg, nextIndex]
55+
}
56+
57+
const parseOlderThanDays = (value: string): number => {
58+
if (!/^\d+$/.test(value)) {
59+
throw new Error('--older-than must be a positive integer')
60+
}
61+
62+
const days = Number(value)
63+
if (!Number.isSafeInteger(days) || days <= 0) {
64+
throw new Error('--older-than must be a positive integer')
65+
}
66+
67+
return days
68+
}
69+
70+
const parseKinds = (value: string): number[] => {
71+
const parts = value
72+
.split(',')
73+
.map((kind) => kind.trim())
74+
.filter(Boolean)
75+
76+
if (!parts.length) {
77+
throw new Error('--kinds requires at least one kind')
78+
}
79+
80+
const kinds = parts.map((kind) => {
81+
if (!/^\d+$/.test(kind)) {
82+
throw new Error('--kinds must be a comma-separated list of non-negative integers')
83+
}
84+
85+
const parsed = Number(kind)
86+
if (!Number.isSafeInteger(parsed)) {
87+
throw new Error('--kinds must contain valid integers')
88+
}
89+
90+
return parsed
91+
})
92+
93+
return Array.from(new Set(kinds))
94+
}
95+
96+
const matchesOption = (arg: string, option: string): boolean => {
97+
return arg === option || arg.startsWith(`${option}=`)
98+
}
99+
100+
export const parseCleanDbOptions = (args: string[]): CleanDbOptions => {
101+
const options: CleanDbOptions = {
102+
all: false,
103+
dryRun: false,
104+
force: false,
105+
help: false,
106+
kinds: [],
107+
}
108+
109+
for (let index = 0; index < args.length; index++) {
110+
const arg = args[index]
111+
112+
if (arg === '--all') {
113+
options.all = true
114+
continue
115+
}
116+
117+
if (arg === '--dry-run') {
118+
options.dryRun = true
119+
continue
120+
}
121+
122+
if (arg === '--force') {
123+
options.force = true
124+
continue
125+
}
126+
127+
if (arg === '--help' || arg === '-h') {
128+
options.help = true
129+
continue
130+
}
131+
132+
if (matchesOption(arg, '--older-than')) {
133+
const [value, nextIndex] = getOptionValue(arg, args, index)
134+
options.olderThanDays = parseOlderThanDays(value)
135+
index = nextIndex
136+
continue
137+
}
138+
139+
if (matchesOption(arg, '--kinds')) {
140+
const [value, nextIndex] = getOptionValue(arg, args, index)
141+
options.kinds = parseKinds(value)
142+
index = nextIndex
143+
continue
144+
}
145+
146+
throw new Error(`Unknown option: ${arg}`)
147+
}
148+
149+
if (options.help) {
150+
return options
151+
}
152+
153+
if (!options.all && options.olderThanDays === undefined && !options.kinds.length) {
154+
throw new Error('Select a target with --all, --older-than, or --kinds')
155+
}
156+
157+
if (options.all && (options.olderThanDays !== undefined || options.kinds.length)) {
158+
throw new Error('--all cannot be combined with --older-than or --kinds')
159+
}
160+
161+
return options
162+
}
163+
164+
const applySelectiveFilters = (query: Knex.QueryBuilder, options: CleanDbOptions): Knex.QueryBuilder => {
165+
if (options.olderThanDays !== undefined) {
166+
const olderThanSeconds = options.olderThanDays * 24 * 60 * 60
167+
const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds
168+
query.where('event_created_at', '<', cutoff)
169+
}
170+
171+
if (options.kinds.length) {
172+
query.whereIn('event_kind', options.kinds)
173+
}
174+
175+
return query
176+
}
177+
178+
const getMatchingEventsCount = async (dbClient: Knex, options: CleanDbOptions): Promise<number> => {
179+
const query = dbClient('events')
180+
181+
if (!options.all) {
182+
applySelectiveFilters(query, options)
183+
}
184+
185+
const result = await query.count<{ count: string | number }>('* as count').first()
186+
return Number(result?.count ?? 0)
187+
}
188+
189+
const askForConfirmation = async (): Promise<boolean> => {
190+
const readline = createInterface({
191+
input: process.stdin,
192+
output: process.stdout,
193+
})
194+
195+
const answer = await new Promise<string>((resolve) => {
196+
readline.question("Type 'DELETE' to confirm: ", (input) => resolve(input))
197+
})
198+
199+
readline.close()
200+
return answer.trim() === 'DELETE'
201+
}
202+
203+
const runAllDelete = async (dbClient: Knex): Promise<boolean> => {
204+
const hasEventTagsTable = await dbClient.schema.hasTable('event_tags')
205+
if (hasEventTagsTable) {
206+
await dbClient.raw('TRUNCATE TABLE events, event_tags RESTART IDENTITY CASCADE;')
207+
return true
208+
}
209+
210+
await dbClient.raw('TRUNCATE TABLE events RESTART IDENTITY CASCADE;')
211+
return false
212+
}
213+
214+
const runSelectiveDelete = async (dbClient: Knex, options: CleanDbOptions): Promise<number> => {
215+
const deleteQuery = applySelectiveFilters(dbClient('events'), options)
216+
const deletedRows = await deleteQuery.del()
217+
await dbClient.raw('VACUUM ANALYZE events;')
218+
return Number(deletedRows)
219+
}
220+
221+
export const runCleanDb = async (args: string[] = process.argv.slice(2)): Promise<number> => {
222+
const options = parseCleanDbOptions(args)
223+
224+
if (options.help) {
225+
console.log(HELP_TEXT)
226+
return 0
227+
}
228+
229+
if (process.env.NODE_ENV === 'production') {
230+
console.warn('WARNING: NODE_ENV=production detected. This operation permanently deletes data.')
231+
}
232+
233+
const dbClient = getMasterDbClient()
234+
235+
try {
236+
if (options.dryRun) {
237+
const matchingEvents = await getMatchingEventsCount(dbClient, options)
238+
console.log(`Dry run: ${matchingEvents} events would be deleted.`)
239+
return 0
240+
}
241+
242+
if (!options.force) {
243+
if (!process.stdin.isTTY) {
244+
throw new Error('Interactive confirmation is unavailable. Re-run with --force.')
245+
}
246+
247+
const confirmed = await askForConfirmation()
248+
if (!confirmed) {
249+
console.log('Aborted. Confirmation text did not match DELETE.')
250+
return 1
251+
}
252+
}
253+
254+
if (options.all) {
255+
const deletedEventTags = await runAllDelete(dbClient)
256+
if (deletedEventTags) {
257+
console.log('Deleted all rows from events and event_tags with TRUNCATE.')
258+
} else {
259+
console.log('Deleted all events with TRUNCATE.')
260+
}
261+
return 0
262+
}
263+
264+
const deletedRows = await runSelectiveDelete(dbClient, options)
265+
console.log(`Deleted ${deletedRows} events. VACUUM ANALYZE completed.`)
266+
return 0
267+
} finally {
268+
await dbClient.destroy()
269+
}
270+
}
271+
272+
if (require.main === module) {
273+
runCleanDb().then((exitCode) => {
274+
process.exitCode = exitCode
275+
}).catch((error) => {
276+
const message = error instanceof Error ? error.message : String(error)
277+
console.error(message)
278+
process.exitCode = 1
279+
})
280+
}

0 commit comments

Comments
 (0)