Skip to content
Merged
Show file tree
Hide file tree
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
17 changes: 17 additions & 0 deletions .changeset/import-undo-preserveaudit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/rest": patch
---

fix(rest): undo of a historical import now preserves the audit timeline (#3549)

A `treatAsHistorical` import writes with `preserveAudit` (#3493), keeping the
original `updated_at`/`updated_by` and business `readonly` fields instead of
stamping-now / stripping them. Its undo route, however, restored the captured
pre-import snapshot with a plain write context — so the audit auto-stamp
re-wrote `updated_at`/`updated_by` to "now", silently corrupting the very
timeline the historical import had preserved.

The undo write context now mirrors the import's own: it carries
`preserveAudit` iff the job row is flagged `treat_as_historical`, so restoring
`u.before` re-writes the snapshotted audit/timestamp values verbatim. A normal
import's undo is unchanged (default stamp/strip).
63 changes: 63 additions & 0 deletions packages/rest/src/import-job-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const SYS_IMPORT_JOB = {
write_mode: { name: 'write_mode', type: 'text' as const },
dry_run: { name: 'dry_run', type: 'boolean' as const },
run_automations: { name: 'run_automations', type: 'boolean' as const },
treat_as_historical: { name: 'treat_as_historical', type: 'boolean' as const },
error: { name: 'error', type: 'textarea' as const },
results: { name: 'results', type: 'json' as const },
started_at: { name: 'started_at', type: 'text' as const },
Expand Down Expand Up @@ -387,4 +388,66 @@ describe('async import job — real engine + protocol integration', () => {
const res = await callJob(ctx.undo, 'imp_nope');
expect(res._status).toBe(404);
});

// #3549 — the undo write context must mirror the import's own write context.
// A historical import carries `preserveAudit` (#3493) so it keeps the original
// `updated_at`/`updated_by` (and business `readonly` fields) instead of
// stamping-now / stripping them. The undo restores the captured pre-import
// snapshot; if that restore write does NOT also carry `preserveAudit`, the
// audit auto-stamp re-writes `updated_at`/`updated_by` to "now" — silently
// corrupting the very timeline the historical import preserved. So undo of a
// historical import must set `preserveAudit`, and undo of a normal import
// must not. We assert on the write context the route hands the protocol.
function spyUpdateContexts() {
const seen: Array<{ object: string; context: any }> = [];
const orig = (ctx.protocol as any).updateData.bind(ctx.protocol);
(ctx.protocol as any).updateData = async (args: any) => {
seen.push({ object: args.object, context: args.context });
return orig(args);
};
return seen;
}

it('undo of a historical import carries preserveAudit on the restore write (#3549)', async () => {
// Seed an existing row so the historical upsert updates it (populates the undo log).
await ctx.protocol.createData({ object: 'task', data: { id: 'h_existing', title: 'orig', score: 1 } });

const created = await callCreate(ctx.create, {
format: 'json', writeMode: 'upsert', matchFields: ['id'], treatAsHistorical: true,
rows: [{ id: 'h_existing', title: 'historical', score: 42 }],
});
const jobId = created._json.jobId;
const done = await waitForTerminal(ctx.progress, jobId);
expect(done).toMatchObject({ status: 'succeeded', updated: 1 });
expect(done.undoable).toBe(true);

// Observe only the undo phase.
const seen = spyUpdateContexts();
const u = await callJob(ctx.undo, jobId);
expect(u._json).toMatchObject({ success: true, restored: 1 });

const restoreWrites = seen.filter((s) => s.object === 'task');
expect(restoreWrites.length).toBeGreaterThan(0);
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBe(true);
});

it('undo of a normal import does NOT set preserveAudit — default stamp/strip (#3549)', async () => {
await ctx.protocol.createData({ object: 'task', data: { id: 'n_existing', title: 'orig', score: 1 } });

const created = await callCreate(ctx.create, {
format: 'json', writeMode: 'upsert', matchFields: ['id'], // treatAsHistorical omitted
rows: [{ id: 'n_existing', title: 'updated', score: 7 }],
});
const jobId = created._json.jobId;
const done = await waitForTerminal(ctx.progress, jobId);
expect(done).toMatchObject({ status: 'succeeded', updated: 1 });

const seen = spyUpdateContexts();
const u = await callJob(ctx.undo, jobId);
expect(u._json).toMatchObject({ success: true, restored: 1 });

const restoreWrites = seen.filter((s) => s.object === 'task');
expect(restoreWrites.length).toBeGreaterThan(0);
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBeUndefined();
});
});
15 changes: 14 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4049,7 +4049,20 @@ export class RestServer {
// re-writes the row's earlier state, which need not be a legal
// transition from where it is now — an undo reinstates an established
// fact, it does not walk the FSM.
const writeCtx = { ...(context ?? {}), skipAutomations: true, skipStateMachine: true };
//
// For a historical import (#3493/#3549), the undo write must mirror
// the import's own write context: carry `preserveAudit` so restoring
// `u.before` re-writes the captured `updated_at`/`updated_by` (and any
// business `readonly` fields in the snapshot) verbatim, rather than
// stamping-now / stripping them. Without this, undoing a historical
// import would silently rewrite the audit timeline the import took
// pains to preserve. A normal import keeps the default (stamp/strip).
const writeCtx = {
...(context ?? {}),
skipAutomations: true,
skipStateMachine: true,
...(row.treat_as_historical ? { preserveAudit: true } : {}),
};
let deleted = 0, restored = 0, failed = 0;

// Delete created records first (they didn't exist before).
Expand Down