# History, savepoints, and undo

> Enable history on workspaces, .history/.log paths, savepoint JSON, .undo preview/apply, per-user filters, auto-savepoints, and migration boundaries.

- Repository: timescale/tigerfs
- GitHub: https://github.com/timescale/tigerfs
- Human docs: https://grok-wiki.com/public/docs/timescale-tigerfs-60719456a5c3
- Complete Markdown: https://grok-wiki.com/public/docs/timescale-tigerfs-60719456a5c3/llms-full.txt

## Source Files

- `docs/history.md`
- `internal/tigerfs/fs/undo.go`
- `internal/tigerfs/fs/history.go`
- `internal/tigerfs/fs/auto_savepoint_test.go`
- `test/integration/undo_test.go`
- `docs/adr/016-undo-and-recovery.md`

---

---
title: "History, savepoints, and undo"
description: "Enable history on workspaces, .history/.log paths, savepoint JSON, .undo preview/apply, per-user filters, auto-savepoints, and migration boundaries."
---

History-enabled file-first workspaces get four tigerfs-schema companion tables (`<app>_history`, `<app>_log`, `<app>_savepoint`, `<app>_metadata`) plus workspace-level dot directories `.history/`, `.log/`, `.savepoint/`, and `.undo/`. A PostgreSQL BEFORE trigger archives row snapshots into `_history`; TigerFS writes `_log` entries on each create/edit/rename/delete/undo. Rollback uses preview-then-`touch .apply`, with optional per-user scoping and a post-migration undo boundary enforced via `_metadata`.

## Enable history on a workspace

History is a synth feature flag, not a separate product mode. Enable it at workspace creation or on an existing app.

<Steps>
<Step title="Create with history">
Write a `.build/` spec that includes `history` (with a format such as `markdown` or `txt`):

```bash
echo "markdown,history" > /mnt/db/.build/notes
```

TigerFS runs `GenerateBuildSQLWithFeatures`, creating the backing table, view comment (`tigerfs:md,history`), history hypertable + archive trigger, log hypertable, savepoint table, and metadata table.
</Step>
<Step title="Add history to an existing workspace">
```bash
echo "history" > /mnt/db/.build/notes
```

This uses `GenerateHistoryOnlySQL` to add history infrastructure and update the view comment.
</Step>
<Step title="Verify">
```bash
ls /mnt/db/notes/.history/
ls /mnt/db/notes/.log/
ls /mnt/db/notes/.savepoint/
ls /mnt/db/notes/.undo/
```
</Step>
</Steps>

<Warning>
History requires **TimescaleDB** (hypertables for `_history` and `_log`). It applies only to **file-first synth workspaces** with history enabled. Data-first tables have no history trigger. Writes that bypass the synth layer (for example direct edits under `.tables/`) are not logged.
</Warning>

Detection uses the view comment (`tigerfs:md,history`) or presence of a companion `_history` table (`ViewInfo.HasHistory`).

## Storage layout

```mermaid
flowchart TB
  subgraph mount["Workspace mount (file-first)"]
    files["notes/hello.md"]
    hist[".history/"]
    log[".log/"]
    sp[".savepoint/"]
    undo[".undo/"]
  end
  subgraph tigerfs["tigerfs schema"]
    src["notes backing table"]
    h["notes_history hypertable"]
    l["notes_log hypertable"]
    s["notes_savepoint"]
    m["notes_metadata"]
  end
  files --> src
  hist --> h
  log --> l
  sp --> s
  undo --> l
  undo --> h
  undo --> s
  src -->|"BEFORE trigger"| h
  src -->|"logSynthOp on write"| l
  m -->|"checkBoundary"| undo
```

| Table | Role |
|-------|------|
| `<app>_history` | Full row snapshots (`version_id` UUIDv7 PK, `operation` create/edit/rename/delete) |
| `<app>_log` | Operation metadata: `log_id`, `file_id`, `type`, `user_id`, denormalized `filename`, `version_id` → before-state |
| `<app>_savepoint` | Named bookmarks: `name` (PK), `savepoint_id` (UUIDv7), `user_id`, `description` |
| `<app>_metadata` | Workspace events; `history-format-migration` blocks undo before boundary |

