eco-helpers 3.2.14 → 3.2.23

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -0
  3. data/lib/eco/api/usecases/default/pages.rb +30 -0
  4. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
  5. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
  6. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
  7. data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +223 -0
  8. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  9. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  10. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  11. data/lib/eco/api/usecases/graphql/compat.rb +11 -0
  12. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  13. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  14. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  15. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  16. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  17. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  18. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  19. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  20. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  21. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  22. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  23. data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
  24. data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  38. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  39. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  40. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  41. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  42. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  43. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  44. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  45. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  46. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  47. data/lib/eco/api/usecases/graphql/samples/pages.rb +9 -0
  48. data/lib/eco/api/usecases/graphql/samples.rb +1 -0
  49. data/lib/eco/api/usecases/graphql.rb +1 -0
  50. data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
  51. data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +13 -3
  52. data/lib/eco/version.rb +1 -1
  53. metadata +47 -15
  54. data/.gitignore +0 -23
  55. data/.idea/.gitignore +0 -10
  56. data/.markdownlint.json +0 -4
  57. data/.rspec +0 -3
  58. data/.rubocop.yml +0 -103
  59. data/.ruby-version +0 -1
  60. data/.yardopts +0 -10
  61. data/Gemfile +0 -8
  62. data/Rakefile +0 -38
  63. data/eco-helpers.gemspec +0 -63
