Skip to content

feat(history): unified change history - #337

Merged
stakach merged 3 commits into
masterfrom
feat/unified-history
Oct 19, 2026
Merged

stakach merged 3 commits into
masterfrom
feat/unified-history

Conversation

@stakach

@stakach stakach commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Part of #336

Unified change history: every tracked model records its changes in the history table, filterable by resource_type (table name) + resource_id, or correlation_id (parent), newest first.

Changes

  • Migration
    • Rebuilds history: uuidv7 id, resource_type, resource_id, correlation_id, action, changed_fields, diff, snapshot, user_id, user_email, context, created_at.
    • Migrates group_history rows (group id becomes correlation_id) and drops group_history. Old history rows are dropped, as nothing read them.
  • History::Tracked mixin
    • What it records:
      • create: every field set
      • update: {"field": [old, new]}
      • delete: full snapshot
      • approve: diff + snapshot
      • restore: written by undelete
    • How: captured by pg-orm's new on_persist hook, so the values recorded are the ones written after all callbacks have run, inside the same transaction.
    • Per-model config: history_ignore (an update that only changes ignored fields is not recorded) and history_redact (secrets never stored).
    • Overrides: history_resource_id, history_correlation_id, history_record_as.
    • Queries: #change_history, .change_history(id), History.for_resource, History.correlated. The method is change_history, not history, because Booking already has a history attribute.
    • .undelete(id): restores the latest delete snapshot with its original id. Cascaded children are not restored.
  • History::Metadata
    • Holds user, API key, app id/name/date/build, user agent, IP and request id.
    • Set per fiber with History.with_metadata, so changes made by cascading callbacks are attributed too, or per record with history_metadata=.
  • Retention
    • Settings: PLACE_HISTORY_RETENTION_DAYS=30, PLACE_HISTORY_MAX_ENTRIES=250, PLACE_HISTORY_MIN_ENTRIES=5.
    • Each resource is trimmed when an entry is written.
    • A batched History.cleanup_expired runs at most hourly per process. It removes every entry of resources deleted more than 30 days ago.
  • Tracked models
    • Zone, ControlSystem, Module, Driver, Repository, Trigger, TriggerInstance
    • User, Authority, LDAP/OAuth/SAML strategies, DoorkeeperApplication
    • Asset, AssetType, AssetCategory, AssetPurchaseOrder, Alert, AlertDashboard
    • Playlist, Playlist::Item, SignageTemplate
    • Group, GroupUser, GroupZone, GroupInvitation
    • EventMetadata, Booking, BookingInstance
  • Special cases
    • Playlist::Revision: approvals only, correlated to the playlist.
    • SignageTemplate: drafts are not recorded; approve_draft! records an approve entry.
    • EventMetadata: keyed by event_id, creates skipped, ext_data.<key> changed fields kept.
    • BookingInstance: keyed booking_id:instance_start, correlated to its booking.
    • TriggerInstance: correlated to its system.
  • Dependencies

Fix included

User#destroy guarded the last admin with PgAdvisoryLock, which takes a session lock on whichever pooled connection is free and hands it back to the pool. Advisory locks are re-entrant per connection, so a second fiber could get in alongside the first, and CI once deleted every admin. ensure_admin_remains now takes pg_advisory_xact_lock inside the destroy transaction instead. PgAdvisoryLock itself still needs fixing in pg-orm.

Downstream

This is a breaking change for rest-api. GroupHistory and acting_user are gone, so rest-api needs a follow-up PR to:

  • set History.with_metadata per request
  • repoint /group_history
  • add history and undelete routes

Review

  • Specs: ./test passed, 857 examples (after merging master), 0 failures. crystal tool format and ameba are clean.
  • New coverage in spec/history_spec.cr:
    • diffs, ignored-only and no-change saves skipped
    • values captured after callbacks run
    • redaction
    • delete snapshots
    • composite keys and correlation
    • rollback with the change
    • revision approvals, bookings
    • metadata precedence and nesting
    • undelete (restore, missing, already exists)
    • retention: max entries, age with minimum kept, deleted resources, batching, periodic cleanup
  • Not restored or recorded:
    • Children removed by database ON DELETE CASCADE.
    • Writes through update_all, delete_all or raw SQL.
    • Redacted fields come back empty on undelete.

🤖 Generated with Claude Code

Adds History::Tracked, recording creates, updates ({field: [old, new]}),
deletes (with snapshot), approvals and restores in the history table with
request metadata, retention and undelete. Replaces GroupHistory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the type: enhancement new feature or request label Oct 19, 2026
@stakach stakach mentioned this pull request Oct 19, 2026
18 tasks done
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Oct 19, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Oct 19, 2026
@stakach
stakach merged commit f7c0340 into master Oct 19, 2026
8 checks passed
@stakach
stakach deleted the feat/unified-history branch October 19, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement new feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant