docs/Features/Reports/History/History.md
This page uses the shared Table Page design. Layout, search, pagination, column spec, per-page data loading and RTL rules are defined there and are not repeated here. Below is only what is specific to History: its store, its scopes, and restore/undo.
Status: Draft for approval · Owner: xet7 · Related: card details view, Member settings,
Activities, userPositionHistory, docs/Features/Undo/Undo.md
This document specifies one unified change-history subsystem that records every change a user
makes, keeps it append-only, and lets changes be restored. It is surfaced from many menus, but every one is the same table + restore over the same
store, differing only by the scope filter it passes to changeHistory.page. A History
option is added to each of these menus (and the pattern generalises to any future entity menu — the
UI and method don't change, only the scope):
| Menu / location | View shows the history of… | Scope filter | Section |
|---|---|---|---|
| Card group menu (open card) | that group on that card, with a per-contributor avatar list | { cardId, group } | 7 |
| Card menu (open card) | the whole card (all its groups) | { cardId } | 7a |
| Member settings menu | that user's own changes | { userId } | 7a |
| Board Settings | the whole board (all users/entities) | { boardId } | 7a |
| Swimlane menu | that swimlane and its contents | { scope:'swimlane', scopeId } | 7a |
| List menu | that list and its cards | { scope:'list', scopeId } | 7a |
| (future) Checklist menu, Attachment menu, … | that entity | { entityType, entityId } | 7a |
Scopes nest: a card's history ⊂ its list's ⊂ its swimlane's ⊂ the board's. Container scopes
(board/swimlane/list/card) therefore mean "this entity and its descendants" — implemented as an
OR over the relevant id columns (boardId / swimlaneId / listId / cardId / entityId), which
is why the write side (section 5) stores all the applicable id columns on every row.
Plus one keyboard front-end:
Ctrl+Z / Ctrl+Y = "restore the current user's most recent change from this
history" / "re-apply it". Undo/Redo is therefore not a separate feature; it is the keyboard
restore of the newest own change. (v1 undo/redo of position moves already shipped in #6478 via
userPositionHistory; this design generalises that to every change and merges the two.)It is a design doc only — no code is implied as final until this is approved.
Scope change (this revision): the earlier draft covered only card groups. Per request, the model now records every change (all card fields/groups and board/list/swimlane/etc. structural changes), per user, and adds the Member-settings per-user view.
userPositionHistorybecomes a special case that this unified store supersedes.
On the card open-details view, each group menu gains an option History. Clicking it opens a big popup:
WeKan already logs actions in the Activities collection, but that collection records what
happened (an activityType plus references: cardId, listId, memberId, …) — not the
before/after values. Example: editing a description logs activityType: 'changedDescription'
with the card id, but not the previous text.
Consequences:
Activities.Activities, because the previous content is not
stored. Only userPositionHistory stores before/after (for positions).Therefore the feature splits into two efforts:
"Group" maps to the sections of the card details view. Proposed coverage (phase order later):
| Group | Entity / field | Change types |
|---|---|---|
| Description | cards.description | edited |
| Title | cards.title | edited |
| Labels | cards.labelIds[] | added, removed |
| Members / Assignees | cards.members[], cards.assignees[] | added, removed |
| Dates | received/start/due/end | added, edited, removed |
| Checklists | checklist + items | added, removed, edited, checked/unchecked, moved |
| Subtasks | linked subtask cards | added, removed, moved |
| Attachments | files/avatars | added, removed, renamed |
| Comments | card_comments | added, edited, removed |
| Custom fields | cards.customFields[] | edited |
Change types are a small closed set with i18n keys, e.g. history-change-added,
history-change-removed, history-change-edited, history-change-moved,
history-change-restored. Added to imports/i18n/data/en.i18n.json only (translations follow via
Transifex; see the translation-pull auto-heal note in the changelog).
One new append-only Mongo collection changeHistory (working name) covering every change,
whatever the entity. One document per change:
{
_id,
boardId, // for permission scoping + publications (null for non-board changes, if any)
// What was changed — general, not card-only:
entityType, // 'card' | 'list' | 'swimlane' | 'board' | 'checklist' | 'checklistItem'
// | 'comment' | 'attachment' | 'customField' | ...
entityId, // the changed entity's _id
cardId, // set when the change belongs to a card (drives the card-group view); optional
group, // logical group for the card view: 'description' | 'labels' | 'members'
// | 'dates' | 'checklists' | 'title' | ... (optional for non-card changes)
changeType, // 'added' | 'removed' | 'edited' | 'moved' | 'restored'
// Content for display + restore — structured, not just strings:
previousContent, // blackbox; null for 'added'
newContent, // blackbox; null for 'removed'
userId, // WHO made the change — the axis the Member-settings view filters on
createdAt, // Date; formatted client-side with the viewer's/card's date format
// Undo/redo stack (folds in #6478's userPositionHistory fields):
undone, // Boolean — restored/undone, redoable until superseded
undoneAt, // Date — orders the redo stack
batchId, // groups a multi-entity change (e.g. multi-select move / multi-restore)
// Restore provenance (set only when changeType === 'restored'):
restoredFromId, // the changeHistory _id whose content was restored
restoredByUserId, // who performed the restore
}
Notes:
undone/undoneAt flip for
the undo/redo stack). Retention cap via a server cron (à la userPositionHistory.cleanup).previousContent/newContent are blackbox so each entity/group stores what it needs
({ text } for description, { labelId } for a label, { millis } for a date, { sort, swimlaneId, listId, boardId } for a move, …).userPositionHistory. That collection's move rows map 1:1 onto this schema
(entityType card/list/swimlane, changeType: 'moved', previous/new = the position). Migration:
keep userPositionHistory writing during transition, or one-time copy its rows in; the undo/redo
methods move to read changeHistory.Activities with before/after content. Rejected for v1 —
Activities is deliberately schemaless/high-volume and drives notifications/webhooks; overloading
it risks those paths. A dedicated collection keeps concerns separate and independently cappable.Record on the server, in every mutation path (not only card groups), capturing the value before and after. A single helper:
ChangeHistory.record({ boardId, entityType, entityId, cardId?, group?, changeType, previousContent, newContent, userId, batchId? });
called from the existing setters/methods — card fields (Cards.setDescription, title, label
add/remove, member/assignee add/remove, date setters, custom fields), card sub-entities
(checklist/checklist-item, comment, attachment mutations), and structural changes (list/swimlane
create/rename/move/archive, board-level changes). Position moves come in via the same helper
(replacing userPositionHistory.trackChange).
typeof UserPositionHistory !== 'undefined' was false
without an import (fixed in #6478). This is the single most important implementation lesson.Activity; recording history
next to Activities.insert (with the extra before/after content) avoids sprinkling calls
everywhere. Evaluate during phase 1.Only the current page is loaded. One method (not a naive reactive publication of the whole log) serves the card-group view, the per-user Member-settings view, and any filter combination:
Meteor.call('changeHistory.page', {
// scope (any subset; container scopes match the entity AND its descendants):
scope, // 'board' | 'swimlane' | 'list' | 'card' — the container kind
scopeId, // that container's _id (boardId / swimlaneId / listId / cardId)
group, // narrow a card scope to one group (card-group view)
userId, // one contributor — Member view, or an avatar click within another scope
// list controls:
search, // matches changeType label + content text
page, pageSize, // 1-based page, server clamps pageSize
}) -> { rows, total, page, pageSize, contributors: [{ userId, count }] }
The server turns {scope, scopeId} into the id-column filter (board→boardId; swimlane→
swimlaneId OR its lists'/cards' rows; list→listId OR its cards'; card→cardId), then applies
userId/group/search on top. Member-settings view passes just { userId } (optionally
+ scope:'board' to limit to the current board).
requireBoardVisible). The Member-settings view is scoped to boards the caller can see; it
never leaks a user's changes on boards the caller can't access.newContent/previousContent (same cross-environment numeric/text caveat as card search).userId + counts);
unused when the view is already pinned to one userId.pageInfo() from models/lib/tablePage.js (see
Table Page) — do not add a second paginator. The History-specific
pure helpers are matchesSearch(row, term) and selectionToIds(selected), in models/lib/…
with tests, mirroring models/lib/undoRedoSelection.js.A table page inside one popup opened from the group menu's History item. Only the History-specific parts are listed here:
historyNav) — a History button (default view = newest, all users) plus a
list of +userAvatar (fallback initials) built from contributors. Selecting one sets the
userId filter. This pane is unique to History; no other table page has one.{{_ changeTypeKey}}), the content,
and the datetime..js-history-restore) next to the shared search and
pagination, acting on the checked rows (section 8).group, userId filter, search, page and the Set of selected row ids
live in a ReactiveDict on the template instance, not on the Blaze data context (#6479).Every non-card-group surface in the table above is the same historyTable with a different
changeHistory.page scope; there is one implementation, parametrised by scope. They share the
columns, search, pagination, RTL, and restore (+ dual re-logging) of section 7/8.
{ userId } to the current scope (that user's changes within this scope).Concretely, adding "History" to a new menu = (1) a menu item that opens historyPopup with a scope,
(2) — nothing else. No new method, table, or restore code.
Ctrl+Z / Ctrl+Y are the keyboard front-end to this history for the current user on the current
board:
changeHistory row (mark it
undone), for any entityType/changeType — not just moves.undone rows).This generalises the shipped #6478 methods: userPositionHistory.undoLast/redoLast become
changeHistory.undoLast/redoLast reading the unified store; the selection rule stays the pure,
tested pickUndo/pickRedo; the key bindings in client/lib/keyboard.js are unchanged. "Restore
selected row" (History UI) and "undo last" (keyboard) are the same operation on the same data.
Undo restores content via the same setters as a normal edit (so validation/Activities run), and the restore is itself appended to history (see section 8) — so undo is auditable and itself undoable/redoable.
Meteor.call('cardGroupHistory.restore', historyId):
previousContent (or newContent, per the row's semantics) back to the live group via the
same setters used for normal edits (so validation/activities still run).cardGroupHistory rows with changeType: 'restored' and restoredFromId:
userId of the restored row) — "their" data was
restored,Meteor.userId()) — who restored what to become current.batchId).Per-change-type restore functions live next to each group's setter (description/labels/dates/…), so
each knows how to re-apply its own previousContent.
requireBoardVisible / write access), like
updateListSort and userPositionHistory.*.cardGroupHistory (append-only invariant).userPositionHistory.cleanup pattern).This section is here because the snap now depends on it (#6583, #6585). It is a consequence of section 9's invariant, not a new rule.
The situation. A WeKan snap can end up holding TWO copies of its data that
have both been written to since they were copies of each other: the MongoDB to
FerretDB migration is a snapshot and nothing keeps it in step, snap revert does
not roll back $SNAP_COMMON, and both databases can be written to
back and forth. Which copy gets served then decides what a user sees, and getting
it wrong looks exactly like data loss — that is what both of those issues were.
Why history decides it. File timestamps cannot answer "which copy holds the
work": an mtime says when a file was touched, and merely starting a database
touches its files. The DATA can answer it, and history is the part of the data
that answers it best — every change a user makes writes a row, so the newest
history row is the newest moment somebody was actually working, on either side.
snap-src/bin/db-eval.mjs evidence reads exactly that (per-collection counts plus
the newest timestamp any document carries) and snap-src/bin/database-choose.mjs
compares the two.
Why the other copy is not lost. Because history is APPEND-ONLY — never
rewritten in place, never updated, only added to — rows from one copy can be
inserted into the other without contradicting anything already there. So the snap
serves the copy holding the newer work and copies into it every document whose
_id is ABSENT from it (snap-src/bin/database-merge-missing.mjs):
snap set away;changeHistory rows
written on the other copy become part of the served copy's history, so the work
done there is READABLE IN THE CARD'S HISTORY rather than stranded in a database
nobody opens.What is deliberately not attempted. Reconciling two edits of the same field — a three-way merge — is a decision about somebody's work and is not made by a script. When the two copies cannot be told apart (their newest moments are within hours of each other, or neither carries a timestamp), the snap changes nothing and says so, which is the behaviour #6583 arrived at the hard way.
What this design owes the snap, when it ships:
changeHistory row keeps a stable, content-derived or random _id that
is never reused, so "absent by _id" is a safe test for "this row is not here".changeHistory is listed in the merge's collection list
(MERGE_COLLECTIONS in snap-src/bin/database-choose.mjs) the moment the
collection exists — the list is the only place the snap learns which
collections carry history.changeHistory model + write helper + pure helpers (paging/search/selection/pick undo-redo) +
unit tests. Migrate the shipped position undo/redo onto it: point
changeHistory.undoLast/redoLast at the new store and record card/list/swimlane moves there
(this both proves the model and keeps #6478 working). Ctrl+Z/Ctrl+Y now read changeHistory.changeHistory.page) + the viewer UI (table, search, pagination, avatars); LTR then RTL.
Ctrl+Z now also undoes a description edit.changeHistory.page method + historyTable UI with a different scope
({ userId } vs { boardId }), so they land together once step 3's table/restore exist.page method + UI.userPositionHistory recording was dead because
trackChange was guarded by typeof … !== 'undefined' without importing the collection (fixed in
#6478). The new history helper must be a real import.ReactiveDict.models/lib/undoRedoSelection.js) so the
logic is unit-testable without the Meteor/Blaze runtime.