@@ -0,0 +1,155 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Deploy → verify → monitor orchestration (Phase 5).
4
+ #
5
+ # Ties the four offline-safe pieces into one pass:
6
+ # 1. APPLY — hand a gem `Diff::Deploy` plan to the `Applier` (dry-run by DEFAULT; live only
7
+ # behind an explicit `commit: true` + executor).
8
+ # 2. DRIFT — re-read the target and run a pre/post self-version diff, then compare the applied
9
+ # delta to the intended delta (`DriftReport`). Reports drift honestly.
10
+ # 3. VERIFY — run the post-deploy template doc through a `Verifier` (ecoportal-qa when present,
11
+ # else the null verifier).
12
+ # 4. MONITOR — optional sync-readiness scan of a register subset against the post-deploy template.
13
+ #
14
+ # Everything is OFFLINE against fixtures. The two seams that would touch a live API — applying the
15
+ # batch and re-reading the target — are injected as callables, so specs (and dry-runs) drive them
16
+ # with fixtures and a `RecordingExecutor`:
17
+ #
18
+ # loop = Deploy::Loop.new(
19
+ # plan: gem_deploy_plan,
20
+ # intended: source_version_diff, # the diff the plan was built from
21
+ # pre_doc: target_before_doc, # the target template BEFORE apply
22
+ # reread: -> { recording_executor.doc } # how to fetch the target AFTER apply (post-deploy doc)
23
+ # )
24
+ # result = loop.run(commit: false, executor: recording_executor, entries: register_entries)
25
+ #
26
+ # result.applied # => Applier::Outcome
27
+ # result.drift # => DriftReport (nil if pre_doc/reread not supplied)
28
+ # result.verdict # => Verifier::Verdict
29
+ # result.sync_readiness # => SyncReadiness (nil if no entries)
30
+ # result.ok? # => apply not blocked && drift match && verify pass
31
+ # result.report # => aggregate human summary
32
+ class Loop
33
+ # Aggregate outcome of one deploy→verify→monitor pass.
34
+ Result = Struct.new(:applied, :drift, :verdict, :sync_readiness, keyword_init: true) do
35
+ # Overall gate: the apply was not blocked, the drift (if checked) matched, and verify passed.
36
+ def ok?
37
+ applied_ok? && drift_ok? && verify_ok?
38
+ end
39
+
40
+ def applied_ok?
41
+ applied && applied.skipped_reason.nil?
42
+ end
43
+
44
+ def drift_ok?
45
+ drift.nil? || drift.match?
46
+ end
47
+
48
+ def verify_ok?
49
+ verdict.nil? || verdict.pass?
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ ok: ok?,
55
+ applied: applied&.to_h,
56
+ drift: drift&.to_h,
57
+ verdict: verdict&.to_h,
58
+ sync_readiness: sync_readiness&.to_h
59
+ }.compact
60
+ end
61
+
62
+ def report
63
+ [
64
+ "DEPLOY LOOP: #{ok? ? 'OK' : 'ATTENTION NEEDED'}",
65
+ applied_line,
66
+ drift&.report,
67
+ verdict&.report,
68
+ sync_readiness&.report
69
+ ].compact.join("\n\n")
70
+ end
71
+
72
+ private
73
+
74
+ def applied_line
75
+ if applied.skipped_reason
76
+ "APPLY: BLOCKED — #{applied.skipped_reason}"
77
+ elsif applied.committed?
78
+ "APPLY: committed #{applied.commands.size} command(s)"
79
+ else
80
+ "APPLY: dry-run — #{applied.commands.size} command(s) would be sent"
81
+ end
82
+ end
83
+ end
84
+
85
+ # @param plan [Ecoportal::API::GraphQL::Diff::Deploy] the gem deploy plan.
86
+ # @param intended [#changes, nil] the source diff the plan was built from (for drift check).
87
+ # @param pre_doc [Hash, nil] the target template doc BEFORE apply (for drift check).
88
+ # @param reread [#call, nil] returns the target template doc AFTER apply (for drift + verify).
89
+ # @param target [#id, nil] the target model, forwarded to the Applier for a live apply.
90
+ # @param verifier [Verifier, nil] injected verifier; defaults to Verifier.default.
91
+ # @param diff_builder [#call, nil] `diff_builder.call(pre_doc, post_doc)` returns the applied
92
+ # self-version diff (must expose `#changes`). Defaults to the gem's `Diff::VersionDiff` — which
93
+ # lives in the (currently unreleased) gem Diff module, so it is resolved LAZILY at drift time,
94
+ # never at load. Injectable so offline callers/specs can supply their own diff builder.
95
+ def initialize(plan:, intended: nil, pre_doc: nil, reread: nil, target: nil, verifier: nil,
96
+ diff_builder: nil)
97
+ @plan = plan
98
+ @intended = intended
99
+ @pre_doc = pre_doc
100
+ @reread = reread
101
+ @target = target
102
+ @verifier = verifier || Verifier.default
103
+ @diff_builder = diff_builder
104
+ end
105
+
106
+ # Run the full pass.
107
+ #
108
+ # @param commit [Boolean] false = dry-run (default). true = live apply.
109
+ # @param executor [#execute_workflow_commands, nil] the mutation facade (live) or a
110
+ # RecordingExecutor (offline). Required for both commit AND for an offline drift check
111
+ # (the RecordingExecutor replays the batch to produce the post-deploy doc).
112
+ # @param allow_partial [Boolean] forwarded to the Applier / gem plan.
113
+ # @param entries [Array, nil] register subset for the sync-readiness monitor (optional).
114
+ # @return [Result]
115
+ def run(commit: false, executor: nil, allow_partial: false, entries: nil)
116
+ applied = Applier.new(@plan, target: @target).apply(
117
+ commit: commit, executor: executor, allow_partial: allow_partial
118
+ )
119
+
120
+ post_doc = applied.skipped_reason.nil? ? reread_post_doc : nil
121
+ drift = build_drift(post_doc)
122
+ verdict = post_doc ? @verifier.verify(post_doc) : nil
123
+ monitor = build_monitor(post_doc, entries)
124
+
125
+ Result.new(applied: applied, drift: drift, verdict: verdict, sync_readiness: monitor)
126
+ end
127
+
128
+ private
129
+
130
+ def reread_post_doc
131
+ @reread&.call
132
+ end
133
+
134
+ def build_drift(post_doc)
135
+ return nil unless @intended && @pre_doc && post_doc
136
+
137
+ DriftReport.new(intended: @intended, applied: diff_builder.call(@pre_doc, post_doc))
138
+ end
139
+
140
+ # The gem's Diff::VersionDiff is the default applied-diff builder. Resolved lazily so this file
141
+ # never forces the (unreleased) gem Diff module at load time.
142
+ def diff_builder
143
+ @diff_builder ||= lambda do |pre, post|
144
+ Ecoportal::API::GraphQL::Diff::VersionDiff.new(pre, post)
145
+ end
146
+ end
147
+
148
+ def build_monitor(post_doc, entries)
149
+ return nil if entries.nil? || Array(entries).empty?
150
+
151
+ SyncReadiness.new(template_doc: post_doc || @pre_doc, entries: entries)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,58 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Offline stand-in for a live workflow-command executor.
4
+ #
5
+ # Records every batch it is handed and (optionally) applies each command to an in-memory template
6
+ # doc via a caller-supplied `mutator`, so a deploy loop can compute a faithful POST-deploy snapshot
7
+ # WITHOUT any API call. This is what makes the pre/post self-version drift check runnable offline:
8
+ # the applied delta is derived from replaying the batch onto the pre-deploy doc.
9
+ #
10
+ # It satisfies BOTH executor contracts the gem may call:
11
+ # * `execute_workflow_commands(commands)` (gem Diff::Deploy#execute! positional)
12
+ # * `execute_workflow_commands(model, commands:)` (gem Builder::Page facade)
13
+ #
14
+ # rec = RecordingExecutor.new(doc: before_doc.dup, mutator: my_replayer)
15
+ # plan.execute!(rec)
16
+ # rec.batches # => [[cmd, cmd, ...]]
17
+ # rec.doc # => the mutated post-deploy doc (if a mutator was given)
18
+ class RecordingExecutor
19
+ attr_reader :batches, :doc
20
+
21
+ # @param doc [Hash, nil] a template/page doc to mutate as commands are replayed.
22
+ # @param mutator [#call, nil] `mutator.call(doc, command)` applies one command to `doc`
23
+ # in place (or returns the new doc). When nil, the doc is left untouched (record-only).
24
+ def initialize(doc: nil, mutator: nil)
25
+ @doc = doc
26
+ @mutator = mutator
27
+ @batches = []
28
+ end
29
+
30
+ def execute_workflow_commands(model_or_commands, commands: nil)
31
+ batch = commands || model_or_commands
32
+ @batches << batch
33
+ replay(batch) if @mutator
34
+ record_response(batch)
35
+ end
36
+
37
+ # Marker so the applier does not double-wrap this executor.
38
+ def arity_ok_for_positional_commands?
39
+ true
40
+ end
41
+
42
+ # Every command across all recorded batches, flattened, in order.
43
+ def all_commands
44
+ @batches.flatten(1)
45
+ end
46
+
47
+ private
48
+
49
+ def replay(batch)
50
+ Array(batch).each { |command| @doc = @mutator.call(@doc, command) || @doc }
51
+ end
52
+
53
+ def record_response(batch)
54
+ { recorded: batch.size }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,178 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Sync-readiness monitoring.
4
+ #
5
+ # Given a register subset (its entries) and the register's ACTIVE template, report which entries
6
+ # CAN sync vs which cannot — where "can sync" means: every field the template marks REQUIRED is
7
+ # present on the entry with a non-empty value AND typed correctly (the entry field's type matches
8
+ # the template field's type). This is the pre-flight an integration runs before pushing entries
9
+ # downstream, and the drift-detection the customer-facing integrations mandate needs.
10
+ #
11
+ # Operates on raw docs (template doc + entry docs) so it is session-less and offline-runnable:
12
+ # * template doc: the JSON the gem returns for a template (stages/sections/dataFields).
13
+ # * entry docs: each register entry as { 'id' =>, 'fields' => [ {id/label/type/value}, ... ] }
14
+ # (or anything exposing the same shape). A field is matched to a template field by id first,
15
+ # then by label — mirroring how the pairing engine falls back.
16
+ #
17
+ # monitor = SyncReadiness.new(template_doc: tpl, entries: [e1, e2, ...])
18
+ # monitor.ready # => [EntryReadiness(ready: true), ...]
19
+ # monitor.not_ready # => [EntryReadiness(ready: false, ...), ...]
20
+ # monitor.summary # => { total:, ready:, not_ready: }
21
+ # monitor.report # => human summary
22
+ class SyncReadiness
23
+ # Per-entry readiness verdict.
24
+ EntryReadiness = Struct.new(:entry_id, :ready, :missing_required, :type_mismatches,
25
+ keyword_init: true) do
26
+ def ready?
27
+ ready ? true : false
28
+ end
29
+
30
+ def reasons
31
+ out = Array(missing_required).map { |m| "missing required '#{m}'" }
32
+ Array(type_mismatches).each do |m|
33
+ out << "type mismatch '#{m[:label]}' (want #{m[:expected]}, got #{m[:actual]})"
34
+ end
35
+ out
36
+ end
37
+
38
+ def to_h
39
+ { entry_id: entry_id, ready: ready?, missing_required: Array(missing_required),
40
+ type_mismatches: Array(type_mismatches) }
41
+ end
42
+ end
43
+
44
+ attr_reader :entries
45
+
46
+ # @param template_doc [Hash] the active template doc.
47
+ # @param entries [Array<Hash,#id>] register entry docs to assess.
48
+ def initialize(template_doc:, entries:)
49
+ @template_doc = template_doc || {}
50
+ @entries = Array(entries)
51
+ end
52
+
53
+ def verdicts
54
+ @verdicts ||= @entries.map { |entry| assess(entry) }
55
+ end
56
+
57
+ def ready
58
+ verdicts.select(&:ready?)
59
+ end
60
+
61
+ def not_ready
62
+ verdicts.reject(&:ready?)
63
+ end
64
+
65
+ def summary
66
+ { total: verdicts.size, ready: ready.size, not_ready: not_ready.size }
67
+ end
68
+
69
+ def to_h
70
+ { summary: summary, entries: verdicts.map(&:to_h) }
71
+ end
72
+
73
+ def report
74
+ lines = ["SYNC READINESS: #{summary[:ready]}/#{summary[:total]} entries ready"]
75
+ not_ready.each do |v|
76
+ lines << " * entry #{v.entry_id}: NOT READY"
77
+ v.reasons.each { |r| lines << " - #{r}" }
78
+ end
79
+ lines.join("\n")
80
+ end
81
+
82
+ private
83
+
84
+ # The template fields, indexed by id and by (downcased) label, carrying type + required.
85
+ def template_fields
86
+ @template_fields ||= begin
87
+ out = { by_id: {}, by_label: {} }
88
+ each_template_field do |f|
89
+ spec = { id: f['id'], label: f['label'], type: field_type(f), required: !!f['required'] }
90
+ out[:by_id][f['id']] = spec if f['id']
91
+ out[:by_label][label_key(f['label'])] = spec if f['label']
92
+ end
93
+ out
94
+ end
95
+ end
96
+
97
+ def required_fields
98
+ @required_fields ||= template_fields[:by_id].values.select { |s| s[:required] }.
99
+ +(template_fields[:by_label].values.select { |s| s[:required] }).
100
+ uniq { |s| s[:id] || s[:label] }
101
+ end
102
+
103
+ def assess(entry)
104
+ entry_fields = index_entry_fields(entry)
105
+ missing = []
106
+ mismatches = []
107
+
108
+ required_fields.each do |spec|
109
+ efield = entry_fields[:by_id][spec[:id]] || entry_fields[:by_label][label_key(spec[:label])]
110
+ if efield.nil? || value_empty?(efield[:value])
111
+ missing << spec[:label]
112
+ elsif spec[:type] && efield[:type] && spec[:type] != efield[:type]
113
+ mismatches << { label: spec[:label], expected: spec[:type], actual: efield[:type] }
114
+ end
115
+ end
116
+
117
+ EntryReadiness.new(
118
+ entry_id: entry_id(entry),
119
+ ready: missing.empty? && mismatches.empty?,
120
+ missing_required: missing,
121
+ type_mismatches: mismatches
122
+ )
123
+ end
124
+
125
+ def index_entry_fields(entry)
126
+ out = { by_id: {}, by_label: {} }
127
+ entry_field_docs(entry).each do |f|
128
+ spec = { id: f['id'] || f[:id], label: f['label'] || f[:label],
129
+ type: field_type(f), value: f['value'] || f[:value] }
130
+ out[:by_id][spec[:id]] = spec if spec[:id]
131
+ out[:by_label][label_key(spec[:label])] = spec if spec[:label]
132
+ end
133
+ out
134
+ end
135
+
136
+ def entry_field_docs(entry)
137
+ return Array(entry['fields'] || entry[:fields]) if entry.is_a?(Hash)
138
+ return entry.components.to_a if entry.respond_to?(:components)
139
+
140
+ []
141
+ end
142
+
143
+ def entry_id(entry)
144
+ return entry['id'] || entry[:id] if entry.is_a?(Hash)
145
+
146
+ entry.respond_to?(:id) ? entry.id : nil
147
+ end
148
+
149
+ def each_template_field(&block)
150
+ Array(@template_doc['stages']).each do |st|
151
+ Array(st['sections']).each do |sec|
152
+ fields_of(sec).each(&block)
153
+ end
154
+ end
155
+ end
156
+
157
+ def fields_of(section)
158
+ Array(section['dataFields']) + Array(section['leftDataFields']) + Array(section['rightDataFields'])
159
+ end
160
+
161
+ def field_type(field)
162
+ field['__typename'] || field['type'] || field[:type]
163
+ end
164
+
165
+ def label_key(label)
166
+ label.to_s.strip.downcase
167
+ end
168
+
169
+ def value_empty?(value)
170
+ return true if value.nil?
171
+ return value.strip.empty? if value.is_a?(String)
172
+ return value.empty? if value.respond_to?(:empty?)
173
+
174
+ false
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,141 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Pluggable post-deploy verification interface.
4
+ #
5
+ # The deploy loop wants to run the freshly-deployed template through a check framework (ideally the
6
+ # `ecoportal-qa` gem's check runner). But `ecoportal-qa` is NOT a hard dependency of eco-helpers,
7
+ # so we do not `require` it here. Instead the loop calls a small `Verifier` interface; a concrete
8
+ # verifier is injected. When `ecoportal-qa` IS on the load path, `QaVerifier` wires it in; when it
9
+ # isn't, `NullVerifier` (always-pass, records that qa was unavailable) keeps the loop runnable
10
+ # offline.
11
+ #
12
+ # A verifier receives the post-deploy template doc and returns a `Verdict`.
13
+ #
14
+ # verdict = Verifier.default.verify(post_deploy_doc)
15
+ # verdict.pass? # => true/false
16
+ # verdict.findings # => [ {severity:, message:}, ... ]
17
+ class Verifier
18
+ # A verification verdict. `pass?` is the gate; `findings` carries per-check detail.
19
+ Verdict = Struct.new(:pass, :findings, :verifier, keyword_init: true) do
20
+ def pass?
21
+ pass ? true : false
22
+ end
23
+
24
+ def failures
25
+ Array(findings).select { |f| %i[error fail].include?((f[:severity] || f['severity'])&.to_sym) }
26
+ end
27
+
28
+ def to_h
29
+ { pass: pass?, verifier: verifier, findings: Array(findings) }
30
+ end
31
+
32
+ def report
33
+ head = "VERIFY (#{verifier}): #{pass? ? 'PASS' : 'FAIL'}"
34
+ return head if Array(findings).empty?
35
+
36
+ ([head] + Array(findings).map do |f|
37
+ " - [#{f[:severity] || f['severity']}] #{f[:message] || f['message']}"
38
+ end).join("\n")
39
+ end
40
+ end
41
+
42
+ # The verifier the loop uses when none is injected: qa if available, else the null verifier.
43
+ def self.default
44
+ qa_available? ? QaVerifier.new : NullVerifier.new
45
+ end
46
+
47
+ # Is the ecoportal-qa check framework loadable?
48
+ def self.qa_available?
49
+ return @qa_available unless @qa_available.nil?
50
+
51
+ @qa_available = begin
52
+ require 'ecoportal/qa'
53
+ defined?(::Ecoportal::QA) ? true : false
54
+ rescue LoadError
55
+ false
56
+ end
57
+ end
58
+
59
+ # Reset the memoised availability probe (used by specs that stub the load path).
60
+ def self.reset_qa_probe!
61
+ @qa_available = nil
62
+ end
63
+
64
+ # Subclass / duck-type contract: return a Verdict for the given template doc.
65
+ def verify(_template_doc)
66
+ raise NotImplementedError, "#{self.class} must implement #verify(template_doc)"
67
+ end
68
+
69
+ # Always-pass verifier used when no real check framework is wired in. It is honest about it:
70
+ # the verdict records that qa was unavailable so a report never falsely claims "qa verified".
71
+ class NullVerifier < Verifier
72
+ def verify(_template_doc)
73
+ Verdict.new(
74
+ pass: true,
75
+ verifier: 'null',
76
+ findings: [{ severity: :info, message: 'ecoportal-qa not available; no checks run' }]
77
+ )
78
+ end
79
+ end
80
+
81
+ # Adapter onto the ecoportal-qa check runner. Kept dependency-free at load time — it only touches
82
+ # `Ecoportal::QA` inside #verify, so requiring this file never forces the qa gem. The exact qa
83
+ # runner API is intentionally accessed through a single seam (`run_qa`) so it is easy to correct
84
+ # once the qa public surface stabilises (see TODO).
85
+ #
86
+ # TODO(qa-integration): the qa check-runner public API is still moving (see the qa repo's
87
+ # PHASE3-SCOPE.md). This adapter assumes a `check_set.run(doc)`-style entry returning objects
88
+ # responding to `#pass?` and `#findings`. Confirm and adjust `run_qa` when qa is pinned as a dep.
89
+ class QaVerifier < Verifier
90
+ def initialize(check_set: nil)
91
+ super()
92
+ @check_set = check_set
93
+ end
94
+
95
+ def verify(template_doc)
96
+ result = run_qa(template_doc)
97
+ Verdict.new(pass: result_pass?(result), verifier: 'ecoportal-qa', findings: result_findings(result))
98
+ rescue StandardError => e
99
+ Verdict.new(pass: false, verifier: 'ecoportal-qa',
100
+ findings: [{ severity: :error, message: "qa verification raised: #{e.message}" }])
101
+ end
102
+
103
+ private
104
+
105
+ # SINGLE integration seam — the one place that knows the qa runner shape.
106
+ def run_qa(template_doc)
107
+ set = @check_set || default_check_set
108
+ set.run(template_doc)
109
+ end
110
+
111
+ def default_check_set
112
+ raise NotImplementedError, 'no qa check_set injected and no default wired' unless defined?(::Ecoportal::QA)
113
+
114
+ # Placeholder: the concrete constructor is confirmed once qa is a pinned dependency.
115
+ if ::Ecoportal::QA.respond_to?(:default_check_set)
116
+ ::Ecoportal::QA.default_check_set
117
+ else
118
+ (raise NotImplementedError,
119
+ 'qa default check set not available')
120
+ end
121
+ end
122
+
123
+ def result_pass?(result)
124
+ return result.pass? if result.respond_to?(:pass?)
125
+
126
+ Array(result).all? { |r| r.respond_to?(:pass?) ? r.pass? : true }
127
+ end
128
+
129
+ def result_findings(result)
130
+ source = result.respond_to?(:findings) ? result.findings : result
131
+ Array(source).map do |f|
132
+ next f if f.is_a?(Hash)
133
+
134
+ { severity: (f.respond_to?(:severity) ? f.severity : :info),
135
+ message: (f.respond_to?(:message) ? f.message : f.to_s) }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,21 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ # Phase 5: deploy → verify → monitor loop for template (workflow) changes.
3
+ #
4
+ # Consumes a gem `Ecoportal::API::GraphQL::Diff::Deploy` command batch (from
5
+ # `Diff::Deploy.from_versions(...)`), dry-run applies it (live behind an explicit `commit:` flag),
6
+ # confirms the applied delta matches the intended delta via a pre/post self-version diff
7
+ # (`Diff::VersionDiff`), verifies the result through a pluggable `Verifier` (ecoportal-qa when
8
+ # present), and reports register sync-readiness against the deployed template.
9
+ #
10
+ # All components are session-less and offline-runnable against fixtures — the live seams (apply /
11
+ # re-read) are injected. See each file's header. Dry-run is the DEFAULT.
12
+ module Deploy
13
+ end
14
+ end
15
+
16
+ require_relative 'deploy/applier'
17
+ require_relative 'deploy/recording_executor'
18
+ require_relative 'deploy/drift_report'
19
+ require_relative 'deploy/verifier'
20
+ require_relative 'deploy/sync_readiness'
21
+ require_relative 'deploy/loop'
@@ -0,0 +1,11 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Template (workflow) build-from-scratch + (later) diff-and-update samples.
3
+ # See the ecoportal-api-graphql repo's internal docs.
4
+ module Template
5
+ end
6
+ end
7
+
8
+ require_relative 'template/command_emitter'
9
+ require_relative 'template/base'
10
+ require_relative 'template/csv_build'
11
+ require_relative 'template/deploy'
@@ -0,0 +1,9 @@
1
+ module Eco::API::UseCases::GraphQL::Samples
2
+ module Pages
3
+ end
4
+ end
5
+
6
+ require_relative 'pages/page'
7
+ require_relative 'pages/register'
8
+ require_relative 'pages/org_page'
9
+ require_relative 'pages/template'
@@ -5,3 +5,4 @@ end
5
5
 
