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