Grove

Time-Travel & History

Query CRDT state at any point in time, view field-level history, and build audit trails with opt-in time-travel.

Grove CRDT includes an optional time-travel system that records field-level history. When enabled, you can query the state of any record at any point in time, view the change history of individual fields, and build audit trails for compliance or debugging.

Overview

Time-travel is opt-in and disabled by default to avoid storage overhead. When enabled, the CRDT layer records every field-level change with its HLC timestamp, enabling:

  • State-at-time queries to read what a record looked like at any past moment
  • Field history to see every change to a specific field with timestamps and node IDs
  • Audit trails for compliance, debugging, and accountability
  • Field-level undo by reverting to a previous field value
  • Debugging concurrent merge issues by inspecting the full change timeline

Server Setup (Go)

Enabling Time-Travel

Enable time-travel on the SyncController:

app.Use(groveext.New(
    groveext.WithDriver(pgdb),
    groveext.WithCRDT(plugin, hook.Scope{Tables: []string{"documents"}}),
    groveext.WithSyncController(
        crdt.WithTimeTravelEnabled(true),
    ),
))

Standalone Server

ctrl := crdt.NewSyncController(plugin,
    crdt.WithTimeTravelEnabled(true),
)
defer ctrl.Close()

handler := crdt.NewHTTPHandler(ctrl)
http.Handle("/sync/", handler)

When time-travel is enabled, the Forge extension auto-mounts the history endpoints alongside the existing sync routes.

Querying State at a Point in Time

Read the full state of a record as it was at a specific HLC timestamp:

// Read the state of a document at a specific time.
state, err := ctrl.ReadStateAt(ctx, "documents", "doc-1", targetHLC)
if err != nil {
    log.Fatal(err)
}

// state contains the merged CRDT state as of targetHLC.
// Fields that did not exist at that time are absent.
fmt.Println("Title at that time:", state.Fields["title"].Value)

Using wall-clock time

Convert a time.Time to an HLC for querying:

targetTime := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC)
targetHLC := crdt.HLCFromTime(targetTime)

state, err := ctrl.ReadStateAt(ctx, "documents", "doc-1", targetHLC)

Querying Field History

View the complete change history of a specific field:

// Get all changes to the "title" field of a document.
history, err := ctrl.ReadFieldHistory(ctx, "documents", "doc-1", "title")
if err != nil {
    log.Fatal(err)
}

for _, entry := range history {
    fmt.Printf("  %s: %s (by node %s)\n",
        entry.HLC.Time().Format(time.RFC3339),
        entry.Value,
        entry.NodeID,
    )
}

FieldHistoryEntry

type FieldHistoryEntry struct {
    HLC    HLC             `json:"hlc"`
    NodeID string          `json:"node_id"`
    Value  json.RawMessage `json:"value"`
    Type   CRDTType        `json:"type"`
}

HTTP API

When time-travel is enabled, the following endpoints are registered:

MethodPathDescription
GET/sync/historyGet record state at a point in time
GET/sync/field-historyGet change history for a specific field

Get State at Time

GET /sync/history?table=documents&pk=doc-1&hlc=<hlc-string>

{
  "table": "documents",
  "pk": "doc-1",
  "hlc": "1706000000000000000:0:node-1",
  "state": {
    "fields": {
      "title": {
        "type": "lww",
        "value": "Draft Title",
        "hlc": "1705999000000000000:0:node-1"
      },
      "tags": {
        "type": "set",
        "value": { "entries": { "\"go\"": [...] }, "removed": {} }
      }
    }
  }
}

Get Field History

GET /sync/field-history?table=documents&pk=doc-1&field=title

{
  "table": "documents",
  "pk": "doc-1",
  "field": "title",
  "history": [
    {
      "hlc": "1706000000000000000:0:node-1",
      "node_id": "node-1",
      "value": "Final Title",
      "type": "lww"
    },
    {
      "hlc": "1705999000000000000:0:node-2",
      "node_id": "node-2",
      "value": "Draft v2",
      "type": "lww"
    },
    {
      "hlc": "1705998000000000000:0:node-1",
      "node_id": "node-1",
      "value": "Draft",
      "type": "lww"
    }
  ]
}

History entries are returned in reverse chronological order (newest first).

Use Cases

Audit Trail

Track who changed what and when for compliance:

history, _ := ctrl.ReadFieldHistory(ctx, "contracts", "contract-1", "status")
for _, entry := range history {
    log.Printf("Status changed to %s by %s at %s",
        entry.Value, entry.NodeID, entry.HLC.Time())
}

Field-Level Undo

Revert a field to its previous value:

history, _ := ctrl.ReadFieldHistory(ctx, "documents", "doc-1", "title")
if len(history) >= 2 {
    previousValue := history[1].Value // Second entry is the previous value
    // Apply the previous value as a new write
    plugin.SetField(ctx, "documents", "doc-1", "title", previousValue)
}

Debugging Merge Issues

Inspect the full timeline of changes to understand how a merge resolved:

history, _ := ctrl.ReadFieldHistory(ctx, "documents", "doc-1", "title")
for _, entry := range history {
    fmt.Printf("  [%s] node=%s value=%s\n",
        entry.HLC, entry.NodeID, entry.Value)
}
// Output shows the full sequence of writes from all nodes,
// making it clear which write won and why.

Diffing Two Points in Time

Compare the state of a record at two different times:

stateA, _ := ctrl.ReadStateAt(ctx, "documents", "doc-1", hlcA)
stateB, _ := ctrl.ReadStateAt(ctx, "documents", "doc-1", hlcB)

for field, fsB := range stateB.Fields {
    fsA := stateA.Fields[field]
    if fsA == nil || string(fsA.Value) != string(fsB.Value) {
        fmt.Printf("Field %s changed: %s -> %s\n", field, fsA.Value, fsB.Value)
    }
}

Plugin Integration

Time-travel events can be intercepted via the Plugin System using the TimeTravelInterceptor interface:

type TimeTravelInterceptor interface {
    BeforeReadStateAt(ctx context.Context, table, pk string, hlc HLC) error
    BeforeReadFieldHistory(ctx context.Context, table, pk, field string) error
}

Use this for access control (restricting who can view history) or logging.

Performance Considerations

  • Storage: Time-travel records every field change. For high-write tables, this can grow quickly. Consider enabling it only on tables where history is valuable.
  • Queries: State-at-time queries scan field history up to the target HLC. For records with many changes, this may be slower than reading current state.
  • Cleanup: Consider implementing periodic cleanup of old history entries for tables where only recent history matters.

On this page