6
6
  require_relative 'samples/location'
7
7
  require_relative 'samples/contractors'
8
+ require_relative 'samples/pages'
@@ -10,4 +10,5 @@ end
10
10
  require_relative 'graphql/helpers'
11
11
  require_relative 'graphql/utils'
12
12
  require_relative 'graphql/base'
13
+ require_relative 'graphql/compat'
13
14
  require_relative 'graphql/samples'
@@ -224,6 +224,10 @@ class Eco::API::UseCases::OozeSamples::OozeBaseCase < Eco::API::Common::Loaders:
224
224
  return if options.dig(:feedback, :only_stats)
225
225
  return unless (patch = (patch_doc(entry) || {})['page'])
226
226
 
227
+ # Affirmative dry-run line so an empty/blank page no longer reads as a silent no-op.
228
+ # A create body carries :templateId (CreateFromTemplateInput); anything else is an update.
229
+ verb = patch.is_a?(Hash) && patch[:templateId] ? 'create' : 'update'
230
+ log(:info) { "[dry-run] would #{verb} #{object_reference(entry)} with:" }
227
231
  pp patch
228
232
  end
229
233
 
@@ -54,7 +54,10 @@ class Eco::API::UseCases::OozeSamples::RegisterUpdateCase < Eco::API::UseCases::
54
54
  return unless (pending = queue_shift(ooze_id))
