Skip to content
Merged
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
26 changes: 22 additions & 4 deletions src/lib/import/rollback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
* Rollback manager for bulk import operations.
*
* Callers register compensating actions for each successfully persisted record.
* On failure, `rollback()` executes them in reverse order (LIFO), ensuring
* partial writes are cleaned up correctly.
*
* On failure, `rollback()` executes them in reverse order (LIFO).
*
* Rollback is all-or-nothing at the queue level: if every action succeeds,
* the queue is cleared. If any action fails, the failed actions remain
* registered (in their original order) so a subsequent `rollback()` call
* can retry them — `size` will be non-zero until rollback fully completes.
*
* Usage:
*
Expand All @@ -17,6 +22,9 @@
* if (shouldRollback) {
* const result = await rb.rollback();
* console.log(result.errors); // any rollback failures
* if (!result.complete) {
+ * // rb.size > 0 — call rb.rollback() again to retry the remaining actions
+ * }
* }
*/

Expand All @@ -32,6 +40,8 @@ export interface RollbackResult {
rolledBack: number;
/** Errors that occurred during rollback (non-fatal). */
errors: Array<{ label: string; error: string }>;
/** True only if every registered action succeeded and the queue is now empty. */
complete: boolean;
}

export interface RollbackManager {
Expand All @@ -56,6 +66,7 @@ export function createRollbackManager(): RollbackManager {
async rollback(): Promise<RollbackResult> {
let rolledBack = 0;
const errors: RollbackResult['errors'] = [];
const remaining: RollbackAction[] = [];

// Execute in reverse insertion order
for (let i = actions.length - 1; i >= 0; i--) {
Expand All @@ -65,12 +76,19 @@ export function createRollbackManager(): RollbackManager {
rolledBack++;
} catch (e: unknown) {
errors.push({ label, error: e instanceof Error ? e.message : String(e) });
// Keep the failed action registered (in original relative order)
// so a retry only re-attempts what didn't succeed.
remaining.unshift({ fn, label });
}
}

// Clear after rollback attempt regardless of partial failures
// All-or-nothing at the queue level: only end up empty if every
// action succeeded. On partial failure, the failed actions stay
// queued for a subsequent retry.
actions.length = 0;
return { rolledBack, errors };
actions.push(...remaining);

return { rolledBack, errors, complete: errors.length === 0 };
},

clear() {
Expand Down
Loading