feat(history): GET /api/element/history — an element's publish history derived from main #287

Merged
dominik.polakovics merged 1 commit from afk/283 into main 2026-10-14 18:38:21 +02:00

Closes #283

What

GET /api/element/history?uid=&lang=&before= returns the publish history of one content element in one language's file. Every publish to main that changed the element's own content is listed newest first, as created, changed or removed, each with a labelled field-level diff. A pending top row shows the working tree against main. The history is derived from git on demand, and nothing is written at save or publish time (ADR 0023). The route is documented in docs/api.md.

How

  • internal/elemops/compare.go is now the only place that compares two versions of an element.
    • The pending panel's fingerprint and uid matching moved here from internal/server/pending.go. The panel's counts are unchanged, and every handler-level pending test passes untouched.
    • DiffElement adds the field diff:
      • params, in the form's widget shapes and labels
      • the body
      • items rows matched by uid (kept, added, removed or changed)
      • enabled, via element.EnabledField
      • a source fallback, so an entry is never empty
    • Text-like fields also carry word runs from the new internal/worddiff.
  • internal/gitrepo/history.go reads the git side:
    • MainTip resolves main once per request.
    • NeedleHistory runs git grep and the pickaxe (-S'uid="…"', renames off) over a 1,000-commit range window with a cursor, then one cat-file --batch. Whether a file holds the element is decided from the file's content.
    • NeedleFiles lists the files that hold the uid at one commit.
    • Everything is pinned to one tip and only reads.
    • It works over the remote-tracking main in branch mode; a test shows branch mode returns the same versions as WIP mode.
  • internal/server/history.go is the handler:
    • Moves, renames, bundle conversions, reorders and nested-element changes produce no entry; a move plus an edit is one changed entry.
    • With no repository or no main it answers like the pending panel: 200 with unavailable and its code.
    • It does not take the mutation lock, and a test shows that refs, the index, HEAD and the working tree are unchanged after a request.
  • New codes: history.badCursor, history.unknownElement, history.read, label.body, label.source. servermessages.json, en.json and de.json are updated.
  • elemops.ElementLabel is now the one rule for "sidecar label, else the element name". It was previously written inline twice in linktargets.go, and the history needed it too.

Verification

  • go vet ./... and go test ./... pass.
  • cd web && npm test passes: 124 files, 3213 tests.
  • Opt-in cost check (DECKLE_HISTORY_PERF=1 go test ./internal/server -run TestElementHistoryCostBound -v): on a generated repository of 300 pages × 15 elements and 2,000 publishes touching 3 pages each, one default-window request has a median of about 190 ms, against the 250 ms bound. The wall-clock check sits behind an env var so it can't flake in shared CI; BenchmarkElementHistory measures the same request.
  • The pickaxe assumption holds on git 2.47.3: only files whose count changed are listed. TestPickaxeListsOnlyCountChangedFiles covers it.

Notes

  • No change to scaffold/site/AGENTS.md, the theme README or any skill, because no sidecar, grammar or procedure changes.
  • The ADR's source line reads Issue #283. and should become the commit/PR reference once merged, as earlier ADRs do.
  • Possible later speed-ups, not made: parse each blob once across neighbouring versions, and grep main's tip once instead of twice. Together they would save about 30–40 ms.

🤖 Generated with Claude Code

Closes #283 ## What `GET /api/element/history?uid=&lang=&before=` returns the publish history of one content element in one language's file. Every publish to `main` that changed the element's own content is listed newest first, as `created`, `changed` or `removed`, each with a labelled field-level diff. A `pending` top row shows the working tree against `main`. The history is derived from git on demand, and nothing is written at save or publish time ([ADR 0023](docs/adr/0023-the-element-history-is-derived-from-main.md)). The route is documented in `docs/api.md`. ## How - **`internal/elemops/compare.go`** is now the only place that compares two versions of an element. - The pending panel's fingerprint and uid matching moved here from `internal/server/pending.go`. The panel's counts are unchanged, and every handler-level pending test passes untouched. - `DiffElement` adds the field diff: - params, in the form's widget shapes and labels - the body - items rows matched by uid (kept, added, removed or changed) - `enabled`, via `element.EnabledField` - a `source` fallback, so an entry is never empty - Text-like fields also carry word runs from the new `internal/worddiff`. - **`internal/gitrepo/history.go`** reads the git side: - `MainTip` resolves `main` once per request. - `NeedleHistory` runs `git grep` and the pickaxe (`-S'uid="…"'`, renames off) over a 1,000-commit range window with a cursor, then one `cat-file --batch`. Whether a file holds the element is decided from the file's content. - `NeedleFiles` lists the files that hold the uid at one commit. - Everything is pinned to one tip and only reads. - It works over the remote-tracking main in branch mode; a test shows branch mode returns the same versions as WIP mode. - **`internal/server/history.go`** is the handler: - Moves, renames, bundle conversions, reorders and nested-element changes produce no entry; a move plus an edit is one `changed` entry. - With no repository or no `main` it answers like the pending panel: `200` with `unavailable` and its code. - It does not take the mutation lock, and a test shows that refs, the index, HEAD and the working tree are unchanged after a request. - **New codes:** `history.badCursor`, `history.unknownElement`, `history.read`, `label.body`, `label.source`. `servermessages.json`, `en.json` and `de.json` are updated. - **`elemops.ElementLabel`** is now the one rule for "sidecar label, else the element name". It was previously written inline twice in `linktargets.go`, and the history needed it too. ## Verification - `go vet ./...` and `go test ./...` pass. - `cd web && npm test` passes: 124 files, 3213 tests. - Opt-in cost check (`DECKLE_HISTORY_PERF=1 go test ./internal/server -run TestElementHistoryCostBound -v`): on a generated repository of 300 pages × 15 elements and 2,000 publishes touching 3 pages each, one default-window request has a median of about 190 ms, against the 250 ms bound. The wall-clock check sits behind an env var so it can't flake in shared CI; `BenchmarkElementHistory` measures the same request. - The pickaxe assumption holds on git 2.47.3: only files whose count changed are listed. `TestPickaxeListsOnlyCountChangedFiles` covers it. ## Notes - No change to `scaffold/site/AGENTS.md`, the theme README or any skill, because no sidecar, grammar or procedure changes. - The ADR's source line reads `Issue #283.` and should become the commit/PR reference once merged, as earlier ADRs do. - Possible later speed-ups, not made: parse each blob once across neighbouring versions, and grep `main`'s tip once instead of twice. Together they would save about 30–40 ms. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(history): GET /api/element/history — an element's publish history derived from main
All checks were successful
ci / build-and-test (pull_request) Successful in 4m21s
ci / release (pull_request) Has been skipped
db54e2bb5b
Add a read-only, per-uid-and-language publish history of one content
element, read on demand from main's first-parent chain (ADR 0023):

