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,59 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# One structural change between two snapshots of the same page/template.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately command-ready: op + kind + id + before/after is enough for a later pass to
|
|
8
|
+
# synthesise the matching WorkflowCommand (editStage / addField / removeField /
|
|
9
|
+
# editFieldConfiguration / addSelectFieldOption / …) — the "portable commit".
|
|
10
|
+
#
|
|
11
|
+
# op: :added | :removed | :changed | :moved
|
|
12
|
+
# kind: :stage | :section | :field | :option | :field_config | :gauge_stop
|
|
13
|
+
#
|
|
14
|
+
# parent_id: id of the owning entity when the change is nested and the child id alone is
|
|
15
|
+
# not enough to address a command. Set for :option changes (the parent data field id) so a
|
|
16
|
+
# synthesiser can emit add/edit/removeSelectFieldOption without re-deriving it; also for
|
|
17
|
+
# :field_config (the data field id) and :gauge_stop (the gauge field id).
|
|
18
|
+
#
|
|
19
|
+
# by_type: for :field_config changes, the `byType` sub-hash key (e.g. :gauge, :select, :date)
|
|
20
|
+
# under which the typed editFieldConfiguration property is nested. nil for other kinds.
|
|
21
|
+
#
|
|
22
|
+
# owner_id: the id of the GRANDPARENT node, when a create needs a second structural back-ref
|
|
23
|
+
# that parent_id alone cannot carry. Set for an ADDED :field (parent_id = its section id,
|
|
24
|
+
# owner_id = its stage id) because addField requires BOTH sectionId and stageId (both
|
|
25
|
+
# NON_NULL in the schema). nil for every other kind/op.
|
|
26
|
+
Change = Struct.new(:op, :kind, :id, :label, :path, :attribute, :before, :after, :parent_id,
|
|
27
|
+
:by_type, :owner_id, keyword_init: true) do
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
op: op, kind: kind, id: id, label: label, path: path,
|
|
31
|
+
attribute: attribute, before: before, after: after, parent_id: parent_id,
|
|
32
|
+
by_type: by_type, owner_id: owner_id
|
|
33
|
+
}.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One-line human description for a changelog / ticket comment.
|
|
37
|
+
def description
|
|
38
|
+
case op
|
|
39
|
+
when :added then "+ #{kind} '#{label}' added#{at_path}"
|
|
40
|
+
when :removed then "- #{kind} '#{label}' removed#{at_path}"
|
|
41
|
+
when :changed then "~ #{kind} '#{label}' #{attribute}: #{fmt(before)} -> #{fmt(after)}#{at_path}"
|
|
42
|
+
when :moved then "> #{kind} '#{label}' moved: #{fmt(before)} -> #{fmt(after)}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def at_path
|
|
49
|
+
path && !path.empty? ? " (#{path})" : ''
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fmt(value)
|
|
53
|
+
value.nil? ? 'nil' : value.inspect
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# Turns a set of `Diff::Change` objects (from `VersionDiff`) into an ordered batch of built
|
|
6
|
+
# `WorkflowCommand` hashes — the replayable "commit".
|
|
7
|
+
#
|
|
8
|
+
# changes = VersionDiff.new(v1, v2).changes
|
|
9
|
+
# synth = CommandSynthesizer.new(changes)
|
|
10
|
+
# synth.commands # => [{ editFieldConfiguration: {...} }, { removeField: {...} }, ...]
|
|
11
|
+
# synth.unsupported # => [Change, ...] (change kinds/ops with no matching command)
|
|
12
|
+
#
|
|
13
|
+
# SCOPE — self-version diff by default. The ids carried by each Change are REAL server ids of
|
|
14
|
+
# the SAME object across two of its own versions, so they can be replayed as-is. For a
|
|
15
|
+
# cross-object diff (UAT<->PROD, page<->template) every id in the emitted commands must first
|
|
16
|
+
# be translated through a pairing map before replay.
|
|
17
|
+
#
|
|
18
|
+
# RESOLVER — some edits address a TARGET node by an id the Change does not carry (a field
|
|
19
|
+
# move needs the destination *section id*; a section move needs the destination *stage id*).
|
|
20
|
+
# `VersionDiff` records those targets by their human key (section heading, stage name) because
|
|
21
|
+
# that is all a structural diff knows. Pass a `resolver` that maps
|
|
22
|
+
# `resolve(kind, key) => id` (e.g. the pairing engine's ledger, or a lookup built from the
|
|
23
|
+
# target doc) and those moves become supported. WITHOUT a resolver they stay UNSUPPORTED —
|
|
24
|
+
# the synthesiser never guesses a target id.
|
|
25
|
+
#
|
|
26
|
+
# ORDERING — structure before children, and within a kind: removes, then adds, then moves,
|
|
27
|
+
# then edits. Structural creates (stages, sections, fields) precede the option-level edits
|
|
28
|
+
# that depend on them, so a straight left-to-right replay stays valid.
|
|
29
|
+
#
|
|
30
|
+
# SAFETY — a change with no faithful command is NEVER guessed. It is collected into
|
|
31
|
+
# `unsupported` for the caller to inspect. In particular a field *type* change has no edit
|
|
32
|
+
# command in the schema (there is no editFieldType), so it is reported as unsupported rather
|
|
33
|
+
# than silently rebuilt (a rebuild would drop the field's data and history).
|
|
34
|
+
class CommandSynthesizer
|
|
35
|
+
# Emit order: structure first (stages -> sections -> fields), then options; and within a
|
|
36
|
+
# kind, removes -> adds -> moves -> edits, so a straight left-to-right replay stays valid.
|
|
37
|
+
KIND_ORDER = { stage: 0, section: 1, field: 2, field_config: 3, gauge_stop: 4, option: 5 }.freeze
|
|
38
|
+
OP_ORDER = { removed: 0, added: 1, moved: 2, changed: 3 }.freeze
|
|
39
|
+
|
|
40
|
+
# @param changes [Array<Change>]
|
|
41
|
+
# @param resolver [#resolve, nil] maps (kind, human-key) -> target id for move commands.
|
|
42
|
+
# Any object answering `resolve(kind, key)` (returning an id or nil). When nil, moves
|
|
43
|
+
# that need a target id are reported as unsupported.
|
|
44
|
+
# @param thread_placeholders [Boolean] when true, structural creates (addStage/addSection/
|
|
45
|
+
# addField) emit a client-chosen `placeholderId`, and any later command in the SAME batch
|
|
46
|
+
# that references a node created here substitutes that placeholder for the (target-invalid)
|
|
47
|
+
# source id. This keeps add-then-reference sequences self-consistent within one
|
|
48
|
+
# executeWorkflowCommands call. Off by default (self-version replay uses the real ids).
|
|
49
|
+
def initialize(changes, resolver: nil, thread_placeholders: false)
|
|
50
|
+
@changes = Array(changes)
|
|
51
|
+
@resolver = resolver
|
|
52
|
+
@thread_placeholders = thread_placeholders
|
|
53
|
+
@unsupported = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ordered Array of built command hashes (each `{ commandKey => {..input..} }`).
|
|
57
|
+
def commands
|
|
58
|
+
@commands ||= build_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Changes that could not be mapped to a command. Populated as a side effect of `commands`.
|
|
62
|
+
def unsupported
|
|
63
|
+
commands
|
|
64
|
+
@unsupported
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Group by (kind, op) and emit in a dependency-safe order:
|
|
70
|
+
# stages -> sections -> fields -> options, and remove -> add -> move -> change within each.
|
|
71
|
+
def build_all
|
|
72
|
+
build_placeholder_map
|
|
73
|
+
ordered = @changes.sort_by { |c| [KIND_ORDER.fetch(c.kind, 99), OP_ORDER.fetch(c.op, 99)] }
|
|
74
|
+
# command_for may return a single built hash, an Array of them (a move that emits
|
|
75
|
+
# remove+add), or nil (unsupported). Normalise each to a list, flatten, drop nils.
|
|
76
|
+
ordered.flat_map { |change| wrap(command_for(change)) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# When threading is on, assign a deterministic placeholderId to every ADDED structural
|
|
80
|
+
# node (stage/section/field), keyed by its source id. A later command in this batch that
|
|
81
|
+
# references such an id is rewritten to the placeholder (the source id is not valid on the
|
|
82
|
+
# target). Threading OFF -> empty map -> `ref`/`placeholder_for` are no-ops.
|
|
83
|
+
def build_placeholder_map
|
|
84
|
+
@placeholders = {}
|
|
85
|
+
return unless @thread_placeholders
|
|
86
|
+
|
|
87
|
+
prefixes = { stage: 'stg', section: 'sec', field: 'fld' }
|
|
88
|
+
@changes.select { |c| c.op == :added && prefixes.key?(c.kind) }.each_with_index do |change, i|
|
|
89
|
+
next if change.id.nil?
|
|
90
|
+
|
|
91
|
+
@placeholders[change.id] = "ph_#{prefixes[change.kind]}_#{i}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The placeholder token for a node created in THIS batch, or nil if it was not.
|
|
96
|
+
def placeholder_for(id)
|
|
97
|
+
@placeholders[id]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reference an id the way a sibling command must: the batch-local placeholder if the node
|
|
101
|
+
# is being created here, otherwise the id as-is (a pre-existing target node).
|
|
102
|
+
def ref(id)
|
|
103
|
+
placeholder_for(id) || id
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def command_for(change)
|
|
107
|
+
case change.kind
|
|
108
|
+
when :stage then stage_command(change)
|
|
109
|
+
when :section then section_command(change)
|
|
110
|
+
when :field then field_command(change)
|
|
111
|
+
when :field_config then field_config_command(change)
|
|
112
|
+
when :gauge_stop then gauge_stop_command(change)
|
|
113
|
+
when :option then option_command(change)
|
|
114
|
+
else unsupported!(change)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- stage ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def stage_command(change)
|
|
121
|
+
case change.op
|
|
122
|
+
when :added then build(:addStage, name: change.label, placeholderId: placeholder_for(change.id))
|
|
123
|
+
when :removed then build(:removeStage, stageId: change.id)
|
|
124
|
+
when :moved then build(:moveStage, stageId: change.id, ordering: change.after)
|
|
125
|
+
when :changed
|
|
126
|
+
return build(:editStage, stageId: change.id, name: change.after) if change.attribute == 'name'
|
|
127
|
+
|
|
128
|
+
unsupported!(change)
|
|
129
|
+
else unsupported!(change)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- section -------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def section_command(change)
|
|
136
|
+
case change.op
|
|
137
|
+
when :added then add_section(change)
|
|
138
|
+
when :removed then build(:removeSection, sectionId: change.id)
|
|
139
|
+
when :moved then section_move(change)
|
|
140
|
+
when :changed
|
|
141
|
+
return build(:editSectionHeader, sectionId: change.id, header: change.after) if change.attribute == 'heading'
|
|
142
|
+
|
|
143
|
+
unsupported!(change)
|
|
144
|
+
else unsupported!(change)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# An added section is created with addSection (layout/placeholder), then WIRED to its
|
|
149
|
+
# parent stage with a follow-up addStageSection — addSection itself has no stage-id key in
|
|
150
|
+
# the schema, the stage<->section link is a separate command. The stage back-ref threads
|
|
151
|
+
# through `ref`: the parent stage's placeholder when it is created in this same batch, else
|
|
152
|
+
# its real id. When the parent stage id is unknown (a hand-assembled Change with no
|
|
153
|
+
# parent_id), only addSection is emitted — the historical behaviour, unchanged.
|
|
154
|
+
def add_section(change)
|
|
155
|
+
cmds = [build(:addSection, placeholderId: placeholder_for(change.id))]
|
|
156
|
+
cmds << build(:addStageSection, stageId: ref(change.parent_id), sectionId: ref(change.id)) unless change.parent_id.nil?
|
|
157
|
+
cmds
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A section 'move' is a stage reassignment: detach from the old stage, attach to the new.
|
|
161
|
+
# Both need a stage id; VersionDiff records only the stage NAME (before/after), so a
|
|
162
|
+
# resolver is required to turn those names into ids. Emitted as two commands so the replay
|
|
163
|
+
# is faithful (remove then add). Without a resolver -> unsupported (never guess a stage id).
|
|
164
|
+
def section_move(change)
|
|
165
|
+
return unsupported!(change) if change.attribute != 'stage'
|
|
166
|
+
|
|
167
|
+
from_id = resolve(:stage, change.before)
|
|
168
|
+
to_id = resolve(:stage, change.after)
|
|
169
|
+
return unsupported!(change) if to_id.nil?
|
|
170
|
+
|
|
171
|
+
cmds = []
|
|
172
|
+
cmds << build(:removeStageSection, stageId: from_id, sectionId: change.id) if from_id
|
|
173
|
+
cmds << build(:addStageSection, stageId: to_id, sectionId: change.id)
|
|
174
|
+
cmds
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# --- field ---------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def field_command(change)
|
|
180
|
+
case change.op
|
|
181
|
+
when :added then add_field(change)
|
|
182
|
+
when :removed then build(:removeField, id: change.id)
|
|
183
|
+
when :moved then field_move(change)
|
|
184
|
+
when :changed then field_edit(change)
|
|
185
|
+
else unsupported!(change)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# An added field carries its structural back-refs: sectionId (parent_id) and stageId
|
|
190
|
+
# (owner_id) — both NON_NULL on the schema's addField input. Each threads through `ref`:
|
|
191
|
+
# the parent's placeholder when the parent stage/section is created in this same batch,
|
|
192
|
+
# else its real id. When a back-ref id is unknown (a hand-assembled Change without
|
|
193
|
+
# parent_id/owner_id) that key is simply omitted (AddField.build compacts nils) — the
|
|
194
|
+
# historical behaviour, unchanged.
|
|
195
|
+
def add_field(change)
|
|
196
|
+
build(:addField,
|
|
197
|
+
label: change.label,
|
|
198
|
+
sectionId: (ref(change.parent_id) unless change.parent_id.nil?),
|
|
199
|
+
stageId: (ref(change.owner_id) unless change.owner_id.nil?),
|
|
200
|
+
placeholderId: placeholder_for(change.id))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def field_edit(change)
|
|
204
|
+
return build(:editFieldConfiguration, data_field_id: change.id, label: change.after) if change.attribute == 'label'
|
|
205
|
+
|
|
206
|
+
# Field TYPE change: no editFieldType command exists in the schema. Do not synthesise a
|
|
207
|
+
# remove+add (it would destroy the field's data) — report it for manual handling.
|
|
208
|
+
unsupported!(change)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A field 'move' is a section reassignment. moveField needs the destination SECTION id;
|
|
212
|
+
# VersionDiff records only the section HEADING (before/after). A resolver turns the target
|
|
213
|
+
# heading into a section id. Without one -> unsupported (never guess a section id).
|
|
214
|
+
def field_move(change)
|
|
215
|
+
return unsupported!(change) if change.attribute != 'section'
|
|
216
|
+
|
|
217
|
+
section_id = resolve(:section, change.after)
|
|
218
|
+
return unsupported!(change) if section_id.nil?
|
|
219
|
+
|
|
220
|
+
build(:moveField, id: change.id, sectionId: section_id)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- typed field configuration (byType) ----------------------------
|
|
224
|
+
|
|
225
|
+
# A per-type field-configuration change maps to editFieldConfiguration with the changed
|
|
226
|
+
# property nested under its `byType` sub-hash (e.g. { gauge: { max: 5.0 } }). VersionDiff
|
|
227
|
+
# stamps `by_type` (the sub-hash key) and `parent_id` (the data field id). Without a
|
|
228
|
+
# by_type key we cannot address the typed input -> unsupported (never guessed).
|
|
229
|
+
def field_config_command(change)
|
|
230
|
+
return unsupported!(change) unless change.op == :changed
|
|
231
|
+
|
|
232
|
+
field_id = change.parent_id || change.id
|
|
233
|
+
by_type = change.by_type
|
|
234
|
+
return unsupported!(change) if field_id.nil? || by_type.nil? || change.attribute.nil?
|
|
235
|
+
|
|
236
|
+
body = { by_type => { change.attribute.to_sym => change.after } }
|
|
237
|
+
build(:editFieldConfiguration, data_field_id: ref(field_id), byType: body)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# --- gauge stops ---------------------------------------------------
|
|
241
|
+
|
|
242
|
+
# Gauge stops address the parent gauge field id (parent_id). Add needs threshold+color
|
|
243
|
+
# (carried on change.after); edit/remove need the stop id (change.id).
|
|
244
|
+
def gauge_stop_command(change)
|
|
245
|
+
field_id = change.parent_id
|
|
246
|
+
return unsupported!(change) if field_id.nil?
|
|
247
|
+
|
|
248
|
+
field_ref = ref(field_id)
|
|
249
|
+
case change.op
|
|
250
|
+
when :added then add_gauge_stop(change, field_ref)
|
|
251
|
+
when :removed then build(:removeGaugeFieldStop, data_field_id: field_ref, stop_id: change.id)
|
|
252
|
+
when :changed then edit_gauge_stop(change, field_ref)
|
|
253
|
+
else unsupported!(change)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def add_gauge_stop(change, field_id)
|
|
258
|
+
spec = change.after
|
|
259
|
+
return unsupported!(change) unless spec.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
build(:addGaugeFieldStop, data_field_id: field_id, threshold: spec[:threshold], color: spec[:color])
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def edit_gauge_stop(change, field_id)
|
|
265
|
+
case change.attribute
|
|
266
|
+
when 'threshold' then build(:editGaugeFieldStop, data_field_id: field_id, stop_id: change.id, threshold: change.after)
|
|
267
|
+
when 'color' then build(:editGaugeFieldStop, data_field_id: field_id, stop_id: change.id, color: change.after)
|
|
268
|
+
else unsupported!(change)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# --- option --------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def option_command(change)
|
|
275
|
+
field_id = change.parent_id
|
|
276
|
+
return unsupported!(change) if field_id.nil?
|
|
277
|
+
|
|
278
|
+
field_ref = ref(field_id)
|
|
279
|
+
case change.op
|
|
280
|
+
when :added then build(:addSelectFieldOption, data_field_id: field_ref, label: change.label)
|
|
281
|
+
when :removed then build(:removeSelectFieldOption, data_field_id: field_ref, option_id: change.id)
|
|
282
|
+
when :changed then edit_option(change, field_ref)
|
|
283
|
+
else unsupported!(change)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def edit_option(change, field_id)
|
|
288
|
+
case change.attribute
|
|
289
|
+
when 'label' then build(:editSelectFieldOption, data_field_id: field_id, option_id: change.id, label: change.after)
|
|
290
|
+
when 'weight' then build(:editSelectFieldOption, data_field_id: field_id, option_id: change.id, weight: change.after)
|
|
291
|
+
else unsupported!(change)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# --- helpers -------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def build(command_key, **kwargs)
|
|
298
|
+
Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Ask the resolver for the target id of `key` (a heading / stage name). Returns nil when
|
|
302
|
+
# there is no resolver or it cannot resolve — callers treat nil as "unsupported".
|
|
303
|
+
def resolve(kind, key)
|
|
304
|
+
return nil if key.nil? || @resolver.nil?
|
|
305
|
+
|
|
306
|
+
@resolver.resolve(kind, key)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Normalise a command_for result (nil | Hash | Array<Hash>) into a flat list of hashes.
|
|
310
|
+
# A built command is a Hash, so we must NOT use Kernel#Array (it would splat a Hash into
|
|
311
|
+
# key/value pairs).
|
|
312
|
+
def wrap(result)
|
|
313
|
+
case result
|
|
314
|
+
when nil then []
|
|
315
|
+
when Array then result.compact
|
|
316
|
+
else [result]
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Record an unmappable change and return nil (filtered out of the command list).
|
|
321
|
+
def unsupported!(change)
|
|
322
|
+
@unsupported << change
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# Cross-object diff: two templates/pages that DO NOT share Mongo ids (UAT<->PROD,
|
|
6
|
+
# page<->template). Because nothing has the same id as its counterpart, we cannot match by id
|
|
7
|
+
# (that is `VersionDiff`). Instead we PAIR the fields as an equivalence problem
|
|
8
|
+
# (`Pairing::Engine` — genome + type + label + options, human-assisted, ledger-backed), build
|
|
9
|
+
# an id-correspondence map from the accepted pairs, then emit the SAME `Change` output against
|
|
10
|
+
# that map so the existing `CommandSynthesizer` / `Deploy` layer can consume it unchanged.
|
|
11
|
+
#
|
|
12
|
+
# This deliberately operates at the FIELD level — the load-bearing simplifying principle
|
|
13
|
+
# (domain ref §6): sections/stages are scaffolding, customer data lives in the data-fields, so
|
|
14
|
+
# a cross-object diff pairs FIELDS precisely and treats structure as context. Structure-level
|
|
15
|
+
# (stage/section) reconciliation across id-spaces is not attempted here (no reliable pairing
|
|
16
|
+
# signal for scaffolding) — it is left to the human review the engine already routes to.
|
|
17
|
+
#
|
|
18
|
+
# engine = Pairing::Engine.new(ledger: ledger) # optional
|
|
19
|
+
# diff = CrossObjectDiff.new(uat_doc, prod_doc, engine: engine,
|
|
20
|
+
# strategy: Strategy.new(pairing: :assisted, scope: :data_migration))
|
|
21
|
+
# diff.changes # => [Change, ...] (field label/type changes on paired fields; +added/-removed)
|
|
22
|
+
# diff.pairing # => Pairing::Engine::Result (accepted / ambiguous / unmatched — for review)
|
|
23
|
+
# diff.unresolved # => [field-doc, ...] sources the human must adjudicate (ambiguous+unmatched)
|
|
24
|
+
#
|
|
25
|
+
# SAFETY — only ACCEPTED pairs are treated as equivalences; ambiguous/unmatched sources are
|
|
26
|
+
# surfaced in `unresolved` and NEVER auto-paired or auto-removed. A same-label field whose
|
|
27
|
+
# genome contradicts is escalated, not silently matched (the engine guarantees this).
|
|
28
|
+
class CrossObjectDiff
|
|
29
|
+
attr_reader :strategy
|
|
30
|
+
|
|
31
|
+
# @param source_doc [Hash] the "before" template/page (e.g. the desired/UAT state).
|
|
32
|
+
# @param target_doc [Hash] the "after"/destination template/page (e.g. PROD).
|
|
33
|
+
# @param engine [Pairing::Engine,nil] the equivalence matcher; a default one is built when
|
|
34
|
+
# nil. Provide one wired to a `Ledger` to reuse confirmed pairings and learn over time.
|
|
35
|
+
# @param strategy [Strategy] scope + move-sensitivity. Pairing is inherently cross-object
|
|
36
|
+
# here; `strategy.pairing` is informational (which signal set the caller intends).
|
|
37
|
+
def initialize(source_doc, target_doc, engine: nil, strategy: Strategy.new(pairing: :assisted))
|
|
38
|
+
@source = source_doc || {}
|
|
39
|
+
@target = target_doc || {}
|
|
40
|
+
@engine = engine || Pairing::Engine.new
|
|
41
|
+
@strategy = strategy || Strategy.new(pairing: :assisted)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The `Pairing::Engine::Result` for the field sets (accepted / ambiguous / unmatched).
|
|
45
|
+
def pairing
|
|
46
|
+
@pairing ||= @engine.pair(source_fields, target_fields)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Source field docs the engine could not confidently pair — the human review set. These are
|
|
50
|
+
# NOT emitted as removals (we do not know they were deleted vs merely unpaired).
|
|
51
|
+
def unresolved
|
|
52
|
+
pairing.ambiguous.map(&:source) + pairing.unmatched
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Strategy-filtered change-set, emitted against the pairing map (target ids translated into
|
|
56
|
+
# the source id-space so the changes replay coherently through the synthesizer).
|
|
57
|
+
def changes
|
|
58
|
+
@changes ||= @strategy.filter(all_changes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def changelog
|
|
62
|
+
changes.map(&:description)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def summary
|
|
66
|
+
by_op = changes.group_by(&:op)
|
|
67
|
+
{
|
|
68
|
+
added: by_op.fetch(:added, []).size,
|
|
69
|
+
removed: by_op.fetch(:removed, []).size,
|
|
70
|
+
changed: by_op.fetch(:changed, []).size,
|
|
71
|
+
moved: by_op.fetch(:moved, []).size,
|
|
72
|
+
total: changes.size,
|
|
73
|
+
paired: pairing.accepted.size,
|
|
74
|
+
unresolved: unresolved.size
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
{ summary: summary, changes: changes.map(&:to_h), pairing: pairing.to_h }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Emit field-level changes off the pairing:
|
|
85
|
+
# * accepted pair (src<->tgt): compare label/type/config; the change id is the SOURCE
|
|
86
|
+
# field id (the id-space the synthesizer/target-resolver expects to translate);
|
|
87
|
+
# * source with no accepted target: :removed (only from the confidently-paired frame —
|
|
88
|
+
# unresolved sources are held back for review, never auto-removed);
|
|
89
|
+
# * target with no accepted source: :added.
|
|
90
|
+
def all_changes
|
|
91
|
+
accepted = pairing.accepted
|
|
92
|
+
paired_src = accepted.map(&:source_id)
|
|
93
|
+
paired_tgt = accepted.map(&:target_id)
|
|
94
|
+
|
|
95
|
+
compared = accepted.flat_map { |c| compare_pair(c) }
|
|
96
|
+
removed = removed_changes(paired_src)
|
|
97
|
+
added = added_changes(paired_tgt)
|
|
98
|
+
|
|
99
|
+
compared + removed + added
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def compare_pair(candidate)
|
|
103
|
+
src = candidate.source
|
|
104
|
+
tgt = candidate.target
|
|
105
|
+
id = candidate.source_id
|
|
106
|
+
cmp = []
|
|
107
|
+
cmp << field_change(id, src, tgt, 'label', src['label'], tgt['label']) if src['label'] != tgt['label']
|
|
108
|
+
cmp << field_change(id, src, tgt, 'type', field_type(src), field_type(tgt)) if field_type(src) != field_type(tgt)
|
|
109
|
+
cmp
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Source fields with no accepted counterpart AND that were confidently classified (i.e. not
|
|
113
|
+
# sitting in the unresolved review set) → removed. A source in `unresolved` is never emitted
|
|
114
|
+
# as a removal.
|
|
115
|
+
def removed_changes(paired_src)
|
|
116
|
+
held = unresolved.map { |f| f['id'] }
|
|
117
|
+
source_fields.reject { |f| paired_src.include?(f['id']) || held.include?(f['id']) }.map do |f|
|
|
118
|
+
Change.new(op: :removed, kind: :field, id: f['id'], label: f['label'])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Target fields with no accepted counterpart → added, EXCEPT those tangled in an ambiguous
|
|
123
|
+
# candidate (a target the engine proposed but did not confirm): those are held for review,
|
|
124
|
+
# never auto-added (they may be the same field as an unresolved source, relabelled).
|
|
125
|
+
def added_changes(paired_tgt)
|
|
126
|
+
held = pairing.ambiguous.map(&:target_id)
|
|
127
|
+
target_fields.reject { |f| paired_tgt.include?(f['id']) || held.include?(f['id']) }.map do |f|
|
|
128
|
+
Change.new(op: :added, kind: :field, id: f['id'], label: f['label'])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field_change(id, _src, tgt, attribute, before, after) # rubocop:disable Metrics/ParameterLists
|
|
133
|
+
Change.new(op: :changed, kind: :field, id: id, label: tgt['label'],
|
|
134
|
+
attribute: attribute, before: before, after: after)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def field_type(field)
|
|
138
|
+
field['__typename'] || field['type']
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def source_fields
|
|
142
|
+
@source_fields ||= fields_of(@source)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def target_fields
|
|
146
|
+
@target_fields ||= fields_of(@target)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Flatten every data-field doc out of a page/template doc (stages -> sections -> fields).
|
|
150
|
+
def fields_of(doc)
|
|
151
|
+
out = []
|
|
152
|
+
Array(doc['stages']).each do |st|
|
|
153
|
+
Array(st['sections']).each do |sec|
|
|
154
|
+
out.concat(Array(sec['dataFields']))
|
|
155
|
+
out.concat(Array(sec['leftDataFields']))
|
|
156
|
+
out.concat(Array(sec['rightDataFields']))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
out.select { |f| f.is_a?(Hash) && f['id'] }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# Deploy orchestration: turns a diff into the replayable, ordered WorkflowCommand batch that
|
|
6
|
+
# `executeWorkflowCommands` consumes — plus the honest list of changes that could NOT be
|
|
7
|
+
# synthesised (needs-human), so a review step can gate the apply.
|
|
8
|
+
#
|
|
9
|
+
# Two entry points:
|
|
10
|
+
#
|
|
11
|
+
# # self-version replay (same object, real ids) — the "commit" replayed onto itself/a clone
|
|
12
|
+
# plan = Deploy.from_versions(before_doc, after_doc)
|
|
13
|
+
#
|
|
14
|
+
# # cross-object deploy (UAT delta -> a real PROD target): the diff gives the delta, but the
|
|
15
|
+
# # move targets (section/stage) must resolve against the PROD doc's ids.
|
|
16
|
+
# plan = Deploy.from_versions(uat_v1, uat_v2, target_doc: prod_doc)
|
|
17
|
+
#
|
|
18
|
+
# plan.commands # => ordered Array of built command hashes, ready to send
|
|
19
|
+
# plan.unsupported # => [Change, ...] needing human handling (never guessed)
|
|
20
|
+
# plan.changelog # => human one-liners for a ticket / review checklist
|
|
21
|
+
# plan.execute!(page) # => sends via the given executor (page.execute_workflow_commands)
|
|
22
|
+
#
|
|
23
|
+
# SAFETY — `Deploy` is inert until `execute!` is called with an explicit executor. It never
|
|
24
|
+
# applies anything on its own, and it surfaces `unsupported` so a human gates the deploy.
|
|
25
|
+
class Deploy
|
|
26
|
+
attr_reader :diff, :synthesizer
|
|
27
|
+
|
|
28
|
+
# @param diff [VersionDiff]
|
|
29
|
+
# @param resolver [#resolve, nil] maps move targets (stage name / section heading) to ids.
|
|
30
|
+
# @param thread_placeholders [Boolean] thread client-chosen placeholderIds so that an
|
|
31
|
+
# add-then-reference sequence (e.g. add a Select field, then add its options) stays
|
|
32
|
+
# self-consistent within one executeWorkflowCommands call. ON by default for deploy —
|
|
33
|
+
# the whole batch is applied to a target where the source ids are not valid.
|
|
34
|
+
def initialize(diff, resolver: nil, thread_placeholders: true)
|
|
35
|
+
@diff = diff
|
|
36
|
+
@synthesizer = CommandSynthesizer.new(
|
|
37
|
+
diff.changes, resolver: resolver, thread_placeholders: thread_placeholders
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build a plan from two snapshots. When `target_doc` is given (cross-object deploy), a
|
|
42
|
+
# resolver is derived from it so field/section moves can address the target's real ids.
|
|
43
|
+
# An explicit `resolver:` overrides the derived one.
|
|
44
|
+
#
|
|
45
|
+
# `strategy:` selects the diff modality (scope / move-sensitivity / intent). It defaults to
|
|
46
|
+
# the self-version structural changelog strategy — the historical behaviour. When the
|
|
47
|
+
# strategy's `intent` is `:deploy` (or unspecified) placeholder threading stays ON.
|
|
48
|
+
def self.from_versions(before_doc, after_doc, target_doc: nil, resolver: nil,
|
|
49
|
+
thread_placeholders: true, strategy: Strategy.default)
|
|
50
|
+
resolver ||= (IdResolver.from_doc(target_doc) if target_doc)
|
|
51
|
+
diff = VersionDiff.new(before_doc, after_doc, strategy: strategy)
|
|
52
|
+
new(diff, resolver: resolver, thread_placeholders: thread_placeholders)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build a plan for a CROSS-OBJECT deploy (UAT<->PROD, page<->template): the two docs do not
|
|
56
|
+
# share ids, so fields are PAIRED (equivalence) before the delta is emitted. The pairing
|
|
57
|
+
# (accepted/ambiguous/unmatched) is exposed via `#pairing` so a human can adjudicate the
|
|
58
|
+
# unresolved set before applying. `engine:` lets the caller pass a `Pairing::Engine` wired
|
|
59
|
+
# to a `Ledger` (learns over time); `strategy:` defaults to the assisted/data-migration mode.
|
|
60
|
+
#
|
|
61
|
+
# plan = Deploy.from_cross_object(uat_doc, prod_doc, engine: engine)
|
|
62
|
+
# plan.pairing.ambiguous # review before deploy
|
|
63
|
+
# plan.commands # the paired delta as WorkflowCommands
|
|
64
|
+
def self.from_cross_object(source_doc, target_doc, engine: nil, resolver: nil,
|
|
65
|
+
thread_placeholders: true,
|
|
66
|
+
strategy: Strategy.new(pairing: :assisted, scope: :data_migration, intent: :deploy))
|
|
67
|
+
resolver ||= (IdResolver.from_doc(target_doc) if target_doc)
|
|
68
|
+
diff = CrossObjectDiff.new(source_doc, target_doc, engine: engine, strategy: strategy)
|
|
69
|
+
new(diff, resolver: resolver, thread_placeholders: thread_placeholders)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The pairing result when this plan is cross-object (nil for a self-version plan). Lets a
|
|
73
|
+
# caller inspect/adjudicate ambiguous + unmatched pairings before `execute!`.
|
|
74
|
+
def pairing
|
|
75
|
+
@diff.respond_to?(:pairing) ? @diff.pairing : nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ordered, dependency-safe Array of built command hashes ready for executeWorkflowCommands.
|
|
79
|
+
def commands
|
|
80
|
+
@synthesizer.commands
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Changes with no faithful command — surfaced for human review, never guessed.
|
|
84
|
+
def unsupported
|
|
85
|
+
@synthesizer.unsupported
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def changelog
|
|
89
|
+
@diff.changelog
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def summary
|
|
93
|
+
{
|
|
94
|
+
changes: @diff.summary,
|
|
95
|
+
commands: commands.size,
|
|
96
|
+
unsupported: unsupported.size
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# True when every detected change was synthesised into a command (nothing needs a human).
|
|
101
|
+
def fully_supported?
|
|
102
|
+
unsupported.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_h
|
|
106
|
+
{ summary: summary, commands: commands, unsupported: unsupported.map(&:to_h) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Apply the batch. `executor` must respond to `execute_workflow_commands(commands)` (the
|
|
110
|
+
# gem's page/template mutation facade). Raises unless the plan is fully supported — the
|
|
111
|
+
# caller must review/clear `unsupported` first — unless `allow_partial: true`.
|
|
112
|
+
def execute!(executor, allow_partial: false)
|
|
113
|
+
raise ArgumentError, "#{unsupported.size} unsupported change(s); review before deploy" unless fully_supported? || allow_partial
|
|
114
|
+
|
|
115
|
+
executor.execute_workflow_commands(commands)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|