Log entries are written by TigerFS (`logSynthOp`), not by database triggers. For edit/rename/delete/undo, `version_id` points at the history row captured immediately before the operation.

## Browse versions (`.history/`)

Per-directory `.history/` lists files that have snapshots at that level. Subdirectory history is scoped by `parent_id` (ADR-017 parent-pointer model).

| Path | Returns |
|------|---------|
| `workspace/.history/` | Filenames with history (+ `.by/` at workspace root) |
| `workspace/.history/file.md/` | `.id` plus version IDs, newest first |
| `workspace/.history/file.md/.id` | Stable row UUID (`file_id`) |
| `workspace/.history/file.md/<version_id>` | Full synthesized file at that version |
| `workspace/.history/.by/<uuid>/` | Same versions keyed by row UUID (rename-safe) |

Version directory names use the UUIDv7 display form: `2026-04-07T143000.123Z-zzz0063hd8e5r42` (UTC ms timestamp + base36 entropy). Listing sorts chronologically; export JSON still uses standard hex UUIDs.

Cross-rename tracking: `file_id` is stable; history follows the row even when the leaf `filename` changes.

## Operation log (`.log/`)

`.log/` is data-first on `tigerfs.<app>_log`. Standard pipeline paths work: `.last/N`, `.first/N`, `.by/<col>/<val>`, `.filter/`, `.order/`, `.export/json`, format suffixes.

```bash
cat /mnt/db/notes/.log/.last/10/.export/json
ls /mnt/db/notes/.log/.by/user_id/agent-7/.last/5/
ls /mnt/db/notes/.log/.by/file_id/<uuid>/
ls /mnt/db/notes/.log/.by/type/edit/.last/10/
```

<Note>
Atomic editors (vim, VS Code, many agent write tools) often replace files via temp + rename, producing **2–3 log entries per logical save**. Undo a group by anchoring at the log entry before the group, or use `.undo/to-id/<log_id>/`.
</Note>

### Diff symlinks

Each log entry directory exposes:

| Symlink | Target |
|---------|--------|
| `before` | `.history/<path>/<version_id>` from this entry's `version_id`; `/dev/null` for `create` |
| `after` | Next log entry's before-state, or live file |
| `current` | Live workspace path; `/dev/null` if deleted |

```bash
diff -u --color /mnt/db/notes/.log/<log_id>/before /mnt/db/notes/.log/<log_id>/after
```

`filename` in the log is the **full path at operation time** (historically correct). It is not a `.log/.by/` index column — use `.history/<file>/.id` then `.log/.by/file_id/<uuid>/`.

## Savepoints (`.savepoint/`)

Named bookmarks in the operation timeline. Creating a savepoint does not change workspace files; deleting a savepoint does not remove log or history data.

```bash
echo '{"description":"Before refactoring"}' > /mnt/db/notes/.savepoint/before-refactor.json
cat /mnt/db/notes/.savepoint/before-refactor/description
ls /mnt/db/notes/.savepoint/.last/5/
```

Writes require a **format suffix** (`.json`, `.tsv`, `.csv`, `.yaml`). TSV uses a `description` column; JSON can be `{}` for a minimal bookmark.

| Field | Source |
|-------|--------|
| `name` | Filename without suffix (primary key) |
| `savepoint_id` | UUIDv7 at insert time (undo ordering: `log_id > savepoint_id`) |
| `user_id` | Mount identity when set |
| `description` | User text or auto-savepoint message |

Rename with `mv` updates `name` only; `savepoint_id` is unchanged, so `.undo/to-savepoint/<name>/` references remain valid.

## Auto-savepoints

Before each logged write, TigerFS compares wall-clock time to the last write on that workspace (`schema.table` key). If the gap exceeds the configured interval, it inserts an auto-savepoint **before** the new log entry.

