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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -0
- data/lib/eco/api/usecases/default/pages.rb +30 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +223 -0
- data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
- data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
- data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
- data/lib/eco/api/usecases/graphql/compat.rb +11 -0
- data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
- data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
- data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
- data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
- data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +9 -0
- data/lib/eco/api/usecases/graphql/samples.rb +1 -0
- data/lib/eco/api/usecases/graphql.rb +1 -0
- data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
- data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +13 -3
- data/lib/eco/version.rb +1 -1
- metadata +47 -15
- data/.gitignore +0 -23
- data/.idea/.gitignore +0 -10
- data/.markdownlint.json +0 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -103
- data/.ruby-version +0 -1
- data/.yardopts +0 -10
- data/Gemfile +0 -8
- data/Rakefile +0 -38
- 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
|