Skip to content

Commit 4d944d9

Browse files
committed
feat(import): job-level undo (logical rollback)
Add POST /data/import/jobs/:jobId/undo — logically reverses a finished import: deletes the records it created and restores the fields it updated to their pre-import values. - import-runner: capture an ImportUndoLog (created ids + per-updated before-snapshots of only the written fields) when captureUndo is set. - sys_import_job: persist undo_log (json) + reverted_at (datetime). - rest-server: worker captures undo for non-dry-run jobs <= 5000 rows (IMPORT_JOB_UNDO_MAX_ROWS); undo route deletes/restores with automations skipped, then stamps reverted_at. undoable/revertedAt surfaced on progress + summary DTOs. - spec: undoable/revertedAt on progress+summary; UndoImportJobResponse; undoImportJob contract. - client SDK: data.undoImportJob(jobId) on both namespaces + test. - tests: end-to-end undo (delete created + restore updated), double-undo 409, unknown-job 404.
1 parent fc65eb4 commit 4d944d9

7 files changed

Lines changed: 261 additions & 3 deletions

File tree

packages/client/src/client.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,4 +1098,15 @@ describe('Import-job namespace', () => {
10981098
expect(init.method).toBe('POST');
10991099
expect(res.success).toBe(true);
11001100
});
1101+
1102+
it('undoImportJob POSTs the /undo sub-route', async () => {
1103+
const { client, fetchMock } = createMockClient({ success: true, jobId: 'imp_x', object: 'task', deleted: 3, restored: 2, failed: 0 });
1104+
const res = await client.data.undoImportJob('imp_x');
1105+
const [url, init] = fetchMock.mock.calls[0];
1106+
expect(url).toBe('http://localhost:3000/api/v1/data/import/jobs/imp_x/undo');
1107+
expect(init.method).toBe('POST');
1108+
expect(res.success).toBe(true);
1109+
expect(res.deleted).toBe(3);
1110+
expect(res.restored).toBe(2);
1111+
});
11011112
});

packages/client/src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
ImportJobSummary,
9292
ListImportJobsRequest,
9393
ListImportJobsResponse,
94+
UndoImportJobResponse,
9495
} from '@objectstack/spec/api';
9596
import type {
9697
ApprovalRequestRow,
@@ -3130,6 +3131,20 @@ export class ObjectStackClient {
31303131
return this.unwrapResponse<{ success: boolean }>(res);
31313132
},
31323133

3134+
/**
3135+
* Logically roll back a finished import: delete the records it created and
3136+
* restore the fields it updated to their pre-import values. Only jobs that
3137+
* captured an undo log (small, non-dry-run, not yet reverted) are undoable —
3138+
* others return 422. See {@link ImportJobProgress.undoable}.
3139+
*/
3140+
undoImportJob: async (jobId: string): Promise<UndoImportJobResponse> => {
3141+
const route = this.getRoute('data');
3142+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/undo`, {
3143+
method: 'POST',
3144+
});
3145+
return this.unwrapResponse<UndoImportJobResponse>(res);
3146+
},
3147+
31333148
update: async <T = any>(
31343149
object: string,
31353150
id: string,
@@ -3592,6 +3607,12 @@ export class ScopedProjectClient {
35923607
});
35933608
return this.parent._unwrap<{ success: boolean }>(res);
35943609
},
3610+
undoImportJob: async (jobId: string): Promise<UndoImportJobResponse> => {
3611+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/undo`), {
3612+
method: 'POST',
3613+
});
3614+
return this.parent._unwrap<UndoImportJobResponse>(res);
3615+
},
35953616
update: async <T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>> => {
35963617
const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), {
35973618
method: 'PATCH',
@@ -3781,6 +3802,7 @@ export type {
37813802
ImportJobSummary,
37823803
ListImportJobsRequest,
37833804
ListImportJobsResponse,
3805+
UndoImportJobResponse,
37843806
} from '@objectstack/spec/api';
37853807

37863808
// Approval runtime types (ADR-0019) — surfaced so SDK consumers can type the

packages/platform-objects/src/audit/sys-import-job.object.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ export const SysImportJob = ObjectSchema.create({
7474
group: 'Outcome',
7575
}),
7676

77+
// ── undo / logical rollback ──
78+
undo_log: Field.json({
79+
label: 'Undo Log',
80+
required: false,
81+
description: 'Reversal instructions ({created:[ids], updated:[{id,before}]}) captured for small non-dry-run jobs so the import can be undone',
82+
group: 'Outcome',
83+
}),
84+
reverted_at: Field.datetime({
85+
label: 'Reverted At',
86+
required: false,
87+
description: 'Set when the import was undone (created records deleted, updated records restored)',
88+
group: 'Outcome',
89+
}),
90+
7791
// ── lifecycle timestamps ──
7892
started_at: Field.datetime({ label: 'Started At', required: false, group: 'State' }),
7993
completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }),