55
55
 
56
56
  update_ooze(pending).tap do |result|
57
- if result.is_a?(Ecoportal::API::Common::Response)
57
+ # Duck-type, not is_a?: the GraphQL Compat::Response responds to success?/status but is
58
+ # NOT an Ecoportal::API::Common::Response, so is_a? silently skipped every GraphQL update
59
+ # (updated/failed stuck at 0). false/nil returns (dry-run / no-op) still fall through.
60
+ if result.respond_to?(:success?)
58
61
  if result.success?
59
62
  @updated_oozes += 1
60
63
  else
@@ -105,7 +108,13 @@ class Eco::API::UseCases::OozeSamples::RegisterUpdateCase < Eco::API::UseCases::
105
108
  def enqueue(object)
106
109
  return unless object.respond_to?(:id)
107
110
 
108
- unless object.is_a?(Ecoportal::API::V2::Page) or object.is_a?(Ecoportal::API::V2::Pages::PageStage)
111
+ # Accept v2 Page/PageStage, or any duck-typed entry that carries change-tracking
112
+ # (the GraphQL compat page responds to #dirty? and #as_update). The strict v2-class
113
+ # guard otherwise raised for GraphQL pages under OozeRedirect.
114
+ queueable = object.is_a?(Ecoportal::API::V2::Page) ||
115
+ object.is_a?(Ecoportal::API::V2::Pages::PageStage) ||
116
+ (object.respond_to?(:dirty?) && object.respond_to?(:as_update))
117
+ unless queueable
109
118
  msg = "Queuing is just for entries. Expecting Page or PageStage. Given: #{object.class}"
110
119
  raise ArgumentError, msg
111
120
  end
@@ -182,7 +191,8 @@ class Eco::API::UseCases::OozeSamples::RegisterUpdateCase < Eco::API::UseCases::
182
191
  def update_oozes(batched_oozes = batch_queue)
183
192
  batched_oozes.each do |ooze|
184
193
  update_ooze(ooze).tap do |result|
185
- if result.is_a?(Ecoportal::API::Common::Response)
194
+ # Duck-type, not is_a? — see #before_loading_new_target (GraphQL Compat::Response).
195
+ if result.respond_to?(:success?)
186
196
  if result.success?
187
197
  @updated_oozes += 1
188
198
  else
data/lib/eco/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Eco
2
- VERSION = '3.2.14'.freeze
2
+ VERSION = '3.2.23'.freeze
3
3
  end