eco-helpers 3.2.16 → 3.2.17

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/eco-helpers.gemspec +2 -2
  4. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  5. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  6. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  7. data/lib/eco/api/usecases/graphql/compat.rb +5 -0
  8. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  9. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  10. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  11. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  12. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  13. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  14. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  15. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  16. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  17. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  18. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  19. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  20. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  21. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  22. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  23. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  24. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages.rb +2 -0
  38. data/lib/eco/version.rb +1 -1
  39. metadata +34 -5
@@ -0,0 +1,98 @@
1
+ require 'csv'
2
+
3
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
4
+ module CsvBuild
5
+ # Parse a columnar CSV describing ONE template into an ordered, grouped intermediate structure
6
+ # (stages → sections → fields) that the `Builder` turns into a WorkflowCommand batch.
7
+ #
8
+ # Format specifics live ENTIRELY in `FormatMap` (see its TODO about the ~mid-July format). The
9
+ # parser only groups RowSpecs by their declared hierarchy while PRESERVING first-seen order, so
10
+ # the emitted command batch is deterministic and dependency-safe.
11
+ #
12
+ # parser = Parser.new(csv_string) # or Parser.from_file(path)
13
+ # tree = parser.tree
14
+ # tree # => [ { name:, ordering:, sections: [ { heading:, layout:, ordering:, fields: [...] } ] } ]
15
+ class Parser
16
+ # Parsed field, ready for the builder (already format-agnostic).
17
+ Field = Struct.new(:label, :type, :required, :column, :description, :options, keyword_init: true)
18
+
19
+ def self.from_file(path)
20
+ new(File.read(path))
21
+ end
22
+
23
+ # @param source [String] CSV text (with a header row).
24
+ def initialize(source)
25
+ @source = source.to_s
26
+ end
27
+
28
+ # The grouped hierarchy: stages (first-seen order) → sections → fields.
29
+ def tree
30
+ @tree ||= build_tree
31
+ end
32
+
33
+ # Flat list of RowSpecs (post FormatMap mapping), skipping blank / header-only rows.
34
+ def row_specs
35
+ @row_specs ||= rows.map { |row| FormatMap.row_spec(row) }.
36
+ reject { |rs| rs.stage.nil? && rs.field_label.nil? }
37
+ end
38
+
39
+ private
40
+
41
+ def rows
42
+ table = CSV.parse(@source, headers: true)
43
+ table.map(&:to_h)
44
+ rescue CSV::MalformedCSVError => e
45
+ raise ArgumentError, "malformed template CSV: #{e.message}"
46
+ end
47
+
48
+ def build_tree
49
+ stages = {}
50
+ stage_order = []
51
+ section_order = Hash.new { |h, k| h[k] = [] }
52
+
53
+ row_specs.each do |rs|
54
+ next unless rs.stage # a field row must carry its stage (denormalised format)
55
+
56
+ stage = (stages[rs.stage] ||= new_stage(rs, stage_order))
57
+ section = ensure_section(stage, rs, section_order)
58
+ section[:fields] << build_field(rs) if rs.field_label
59
+ end
60
+
61
+ stage_order.map { |name| finalize_stage(stages[name]) }
62
+ end
63
+
64
+ def new_stage(row_spec, stage_order)
65
+ stage_order << row_spec.stage
66
+ { name: row_spec.stage, ordering: row_spec.stage_ordering, sections: {}, section_order: [] }
67
+ end
68
+
69
+ def ensure_section(stage, row_spec, _section_order)
70
+ heading = row_spec.section
71
+ key = heading.to_s
72
+ stage[:sections][key] ||= begin
73
+ stage[:section_order] << key
74
+ { heading: heading, layout: row_spec.section_layout, ordering: row_spec.section_ordering, fields: [] }
75
+ end
76
+ end
77
+
78
+ def build_field(row_spec)
79
+ Field.new(
80
+ label: row_spec.field_label,
81
+ type: row_spec.field_type,
82
+ required: row_spec.field_required,
83
+ column: row_spec.field_column,
84
+ description: row_spec.field_description,
85
+ options: row_spec.field_options
86
+ )
87
+ end
88
+
89
+ def finalize_stage(stage)
90
+ {
91
+ name: stage[:name],
92
+ ordering: stage[:ordering],
93
+ sections: stage[:section_order].map { |key| stage[:sections][key] }
94
+ }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,17 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ # CSV → template BUILD pipeline.
3
+ #
4
+ # Parse a columnar CSV describing a template (stages / sections / fields / types / options), emit the
5
+ # ordered `WorkflowCommand` batch via the EXISTING `Template::CommandEmitter`, and build it via the
6
+ # released gem's `Builder::Template#create(commands:)`. Offline / dry-run by default; live creation
7
+ # (`Builder#create!`) needs credentials.
8
+ #
9
+ # The exact ~300-template columnar CSV format is due ~mid-July 2026 and is isolated in
10
+ # `CsvBuild::FormatMap` (see its TODO) so only that file changes when the real format lands.
11
+ module CsvBuild
12
+ end
13
+ end
14
+
15
+ require_relative 'csv_build/format_map'
16
+ require_relative 'csv_build/parser'
17
+ require_relative 'csv_build/builder'
@@ -0,0 +1,141 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Thin, offline-safe wrapper around a gem `Diff::Deploy` plan.
4
+ #
5
+ # The gem's `Diff::Deploy` is INERT until `execute!` is called with an explicit executor — this
6
+ # applier keeps that safety and adds a **dry-run default**: nothing hits the API unless the caller
7
+ # opts in with `commit: true` AND supplies a live executor. The dry-run path records the batch it
8
+ # WOULD send (so the pre/post-diff and the specs can inspect it) and never calls the executor.
9
+ #
10
+ # An "executor" is anything responding to `execute_workflow_commands(model_or_id, commands:)` —
11
+ # in production the gem's `graphql.page` / `Builder::Template` update facade. For dry-run + specs a
12
+ # capturing stand-in is enough; see `Deploy::RecordingExecutor`.
13
+ #
14
+ # plan = Ecoportal::API::GraphQL::Diff::Deploy.from_versions(before, after, target_doc: prod)
15
+ # applier = Applier.new(plan, target: prod_template)
16
+ # outcome = applier.apply # dry-run: records commands, no API call
17
+ # outcome = applier.apply(commit: true, executor: graphql.page) # live apply
18
+ #
19
+ # outcome.committed? # => false for dry-run
20
+ # outcome.commands # => the ordered batch that was (or would be) sent
21
+ # outcome.unsupported # => [Change, ...] the plan could not synthesise (needs a human)
22
+ class Applier
23
+ # Result of an apply attempt. `committed?` is only true when the batch was actually sent live.
24
+ Outcome = Struct.new(:committed, :commands, :unsupported, :response, :skipped_reason,
25
+ keyword_init: true) do
26
+ def committed?
27
+ committed ? true : false
28
+ end
29
+
30
+ def applied?
31
+ committed? || dry_run?
32
+ end
33
+
34
+ def dry_run?
35
+ !committed? && skipped_reason.nil?
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ committed: committed?,
41
+ commands: commands,
42
+ unsupported: Array(unsupported).map { |c| c.respond_to?(:to_h) ? c.to_h : c },
43
+ skipped_reason: skipped_reason
44
+ }.compact
45
+ end
46
+ end
47
+
48
+ attr_reader :plan, :target
49
+
50
+ # @param plan [Ecoportal::API::GraphQL::Diff::Deploy] the gem deploy plan.
51
+ # @param target [#id, nil] the template/page model the batch applies to (needed for a live
52
+ # executor that reads id/patchVer). Optional for dry-run.
53
+ def initialize(plan, target: nil)
54
+ @plan = plan
55
+ @target = target
56
+ end
57
+
58
+ # Apply the plan's command batch.
59
+ #
60
+ # @param commit [Boolean] false (default) = dry-run, records the batch and returns without
61
+ # touching the API. true = live apply (requires an executor).
62
+ # @param executor [#execute_workflow_commands, nil] the live mutation facade. Ignored on dry-run.
63
+ # @param allow_partial [Boolean] forwarded to the gem plan: apply even with unsupported changes.
64
+ # @return [Outcome]
65
+ def apply(commit: false, executor: nil, allow_partial: false)
66
+ return blocked_outcome unless deployable?(allow_partial)
67
+
68
+ return dry_run_outcome unless commit
69
+
70
+ commit_outcome(executor, allow_partial: allow_partial)
71
+ end
72
+
73
+ # The ordered command batch (delegates to the gem plan).
74
+ def commands
75
+ plan.commands
76
+ end
77
+
78
+ # Changes the gem plan could not synthesise — surfaced so a human gates the deploy.
79
+ def unsupported
80
+ plan.unsupported
81
+ end
82
+
83
+ def fully_supported?
84
+ plan.fully_supported?
85
+ end
86
+
87
+ private
88
+
89
+ # A deploy is refused (never silently no-ops into a live call) when it is not fully supported
90
+ # and the caller did not explicitly opt into a partial apply.
91
+ def deployable?(allow_partial)
92
+ fully_supported? || allow_partial
93
+ end
94
+
95
+ def blocked_outcome
96
+ Outcome.new(
97
+ committed: false,
98
+ commands: [],
99
+ unsupported: unsupported,
100
+ skipped_reason: "#{unsupported.size} unsupported change(s); review before deploy"
101
+ )
102
+ end
103
+
104
+ def dry_run_outcome
105
+ Outcome.new(committed: false, commands: commands, unsupported: unsupported)
106
+ end
107
+
108
+ def commit_outcome(executor, allow_partial:)
109
+ raise ArgumentError, 'commit: true requires an executor (execute_workflow_commands)' unless executor
110
+
111
+ response = plan.execute!(executor_facade(executor), allow_partial: allow_partial)
112
+ Outcome.new(committed: true, commands: commands, unsupported: unsupported, response: response)
113
+ end
114
+
115
+ # The gem plan calls `executor.execute_workflow_commands(commands)`. The gem's `Builder::Page`
116
+ # facade signature is `execute_workflow_commands(model, commands:)`, so bind the target model
117
+ # here. If the given executor already matches the plan's positional-commands contract, it is
118
+ # used as-is.
119
+ def executor_facade(executor)
120
+ return executor if executor.respond_to?(:arity_ok_for_positional_commands?)
121
+
122
+ TargetBoundExecutor.new(executor, target)
123
+ end
124
+
125
+ # Adapts the gem `Builder::Page`-style facade (`execute_workflow_commands(model, commands:)`) to
126
+ # the positional-commands contract the gem `Diff::Deploy#execute!` expects.
127
+ class TargetBoundExecutor
128
+ def initialize(facade, target)
129
+ @facade = facade
130
+ @target = target
131
+ end
132
+
133
+ def execute_workflow_commands(commands)
134
+ raise ArgumentError, 'a target model is required for a live apply' unless @target
135
+
136
+ @facade.execute_workflow_commands(@target, commands: commands)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,104 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module Deploy
3
+ # Post-deploy verification: did the applied delta == the intended delta?
4
+ #
5
+ # After a deploy, re-read the target template and run a **self-version diff** (gem
6
+ # `Diff::VersionDiff`) between the PRE-deploy snapshot and the POST-deploy snapshot. That "applied
7
+ # delta" is then compared, op/kind/attribute-wise, against the "intended delta" (the changelog of
8
+ # the diff that produced the deploy batch).
9
+ #
10
+ # We compare on a normalised signature per change (op + kind + attribute + before/after) rather
11
+ # than on Mongo ids, because a cross-object deploy re-homes ids on the target — the SHAPE of the
12
+ # change is what must match. Any change present in one side but not the other is reported honestly
13
+ # as drift; nothing is hand-waved as "close enough".
14
+ #
15
+ # report = DriftReport.new(
16
+ # intended: source_diff, # the VersionDiff the deploy batch came from
17
+ # applied: Diff::VersionDiff.new(pre, post) # pre/post self-diff of the target
18
+ # )
19
+ # report.match? # => true when applied delta == intended delta (shape-wise)
20
+ # report.missing # => intended changes NOT observed on the target (under-applied)
21
+ # report.unexpected # => changes observed on the target that were NOT intended (over/side-effect)
22
+ # report.report # => human summary for a ticket / log
23
+ class DriftReport
24
+ attr_reader :intended, :applied
25
+
26
+ # @param intended [#changes] the source diff the deploy batch was synthesised from.
27
+ # @param applied [#changes] the pre/post self-version diff of the deployed target.
28
+ def initialize(intended:, applied:)
29
+ @intended = intended
30
+ @applied = applied
31
+ end
32
+
33
+ # Signatures present in the intended delta but missing from the applied delta.
34
+ def missing
35
+ @missing ||= (intended_signatures.keys - applied_signatures.keys).map { |sig| intended_signatures[sig] }
36
+ end
37
+
38
+ # Signatures present in the applied delta but not intended (side effects / over-apply).
39
+ def unexpected
40
+ @unexpected ||= (applied_signatures.keys - intended_signatures.keys).map { |sig| applied_signatures[sig] }
41
+ end
42
+
43
+ # True when the applied delta exactly matches the intended delta (no drift either way).
44
+ def match?
45
+ missing.empty? && unexpected.empty?
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ match: match?,
51
+ intended: intended_signatures.size,
52
+ applied: applied_signatures.size,
53
+ missing: missing.map { |c| describe(c) },
54
+ unexpected: unexpected.map { |c| describe(c) }
55
+ }
56
+ end
57
+
58
+ def report
59
+ return 'DEPLOY VERIFY OK — applied delta matches intended delta.' if match?
60
+
61
+ lines = ['DEPLOY DRIFT detected:']
62
+ lines << " Missing (intended but not applied): #{missing.size}"
63
+ missing.each { |c| lines << " - #{describe(c)}" }
64
+ lines << " Unexpected (applied but not intended): #{unexpected.size}"
65
+ unexpected.each { |c| lines << " + #{describe(c)}" }
66
+ lines.join("\n")
67
+ end
68
+
69
+ private
70
+
71
+ def intended_signatures
72
+ @intended_signatures ||= signatures(@intended)
73
+ end
74
+
75
+ def applied_signatures
76
+ @applied_signatures ||= signatures(@applied)
77
+ end
78
+
79
+ # Index changes by a shape signature. Duplicate signatures (unlikely across a single template
80
+ # delta) collapse — acceptable for a drift verdict, which is set-membership, not count.
81
+ def signatures(diff)
82
+ Array(diff.changes).to_h do |change|
83
+ [signature(change), change]
84
+ end
85
+ end
86
+
87
+ # id-free shape signature: what changed, not which Mongo doc it landed on.
88
+ def signature(change)
89
+ [change.op, change.kind, change.attribute, norm(change.before), norm(change.after),
90
+ change.respond_to?(:label) ? change.label : nil]
91
+ end
92
+
93
+ def norm(value)
94
+ value.nil? ? nil : value
95
+ end
96
+
97
+ def describe(change)
98
+ return change.description if change.respond_to?(:description)
99
+
100
+ change.to_h.inspect
101
+ end
102
+ end
103
+ end
104
+ end
@@ -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