Skip to content

Commit ea48559

Browse files
committed
fix(rest): undo of a historical import 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 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). Adds two regression tests in import-job-integration.test.ts asserting the undo write context carries preserveAudit iff the import was historical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5rdfBKjkbcoEif4KUV6xE
1 parent f07808c commit ea48559

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): undo of a historical import now preserves the audit timeline (#3549)
6+
7+
A `treatAsHistorical` import writes with `preserveAudit` (#3493), keeping the
8+
original `updated_at`/`updated_by` and business `readonly` fields instead of
9+
stamping-now / stripping them. Its undo route, however, restored the captured
10+
pre-import snapshot with a plain write context — so the audit auto-stamp
11+
re-wrote `updated_at`/`updated_by` to "now", silently corrupting the very
12+
timeline the historical import had preserved.
13+
14+
The undo write context now mirrors the import's own: it carries
15+
`preserveAudit` iff the job row is flagged `treat_as_historical`, so restoring
16+
`u.before` re-writes the snapshotted audit/timestamp values verbatim. A normal
17+
import's undo is unchanged (default stamp/strip).

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const SYS_IMPORT_JOB = {
9696
write_mode: { name: 'write_mode', type: 'text' as const },
9797
dry_run: { name: 'dry_run', type: 'boolean' as const },
9898
run_automations: { name: 'run_automations', type: 'boolean' as const },
99+
treat_as_historical: { name: 'treat_as_historical', type: 'boolean' as const },
99100
error: { name: 'error', type: 'textarea' as const },
100101
results: { name: 'results', type: 'json' as const },
101102
started_at: { name: 'started_at', type: 'text' as const },
@@ -387,4 +388,66 @@ describe('async import job — real engine + protocol integration', () => {
387388
const res = await callJob(ctx.undo, 'imp_nope');
388389
expect(res._status).toBe(404);
389390
});
391+
392+
// #3549 — the undo write context must mirror the import's own write context.
393+
// A historical import carries `preserveAudit` (#3493) so it keeps the original
394+
// `updated_at`/`updated_by` (and business `readonly` fields) instead of
395+
// stamping-now / stripping them. The undo restores the captured pre-import
396+
// snapshot; if that restore write does NOT also carry `preserveAudit`, the
397+
// audit auto-stamp re-writes `updated_at`/`updated_by` to "now" — silently
398+
// corrupting the very timeline the historical import preserved. So undo of a
399+
// historical import must set `preserveAudit`, and undo of a normal import
400+
// must not. We assert on the write context the route hands the protocol.
401+
function spyUpdateContexts() {
402+
const seen: Array<{ object: string; context: any }> = [];
403+
const orig = (ctx.protocol as any).updateData.bind(ctx.protocol);
404+
(ctx.protocol as any).updateData = async (args: any) => {
405+
seen.push({ object: args.object, context: args.context });
406+
return orig(args);
407+
};
408+
return seen;
409+
}
410+
411+
it('undo of a historical import carries preserveAudit on the restore write (#3549)', async () => {
412+
// Seed an existing row so the historical upsert updates it (populates the undo log).
413+
await ctx.protocol.createData({ object: 'task', data: { id: 'h_existing', title: 'orig', score: 1 } });
414+
415+
const created = await callCreate(ctx.create, {
416+
format: 'json', writeMode: 'upsert', matchFields: ['id'], treatAsHistorical: true,
417+
rows: [{ id: 'h_existing', title: 'historical', score: 42 }],
418+
});
419+
const jobId = created._json.jobId;
420+
const done = await waitForTerminal(ctx.progress, jobId);
421+
expect(done).toMatchObject({ status: 'succeeded', updated: 1 });
422+
expect(done.undoable).toBe(true);
423+
424+
// Observe only the undo phase.
425+
const seen = spyUpdateContexts();
426+
const u = await callJob(ctx.undo, jobId);
427+
expect(u._json).toMatchObject({ success: true, restored: 1 });
428+
429+
const restoreWrites = seen.filter((s) => s.object === 'task');
430+
expect(restoreWrites.length).toBeGreaterThan(0);
431+
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBe(true);
432+
});
433+
434+
it('undo of a normal import does NOT set preserveAudit — default stamp/strip (#3549)', async () => {
435+
await ctx.protocol.createData({ object: 'task', data: { id: 'n_existing', title: 'orig', score: 1 } });
436+
437+
const created = await callCreate(ctx.create, {
438+
format: 'json', writeMode: 'upsert', matchFields: ['id'], // treatAsHistorical omitted
439+
rows: [{ id: 'n_existing', title: 'updated', score: 7 }],
440+
});
441+
const jobId = created._json.jobId;
442+
const done = await waitForTerminal(ctx.progress, jobId);
443+
expect(done).toMatchObject({ status: 'succeeded', updated: 1 });
444+
445+
const seen = spyUpdateContexts();
446+
const u = await callJob(ctx.undo, jobId);
447+
expect(u._json).toMatchObject({ success: true, restored: 1 });
448+
449+
const restoreWrites = seen.filter((s) => s.object === 'task');
450+
expect(restoreWrites.length).toBeGreaterThan(0);
451+
for (const w of restoreWrites) expect(w.context?.preserveAudit).toBeUndefined();
452+
});
390453
});

packages/rest/src/rest-server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4049,7 +4049,20 @@ export class RestServer {
40494049
// re-writes the row's earlier state, which need not be a legal
40504050
// transition from where it is now — an undo reinstates an established
40514051
// fact, it does not walk the FSM.
4052-
const writeCtx = { ...(context ?? {}), skipAutomations: true, skipStateMachine: true };
4052+
//
4053+
// For a historical import (#3493/#3549), the undo write must mirror
4054+
// the import's own write context: carry `preserveAudit` so restoring
4055+
// `u.before` re-writes the captured `updated_at`/`updated_by` (and any
4056+
// business `readonly` fields in the snapshot) verbatim, rather than
4057+
// stamping-now / stripping them. Without this, undoing a historical
4058+
// import would silently rewrite the audit timeline the import took
4059+
// pains to preserve. A normal import keeps the default (stamp/strip).
4060+
const writeCtx = {
4061+
...(context ?? {}),
4062+
skipAutomations: true,
4063+
skipStateMachine: true,
4064+
...(row.treat_as_historical ? { preserveAudit: true } : {}),
4065+
};
40534066
let deleted = 0, restored = 0, failed = 0;
40544067

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

0 commit comments

Comments
 (0)