ecoportal-api-graphql 1.3.9 → 1.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ai-assistance/code/diff_pairing_engine.md +243 -0
- data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
- data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
- data/.ai-assistance/code/workflow-command-guide.md +28 -0
- data/.ai-assistance/projects/TODO.md +21 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
- data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
- data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
- data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
- data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
- data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +77 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +97 -0
- data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
- data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
- data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
- data/.ai-assistance/projects/template-maintenance/README.md +14 -0
- data/CHANGELOG.md +116 -0
- data/docs/worklog.md +408 -0
- data/ecoportal-api-graphql.gemspec +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
- data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
- data/lib/ecoportal/api/graphql/builder.rb +17 -16
- data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
- data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
- data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
- data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
- data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
- data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
- data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
- data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
- data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
- data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
- data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
- data/lib/ecoportal/api/graphql/diff.rb +34 -0
- data/lib/ecoportal/api/graphql/fragment/location_draft.rb +53 -53
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
- data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/query/location_structure/draft.rb +62 -62
- data/lib/ecoportal/api/graphql/query/location_structure.rb +61 -61
- data/lib/ecoportal/api/graphql.rb +139 -138
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- data/tests/dump_template_model.rb +90 -0
- data/tests/validate_queries.rb +127 -0
- metadata +32 -3
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# Self-version diff: two snapshots of the SAME page/template (same Mongo ids).
|
|
6
|
+
#
|
|
7
|
+
# Because the object keeps its ids across versions, pairing is EXACT (match by id) — no
|
|
8
|
+
# equivalence matching needed. This is the "what did this ticket change to the UAT template"
|
|
9
|
+
# tool, and the basis of a portable changelog ("commit") that — once id-translated through a
|
|
10
|
+
# pairing map — can be replayed onto PROD. Cross-object diff (UAT<->PROD, page<->template)
|
|
11
|
+
# is a different, harder problem handled by the pairing engine (not here).
|
|
12
|
+
#
|
|
13
|
+
# Operates on the raw page/template doc (the JSON the gem returns for a page/template), so it
|
|
14
|
+
# is a shared, session-less capability usable by admin/troubleshooting/integration/QA alike.
|
|
15
|
+
#
|
|
16
|
+
# diff = Diff::VersionDiff.new(before_doc, after_doc)
|
|
17
|
+
# diff.changes # => [Change, ...]
|
|
18
|
+
# diff.summary # => { added:, removed:, changed:, moved:, total: }
|
|
19
|
+
# diff.changelog # => ["~ field 'Severity' ...", ...]
|
|
20
|
+
#
|
|
21
|
+
# MODALITIES — pass a `Diff::Strategy` to select a scope / move-sensitivity. The default
|
|
22
|
+
# strategy (id-paired, structural, move-aware, changelog) reproduces the pre-Phase-4 behaviour
|
|
23
|
+
# exactly, so `VersionDiff.new(before, after).changes` is unchanged. A narrower scope
|
|
24
|
+
# (`:config_only`, `:data_migration`) or a move-insensitive strategy filters the SAME computed
|
|
25
|
+
# change-set — the pairing is still by-id here (`VersionDiff` is the `:id` pairing front-end).
|
|
26
|
+
# Cross-object pairings (:genome/:type_label/:assisted) are handled by `CrossObjectDiff`.
|
|
27
|
+
class VersionDiff
|
|
28
|
+
# Typed field-configuration properties that this diff EMITS, keyed by the field's
|
|
29
|
+
# `__typename`. Each entry maps a `byType` input key (the sub-hash the schema's
|
|
30
|
+
# `editFieldConfiguration.byType` accepts) to the list of doc property names that make up
|
|
31
|
+
# that sub-hash. Only pairs where the READ property name matches the WRITE (byType) input
|
|
32
|
+
# key EXACTLY are listed here — this diff never fabricates a config change it cannot map
|
|
33
|
+
# back to a real command input.
|
|
34
|
+
#
|
|
35
|
+
# Deliberately conservative: e.g. PlainText carries `multiline` in the read model but the
|
|
36
|
+
# `plainText` byType input has no `multiline` key (it exposes `maxLength`), so PlainText
|
|
37
|
+
# config is NOT emitted here (it would be unmappable). Likewise `required` has no
|
|
38
|
+
# editFieldConfiguration key at all, so a `required` flip is not a config change we emit.
|
|
39
|
+
BYTYPE_CONFIG = {
|
|
40
|
+
'Gauge' => { gauge: %w[max] },
|
|
41
|
+
'Select' => { select: %w[dataType multiple flat other otherDesc] },
|
|
42
|
+
'Date' => { date: %w[showTime pastOnly todayButton] }
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def initialize(before_doc, after_doc, strategy: Strategy.default)
|
|
46
|
+
@before = before_doc || {}
|
|
47
|
+
@after = after_doc || {}
|
|
48
|
+
@strategy = strategy || Strategy.default
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The change-set, filtered by the strategy's scope + move-sensitivity. The full set is
|
|
52
|
+
# computed once (`all_changes`); the strategy decides what survives. The default strategy
|
|
53
|
+
# keeps everything (the historical behaviour).
|
|
54
|
+
def changes
|
|
55
|
+
@changes ||= @strategy.filter(all_changes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def summary
|
|
59
|
+
by_op = changes.group_by(&:op)
|
|
60
|
+
{
|
|
61
|
+
added: by_op.fetch(:added, []).size,
|
|
62
|
+
removed: by_op.fetch(:removed, []).size,
|
|
63
|
+
changed: by_op.fetch(:changed, []).size,
|
|
64
|
+
moved: by_op.fetch(:moved, []).size,
|
|
65
|
+
total: changes.size
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def changelog
|
|
70
|
+
changes.map(&:description)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
{ summary: summary, changes: changes.map(&:to_h) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# The complete id-paired change-set (all kinds, all ops) before any strategy filtering.
|
|
80
|
+
def all_changes
|
|
81
|
+
stage_changes + section_changes + field_changes + option_changes +
|
|
82
|
+
field_config_changes + gauge_stop_changes
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Generic set-diff over an index {id => info}. Emits added/removed (label/path pulled from
|
|
86
|
+
# info via the given procs) and delegates matched-id comparison to the block.
|
|
87
|
+
#
|
|
88
|
+
# `parent:` (optional) is a proc mapping an entry's info to the id of its OWNING node — the
|
|
89
|
+
# section id for a field, the stage id for a section. Recorded on ADDED changes as
|
|
90
|
+
# `parent_id` so the synthesiser can wire the structural back-ref (addField.sectionId /
|
|
91
|
+
# addStageSection.stageId), threading through a placeholder when the parent is created in
|
|
92
|
+
# the same batch. nil (default) means no parent id is known/recorded — the historical
|
|
93
|
+
# behaviour, unchanged.
|
|
94
|
+
def diff_index(kind, before_idx, after_idx, label:, path:, parent: nil, owner: nil, &compare)
|
|
95
|
+
procs = { label: label, path: path, parent: parent, owner: owner }
|
|
96
|
+
removed = (before_idx.keys - after_idx.keys).map { |id| removed_change(kind, id, before_idx[id], procs) }
|
|
97
|
+
added = (after_idx.keys - before_idx.keys).map { |id| added_change(kind, id, after_idx[id], procs) }
|
|
98
|
+
matched = (before_idx.keys & after_idx.keys).flat_map { |id| Array(compare.call(id, before_idx[id], after_idx[id])) }
|
|
99
|
+
removed + added + matched
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def removed_change(kind, id, info, procs)
|
|
103
|
+
Change.new(op: :removed, kind: kind, id: id, label: procs[:label].call(info), path: procs[:path].call(info))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def added_change(kind, id, info, procs)
|
|
107
|
+
Change.new(op: :added, kind: kind, id: id, label: procs[:label].call(info), path: procs[:path].call(info),
|
|
108
|
+
parent_id: procs[:parent]&.call(info), owner_id: procs[:owner]&.call(info))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stage_changes
|
|
112
|
+
diff_index(:stage, index_stages(@before), index_stages(@after),
|
|
113
|
+
label: ->(s) { s['name'] }, path: ->(s) { s['name'] }) do |id, b, a|
|
|
114
|
+
cmp = []
|
|
115
|
+
cmp << changed(:stage, id, a['name'], a['name'], 'name', b['name'], a['name']) if b['name'] != a['name']
|
|
116
|
+
cmp << moved(:stage, id, a['name'], 'ordering', b['ordering'], a['ordering']) if b['ordering'] != a['ordering']
|
|
117
|
+
cmp
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def section_changes
|
|
122
|
+
diff_index(:section, index_sections(@before), index_sections(@after),
|
|
123
|
+
label: ->(s) { s[:heading] }, path: ->(s) { s[:path] },
|
|
124
|
+
parent: ->(s) { s[:stage_id] }) do |id, b, a|
|
|
125
|
+
cmp = []
|
|
126
|
+
cmp << changed(:section, id, a[:heading], a[:path], 'heading', b[:heading], a[:heading]) if b[:heading] != a[:heading]
|
|
127
|
+
cmp << moved(:section, id, a[:heading], 'stage', b[:stage], a[:stage]) if b[:stage] != a[:stage]
|
|
128
|
+
cmp
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field_changes
|
|
133
|
+
diff_index(:field, index_fields(@before), index_fields(@after),
|
|
134
|
+
label: ->(f) { f[:label] }, path: ->(f) { f[:path] },
|
|
135
|
+
parent: ->(f) { f[:section_id] }, owner: ->(f) { f[:stage_id] }, &method(:compare_field))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def compare_field(id, before, after)
|
|
139
|
+
cmp = []
|
|
140
|
+
cmp << changed(:field, id, after[:label], after[:path], 'label', before[:label], after[:label]) if before[:label] != after[:label]
|
|
141
|
+
cmp << changed(:field, id, after[:label], after[:path], 'type', before[:type], after[:type]) if before[:type] != after[:type]
|
|
142
|
+
cmp << moved(:field, id, after[:label], 'section', before[:section], after[:section]) if before[:section] != after[:section]
|
|
143
|
+
cmp
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def option_changes
|
|
147
|
+
bf = index_fields(@before)
|
|
148
|
+
af = index_fields(@after)
|
|
149
|
+
(bf.keys & af.keys).flat_map do |fid|
|
|
150
|
+
options_diff(fid, af[fid][:path], bf[fid][:field], af[fid][:field])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def options_diff(field_id, path, before_field, after_field)
|
|
155
|
+
diff_index(:option, index_options(before_field), index_options(after_field),
|
|
156
|
+
label: ->(o) { o[:label] }, path: ->(_o) { path }) do |k, b, a|
|
|
157
|
+
cmp = []
|
|
158
|
+
cmp << changed(:option, k, a[:label], path, 'label', b[:label], a[:label], field_id) if b[:label] != a[:label]
|
|
159
|
+
cmp << changed(:option, k, a[:label], path, 'weight', b[:weight], a[:weight], field_id) if b[:weight] != a[:weight]
|
|
160
|
+
cmp
|
|
161
|
+
end.each { |c| c.parent_id ||= field_id }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- typed field configuration (byType) ---------------------------
|
|
165
|
+
|
|
166
|
+
# Per-type field-configuration changes (the `byType` body of editFieldConfiguration).
|
|
167
|
+
# Matched by retained field id (self-version). For each field type in BYTYPE_CONFIG, emit
|
|
168
|
+
# a `:field_config` change per changed property, carrying the byType key so the synthesiser
|
|
169
|
+
# can nest it under the right sub-hash. Properties absent from BYTYPE_CONFIG are ignored
|
|
170
|
+
# here (unmappable → never fabricated).
|
|
171
|
+
def field_config_changes
|
|
172
|
+
bf = index_fields(@before)
|
|
173
|
+
af = index_fields(@after)
|
|
174
|
+
(bf.keys & af.keys).flat_map do |fid|
|
|
175
|
+
config_diff_for_field(fid, bf[fid], af[fid])
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def config_diff_for_field(field_id, before_info, after_info)
|
|
180
|
+
type = after_info[:type]
|
|
181
|
+
return [] if before_info[:type] != type # a type change is handled/blocked elsewhere
|
|
182
|
+
|
|
183
|
+
(BYTYPE_CONFIG[type] || {}).flat_map do |by_type, props|
|
|
184
|
+
props.filter_map do |prop|
|
|
185
|
+
bv = before_info[:field][prop]
|
|
186
|
+
av = after_info[:field][prop]
|
|
187
|
+
next if bv == av
|
|
188
|
+
|
|
189
|
+
config_change(field_id, after_info, by_type, prop, bv, av)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def config_change(field_id, after_info, by_type, prop, before, after) # rubocop:disable Metrics/ParameterLists
|
|
195
|
+
Change.new(op: :changed, kind: :field_config, id: field_id, label: after_info[:label],
|
|
196
|
+
path: after_info[:path], attribute: prop, before: before, after: after,
|
|
197
|
+
parent_id: field_id, by_type: by_type)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --- gauge stops ---------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# Gauge fields carry an ordered list of `stops` ({ id, threshold, color }). Diff them per
|
|
203
|
+
# gauge field, matched by retained stop id (self-version). Added / removed / changed
|
|
204
|
+
# (threshold or color). parent_id = the gauge field id (the stop commands address it).
|
|
205
|
+
def gauge_stop_changes
|
|
206
|
+
bf = index_fields(@before)
|
|
207
|
+
af = index_fields(@after)
|
|
208
|
+
(bf.keys & af.keys).flat_map do |fid|
|
|
209
|
+
next [] unless af[fid][:type] == 'Gauge' && bf[fid][:type] == 'Gauge'
|
|
210
|
+
|
|
211
|
+
stops_diff(fid, af[fid][:path], bf[fid][:field], af[fid][:field])
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Explicit add/remove/change diff (NOT the generic diff_index): an ADDED stop must carry
|
|
216
|
+
# its full spec (threshold + color) on the Change so addGaugeFieldStop can be built without
|
|
217
|
+
# re-reading the doc.
|
|
218
|
+
def stops_diff(field_id, path, before_field, after_field)
|
|
219
|
+
before_idx = index_stops(before_field)
|
|
220
|
+
after_idx = index_stops(after_field)
|
|
221
|
+
|
|
222
|
+
removed = (before_idx.keys - after_idx.keys).map do |sid|
|
|
223
|
+
Change.new(op: :removed, kind: :gauge_stop, id: sid, label: before_idx[sid][:label],
|
|
224
|
+
path: path, parent_id: field_id)
|
|
225
|
+
end
|
|
226
|
+
added = (after_idx.keys - before_idx.keys).map do |sid|
|
|
227
|
+
s = after_idx[sid]
|
|
228
|
+
Change.new(op: :added, kind: :gauge_stop, id: sid, label: s[:label], path: path,
|
|
229
|
+
parent_id: field_id, after: { threshold: s[:threshold], color: s[:color] })
|
|
230
|
+
end
|
|
231
|
+
changed = (before_idx.keys & after_idx.keys).flat_map do |sid|
|
|
232
|
+
stop_attr_changes(field_id, sid, path, before_idx[sid], after_idx[sid])
|
|
233
|
+
end
|
|
234
|
+
removed + added + changed
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def stop_attr_changes(field_id, stop_id, path, before, after)
|
|
238
|
+
cmp = []
|
|
239
|
+
cmp << stop_changed(field_id, stop_id, path, 'threshold', before[:threshold], after[:threshold]) if before[:threshold] != after[:threshold]
|
|
240
|
+
cmp << stop_changed(field_id, stop_id, path, 'color', before[:color], after[:color]) if before[:color] != after[:color]
|
|
241
|
+
cmp
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def stop_changed(field_id, stop_id, path, attribute, before, after) # rubocop:disable Metrics/ParameterLists
|
|
245
|
+
Change.new(op: :changed, kind: :gauge_stop, id: stop_id, label: "#{attribute} #{after}".strip,
|
|
246
|
+
path: path, attribute: attribute, before: before, after: after, parent_id: field_id)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Stops keyed by id, carrying threshold + color (an ADD needs both to build the command).
|
|
250
|
+
def index_stops(field)
|
|
251
|
+
Array(field['stops']).each_with_object({}) do |s, h|
|
|
252
|
+
next unless s.is_a?(Hash) && s['id']
|
|
253
|
+
|
|
254
|
+
h[s['id']] = { threshold: s['threshold'], color: s['color'],
|
|
255
|
+
label: "#{s['threshold']} #{s['color']}".strip }
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# --- indexing helpers ---------------------------------------------
|
|
260
|
+
|
|
261
|
+
def index_stages(doc)
|
|
262
|
+
stages(doc).each_with_object({}) { |st, h| h[st['id']] = st if st['id'] }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def index_sections(doc)
|
|
266
|
+
out = {}
|
|
267
|
+
stages(doc).each do |st|
|
|
268
|
+
sections(st).each do |sec|
|
|
269
|
+
next unless sec['id']
|
|
270
|
+
|
|
271
|
+
out[sec['id']] = { heading: sec['heading'], stage: st['name'], stage_id: st['id'],
|
|
272
|
+
path: "#{st['name']} / #{sec['heading']}" }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
out
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def index_fields(doc)
|
|
279
|
+
out = {}
|
|
280
|
+
stages(doc).each do |st|
|
|
281
|
+
sections(st).each do |sec|
|
|
282
|
+
fields(sec).each do |f|
|
|
283
|
+
next unless f['id']
|
|
284
|
+
|
|
285
|
+
out[f['id']] = {
|
|
286
|
+
field: f, label: f['label'], type: f['__typename'] || f['type'],
|
|
287
|
+
section: sec['heading'], section_id: sec['id'], stage_id: st['id'],
|
|
288
|
+
path: "#{st['name']} / #{sec['heading']} / #{f['label']}"
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
out
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Options keyed by id when present, else by value (primary) / name (auxiliary) — the
|
|
297
|
+
# correct select-option identity (NOT genome).
|
|
298
|
+
def index_options(field)
|
|
299
|
+
Array(field['options']).each_with_object({}) do |o, h|
|
|
300
|
+
next unless o.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
key = o['id'] || o['value'] || o['label'] || o['name']
|
|
303
|
+
h[key] = { label: o['label'] || o['name'] || o['value'], weight: o['weight'] }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def stages(doc)
|
|
308
|
+
Array(doc['stages'])
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def sections(stage)
|
|
312
|
+
Array(stage['sections'])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def fields(section)
|
|
316
|
+
Array(section['dataFields']) + Array(section['leftDataFields']) + Array(section['rightDataFields'])
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def changed(kind, id, label, path, attribute, before, after, parent_id = nil) # rubocop:disable Metrics/ParameterLists
|
|
320
|
+
Change.new(op: :changed, kind: kind, id: id, label: label, path: path,
|
|
321
|
+
attribute: attribute, before: before, after: after, parent_id: parent_id)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def moved(kind, id, label, attribute, before, after) # rubocop:disable Metrics/ParameterLists
|
|
325
|
+
Change.new(op: :moved, kind: kind, id: id, label: label,
|
|
326
|
+
attribute: attribute, before: before, after: after)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
# Diff engines over the page/template model.
|
|
5
|
+
#
|
|
6
|
+
# `VersionDiff` handles the EASY, exact case — two snapshots of the SAME object (same ids),
|
|
7
|
+
# e.g. a UAT template at version N vs N+1 ("what did this ticket change"). The HARD case —
|
|
8
|
+
# pairing two DIFFERENT objects with different ids (UAT<->PROD, page<->template) — is an
|
|
9
|
+
# equivalence-matching problem handled by `Diff::Pairing` (multi-signal + learning ledger).
|
|
10
|
+
#
|
|
11
|
+
# `CommandSynthesizer` turns a change-set into an ordered WorkflowCommand batch (the replayable
|
|
12
|
+
# "commit"); `IdResolver` fills in move-target ids from a target doc; `Deploy` orchestrates
|
|
13
|
+
# diff -> commands -> apply, gating on the honest `unsupported` list.
|
|
14
|
+
#
|
|
15
|
+
# `Strategy` makes diffing a COMPOSABLE family (pairing x scope x move-sensitivity x intent);
|
|
16
|
+
# `CrossObjectDiff` is the cross-object front-end (pairs fields via `Pairing::Engine`, then
|
|
17
|
+
# emits the same `Change` output against the pairing map).
|
|
18
|
+
module Diff
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require_relative 'diff/change'
|
|
25
|
+
require_relative 'diff/strategy'
|
|
26
|
+
require_relative 'diff/version_diff'
|
|
27
|
+
require_relative 'diff/id_resolver'
|
|
28
|
+
require_relative 'diff/command_synthesizer'
|
|
29
|
+
require_relative 'diff/pairing/ledger'
|
|
30
|
+
require_relative 'diff/pairing/candidate'
|
|
31
|
+
require_relative 'diff/pairing/signals'
|
|
32
|
+
require_relative 'diff/pairing/engine'
|
|
33
|
+
require_relative 'diff/cross_object_diff'
|
|
34
|
+
require_relative 'diff/deploy'
|
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
class Fragment
|
|
5
|
-
fragment :LocationDraft, <<~GRAPHQL
|
|
6
|
-
fragment LocationDraft on Draft {
|
|
7
|
-
id
|
|
8
|
-
name
|
|
9
|
-
notes
|
|
10
|
-
committed
|
|
11
|
-
createdAt
|
|
12
|
-
ok
|
|
13
|
-
conflictingIds
|
|
14
|
-
results {
|
|
15
|
-
ok
|
|
16
|
-
command {
|
|
17
|
-
id
|
|
18
|
-
state
|
|
19
|
-
__typename
|
|
20
|
-
... on LocationInsertCommand {
|
|
21
|
-
nodeId name parentId insertBefore classificationIds
|
|
22
|
-
}
|
|
23
|
-
... on LocationUpdateCommand {
|
|
24
|
-
nodeId newId
|
|
25
|
-
newName: name
|
|
26
|
-
newClassificationIds: classificationIds
|
|
27
|
-
}
|
|
28
|
-
... on LocationArchiveCommand { nodeId }
|
|
29
|
-
... on LocationUnarchiveCommand { nodeId }
|
|
30
|
-
... on LocationMoveCommand { nodeId parentId insertBefore }
|
|
31
|
-
... on LocationReorderCommand { parentId orderedIds }
|
|
32
|
-
}
|
|
33
|
-
error {
|
|
34
|
-
message
|
|
35
|
-
conflictingIds
|
|
36
|
-
validationErrors {
|
|
37
|
-
property
|
|
38
|
-
error
|
|
39
|
-
message
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
commands {
|
|
44
|
-
id
|
|
45
|
-
state
|
|
46
|
-
__typename
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
GRAPHQL
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
class Fragment
|
|
5
|
+
fragment :LocationDraft, <<~GRAPHQL
|
|
6
|
+
fragment LocationDraft on Draft {
|
|
7
|
+
id
|
|
8
|
+
name
|
|
9
|
+
notes
|
|
10
|
+
committed
|
|
11
|
+
createdAt
|
|
12
|
+
ok
|
|
13
|
+
conflictingIds
|
|
14
|
+
results {
|
|
15
|
+
ok
|
|
16
|
+
command {
|
|
17
|
+
id
|
|
18
|
+
state
|
|
19
|
+
__typename
|
|
20
|
+
... on LocationInsertCommand {
|
|
21
|
+
nodeId name parentId insertBefore classificationIds
|
|
22
|
+
}
|
|
23
|
+
... on LocationUpdateCommand {
|
|
24
|
+
nodeId newId
|
|
25
|
+
newName: name
|
|
26
|
+
newClassificationIds: classificationIds
|
|
27
|
+
}
|
|
28
|
+
... on LocationArchiveCommand { nodeId }
|
|
29
|
+
... on LocationUnarchiveCommand { nodeId }
|
|
30
|
+
... on LocationMoveCommand { nodeId parentId insertBefore }
|
|
31
|
+
... on LocationReorderCommand { parentId orderedIds }
|
|
32
|
+
}
|
|
33
|
+
error {
|
|
34
|
+
message
|
|
35
|
+
conflictingIds
|
|
36
|
+
validationErrors {
|
|
37
|
+
property
|
|
38
|
+
error
|
|
39
|
+
message
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
commands {
|
|
44
|
+
id
|
|
45
|
+
state
|
|
46
|
+
__typename
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
GRAPHQL
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
module Input
|
|
5
|
-
class WorkflowCommand
|
|
6
|
-
module AddField
|
|
7
|
-
SCHEMA_VERSION = '20260605'.freeze
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Input
|
|
5
|
+
class WorkflowCommand
|
|
6
|
+
module AddField
|
|
7
|
+
SCHEMA_VERSION = '20260605'.freeze
|
|
8
|
+
# VERIFIED against the live schema dump (WorkflowAddFieldInput, 20260605): the create
|
|
9
|
+
# command accepts ONLY these keys. There is NO `description` and NO `required` field on
|
|
10
|
+
# WorkflowAddFieldInput. `description` is instead a top-level key of
|
|
11
|
+
# WorkflowEditFieldConfigurationInput, so a field's description is persisted via a
|
|
12
|
+
# follow-up editFieldConfiguration command (see Builder::TemplateBuilder /
|
|
13
|
+
# Diff::CommandSynthesizer). `required` has NO input key anywhere in the schema (not on
|
|
14
|
+
# AddField, not on editFieldConfiguration top-level or any byType sub-input) and so
|
|
15
|
+
# cannot be persisted through the workflow command bus at all — callers must treat it as
|
|
16
|
+
# a read-only/unsupported attribute. Do NOT add these keys here (the server rejects them).
|
|
17
|
+
VALID_KEYS = %i[placeholderId fieldType label stageId sectionId column].freeze
|
|
18
|
+
|
|
19
|
+
def self.build(**kwargs)
|
|
20
|
+
kwargs.slice(*VALID_KEYS).compact
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|