feat(agent): reconstruct a record state at a past timestamp from audit logs#1682
feat(agent): reconstruct a record state at a past timestamp from audit logs#1682ShohanRahman wants to merge 4 commits into
Conversation
…t logs
Exposes `GET /_audit-trail/{collection}/{id}/state?timestamp=...` next to the existing history
route. The handler reads the record's current value and replays every audit entry with
`timestamp >= target` in reverse, applying `previousValues` to undo each change. Walking back
stops on a terminal event: a `create` returns 404 (record did not exist yet at the target
instant) and a `delete` adopts its `previousValues` snapshot and ignores anything older. Only
the audited columns (writable + primary keys) are returned — read-only/computed fields are not
captured by the audit log and can't be reconstructed.
The revert logic lives next to the route in the agent rather than in the plugin so the agent
runtime doesn't take a new dependency on `plugin-audit-trail` (the agent already locally
re-declares the audit store interface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 new issues
|
| export default function revertRecord( | ||
| current: Record<string, unknown> | null, | ||
| entries: AuditEntry[], | ||
| ): Record<string, unknown> | null { | ||
| let state = current; | ||
|
|
||
| for (const entry of entries) { | ||
| if (entry.operation === 'create') return null; | ||
|
|
||
| if (entry.operation === 'delete') return { ...entry.previousValues }; |
There was a problem hiding this comment.
🟡 Medium access/audit-trail-revert.ts:63
When revertRecord encounters a delete entry, it immediately returns entry.previousValues without reverting any older entries in the list. This produces the wrong state when entries exist between the target time and the deletion. For example, if the record was updated at t=1 and deleted at t=2, and the target is t=0.5, the function returns the state at deletion time ({a:2}) instead of reverting the update to get the state at t=0.5 ({a:1}). The loop should continue applying reverts to entry.previousValues instead of returning it directly.
- if (entry.operation === 'delete') return { ...entry.previousValues };
+ if (entry.operation === 'delete') state = { ...entry.previousValues };🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/routes/access/audit-trail-revert.ts around lines 63-72:
When `revertRecord` encounters a `delete` entry, it immediately returns `entry.previousValues` without reverting any older entries in the list. This produces the wrong state when entries exist between the target time and the deletion. For example, if the record was updated at t=1 and deleted at t=2, and the target is t=0.5, the function returns the state at deletion time (`{a:2}`) instead of reverting the update to get the state at t=0.5 (`{a:1}`). The loop should continue applying reverts to `entry.previousValues` instead of returning it directly.
Evidence trail:
packages/agent/src/routes/access/audit-trail-revert.ts lines 63-78 at REVIEWED_COMMIT: `revertRecord` function. Line 72: `if (entry.operation === 'delete') return { ...entry.previousValues };` — immediate return prevents processing of older entries. Lines 54-61: docstring incorrectly claims 'the snapshot already contains everything that happened before the deletion'. Lines 37-52: `revertOne` function that would correctly revert an update entry against a state — but is never reached for entries after a delete.
|
Coverage Impact Unable to calculate total coverage change because base branch coverage was not found. Modified Files with Diff Coverage (3)
🛟 Help
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| }; | ||
|
|
||
| const isPlainObject = (value: unknown): value is Record<string, unknown> => | ||
| Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); |
| Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); | ||
|
|
||
| // Plain object on both sides ⇒ nested diff; otherwise `previous` is the leaf to write back. | ||
| const revertValue = (current: unknown, previous: unknown, next: unknown): unknown => { |
There was a problem hiding this comment.
ts not very useful. can we delete it ?
| // ISO 8601 instant: carries its own timezone designator (`Z` or `±HH:mm` / `±HHMM`). | ||
| const ISO_INSTANT = /[Zz]$|[+-]\d{2}:?\d{2}$/; |
There was a problem hiding this comment.
TODO: check if there is a better format for a timestamp with timezone ?
| private static auditedColumns(schema: CollectionSchema): string[] { | ||
| const writable = Object.keys(schema.fields).filter(name => { | ||
| const field = schema.fields[name]; | ||
|
|
||
| return field.type === 'Column' && !field.isReadOnly; | ||
| }); | ||
|
|
||
| return [...new Set([...SchemaUtils.getPrimaryKeys(schema), ...writable])]; | ||
| } |
There was a problem hiding this comment.
is it necessary to filter out the readonly fields ? because they will not be changed in the audit logs
| instant.invalidReason === 'unsupported zone' | ||
| ? `Invalid timezone: "${timezone}"` | ||
| : `Invalid date: "${raw}" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)`, | ||
| : `Invalid date: "${raw}" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)`, |
There was a problem hiding this comment.
IMO we should only expect one format: the one sent by the frontend
| // An embedded offset already pins the instant — the request timezone and start/end boundary | ||
| // don't apply. | ||
| if (ISO_INSTANT.test(raw)) return DateTime.fromISO(raw, { setZone: true }); | ||
|
|

Summary
GET /_audit-trail/{collection}/{id}/state?timestamp=...next to the existing history route, stacked on top of feat(audit-trail): create compliant audit trail #1670.timestamp >= targetin reverse to reconstruct the state at the target instant.create→ 404 (record did not exist yet),delete→ adopts itspreviousValuessnapshot and ignores older entries. Only audited columns (writable + PKs) are returned.Notes
audit-trail-revert.ts) rather than inplugin-audit-trailso the agent runtime keeps its current decoupling from the plugin — the same trade-off already taken withAuditTrailRecordReaderinagent/src/types.ts.Test plan
revertRecordcovering single update, multi-step walkback, nested objects, array-of-objects, primitive-array replacement, create stop, delete stop (deleted now and recreated), no-mutation.yarn workspace @forestadmin/agent test— 979 tests pass.yarn workspace @forestadmin/agent lint— clean.🤖 Generated with Claude Code
Note
Add endpoint to reconstruct a record's state at a past timestamp from audit logs
GET /_audit-trail/<collection>/:id/state?at=<timestamp>endpoint inaudit-trail.tsthat returns an audited record's column snapshot as of a given time, or 404 if the record did not exist then.audit-trail-revert.tswith arevertRecordfunction that walks audit entries newest-first, applying update reversions recursively for nested objects and arrays, and terminates on create/delete entries.QueryStringParser.parseCallerinquery-string.tsto accept an optionaldefaultTimezone, used when the query lacks one (defaults to UTC for the state endpoint).atparameter accepts wall-clock datetimes with atimezonequery param, or ISO 8601 instants with embedded offsets (which bypass the request timezone).Macroscope summarized 6576c15.