@@ -10,6 +10,8 @@ import { Process } from "../../util/process"
1010import { EOL } from "os"
1111import path from "path"
1212import { which } from "../../util/which"
13+ import { Database , sql , eq , or , lt , isNull , not , and , desc } from "../../storage/db"
14+ import { SessionTable } from "../../session/session.sql"
1315
1416function pagerCmd ( ) : string [ ] {
1517 const lessOptions = [ "-R" , "-S" ]
@@ -41,7 +43,15 @@ function pagerCmd(): string[] {
4143export const SessionCommand = cmd ( {
4244 command : "session" ,
4345 describe : "manage sessions" ,
44- builder : ( yargs : Argv ) => yargs . command ( SessionListCommand ) . command ( SessionDeleteCommand ) . demandCommand ( ) ,
46+ builder : ( yargs : Argv ) =>
47+ yargs
48+ . command ( SessionListCommand )
49+ . command ( SessionDeleteCommand )
50+ . command ( SessionArchiveCommand )
51+ . command ( SessionUnarchiveCommand )
52+ . command ( SessionStatsCommand )
53+ . command ( SessionPruneCommand )
54+ . demandCommand ( ) ,
4555 async handler ( ) { } ,
4656} )
4757
@@ -155,3 +165,230 @@ function formatSessionJSON(sessions: Session.Info[]): string {
155165 } ) )
156166 return JSON . stringify ( jsonData , null , 2 )
157167}
168+
169+ export const SessionArchiveCommand = cmd ( {
170+ command : "archive <sessionID>" ,
171+ describe : "archive a session" ,
172+ builder : ( yargs : Argv ) => {
173+ return yargs . positional ( "sessionID" , {
174+ describe : "session ID to archive" ,
175+ type : "string" ,
176+ demandOption : true ,
177+ } )
178+ } ,
179+ handler : async ( args ) => {
180+ await bootstrap ( process . cwd ( ) , async ( ) => {
181+ try {
182+ await Session . get ( args . sessionID )
183+ } catch {
184+ UI . error ( `Session not found: ${ args . sessionID } ` )
185+ process . exit ( 1 )
186+ }
187+ await Session . setArchived ( { sessionID : args . sessionID , time : Date . now ( ) } )
188+ UI . println ( UI . Style . TEXT_SUCCESS_BOLD + `Session ${ args . sessionID } archived` + UI . Style . TEXT_NORMAL )
189+ } )
190+ } ,
191+ } )
192+
193+ export const SessionUnarchiveCommand = cmd ( {
194+ command : "unarchive <sessionID>" ,
195+ describe : "unarchive a session" ,
196+ builder : ( yargs : Argv ) => {
197+ return yargs . positional ( "sessionID" , {
198+ describe : "session ID to unarchive" ,
199+ type : "string" ,
200+ demandOption : true ,
201+ } )
202+ } ,
203+ handler : async ( args ) => {
204+ await bootstrap ( process . cwd ( ) , async ( ) => {
205+ try {
206+ await Session . get ( args . sessionID )
207+ } catch {
208+ UI . error ( `Session not found: ${ args . sessionID } ` )
209+ process . exit ( 1 )
210+ }
211+ await Session . setArchived ( { sessionID : args . sessionID , time : undefined } )
212+ UI . println ( UI . Style . TEXT_SUCCESS_BOLD + `Session ${ args . sessionID } unarchived` + UI . Style . TEXT_NORMAL )
213+ } )
214+ } ,
215+ } )
216+
217+ function formatSize ( bytes : number ) : string {
218+ if ( bytes >= 1_073_741_824 ) return ( bytes / 1_073_741_824 ) . toFixed ( 1 ) + " GB"
219+ if ( bytes >= 1_048_576 ) return ( bytes / 1_048_576 ) . toFixed ( 1 ) + " MB"
220+ if ( bytes >= 1024 ) return ( bytes / 1024 ) . toFixed ( 1 ) + " KB"
221+ return bytes + " B"
222+ }
223+
224+ export const SessionStatsCommand = cmd ( {
225+ command : "stats" ,
226+ describe : "show session storage statistics" ,
227+ builder : ( yargs : Argv ) => yargs ,
228+ handler : async ( ) => {
229+ await bootstrap ( process . cwd ( ) , async ( ) => {
230+ const size = Number ( Filesystem . stat ( Database . Path ) ?. size ?? 0 )
231+ const wal = Number ( Filesystem . stat ( Database . Path + "-wal" ) ?. size ?? 0 )
232+
233+ const counts = Database . use ( ( db ) =>
234+ db
235+ . get < {
236+ roots : number
237+ children : number
238+ archived : number
239+ messages : number
240+ parts : number
241+ } > (
242+ sql `SELECT
243+ (SELECT COUNT(*) FROM session WHERE parent_id IS NULL) as roots,
244+ (SELECT COUNT(*) FROM session WHERE parent_id IS NOT NULL) as children,
245+ (SELECT COUNT(*) FROM session WHERE time_archived IS NOT NULL) as archived,
246+ (SELECT COUNT(*) FROM message) as messages,
247+ (SELECT COUNT(*) FROM part) as parts` ,
248+ )
249+ )
250+
251+ const r = counts ?. roots ?? 0
252+ const c = counts ?. children ?? 0
253+
254+ console . log ( "Session Storage Statistics" )
255+ console . log ( "─" . repeat ( 40 ) )
256+ console . log ( `Database size: ${ formatSize ( size ) } ` )
257+ if ( wal > 0 ) console . log ( `WAL file size: ${ formatSize ( wal ) } ` )
258+ console . log ( `Total sessions: ${ r + c } (${ r } root, ${ c } child)` )
259+ console . log ( `Archived sessions: ${ counts ?. archived ?? 0 } ` )
260+ console . log ( `Total messages: ${ ( counts ?. messages ?? 0 ) . toLocaleString ( ) } ` )
261+ console . log ( `Total parts: ${ ( counts ?. parts ?? 0 ) . toLocaleString ( ) } ` )
262+ } )
263+ } ,
264+ } )
265+
266+ export const SessionPruneCommand = cmd ( {
267+ command : "prune" ,
268+ describe : "delete old and archived sessions to reclaim storage" ,
269+ builder : ( yargs : Argv ) =>
270+ yargs
271+ . option ( "older-than" , {
272+ describe : "prune sessions inactive for N days (default: 30)" ,
273+ type : "number" ,
274+ default : 30 ,
275+ } )
276+ . option ( "children" , {
277+ describe : "also prune child sessions independently" ,
278+ type : "boolean" ,
279+ default : false ,
280+ } )
281+ . option ( "vacuum" , {
282+ describe : "run VACUUM after pruning" ,
283+ type : "boolean" ,
284+ default : false ,
285+ } )
286+ . option ( "dry-run" , {
287+ describe : "show what would be pruned without deleting" ,
288+ type : "boolean" ,
289+ default : false ,
290+ } ) ,
291+ handler : async ( args ) => {
292+ await bootstrap ( process . cwd ( ) , async ( ) => {
293+ const cutoff = Date . now ( ) - args . olderThan * 86_400_000
294+ const BATCH = 100
295+ const candidates : { id : string ; title : string ; archived : boolean ; parent : boolean } [ ] = [ ]
296+
297+ // paginate through all prunable sessions in batches
298+ let offset = 0
299+ while ( true ) {
300+ const rows = Database . use ( ( db ) => {
301+ const conditions = [
302+ or (
303+ not ( isNull ( SessionTable . time_archived ) ) ,
304+ lt ( SessionTable . time_updated , cutoff ) ,
305+ ) ,
306+ ]
307+ if ( ! args . children ) {
308+ conditions . push ( isNull ( SessionTable . parent_id ) )
309+ }
310+ return db
311+ . select ( {
312+ id : SessionTable . id ,
313+ title : SessionTable . title ,
314+ time_archived : SessionTable . time_archived ,
315+ parent_id : SessionTable . parent_id ,
316+ } )
317+ . from ( SessionTable )
318+ . where ( and ( ...conditions ) )
319+ . orderBy ( desc ( SessionTable . time_updated ) , desc ( SessionTable . id ) )
320+ . limit ( BATCH )
321+ . offset ( offset )
322+ . all ( )
323+ } )
324+ if ( rows . length === 0 ) break
325+ for ( const row of rows ) {
326+ candidates . push ( {
327+ id : row . id ,
328+ title : row . title ,
329+ archived : row . time_archived !== null ,
330+ parent : row . parent_id === null ,
331+ } )
332+ }
333+ if ( rows . length < BATCH ) break
334+ offset += BATCH
335+ }
336+
337+ if ( candidates . length === 0 ) {
338+ UI . println ( "No sessions to prune" )
339+ return
340+ }
341+
342+ // sort roots before children to avoid double-delete
343+ candidates . sort ( ( a , b ) => ( a . parent === b . parent ? 0 : a . parent ? - 1 : 1 ) )
344+
345+ if ( args . dryRun ) {
346+ UI . println ( `Would prune ${ candidates . length } session(s):` )
347+ for ( const s of candidates ) {
348+ const tag = s . archived ? " [archived]" : ""
349+ UI . println ( ` ${ s . id } ${ Locale . truncate ( s . title , 40 ) } ${ tag } ` )
350+ }
351+ return
352+ }
353+
354+ const before = Number ( Filesystem . stat ( Database . Path ) ?. size ?? 0 )
355+ const deleted = new Set < string > ( )
356+ for ( const s of candidates ) {
357+ if ( deleted . has ( s . id ) ) continue
358+ const descendants = collectDescendants ( s . id )
359+ await Session . remove ( s . id )
360+ deleted . add ( s . id )
361+ for ( const d of descendants ) deleted . add ( d )
362+ }
363+ UI . println ( UI . Style . TEXT_SUCCESS_BOLD + `Pruned ${ deleted . size } session(s)` + UI . Style . TEXT_NORMAL )
364+
365+ if ( args . vacuum ) {
366+ try {
367+ Database . vacuum ( )
368+ const after = Number ( Filesystem . stat ( Database . Path ) ?. size ?? 0 )
369+ const freed = before - after
370+ if ( freed > 0 ) UI . println ( `Reclaimed ${ formatSize ( freed ) } ` )
371+ else UI . println ( "Database vacuumed" )
372+ } catch {
373+ UI . error ( "Database is busy or locked — try again when no sessions are active" )
374+ }
375+ }
376+ } )
377+ } ,
378+ } )
379+
380+ function collectDescendants ( id : string ) : string [ ] {
381+ const rows = Database . use ( ( db ) =>
382+ db
383+ . select ( { id : SessionTable . id } )
384+ . from ( SessionTable )
385+ . where ( eq ( SessionTable . parent_id , id ) )
386+ . all ( ) ,
387+ )
388+ const result : string [ ] = [ ]
389+ for ( const row of rows ) {
390+ result . push ( row . id )
391+ result . push ( ...collectDescendants ( row . id ) )
392+ }
393+ return result
394+ }
0 commit comments