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,126 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module CsvBuild
3
+ # CSV → template BUILD pipeline.
4
+ #
5
+ # Ties the pieces together: a `Parser` turns a columnar CSV into a stage/section/field tree, this
6
+ # Builder replays that tree onto the EXISTING `Template::CommandEmitter` (reused verbatim — same
7
+ # placeholder-id threading the create path already relies on), and then hands the resulting ordered
8
+ # `WorkflowCommand` batch to the released gem's `Builder::Template#create(commands:)`.
9
+ #
10
+ # Offline / dry-run by DEFAULT — real creation needs credentials, so `build` only assembles and
11
+ # returns the command batch; `create!` is the explicit live path (requires a gem `Builder::Template`
12
+ # or `GraphQL#template` facade).
13
+ #
14
+ # builder = CsvBuild::Builder.from_csv(csv_string)
15
+ # commands = builder.commands # ordered { commandKey => kwargs } batch, offline
16
+ # # live (needs creds):
17
+ # builder.create!(graphql.template) # => gem create mutation response
18
+ #
19
+ # SECTION / FIELD IDENTITY: each field's `description` (from FormatMap) is passed to the emitter's
20
+ # `field(description:)`, and a hidden anchor per section carries the section identity — so a rebuilt
21
+ # template stays pair-able against a prior version by the diff engine. When the field type is
22
+ # `select`, the parsed options are emitted as addSelectFieldOption commands.
23
+ #
24
+ # ⚠ HONEST LIMITATION: the RELEASED gem's `addField` input drops any key outside
25
+ # placeholderId/fieldType/label/stageId/sectionId/column (see CommandEmitter#field note). So today
26
+ # the `description` identity token is NOT persisted through addField — the hidden ANCHOR FIELD's
27
+ # presence (its label) is the only identity signal that currently survives to the batch. The
28
+ # description wiring is in place so identity flows end-to-end the moment the gem's addField input
29
+ # gains a description key. Until then, section/field identity relies on the anchor + heading/label.
30
+ class Builder
31
+ # Hidden marker field type used to anchor SECTION identity across rebuilds (identity convention:
32
+ # hidden-field + description). Kept as a constant so it is trivial to retune when the real format
33
+ # / platform convention for the anchor is confirmed.
34
+ SECTION_ANCHOR_TYPE = 'hidden'.freeze
35
+ SECTION_ANCHOR_LABEL = '__section_id'.freeze
36
+
37
+ def self.from_csv(csv_string, **opts)
38
+ new(Parser.new(csv_string), **opts)
39
+ end
40
+
41
+ def self.from_file(path, **opts)
42
+ new(Parser.from_file(path), **opts)
43
+ end
44
+
45
+ # @param parser [Parser]
46
+ # @param section_anchors [Boolean] emit a hidden anchor field per section for stable identity
47
+ # (default true — the CSV-pipeline identity convention). Set false for a plain build.
48
+ def initialize(parser, section_anchors: true)
49
+ @parser = parser
50
+ @section_anchors = section_anchors
51
+ end
52
+
53
+ # The ordered command batch — PURE, no client needed. This is what specs assert against.
54
+ def commands
55
+ @commands ||= begin
56
+ emitter = CommandEmitter.new
57
+ emit_tree(emitter)
58
+ emitter.commands
59
+ end
60
+ end
61
+
62
+ # LIVE build (needs creds). `template_facade` is the gem `Builder::Template` (a.k.a.
63
+ # `graphql.template`) exposing `create(commands:)`.
64
+ def create!(template_facade, &block)
65
+ unless template_facade.respond_to?(:create)
66
+ raise ArgumentError,
67
+ 'template facade must respond to #create(commands:)'
68
+ end
69
+
70
+ template_facade.create(commands: commands, &block)
71
+ end
72
+
73
+ # Human preview of the batch (for a dry-run / simulate log).
74
+ def preview
75
+ ["CSV build — #{commands.size} workflow command(s):",
76
+ *commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }].join("\n")
77
+ end
78
+
79
+ private
80
+
81
+ def emit_tree(emitter)
82
+ @parser.tree.each do |stage|
83
+ emitter.stage(name: stage[:name], ordering: stage[:ordering]) do |stage_builder|
84
+ stage[:sections].each { |section| emit_section(stage_builder, section) }
85
+ end
86
+ end
87
+ end
88
+
89
+ def emit_section(stage_builder, section)
90
+ stage_builder.section(layout: section[:layout]) do |section_builder|
91
+ emit_section_anchor(section_builder, section) if @section_anchors
92
+ section[:fields].each { |field| emit_field(section_builder, field) }
93
+ end
94
+ end
95
+
96
+ # Hidden anchor field carrying the section identity in its description (identity convention).
97
+ def emit_section_anchor(section_builder, section)
98
+ section_builder.field(
99
+ label: SECTION_ANCHOR_LABEL,
100
+ field_type: SECTION_ANCHOR_TYPE,
101
+ description: section_identity(section)
102
+ )
103
+ end
104
+
105
+ def emit_field(section_builder, field)
106
+ section_builder.field(
107
+ label: field.label,
108
+ field_type: field.type,
109
+ column: field.column,
110
+ required: field.required,
111
+ description: field.description
112
+ ) do |field_builder|
113
+ Array(field.options).each do |opt|
114
+ field_builder.option(label: opt[:label], weight: opt[:weight])
115
+ end
116
+ end
117
+ end
118
+
119
+ # Stable section identity token. Uses the section heading; a hidden anchor field carries it so
120
+ # the diff/pairing engine can re-home the section on a rebuild.
121
+ def section_identity(section)
122
+ "section:#{section[:heading]}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module CsvBuild
3
+ # ISOLATED column-format mapping for the CSV → template build pipeline.
4
+ #
5
+ # ============================ IMPORTANT / TODO ============================
6
+ # The EXACT columnar CSV format for the ~300-template bulk build is due ~mid-July 2026 and is NOT
7
+ # yet confirmed. This class is the SINGLE place the assumed format is encoded, precisely so that
8
+ # when the real format lands, only THIS file (and its spec) needs to change — the Parser and
9
+ # Builder are written against the neutral intermediate `RowSpec`, not against column names.
10
+ #
11
+ # Assumed format (documented here, one row per FIELD, hierarchy carried on each row):
12
+ #
13
+ # stage,stage_ordering,section,section_ordering,section_layout,
14
+ # field_label,field_type,field_required,field_column,field_description,field_options
15
+ #
16
+ # * `field_type` — a template field type accepted by addField (e.g. plain_text, select,
17
+ # date, number, gauge, rich_text, people, ...). Passed through verbatim.
18
+ # * `field_options` — for select-type fields: pipe-separated `label:weight` pairs,
19
+ # e.g. "High:10|Medium:5|Low:0". Weight optional ("High|Medium|Low").
20
+ # * `field_description` — carries the SECTION/FIELD IDENTITY convention (see below).
21
+ #
22
+ # SECTION / FIELD IDENTITY CONVENTION (per the CSV-pipeline project notes): stable identity for a
23
+ # section/field across rebuilds is expressed via a HIDDEN marker field + a DESCRIPTION token, not
24
+ # via the volatile Mongo id. The parser threads `field_description` through so the builder can
25
+ # stamp a deterministic identity token; sections inherit identity from their heading + a hidden
26
+ # anchor field. This keeps a rebuilt template pair-able against a prior version by the diff engine.
27
+ # ==========================================================================
28
+ #
29
+ # To adapt to the real format: change COLUMNS + the `#value`/`#options` readers here. Everything
30
+ # downstream consumes `RowSpec`, which is format-agnostic.
31
+ module FormatMap
32
+ # Logical field => CSV header name (assumed). CHANGE HERE when the real format arrives.
33
+ COLUMNS = {
34
+ stage: 'stage',
35
+ stage_ordering: 'stage_ordering',
36
+ section: 'section',
37
+ section_ordering: 'section_ordering',
38
+ section_layout: 'section_layout',
39
+ field_label: 'field_label',
40
+ field_type: 'field_type',
41
+ field_required: 'field_required',
42
+ field_column: 'field_column',
43
+ field_description: 'field_description',
44
+ field_options: 'field_options'
45
+ }.freeze
46
+
47
+ # Delimiters (assumed). CHANGE HERE if the real format differs.
48
+ OPTIONS_DELIMITER = '|'.freeze
49
+ OPTION_WEIGHT_SEPARATOR = ':'.freeze
50
+
51
+ # Neutral, format-agnostic row: what the Builder consumes. Only THIS is passed downstream.
52
+ RowSpec = Struct.new(:stage, :stage_ordering, :section, :section_ordering, :section_layout,
53
+ :field_label, :field_type, :field_required, :field_column,
54
+ :field_description, :field_options, keyword_init: true)
55
+
56
+ module_function
57
+
58
+ # Map one CSV row (a Hash keyed by header string) to a RowSpec.
59
+ def row_spec(row)
60
+ RowSpec.new(
61
+ stage: value(row, :stage),
62
+ stage_ordering: integer(row, :stage_ordering),
63
+ section: value(row, :section),
64
+ section_ordering: integer(row, :section_ordering),
65
+ section_layout: value(row, :section_layout) || 'content',
66
+ field_label: value(row, :field_label),
67
+ field_type: value(row, :field_type),
68
+ field_required: truthy?(row, :field_required),
69
+ field_column: integer(row, :field_column) || 0,
70
+ field_description: value(row, :field_description),
71
+ field_options: options(value(row, :field_options))
72
+ )
73
+ end
74
+
75
+ # Parse the options cell into [ { label:, weight: }, ... ]. Empty → [].
76
+ def options(cell)
77
+ return [] if cell.nil? || cell.to_s.strip.empty?
78
+
79
+ cell.to_s.split(OPTIONS_DELIMITER).map do |token|
80
+ label, weight = token.split(OPTION_WEIGHT_SEPARATOR, 2).map(&:strip)
81
+ { label: label, weight: (weight && !weight.empty? ? Integer(weight, exception: false) : nil) }
82
+ end
83
+ end
84
+
85
+ def value(row, logical)
86
+ header = COLUMNS.fetch(logical)
87
+ raw = row[header]
88
+ raw = row[header.to_sym] if raw.nil? && row.respond_to?(:key?)
89
+ return nil if raw.nil?
90
+
91
+ str = raw.to_s.strip
92
+ str.empty? ? nil : str
93
+ end
94
+
95
+ def integer(row, logical)
96
+ raw = value(row, logical)
97
+ raw && Integer(raw, exception: false)
98
+ end
99
+
100
+ def truthy?(row, logical)
101
+ raw = value(row, logical)
102
+ return false if raw.nil?
103
+
104
+ %w[1 true yes y required].include?(raw.downcase)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -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