@@ -582,6 +582,9 @@ const IMPORT_JOB_OBJECT = 'sys_import_job';
582582const IMPORT_JOB_MAX_ROWS = 50_000 ;
583583/** Cap on per-row results persisted on the job (failures first). */
584584const IMPORT_JOB_RESULTS_CAP = 500 ;
585+ /** Undo (logical rollback) is only recorded for jobs at or under this row
586+ * count — larger jobs skip the undo log to bound the stored before-snapshots. */
587+ const IMPORT_JOB_UNDO_MAX_ROWS = 5_000 ;
585588
586589/** Generate a sortable-ish, collision-resistant import job id. */
587590function newImportJobId ( ) : string {
@@ -597,11 +600,36 @@ function capImportResults(results: Array<{ ok: boolean }>): { items: any[]; trun
597600 return { items, truncated : true } ;
598601}
599602
603+ /** Parse the persisted undo log (json column may arrive as object or string). */
604+ function parseUndoLog ( raw : any ) : { created : string [ ] ; updated : Array < { id : string ; before : Record < string , any > } > } | undefined {
605+ if ( ! raw ) return undefined ;
606+ let v = raw ;
607+ if ( typeof v === 'string' ) { try { v = JSON . parse ( v ) ; } catch { return undefined ; } }
608+ if ( ! v || typeof v !== 'object' ) return undefined ;
609+ const created = Array . isArray ( v . created ) ? v . created . map ( String ) : [ ] ;
610+ const updated = Array . isArray ( v . updated )
611+ ? v . updated . filter ( ( u : any ) => u && u . id != null ) . map ( ( u : any ) => ( { id : String ( u . id ) , before : u . before ?? { } } ) )
612+ : [ ] ;
613+ return { created, updated } ;
614+ }
615+
616+ /** True when a job can still be undone: it wrote data (undo log present with
617+ * entries), hasn't already been reverted, and finished in a terminal state. */
618+ function importJobUndoable ( row : any ) : boolean {
619+ if ( row ?. reverted_at ) return false ;
620+ const status = String ( row ?. status ?? '' ) ;
621+ if ( status !== 'succeeded' && status !== 'cancelled' ) return false ;
622+ const log = parseUndoLog ( row ?. undo_log ) ;
623+ return ! ! log && ( log . created . length > 0 || log . updated . length > 0 ) ;
624+ }
625+
600626/** Map a persisted `sys_import_job` row to the ImportJobProgress DTO. */
601627function importJobToProgress ( row : any ) : Record < string , any > {
602628 const total = Number ( row ?. total_rows ?? 0 ) ;
603629 const processed = Number ( row ?. processed_rows ?? 0 ) ;
604630 return {
631+ undoable : importJobUndoable ( row ) ,
632+ ...( row ?. reverted_at ? { revertedAt : String ( row . reverted_at ) } : { } ) ,
605633 jobId : String ( row ?. id ?? '' ) ,
606634 object : String ( row ?. object_name ?? '' ) ,
607635 status : String ( row ?. status ?? 'pending' ) ,
@@ -629,7 +657,9 @@ function importJobToSummary(row: any): Record<string, any> {
629657 total : p . total , processed : p . processed ,
630658 created : p . created , updated : p . updated , skipped : p . skipped , errors : p . errors ,
631659 createdAt : p . createdAt ,
660+ undoable : p . undoable ,
632661 ...( p . completedAt ? { completedAt : p . completedAt } : { } ) ,
662+ ...( p . revertedAt ? { revertedAt : p . revertedAt } : { } ) ,
633663 } ;
634664}
635665
@@ -3583,11 +3613,15 @@ export class RestServer {
35833613 logError ( '[REST] import job progress write failed:' , err ) ;
35843614 }
35853615 } ;
3616+ // Record undo instructions for small non-dry-run jobs so the
3617+ // import can be logically rolled back later.
3618+ const captureUndo = ! prepared . dryRun && prepared . rows . length <= IMPORT_JOB_UNDO_MAX_ROWS ;
35863619 void ( async ( ) => {
35873620 await patch ( { status : 'running' , started_at : new Date ( ) . toISOString ( ) } ) ;
35883621 try {
35893622 const summary = await runImport ( {
35903623 p, objectName, environmentId, context, ...prepared ,
3624+ captureUndo,
35913625 progressEvery : 200 ,
35923626 onProgress : ( pr ) => patch ( {
35933627 processed_rows : pr . processed ,
@@ -3607,6 +3641,7 @@ export class RestServer {
36073641 error_count : summary . errors ,
36083642 results : capImportResults ( summary . results ) ,
36093643 completed_at : new Date ( ) . toISOString ( ) ,
3644+ ...( summary . undoLog ? { undo_log : summary . undoLog } : { } ) ,
36103645 } ) ;
36113646 } catch ( err : any ) {
36123647 await patch ( {
@@ -3677,6 +3712,67 @@ export class RestServer {
36773712 metadata : { summary : 'Cancel an in-flight import job' , tags : [ 'data' , 'import' ] } ,
36783713 } ) ;
36793714
3715+ // POST /data/import/jobs/:jobId/undo — logical rollback of a finished
3716+ // job: delete the records it created and restore the fields it updated
3717+ // to their pre-import values (from the captured undo log).
3718+ this . routeManager . register ( {
3719+ method : 'POST' ,
3720+ path : `${ dataPath } /import/jobs/:jobId/undo` ,
3721+ handler : async ( req : any , res : any ) => {
3722+ try {
3723+ const environmentId = isScoped ? req . params ?. environmentId : undefined ;
3724+ const p = await this . resolveProtocol ( environmentId , req ) ;
3725+ const context = await this . resolveExecCtx ( environmentId , req ) ;
3726+ if ( this . enforceAuth ( req , res , context ) ) return ;
3727+ const jobId = String ( req . params . jobId || '' ) ;
3728+ const row = await loadImportJob ( p , jobId , environmentId , context ) ;
3729+ if ( ! row ) {
3730+ res . status ( 404 ) . json ( { code : 'NOT_FOUND' , error : `No import job ${ jobId } ` } ) ;
3731+ return ;
3732+ }
3733+ if ( row . reverted_at ) {
3734+ res . status ( 409 ) . json ( { code : 'ALREADY_REVERTED' , error : 'This import has already been undone' } ) ;
3735+ return ;
3736+ }
3737+ if ( ! importJobUndoable ( row ) ) {
3738+ res . status ( 422 ) . json ( { code : 'NOT_UNDOABLE' , error : 'This import cannot be undone (too large, still running, or nothing was written)' } ) ;
3739+ return ;
3740+ }
3741+ const objectName = String ( row . object_name ?? '' ) ;
3742+ const log = parseUndoLog ( row . undo_log ) ! ;
3743+ // Undo automations too: reversing writes shouldn't re-fire triggers.
3744+ const writeCtx = { ...( context ?? { } ) , skipAutomations : true } ;
3745+ let deleted = 0 , restored = 0 , failed = 0 ;
3746+
3747+ // Delete created records first (they didn't exist before).
3748+ for ( const id of log . created ) {
3749+ try {
3750+ await ( p as any ) . deleteData ( { object : objectName , id, context : writeCtx , ...( environmentId ? { environmentId } : { } ) } ) ;
3751+ deleted ++ ;
3752+ } catch { failed ++ ; }
3753+ }
3754+ // Restore the touched fields on updated records.
3755+ for ( const u of log . updated ) {
3756+ try {
3757+ await ( p as any ) . updateData ( { object : objectName , id : u . id , data : u . before , context : writeCtx , ...( environmentId ? { environmentId } : { } ) } ) ;
3758+ restored ++ ;
3759+ } catch { failed ++ ; }
3760+ }
3761+
3762+ await ( p as any ) . updateData ( {
3763+ object : IMPORT_JOB_OBJECT , id : jobId ,
3764+ data : { reverted_at : new Date ( ) . toISOString ( ) } ,
3765+ context, ...( environmentId ? { environmentId } : { } ) ,
3766+ } ) ;
3767+ res . json ( { success : true , jobId, object : objectName , deleted, restored, failed } ) ;
3768+ } catch ( error : any ) {
3769+ logError ( '[REST] Unhandled error:' , error ) ;
3770+ sendError ( res , error , '' ) ;
3771+ }
3772+ } ,
3773+ metadata : { summary : 'Undo (logically roll back) a finished import job' , tags : [ 'data' , 'import' ] } ,
3774+ } ) ;
3775+
36803776 // GET /data/import/jobs/:jobId/results — progress + capped per-row report.
36813777 this . routeManager . register ( {
36823778 method : 'GET' ,
0 commit comments