feat(history): GET /api/element/history — the publish history of one content element, derived from main #283
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#283
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add a read-only, TYPO3-style publish history for one content element: a new
GET /api/element/historythat lists every publish tomainthat changed the element's own content, each with a field-level diff labelled by the element's form, plus a top row for the element's unpublished working-tree change. The history is derived from git on demand; nothing is written at save or publish time. This is the server half; the editor's sheet and menu entries are a separate issue that depends on this one. The visual reference isdocs/mockups/element-history/(issue #282; artifact https://claude.ai/artifact/22NW7Pjqc1QUSUEJCxv1qT, private to the owner).Current behavior
refs/deckle/wipin WIP mode, a draft-branch commit in branch mode;internal/gitrepo, ADR 0003 and 0011). Undo is single-step and page-scoped. There is no way to see what earlier publishes changed in an element.main's first-parent chain is one commit per publish (ADR 0005, 0013). In branch mode the draft branch is rebased and collapsed after a publish, so onlymainholds durable history.mainis read through the main comparison ref (Repo.mainRef():refs/heads/mainin WIP mode, the remote-tracking main in branch mode).internal/server/pending.go) already compares a page's elements onmainwith the working tree:collectElements,elementFingerprint(name, tag form, params minus the uid, body text, folded item/text children; nested role-elementchildren excluded) anddiffElements(match by uid, uid-less elements by fingerprint) produce the added/changed/removed counts of apendingChange. That is the only owner of "what changed in an element" today, and it yields counts, not fields.uidparam (internal/elemops/uid.go), stable across moves, reorders and transfers (a transfer re-stamps only when the destination already holds the uid). The engine's one spelling of a string param isuid="<uid>"(content.ParamValueString); links to an element use the different form#c-<uid>(internal/linkrefs). A connected translation keeps the source's uids in its own file beside the page (index.de.md), ADR 0016.POST /api/form): every field has a label and a widget name;web/src/widgets/names.jsonpins the widget vocabulary.Desired behavior
The route
GET /api/element/history?uid=<uid>&lang=<code>&before=<commit>answers the publish history of the element with that uid in that language's file (the default language whenlangis absent).beforeis the cursor from a previous answer: history continues from the publish before it. Absent: frommain's tip.mainand answersnext(the commit to pass asbefore) when the chain continues beyond the window, absent when it is exhausted.pending: one entry for the working tree againstmain, present only when the element differs there (added, changed or removed), computed with the same comparison as the pending panel;entries: newest first, one per publish in the window in which the element's own content changed, or in which it first appeared or disappeared;next.kindofcreated(first publish carrying the uid in this language's file, or the uid returning after a publish without it),changedorremoved, andfields: the field-level diff against the element's previous version (forcreated, every field against nothing; forremoved, every field against nothing the other way).body, the items collection name, orenabled), its form label, its widget, and its old and new values in the widget's shape. For text-like widgets (text,multiline, the body, rich text) it additionally carries a word-level diff as a sequence of equal/deleted/inserted runs, so the client renders inline without diffing. An items collection reports its rows matched by uid as kept, added, removed or changed, a changed row with its own field changes.enabledreports the reserved disabled state as a boolean field labelled by the Enabled field'sswitchsettings.changedentry. A pure reorder within the page produces no entry (the fingerprint ignores position). Nested role-elementchildren are not part of their parent's history; they have their own.main, the route answers200with emptyentries, nopending, andunavailablewith its coded form. Errors:400request.missingParam {name: "uid"},404editor.unknownLang, and a404when neither the working tree nor the scanned window holds the uid in that language. New codes followinternal/apimsg(one owner per code, exported toweb/src/i18n/servermessages.json; the editor's parity test needs theen.json/de.jsonentries in the same change).maintip resolved once at the start of the request, so a publish landing mid-request cannot mix two states.The reader
main's first-parent chain, restricted to the content root, with the fixed stringuid="<uid>", listing the files per commit (git log --first-parent -S'uid="<uid>"' --name-only <range> -- content/). Each hit is the element entering or leaving a file; without--pickaxe-allgit lists only the files where the count changed. The window is a commit range (<tip>~1000..<tip>or the cursor), never-n, which does not bound the scan. Use-Swith a fixed string, not-G: twice as fast and the spelling is canonical.internal/pagepath) selects the requested language's file.git cat-file --batch, parsed withinternal/content, the element located by uid withinternal/elemops, and consecutive versions whose fingerprint is equal collapsed to one.Decisions
main, never a second store — the snapshots andmainalready hold every version; a journal would be a second evaluation of "what changed" (CLAUDE.md, one owner per rule) and would be blind to hand edits and host reconciles. Record this as a new ADR.main's first-parent chain only — in branch mode per-save history is rewritten and collapsed at every publish, so it cannot be a durable history;mainis the one chain both modes share and never rewrite.internal/elemopsand extended from counts to fields;pendingChange.elementsis computed from the new owner's result. Adding a sibling comparison would be the #230 class of defect.created,changed,removed— a reorder is silent; enable/disable is theenabledfield inside achangedentry.Key interfaces
internal/elemops— the new owner of the element comparison: a function that, given an element's previous and current shortcode (either may be nil) and the schemas, returns the structured field diff described above, and the fingerprint the pending panel uses today.internal/server/pending.go'scollectElements,elementFingerprintanddiffElementscall or move into it; the counts stay what they are.internal/gitrepo— the residency scan and the version reads over the main comparison ref (mainRef()), pinned to one resolved tip; a new file besidelog.go/show.go, documented in the package comment like the other operations. Site-relative paths in and out,SitePrefixapplied internally as everywhere in the package.internal/server— the handler, registered in the route table and gated likeGET /api/publish/pending(quiet degradation,200withunavailable); the form description's labels and widgets for the element's schema (forms.go); the word-level diff for text-like widgets.internal/apimsg— the new codes.docs/api.mdgets a### GET /api/element/historyheading (the route test ininternal/serverenforces registered routes ⇔ headings);docs/architecture.md"Git backbone" gets a paragraph linking it; a newdocs/adr/0023-…records the derivation decision and is listed indocs/adr/README.md;docs/agents/docs-map.mdif the area is new there. No change toscaffold/site/AGENTS.md, the theme README or any skill: no sidecar, grammar or procedure changes.internal/server/pending.go(comparison),internal/server/git.go(how git-backed routes degrade),internal/gitrepo/log.goandshow.go(reading commits and blobs),internal/gitrepo/helpers_test.go(building repositories in tests),internal/server/forms.go(labels and widgets).Acceptance criteria
GET /api/element/historyis registered, documented indocs/api.mdwith request, response and every code, and the route and docs drift tests passchangedentries newest first, each with the right fields, old and new values and labels, and acreatedentry for the first publishchangedentrychangedentry whose only field isenabledpendingis present with the working-tree diff; without, it is absent; the pending panel's counts are unchanged for every existing pending testlang=de) lists the publishes that changedindex.de.md's element and none that changed only the sourcenextwhen older history exists; passing it asbeforecontinues without gaps or duplicates; the last window answers nonextmainmain:200withunavailableand its code; missinguid:400; unknown language:404; unknown uid:404internal/elemopsonly;internal/server/pending.goholds no fingerprint or matching of its owndocs/architecture.mdlinks it;web/src/i18n/servermessages.json,en.jsonandde.jsoncarry the new codes and the i18n parity test passesHow to verify
go test ./...andcd web && npm test(the parity test needs the catalog entries;go testneedsweb/dist, see the Makefile)gitrepotest that builds a repository with a scripted publish history (edits, a transfer, a rename, a reorder, a translation) with the package's test helpers and checks the entries against the scripttestdata/sitewith a scriptedmain, asserting the wire shape of each entry kind, the pending row, the cursor and the degradationsAssumptions to check
--pickaxe-alllists only the files whose match count changed, on the git version the hosted image ships; the measurement used git 2.47content.ParamValueStringis the only spelling a uid ever takes in a stored file, including hand-written sites thatharness checkacceptsforms.go) can be built for an element's schema without a page context, for aremovedelement whose page no longer holds itOut of scope
POST /api/page/save)Blocked by
None - can start immediately