ecoportal-api-graphql 1.3.10 → 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/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 +87 -0
- data/docs/worklog.md +279 -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/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.rb +1 -0
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- data/tests/dump_template_model.rb +90 -0
- data/tests/validate_queries.rb +31 -9
- metadata +31 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
module Pairing
|
|
6
|
+
# Weak pairing SIGNALS combined into a confidence score. Each signal scores a candidate
|
|
7
|
+
# (source-field-doc, target-field-doc) pair in 0.0..1.0. NONE is ground truth on its own —
|
|
8
|
+
# per the domain reference genomeSignature is strong-but-fallible (6 documented failure
|
|
9
|
+
# modes: manual/no-platform-support, new features never added, wrong for select options,
|
|
10
|
+
# wrongly syncs checklists, re-used/re-purposed fields, incomplete data-loss accounting).
|
|
11
|
+
# So genome is ONE signal among several, weighted highest but able to be outvoted /
|
|
12
|
+
# confirmed by type+label+options. The Engine aggregates these with `WEIGHTS`.
|
|
13
|
+
module Signals
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Relative weights (need not sum to 1; the Engine normalises by the weights that applied).
|
|
17
|
+
WEIGHTS = {
|
|
18
|
+
genome: 0.5,
|
|
19
|
+
type: 0.2,
|
|
20
|
+
label: 0.2,
|
|
21
|
+
options: 0.1
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# genomeSignature match. Strong but fallible, so a MATCH scores high (not 1.0) and a
|
|
25
|
+
# MISMATCH scores 0 rather than vetoing (the field may have been re-purposed keeping its
|
|
26
|
+
# genome, or genome may be absent on newer field types). Returns nil when neither side
|
|
27
|
+
# carries a genome — the signal simply does not apply and is excluded from the average.
|
|
28
|
+
def genome(source, target)
|
|
29
|
+
a = source['genomeSignature']
|
|
30
|
+
b = target['genomeSignature']
|
|
31
|
+
return nil if blank?(a) && blank?(b)
|
|
32
|
+
return 0.0 if blank?(a) || blank?(b)
|
|
33
|
+
|
|
34
|
+
a == b ? 1.0 : 0.0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Field type (__typename) equality. A type change is a strong DISQUALIFIER for a data
|
|
38
|
+
# pairing (you cannot faithfully migrate PlainText data into a Gauge), so a mismatch is 0.
|
|
39
|
+
def type(source, target)
|
|
40
|
+
ta = type_of(source)
|
|
41
|
+
tb = type_of(target)
|
|
42
|
+
return nil if ta.nil? && tb.nil?
|
|
43
|
+
|
|
44
|
+
ta == tb ? 1.0 : 0.0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Label similarity — exact (case/space-insensitive) = 1.0, else a token-overlap
|
|
48
|
+
# (Jaccard) score, so `Date logged` vs `Date of sign-off` still gets partial credit
|
|
49
|
+
# (a re-purpose candidate the human should adjudicate rather than an auto-accept).
|
|
50
|
+
def label(source, target)
|
|
51
|
+
a = normalise(source['label'])
|
|
52
|
+
b = normalise(target['label'])
|
|
53
|
+
return nil if a.empty? && b.empty?
|
|
54
|
+
return 1.0 if a == b && !a.empty?
|
|
55
|
+
|
|
56
|
+
jaccard(a.split, b.split)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Select-option overlap by VALUE (primary) / label (auxiliary) — the CORRECT option
|
|
60
|
+
# identity, never genome. Returns nil for non-select fields (no options either side).
|
|
61
|
+
def options(source, target)
|
|
62
|
+
a = option_keys(source)
|
|
63
|
+
b = option_keys(target)
|
|
64
|
+
return nil if a.empty? && b.empty?
|
|
65
|
+
|
|
66
|
+
jaccard(a, b)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def type_of(field)
|
|
70
|
+
field['__typename'] || field['type']
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalise(value)
|
|
74
|
+
value.to_s.strip.downcase.gsub(/\s+/, ' ')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def option_keys(field)
|
|
78
|
+
Array(field['options']).filter_map do |o|
|
|
79
|
+
next unless o.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
normalise(o['value'] || o['label'] || o['name'])
|
|
82
|
+
end.reject(&:empty?)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Jaccard similarity of two token collections (|∩| / |∪|). 0.0 when both empty.
|
|
86
|
+
def jaccard(list_a, list_b)
|
|
87
|
+
set_a = list_a.to_a.uniq
|
|
88
|
+
set_b = list_b.to_a.uniq
|
|
89
|
+
return 0.0 if set_a.empty? && set_b.empty?
|
|
90
|
+
|
|
91
|
+
inter = (set_a & set_b).size.to_f
|
|
92
|
+
union = (set_a | set_b).size
|
|
93
|
+
union.zero? ? 0.0 : inter / union
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def blank?(value)
|
|
97
|
+
value.nil? || value.to_s.strip.empty?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Diff
|
|
5
|
+
# Composable configuration for a diff. There is NO single diff — different processes need
|
|
6
|
+
# different modes (see `template_diff_pairing_domain.md` §8). A `Strategy` is the value object
|
|
7
|
+
# that names the axes, consumed by the diff front-end (`VersionDiff` / `CrossObjectDiff`) and
|
|
8
|
+
# by `Deploy`.
|
|
9
|
+
#
|
|
10
|
+
# Axes:
|
|
11
|
+
#
|
|
12
|
+
# * **pairing** — how counterpart objects are identified across the two snapshots:
|
|
13
|
+
# :id same object, retained Mongo ids (self-version) — NO equivalence needed;
|
|
14
|
+
# :genome pair by genomeSignature (cross-object; a strong-but-fallible signal);
|
|
15
|
+
# :type_label pair by __typename + label (cross-object; the TypedFieldsPairing precedent);
|
|
16
|
+
# :assisted the full multi-signal `Pairing::Engine` (+ `Ledger`), human-in-the-loop
|
|
17
|
+
# for ambiguous/unmatched — the general cross-object case.
|
|
18
|
+
# `:id` is the default and keeps the existing self-version behaviour untouched.
|
|
19
|
+
#
|
|
20
|
+
# * **scope** — which change kinds are emitted:
|
|
21
|
+
# :structural stages / sections / fields (add/remove/move/relabel/retype) + options + gauge;
|
|
22
|
+
# :config_only field configuration (byType), select options, gauge stops — NOT structure;
|
|
23
|
+
# :data_migration field<->field data pairing only (structure-agnostic): pairings + type/label
|
|
24
|
+
# changes on paired fields, no add/remove of scaffolding.
|
|
25
|
+
#
|
|
26
|
+
# * **move_sensitive** — when false, section/stage MOVES are suppressed (a section-agnostic
|
|
27
|
+
# diff that only cares about a field's data pairing, not where it now lives).
|
|
28
|
+
#
|
|
29
|
+
# * **intent** — the consuming process (documentation only; does not alter emission, but lets a
|
|
30
|
+
# caller/`Deploy` branch on it):
|
|
31
|
+
# :changelog what changed, for a ticket / review checklist;
|
|
32
|
+
# :deploy UAT->PROD replay delta (drives placeholderId threading ON in Deploy);
|
|
33
|
+
# :sync_readiness can this register/subset be synced to the active template.
|
|
34
|
+
#
|
|
35
|
+
# Cross-object pairings (:genome/:type_label/:assisted) require a pairing map to translate the
|
|
36
|
+
# two id-spaces before change emission — see `CrossObjectDiff`. `:id` needs no map.
|
|
37
|
+
class Strategy
|
|
38
|
+
PAIRINGS = %i[id genome type_label assisted].freeze
|
|
39
|
+
SCOPES = %i[structural config_only data_migration].freeze
|
|
40
|
+
INTENTS = %i[changelog deploy sync_readiness].freeze
|
|
41
|
+
|
|
42
|
+
# Change KINDS considered structure vs configuration — used to filter by `scope`.
|
|
43
|
+
STRUCTURAL_KINDS = %i[stage section field].freeze
|
|
44
|
+
CONFIG_KINDS = %i[field_config option gauge_stop].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :pairing, :scope, :intent
|
|
47
|
+
|
|
48
|
+
def initialize(pairing: :id, scope: :structural, move_sensitive: true, intent: :changelog)
|
|
49
|
+
@pairing = validate!(:pairing, pairing, PAIRINGS)
|
|
50
|
+
@scope = validate!(:scope, scope, SCOPES)
|
|
51
|
+
@move_sensitive = move_sensitive ? true : false
|
|
52
|
+
@intent = validate!(:intent, intent, INTENTS)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The default self-version strategy (id-paired, structural, move-aware, changelog). This is
|
|
56
|
+
# exactly the behaviour `VersionDiff` had before Phase 4 — used when no strategy is passed.
|
|
57
|
+
def self.default
|
|
58
|
+
new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def move_sensitive?
|
|
62
|
+
@move_sensitive
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# True when pairing does NOT rely on retained ids — the two snapshots live in different
|
|
66
|
+
# id-spaces and need a pairing map (`CrossObjectDiff`) before change emission.
|
|
67
|
+
def cross_object?
|
|
68
|
+
@pairing != :id
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Should a change of this KIND be emitted under this scope?
|
|
72
|
+
def emit_kind?(kind)
|
|
73
|
+
case @scope
|
|
74
|
+
when :structural then true
|
|
75
|
+
when :config_only then CONFIG_KINDS.include?(kind)
|
|
76
|
+
when :data_migration then kind == :field # field-level data pairing only
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Should a :moved change be emitted? Suppressed when move-insensitive, or under a scope that
|
|
81
|
+
# ignores scaffolding placement (config_only / data_migration never care about moves).
|
|
82
|
+
def emit_move?
|
|
83
|
+
move_sensitive? && @scope == :structural
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Filter a raw change-set to what this strategy emits (scope + move-sensitivity). The diff
|
|
87
|
+
# front-ends compute the full change-set once and let the strategy decide what survives.
|
|
88
|
+
def filter(changes)
|
|
89
|
+
Array(changes).select do |change|
|
|
90
|
+
next false unless emit_kind?(change.kind)
|
|
91
|
+
next false if change.op == :moved && !emit_move?
|
|
92
|
+
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
{ pairing: @pairing, scope: @scope, move_sensitive: move_sensitive?, intent: @intent }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def validate!(axis, value, allowed)
|
|
104
|
+
sym = value&.to_sym
|
|
105
|
+
return sym if allowed.include?(sym)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "invalid #{axis}: #{value.inspect} (expected one of #{allowed.inspect})"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -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,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
|