Naming:

- `auto-<user_id>-20260408T143000Z` when `--user-id` / `TIGERFS_USER_ID` is set
- `auto-20260408T143000Z` for anonymous mounts

<ParamField body="auto_savepoint_interval" type="duration" default="30m">
Inactivity threshold. Set to `0` to disable.
</ParamField>

| Source | Key / flag |
|--------|------------|
| Config file | `auto_savepoint_interval: 30m` |
| Environment | `TIGERFS_AUTO_SAVEPOINT_INTERVAL=30m` |
| CLI | `--auto-savepoint-interval 30m` |

Auto-savepoints are skipped on the **first write after mount** (no prior session to separate).

## User identity and per-user undo

Identity is a single mount-level string used for new log and savepoint rows.

| Precedence | Source |
|------------|--------|
| 1 | `--user-id` |
| 2 | `TIGERFS_USER_ID` |
| 3 | Anonymous (`NULL` in log) |

```bash
cat /mnt/db/.info/user          # read
echo "agent-9" > /mnt/db/.info/user   # change mid-session
```

<Info>
Use **separate mounts per agent** on the same database when possible. Each mount gets its own `user_id` and in-memory state; PostgreSQL MVCC provides cross-mount consistency.
</Info>

Per-user undo adds `/.by/user_id/<user_id>/` under a `.undo/to-savepoint/` or `.undo/to-id/` path before `.apply`. Only that user's operations after the anchor are reversed; other users' log entries are ignored in the undo query.

<Warning>
If two users interleave edits on the **same file**, per-user undo still rolls the file back to the state before that user's first post-anchor operation — which can revert the other user's interleaved changes on that file (rollback semantics).
</Warning>

## Undo (`.undo/`)

Preview-then-apply: inspect `.info/summary` and the preview tree (or `.log/` symlinks for single ops), then `touch` `.apply`. The full undo runs in one PostgreSQL transaction.

### Modes

| Mode | Path | Effect |
|------|------|--------|
| Single operation | `.undo/id/<log_id>/` | Reverse one log entry (one file) |
| To log entry | `.undo/to-id/<log_id>/` | Reverse all ops **after** that `log_id` per file |
| To savepoint | `.undo/to-savepoint/<name>/` | Reverse all ops **after** `savepoint_id` per file |

`<log_id>` accepts raw UUID or display name (`2026-04-08T143015.234Z-...`).

Default listing under `.undo/id/`, `.undo/to-id/`, and `.undo/to-savepoint/` is **100** most recent targets (`undo_list_limit` / `--undo-list-limit`).

### Preview and apply

```bash
cat /mnt/db/notes/.undo/to-savepoint/before-refactor/.info/summary
diff -ru /mnt/db/notes/.undo/to-savepoint/before-refactor /mnt/db/notes/ -x '.*'
touch /mnt/db/notes/.undo/to-savepoint/before-refactor/.apply
```

Multi-file preview trees include only affected paths. Files whose first post-anchor operation was `create` appear as symlinks to `/dev/null` (will be removed on apply). Restored files show synthesized content from history (`version_id`).

`.undo/id/<log_id>/` has **no** preview tree — use `.log/<log_id>/before` and `after` for diffs.

### Pipeline + `.apply`

Filters on `.undo/...` paths narrow what gets undone; preview matches apply scope. `.apply` works with `.all/`, `.last/N`, `.first/N`, `.by/`, `.filter/`, but **not** with `.sample/` (returns `EINVAL`).

Examples:

```bash
touch /mnt/db/notes/.undo/to-savepoint/x/.filter/type/delete/.apply
touch /mnt/db/notes/.undo/to-savepoint/x/.by/user_id/agent-7/.apply
touch /mnt/db/notes/.undo/to-savepoint/x/.last/5/.apply
```

### Semantics