packages/rest/src/import-job-integration.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async function boot() {
140140
results: find('GET', '/api/v1/data/import/jobs/:jobId/results'),
141141
list: find('GET', '/api/v1/data/import/jobs'),
142142
cancel: find('POST', '/api/v1/data/import/jobs/:jobId/cancel'),
143+
undo: find('POST', '/api/v1/data/import/jobs/:jobId/undo'),
143144
};
144145
}
145146

@@ -168,7 +169,7 @@ describe('async import job — real engine + protocol integration', () => {
168169
beforeEach(async () => {
169170
ctx = await boot();
170171
expect(ctx.create).toBeDefined();
171-
expect(ctx.progress && ctx.results && ctx.list && ctx.cancel).toBeTruthy();
172+
expect(ctx.progress && ctx.results && ctx.list && ctx.cancel && ctx.undo).toBeTruthy();
172173
});
173174

174175
it('creates a job, processes it in the background, and persists rows', async () => {
@@ -243,4 +244,56 @@ describe('async import job — real engine + protocol integration', () => {
243244
const after = await callJob(ctx.progress, jobId);
244245
expect(after._json.status).toBe('succeeded');
245246
});
247+
248+
it('undoes a job: deletes created records and restores updated ones', async () => {
249+
// Seed one existing record so an upsert both creates and updates.
250+
await ctx.protocol.createData({ object: 'task', data: { id: 'u_existing', title: 'old title', score: 1 } });
251+
252+
const created = await callCreate(ctx.create, {
253+
format: 'json', writeMode: 'upsert', matchFields: ['id'],
254+
rows: [
255+
{ id: 'u_existing', title: 'new title', score: 99 }, // update
256+
{ id: 'u_new1', title: 'fresh one' }, // create
257+
{ id: 'u_new2', title: 'fresh two' }, // create
258+
],
259+
});
260+
const jobId = created._json.jobId;
261+
const done = await waitForTerminal(ctx.progress, jobId);
262+
expect(done).toMatchObject({ status: 'succeeded', created: 2, updated: 1 });
263+
expect(done.undoable).toBe(true);
264+
265+
// Writes really landed.
266+
expect(await ctx.engine.findOne('task', { where: { id: 'u_new1' } })).toBeTruthy();
267+
expect(await ctx.engine.findOne('task', { where: { id: 'u_existing' } })).toMatchObject({ title: 'new title', score: 99 });
268+
269+
// Undo.
270+
const u = await callJob(ctx.undo, jobId);
271+
expect(u._json).toMatchObject({ success: true, deleted: 2, restored: 1, failed: 0 });
272+
273+
// Created records are gone; the updated record is back to its pre-import values.
274+
expect(await ctx.engine.findOne('task', { where: { id: 'u_new1' } })).toBeFalsy();
275+
expect(await ctx.engine.findOne('task', { where: { id: 'u_new2' } })).toBeFalsy();
276+
expect(await ctx.engine.findOne('task', { where: { id: 'u_existing' } })).toMatchObject({ title: 'old title', score: 1 });
277+
278+
// Job now flags as reverted + no longer undoable.
279+
const after = await callJob(ctx.progress, jobId);
280+
expect(after._json.undoable).toBe(false);
281+
expect(after._json.revertedAt).toBeTruthy();
282+
});
283+
284+
it('undoing twice is rejected (409 already reverted)', async () => {
285+
const created = await callCreate(ctx.create, { format: 'json', rows: [{ id: 'uu1', title: 'x' }] });
286+
const jobId = created._json.jobId;
287+
await waitForTerminal(ctx.progress, jobId);
288+
const first = await callJob(ctx.undo, jobId);
289+
expect(first._json.success).toBe(true);
290+
const second = await callJob(ctx.undo, jobId);
291+
expect(second._status).toBe(409);
292+
expect(second._json.code).toBe('ALREADY_REVERTED');
293+
});
294+
295+
it('404s undo for an unknown job id', async () => {
296+
const res = await callJob(ctx.undo, 'imp_nope');
297+
expect(res._status).toBe(404);
298+
});
246299
});

packages/rest/src/import-runner.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,25 @@ export interface ImportProgress {
3535
errors: number;
3636
}
3737

38+
/**
39+
* Records exactly what a non-dry-run import changed, so the job can be undone:
40+
* created records are deleted, and updated records have the touched fields
41+
* restored to their pre-import values. Only the fields the import wrote are
42+
* captured (keyed to `before`), keeping the log precise and bounded.
43+
*/
44+
export interface ImportUndoLog {
45+
/** Ids of records this import created (delete to undo). */
46+
created: string[];
47+
/** Per updated record: the touched fields' values *before* the import. */
48+
updated: Array<{ id: string; before: Record<string, any> }>;
49+
}
50+
3851
export interface ImportRunSummary extends ImportProgress {
3952
ok: number;
4053
results: ImportRowResult[];
4154
cancelled: boolean;
55+
/** Present only when `captureUndo` was set — the reversal instructions. */
56+
undoLog?: ImportUndoLog;
4257
}
4358

4459
/** Minimal protocol surface the runner needs (find / create / update). */
@@ -80,15 +95,31 @@ export interface RunImportOptions {
8095
* truthy the runner stops and returns `cancelled: true` with partial results.
8196
*/
8297
shouldCancel?: () => boolean | Promise<boolean>;
98+
/**
99+
* When true (and not a dry run), accumulate an {@link ImportUndoLog} so the
100+
* import can be reverted later. Callers gate this on row count to bound the
101+
* stored snapshot size.
102+
*/
103+
captureUndo?: boolean;
83104
}
84105

85106
export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
86107
const {
87108
p, objectName, environmentId, context, rows, metaMap,
88109
writeMode, matchFields, dryRun, runAutomations,
89110
trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey,
90-
onProgress, shouldCancel,
111+
onProgress, shouldCancel, captureUndo,
91112
} = opts;
113+
const collectUndo = !!captureUndo && !dryRun;
114+
const undoLog: ImportUndoLog = { created: [], updated: [] };
115+
// Snapshot only the fields the import touched, so undo restores exactly what
116+
// changed. A field absent before the import is recorded as null → undo clears
117+
// it. Never captured on dry runs (nothing was written).
118+
const captureBefore = (before: Record<string, any>, written: Record<string, any>): Record<string, any> => {
119+
const snap: Record<string, any> = {};
120+
for (const k of Object.keys(written)) snap[k] = before[k] ?? null;
121+
return snap;
122+
};
92123
const progressEvery = Math.max(1, opts.progressEvery ?? 200);
93124

94125
const findRows = (r: any): any[] =>
@@ -213,11 +244,15 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
213244
const res2 = await p.updateData({ object: objectName, id: target.id, data, context: writeCtx, ...(environmentId ? { environmentId } : {}) });
214245
const id = (res2 as any)?.id ?? (res2 as any)?.record?.id ?? target.id;
215246
okCount++; updated++;
247+
if (collectUndo && target.id != null) {
248+
undoLog.updated.push({ id: String(target.id), before: captureBefore(target, data) });
249+
}
216250
results.push({ row: rowNo, ok: true, action: 'updated', id: id != null ? String(id) : undefined });
217251
} else {
218252
const res2 = await p.createData({ object: objectName, data, context: writeCtx, ...(environmentId ? { environmentId } : {}) });
219253
const id = (res2 as any)?.id ?? (res2 as any)?.record?.id;
220254
okCount++; created++;
255+
if (collectUndo && id != null) undoLog.created.push(String(id));
221256
results.push({ row: rowNo, ok: true, action: 'created', id: id != null ? String(id) : undefined });
222257
}
223258
}
@@ -238,6 +273,9 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
238273
}
239274
}
240275

241-
return { ...snapshot(results.length), ok: okCount, results, cancelled };
276+
return {
277+
...snapshot(results.length), ok: okCount, results, cancelled,
278+
...(collectUndo ? { undoLog } : {}),
279+
};
242280
})();
243281
}

packages/rest/src/rest-server.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,9 @@ const IMPORT_JOB_OBJECT = 'sys_import_job';
582582
const IMPORT_JOB_MAX_ROWS = 50_000;
583583
/** Cap on per-row results persisted on the job (failures first). */
584584
const 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. */
587590
function 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. */
601627
function 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

Comments
 (0)