- internal/elemops/compare.go becomes the one owner of the element
  comparison: the pending panel's fingerprint and uid matching move here
  from internal/server/pending.go (its counts unchanged), extended by
  DiffElement to a labelled field diff (params in the form's widget
  shapes, body, items rows by uid, enabled, a source fallback) with a
  word-level diff for text-like fields (new internal/worddiff).
- internal/gitrepo/history.go: MainTip, NeedleHistory (git grep + pickaxe
  over a 1,000-commit range window with a cursor, one cat-file --batch)
  and NeedleFiles, all pinned to one resolved tip and read-only.
- internal/server/history.go: the route, with a pending top row,
  created/changed/removed entries, moves/renames/reorders followed but
  never listed, degradation like the pending panel, and new codes
  history.badCursor, history.unknownElement, history.read, label.body,
  label.source (catalogs updated).
- elemops.ElementLabel owns "sidecar label, else name", now shared by the
  link picker rows, link chips and the history.
- docs/api.md documents the route; ADR 0023, architecture.md and
  docs-map.md record the derivation. An opt-in timed test and benchmark
  (DECKLE_HISTORY_PERF=1) observe ~190 ms per request on a 300-page,
  2,000-publish repository.

Closes #283

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS. Signal relied on: CI aggregate success (ci / build-and-test: npm ci/build/test, go vet ./..., go test ./..., go build). Not re-run. gofmt -l on the changed Go files is clean.

Conventions: the Conventional Commits title is fine, Closes #283 is present, and the branch is level with main (merge-base = origin/main bf70703).

Scope against #283: every acceptance criterion is implemented and tested.

  • The comparison moved to internal/elemops/compare.go, and pending.go keeps no fingerprint or matching of its own. Its unit tests moved with it to compare_test.go.
  • The git reader in internal/gitrepo/history.go is pinned to one MainTip and reads only. It uses -S with renames off and a range window with a cursor. Merges on main are covered (history_test.go:265).
  • The handler in internal/server/history.go degrades like the pending panel and never takes the mutation lock.
  • docs/api.md has the route heading. ADR 0023 is added and linked from architecture.md. The new codes are in servermessages.json, en.json and de.json.
  • The ElementLabel extraction in linktargets.go applies the one-owner rule to a rule the history also needs, so it is not a drive-by change.
  • Rightly, nothing changed in AGENTS.md or the skills: no sidecar, grammar or procedure changed.

Nits (not blocking): the ADR 0023 source line reads 'Issue #283.' The retro-written ADRs 0001–0022 carry commit and PR references, so a later docs sync can add them. The package comment's first paragraph in internal/gitrepo/doc.go has one over-long line after the edit.

[autoland] verdict: pass PASS. Signal relied on: CI aggregate `success` (ci / build-and-test: npm ci/build/test, go vet ./..., go test ./..., go build). Not re-run. gofmt -l on the changed Go files is clean. Conventions: the Conventional Commits title is fine, `Closes #283` is present, and the branch is level with main (merge-base = origin/main bf70703). Scope against #283: every acceptance criterion is implemented and tested. - The comparison moved to internal/elemops/compare.go, and pending.go keeps no fingerprint or matching of its own. Its unit tests moved with it to compare_test.go. - The git reader in internal/gitrepo/history.go is pinned to one MainTip and reads only. It uses -S with renames off and a range window with a cursor. Merges on main are covered (history_test.go:265). - The handler in internal/server/history.go degrades like the pending panel and never takes the mutation lock. - docs/api.md has the route heading. ADR 0023 is added and linked from architecture.md. The new codes are in servermessages.json, en.json and de.json. - The ElementLabel extraction in linktargets.go applies the one-owner rule to a rule the history also needs, so it is not a drive-by change. - Rightly, nothing changed in AGENTS.md or the skills: no sidecar, grammar or procedure changed. Nits (not blocking): the ADR 0023 source line reads 'Issue #283.' The retro-written ADRs 0001–0022 carry commit and PR references, so a later docs sync can add them. The package comment's first paragraph in internal/gitrepo/doc.go has one over-long line after the edit.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!287
No description provided.