- **Rollback**, not surgical revert: restores full row state from history.
- **Undo of undo**: undo ops are logged as `type=undo`; undoing them uses the same paths (with `undo-of-undo` dispatch via history `operation`).
- **Idempotent** undo-to-savepoint: repeating apply toward the same anchor yields the same data state (new log/history rows each time).

On success, TigerFS logs `undo applied` at Info with file counts; caches (`stat`, `path`, `undo`) invalidate under the **user schema** (where the view lives).

## Migration undo boundary

Workspaces upgraded from pre–parent-pointer history (v0.6 → v0.7) receive one `history-format-migration` row in `<app>_metadata` when `tigerfs migrate` runs. Pre-migration history kept **lossy `parent_id`** (filled from current source, not historical placement), so undo of old edits could restore files to the wrong directory.

| Surface | Pre-boundary behavior |
|---------|----------------------|
| `.history/`, `.log/` | Read/browse still works |
| `.undo/` | Refused with **EPERM** (`ErrPermission`) when target `log_id` or `savepoint_id` sorts **before** the marker `entry_id` |

The refusal message includes the marker's `description` as the structured hint (check stderr / JSON logs on FUSE/NFS).

Fresh installs with v0.7 history have an empty metadata table — no boundary, undo works for all entries.

<Tip>
Verify migration and boundary behavior with `tigerfs migrate --describe` / `--dry-run` and integration coverage in `test/integration/migrate_test.go` (`verifyMigrationUndoBoundary`).
</Tip>

## Configuration reference

| Setting | Default | Purpose |
|---------|---------|---------|
| `user_id` / `--user-id` / `TIGERFS_USER_ID` | empty | Attributed log/savepoint rows |
| `auto_savepoint_interval` | `30m` | Session gap auto-savepoint (`0` = off) |
| `undo_list_limit` | `100` | Default `.undo/` target listing |

## Limitations and operations notes

| Topic | Constraint |
|-------|------------|
| Database | TimescaleDB hypertables + compression (7-day chunks) |
| Scope | File-first synth with `history` only |
| DDL | Undo does not reverse schema changes |
| Storage | Every edit adds a history row (compression mitigates) |
| Concurrency | Last writer wins; undo can overwrite concurrent edits |
| Direct table access | `.tables/` writes bypass logging |

Reserved workspace-level names (cannot be used as file names): `.history`, `.log`, `.savepoint`, `.undo`, plus standard capability dirs.

## Quick reference

| Goal | Command / path |
|------|----------------|
| Enable history | `echo "markdown,history" > .build/<app>` |
| List versions | `ls <app>/.history/<file>/` |
| Row UUID | `cat <app>/.history/<file>/.id` |
| Recent ops | `cat <app>/.log/.last/10/.export/json` |
| Diff one change | `diff -u <app>/.log/<id>/before <app>/.log/<id>/after` |
| Create savepoint | `echo '{"description":"..."}' > <app>/.savepoint/name.json` |
| Preview undo | `cat <app>/.undo/to-savepoint/name/.info/summary` |
| Apply undo | `touch <app>/.undo/to-savepoint/name/.apply` |
| Per-user undo | `touch <app>/.undo/to-savepoint/name/.by/user_id/X/.apply` |
| Set user | `echo "agent-7" > /.info/user` |

## Related pages

<CardGroup>
<Card title="File-first workspaces" href="/file-first-workspaces">
Create workspaces, markdown/plaintext I/O, and `.tables/` backing access.
</Card>
<Card title="Capability directories" href="/capability-directories">
Pipeline grammar used by `.log/` and `.savepoint/` listings.
</Card>
<Card title="Migrate workspaces" href="/migrate-workspaces">
`migrate --describe` / `--dry-run`, history-format upgrades, and boundary verification.
</Card>
<Card title="Consistency and caching" href="/consistency-and-caching">
Why undo invalidates stat/path caches and never caches read content.
</Card>
<Card title="Error codes" href="/error-codes">
Mapping `ErrPermission` / `EINVAL` from `.undo/` to errno and zap hints.
</Card>
</CardGroup>
