summaryrefslogtreecommitdiff
path: root/app/models/node_action.rb
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-16 02:40:03 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-16 02:40:03 +0200
commitc0c9250141c2fa64fed967b8b9ad9bada3694c3d (patch)
tree8a36020521dafc0d259d5d78461c59a4635b6ba4 /app/models/node_action.rb
parentfb7b8233f1bbf7acb88d48f96e6f81cefe168d1c (diff)
Record the full lifecycle contract in NodeAction entries
A contract comment above NodeAction.record! now specifies every verb's metadata shape. NodeAction.head_diff computes the publish diff between an outgoing head and its replacement -- default-locale title pair always, author/tags pairs and template/assets/abstract/ body flags only when changed, and a per-locale translation_diff with added/removed/changed status. It is a pure function of its two pages, shared by publish, rollback, and the future backfill, and reads translation rows directly so fallbacks never masquerade as content. publish entries carry via ("draft" or "revision"); restore_revision! is now transactional, takes the acting user, and logs through the same diff. Staged slug/parent changes applied at publish log a move entry with the path pair. Node creation logs a create entry with initial title and path. The draft-scoped translation_destroy writer is retired -- locale removal is recorded by the publish diff, where it becomes public fact.
Diffstat (limited to 'app/models/node_action.rb')
-rw-r--r--app/models/node_action.rb132
1 files changed, 132 insertions, 0 deletions
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index f32b5b7..39935c7 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -6,6 +6,82 @@ class NodeAction < ApplicationRecord
6 validates :action, presence: true 6 validates :action, presence: true
7 validates :occurred_at, presence: true 7 validates :occurred_at, presence: true
8 8
9 # == Metadata contract ==
10 #
11 # metadata is written once at creation and never updated. It is the
12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y} --
15 # never _old/_new suffixes. Optional pairs are written only when the
16 # two sides differ; required keys are always present. All titles and
17 # names are read pinned to I18n.default_locale unless inside a
18 # locale-keyed block.
19 #
20 # Baseline, every entry (written here, not by call sites):
21 # "username" -- actor's login at write time
22 # "human_readable_node_name" -- node title, default locale
23 #
24 # "create":
25 # "title" -- initial title, default locale
26 # "path" -- unique path at creation time. Historical value only:
27 # later renames/moves do NOT update old entries. Never
28 # use as a join key; node_id is the join key while the
29 # node lives.
30 #
31 # "publish" (any promotion of a page to head; the diff against the
32 # outgoing head is computed BEFORE head is re-pointed, over the
33 # union of both pages' locales, by the shared diff function also
34 # used by the backfill):
35 # "via" -- "draft" (ordinary publish) | "revision" (rollback
36 # via restore_revision!). Always written; absent
37 # means a pre-contract entry.
38 # "title" -- pair, always present. "from" is null on a first
39 # publish (no outgoing head).
40 # "author" -- pair, only when the byline (page.user) changed
41 # "tags" -- pair of arrays, only when changed
42 # "assets_changed", "template_changed"
43 # -- booleans, only when true; page_id links to the
44 # revision for the real diff (never stored here)
45 # "abstract_changed", "body_changed"
46 # -- booleans for the default locale, only when true
47 # "translation_diff" -- only when any non-default locale differs:
48 # { "<locale>" => {
49 # "status" -- "added" | "removed" | "changed"
50 # "title" -- pair; "from" null when added,
51 # "to" null when removed
52 # "abstract_changed", "body_changed" -- booleans, only when
53 # true, only for status "changed"
54 # } }
55 #
56 # "move" (reparenting and/or unique-path change; one entry at the
57 # subtree root, descendants get none -- a descendant's own zoomed
58 # view will not show path history):
59 # "path" -- pair
60 #
61 # Reserved for the Trash feature, final shape decided there:
62 # "demote" (via "trash" | "depublish"; carries the leaving-public-
63 # view snapshot: head presence, final published_at), "trash",
64 # "restore_from_trash", "destroy" (final path only; publish-state
65 # snapshot lives on the entry that removed it from public view).
66 # Whether trash logs one entry or a trash+demote pair is decided
67 # with that feature.
68 #
69 # Backfilled entries mirror this vocabulary exactly. Their diff
70 # content is computed, not guessed (consecutive revisions still
71 # exist); only actor (from page.editor) and occurred_at are
72 # inferred, and inferred_from names the heuristic per entry
73 # ("from_page_revision", "from_published_at_heuristic").
74 # inferred_from null = witnessed live.
75 #
76 # The "locale" column is currently written by no verb (it belonged
77 # to the retired translation_destroy) and is retained for backfill
78 # or future draft-scoped verbs.
79 #
80 # This log records; it does not undo. Reversibility stays in
81 # restore_revision! and the revisions system. No IP, session, or
82 # user agent, ever. Success only -- rejected attempts are not
83 # logged.
84
9 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) 85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra)
10 create!( 86 create!(
11 :node => node, 87 :node => node,
@@ -23,6 +99,62 @@ class NodeAction < ApplicationRecord
23 ) 99 )
24 end 100 end
25 101
102 # Computes the publish-entry diff between an outgoing head and the
103 # page replacing it, in the exact metadata shape the contract above
104 # specifies. Pure function of its two arguments -- shared verbatim by
105 # publish_draft!, restore_revision!, and the backfill task. Reads
106 # translation rows directly, never locale-dependent accessors, so a
107 # fallback value is never mistaken for real content. Returns
108 # symbol-keyed top level for splatting into record!; nested keys are
109 # strings and jsonb serialization stringifies the rest at write time.
110 def self.head_diff old_page, new_page
111 default = I18n.default_locale
112
113 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
114 diff = { :title => { "from" => title_of.call(old_page),
115 "to" => title_of.call(new_page) } }
116 return diff unless old_page
117
118 old_author, new_author = old_page.user&.login, new_page.user&.login
119 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
120
121 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
122 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
123
124 diff[:template_changed] = true if old_page.template_name != new_page.template_name
125 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
126
127 old_t = old_page.translations.find_by(:locale => default)
128 new_t = new_page.translations.find_by(:locale => default)
129 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
130 diff[:body_changed] = true if old_t&.body != new_t&.body
131
132 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
133 translation_diff = {}
134
135 locales.sort_by(&:to_s).each do |locale|
136 o = old_page.translations.find_by(:locale => locale)
137 n = new_page.translations.find_by(:locale => locale)
138
139 if o.nil?
140 translation_diff[locale.to_s] = { "status" => "added",
141 "title" => { "from" => nil, "to" => n.title } }
142 elsif n.nil?
143 translation_diff[locale.to_s] = { "status" => "removed",
144 "title" => { "from" => o.title, "to" => nil } }
145 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
146 entry = { "status" => "changed" }
147 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
148 entry["abstract_changed"] = true if o.abstract != n.abstract
149 entry["body_changed"] = true if o.body != n.body
150 translation_diff[locale.to_s] = entry
151 end
152 end
153
154 diff[:translation_diff] = translation_diff if translation_diff.any?
155 diff
156 end
157
26 def actor_name 158 def actor_name
27 metadata["username"] || "unknown" 159 metadata["username"] || "unknown"
28 end 160 end