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,181 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::RegisterUpdateCase.
3
+ #
4
+ # Register-scoped page processing: iterate the entries of a register in
5
+ # cursor-paginated batches, hand each page to `process_page` (a.k.a.
6
+ # `process_ooze` for naming parity with the v2 case), and track KPI counters
7
+ # over the whole run. Supports creating new entries via the native Creatable.
8
+ #
9
+ # This reproduces the SHAPE of RegisterUpdateCase (batched search, dedup-by-id,
10
+ # search/retrieved/updated/created/failed counters, a dry-run preview) natively,
11
+ # without the Ecoportal::API::V2 queue/enqueue coupling: the GraphQL update path
12
+ # is per-page (graphql.pages.update) rather than a batched patch queue, so the
13
+ # v2 batch_queue / enqueue / queue_shift machinery is intentionally dropped.
14
+ #
15
+ # == Subclass interface
16
+ #
17
+ # class Custom::UseCase::CloseOldEntries < Eco::API::UseCases::GraphQL::Samples::Pages::Register::Base
18
+ # name 'close-old-entries'
19
+ # register_id 'REG_ABC123'
20
+ # batch_size 25
21
+ #
22
+ # def process_page(page)
23
+ # return skip('not stale') unless stale?(page)
24
+ # page.name = "[CLOSED] #{page.name}"
25
+ # update_page(page)
26
+ # end
27
+ # end
28
+ #
29
+ # `register_id` may also come from `options[:source][:register_id]` (v2 parity),
30
+ # which takes precedence over the class-level declaration when present.
31
+ class Register::Base < Page::Base
32
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
33
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::OozeHandlers
34
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Rescuable
35
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Creatable
36
+
37
+ name 'graphql-register-base'
38
+ type :other
39
+
40
+ class << self
41
+ def batch_size(size = nil)
42
+ @batch_size ||= 25
43
+ return @batch_size unless size
44
+
45
+ @batch_size = size
46
+ end
47
+ end
48
+
49
+ attr_reader :total_search_pages, :dupped_search_pages
50
+ attr_reader :retrieved_pages, :page_result_ids
51
+ attr_reader :created_pages, :page_create_attempts
52
+
53
+ # Entry point — called by GraphQL::Base#main.
54
+ def process
55
+ init_kpis
56
+ results_preview
57
+ each_page do |page|
58
+ process_page(page)
59
+ @processed_pages += 1
60
+ end
61
+ log_kpis
62
+ end
63
+
64
+ # == Subclass override points ==============================================
65
+
66
+ # Transform or act on one page. Call update_page(page) to persist changes.
67
+ # Alias `process_ooze` is provided for naming parity with the v2 case.
68
+ def process_page(_page)
69
+ raise NotImplementedError, "Implement #process_page in #{self.class}"
70
+ end
71
+
72
+ # Naming-parity alias for the v2 case. Forwards (rather than alias_method) so a
73
+ # subclass overriding #process_page is honoured when called as #process_ooze.
74
+ def process_ooze(page)
75
+ process_page(page)
76
+ end
77
+
78
+ # Return the register id to scope the search to.
79
+ # `options[:source][:register_id]` (v2 parity) wins over the class declaration.
80
+ def register_id
81
+ options.dig(:source, :register_id) || self.class.register_id
82
+ end
83
+
84
+ # Return a SearchConf for the page search. Override to add filters.
85
+ # Default: scoped to register_id (raises if none configured — a register case
86
+ # without a register would silently scan the whole org).
87
+ def search_conf
88
+ reg = register_id
89
+ raise ArgumentError, 'No register_id configured (class register_id or options[:source][:register_id])' unless reg
90
+
91
+ sc.new.filter(in_register(reg))
92
+ end
93
+
94
+ protected
95
+
96
+ # Typed value merge for a GraphQL data field. Convenience over OozeHandlers#merge_values
97
+ # that reads the field's own #type so a subclass need not pass it explicitly.
98
+ #
99
+ # fld = page.components.get_by_name('Notes')
100
+ # fld.value = merge_field_values(fld, incoming_notes)
101
+ #
102
+ # @param field [#type] a GraphQL DataField (or anything responding to #type).
103
+ # @param append [Object] the value to merge onto the field's current value.
104
+ # @param origin [Object] the base value; defaults to the field's current #value.
105
+ # @param delimiter [String] delimiter for :delimiter-strategy types.
106
+ # @return [Object] the merged value.
107
+ def merge_field_values(field, append, origin: nil, delimiter: "\n")
108
+ base = origin.nil? && field.respond_to?(:value) ? field.value : origin
109
+ merge_values(base, append, type: field, delimiter: delimiter)
110
+ end
111
+
112
+ # Launches the actual creation of the entry (hook driven by Creatable).
113
+ # Respects simulate? for dry-run; on live create, bumps the created counter.
114
+ # @return [Ecoportal::API::GraphQL::Compat::PageReference, false]
115
+ def create_ooze(draft, template_id: draft&.template_id)
116
+ @page_create_attempts += 1
117
+ if simulate?
118
+ dry_run_feedback(draft)
119
+ return false
120
+ end
121
+
122
+ graphql.pages.create(draft, from: template_id).tap do |result|
123
+ @created_pages += 1 if result.respond_to?(:page_id) && result.page_id
124
+ end
125
+ end
126
+
127
+ # Cursor-paginated iteration over the register's pages. Counts total search
128
+ # results and flags duplicated page ids (dedup-by-id, v2 parity).
129
+ def each_page(conf: search_conf, first: self.class.batch_size)
130
+ graphql.pages.each(search_conf: conf.to_h, first: first) do |page|
131
+ @total_search_pages += 1
132
+ if page_result_ids[page.id]
133
+ @dupped_search_pages += 1
134
+ else
135
+ page_result_ids[page.id] = true
136
+ @total_pages += 1
137
+ end
138
+ @retrieved_pages += 1
139
+ yield page
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # Dry-run preview of what a create WOULD send. Faithful but scoped: the
146
+ # GraphQL cursor search offers no cheap "total before filtering" count like the
147
+ # v2 registers.search(only_first:), so there is no up-front count prompt here.
148
+ def dry_run_feedback(draft)
149
+ log(:info) { "Simulate — would create page from template #{draft&.template_id}" }
150
+ nil
151
+ end
152
+
153
+ def results_preview
154
+ log(:info) { "Processing register '#{register_id}' in batches of #{self.class.batch_size}" }
155
+ end
156
+
157
+ def init_kpis
158
+ super
159
+ @total_search_pages = 0
160
+ @dupped_search_pages = 0
161
+ @retrieved_pages = 0
162
+ @page_result_ids = {}
163
+ @created_pages = 0
164
+ @page_create_attempts = 0
165
+ end
166
+
167
+ def kpis_message
168
+ [
169
+ 'Run end:',
170
+ " * Search results: #{total_search_pages}",
171
+ " * Duplicated: #{dupped_search_pages}",
172
+ " * Retrieved: #{retrieved_pages}",
173
+ " * Processed: #{processed_pages}",
174
+ " * Updated: #{updated_pages} (of #{total_pages} unique)",
175
+ " * Created: #{created_pages} (of #{page_create_attempts} attempts)",
176
+ " * Skipped: #{skipped_pages}",
177
+ " * Failed: #{failed_pages}"
178
+ ].join("\n")
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,132 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::RegisterMigrationCase.
3
+ #
4
+ # Migrates the entries of a register into NEW entries of a (possibly different)
5
+ # template, copying data across by PAIRING typed fields between the source page
6
+ # and the freshly drafted destination page. Field pairing is delegated to the
7
+ # native `Helpers::Pages::TypedFieldsPairing` (label + type matching over
8
+ # GraphQL DataField types); the actual per-field content copy dispatches on the
9
+ # GraphQL DataField TYPE STRING (mirroring how OozeHandlers#merge_values ports
10
+ # the v2 type-class dispatch).
11
+ #
12
+ # It reproduces the SHAPE of RegisterMigrationCase natively:
13
+ # * iterate the source register (inherited from Register::Base#each_page)
14
+ # * draft a new entry of TEMPLATE_ID (via the native Creatable)
15
+ # * pair the source fields to the draft fields and copy content
16
+ # * run the unpaired / multi-pair reports (data-loss visibility)
17
+ # * let a subclass finalise the draft via #custom_processing
18
+ #
19
+ # As with the rest of the native samples layer this is NON-BREAKING and
20
+ # additive: no OozeSamples name is flipped and the legacy
21
+ # HelpersMigration::TypedFieldsPairing / RegisterMigrationCase are untouched.
22
+ #
23
+ # == What differs from the v2 case
24
+ # * dispatch is on GraphQL DataField type strings (PlainText/Select/...) not
25
+ # V2 component classes / snake_case types
26
+ # * the per-page persistence is the native create path (Creatable#create_ooze →
27
+ # graphql.pages.create), not the v2 batch queue
28
+ # * the v2 JSON `field_maps` / `copy_hooked_fields` mapping layer is NOT ported
29
+ # here (that consumes the v2 `with_fields`/regex-hook helpers); this scaffold
30
+ # covers the generic typed pairing. Mapped-field hooks are a later increment.
31
+ #
32
+ # == Subclass interface
33
+ #
34
+ # class Custom::UseCase::MigrateTraining < Eco::API::UseCases::GraphQL::Samples::Pages::Register::MigrationCase
35
+ # name 'migrate-training'
36
+ # register_id 'REG_SOURCE' # source register to read from
37
+ # TEMPLATE_ID = 'TPL_DEST' # destination template to create entries of
38
+ #
39
+ # # Optional: finalise the draft after generic pairing has run.
40
+ # def custom_processing(draft, source, fields_tracker: nil)
41
+ # draft.name = source.name
42
+ # end
43
+ # end
44
+ #
45
+ # Expects `options[:source][:register_id]` (or class `register_id`) for the
46
+ # source scope, and a destination template id via the `TEMPLATE_ID` constant
47
+ # (overridable through #destination_template_id).
48
+ class Register::MigrationCase < Register::Base
49
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Copying
50
+
51
+ name 'graphql-register-migration-case'
52
+ type :other
53
+
54
+ # Destination template constant (subclasses set this).
55
+ TEMPLATE_ID = nil
56
+
57
+ # whether or not it should try to pair src <-> dst ximport fields
58
+ EXCLUDE_XIMPORT = false
59
+ # whether or not it should warn on unpaired destination ximport fields
60
+ REPORT_DST_XIMPORT = true
61
+ # whether or not it should warn on unpaired source fields that are empty (no data loss)
62
+ REPORT_SRC_EMPTY = false
63
+ # whether or not it should warn on unpaired source fields that are deindexed
64
+ REPORT_SRC_DEINDEX = false
65
+
66
+ # One source entry → one new destination entry.
67
+ #
68
+ # Drafts a destination page of `destination_template_id`, pairs the source
69
+ # fields to the draft's fields, copies content across, runs the reports, and
70
+ # (optionally) hands the draft to #custom_processing for finalising.
71
+ def process_page(source)
72
+ draft_reference = object_reference(source)
73
+ creating_new_page(draft_reference, template_id: destination_template_id) do |draft|
74
+ pairing_tracking = copy_pairings(source, draft)
75
+
76
+ if respond_to?(:custom_processing, true)
77
+ custom_processing(draft, source, fields_tracker: pairing_tracking)
78
+ elsif block_given?
79
+ yield(draft, source, fields_tracker: pairing_tracking)
80
+ end
81
+
82
+ report_pairing(pairing_tracking)
83
+ end
84
+ end
85
+
86
+ protected
87
+
88
+ # The destination template to create entries of. Override or set TEMPLATE_ID.
89
+ def destination_template_id
90
+ tpl = self.class::TEMPLATE_ID
91
+ raise ArgumentError, "No destination template configured (set #{self.class}::TEMPLATE_ID)" unless tpl
92
+
93
+ tpl
94
+ end
95
+
96
+ private
97
+
98
+ # Pair the generic typed fields and copy their content. Returns the tracker so
99
+ # the caller can inspect unpaired / multi-paired fields.
100
+ def copy_pairings(src, dst)
101
+ after_copy = respond_to?(:paired_fields_post_callback, true) ? method(:paired_fields_post_callback) : nil
102
+
103
+ copy_generic_paired_fields(
104
+ src,
105
+ dst,
106
+ exclude_ximport: self.class::EXCLUDE_XIMPORT,
107
+ &after_copy
108
+ )
109
+ end
110
+
111
+ def report_pairing(tracker)
112
+ msg = tracker.report_src_unpaired(
113
+ only_present: !self.class::REPORT_SRC_EMPTY,
114
+ only_indexed: !self.class::REPORT_SRC_DEINDEX
115
+ )
116
+ log(:warn) { msg } if msg
117
+
118
+ msg = tracker.report_multi_pairs(
119
+ only_present: !self.class::REPORT_SRC_EMPTY,
120
+ only_indexed: !self.class::REPORT_SRC_DEINDEX,
121
+ dst_exclude_ximport: !self.class::REPORT_DST_XIMPORT
122
+ )
123
+ log(:warn) { msg } if msg
124
+
125
+ msg = tracker.report_dst_unpaired(
126
+ only_required: false,
127
+ exclude_ximport: !self.class::REPORT_DST_XIMPORT
128
+ )
129
+ log(:warn) { msg } if msg
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,163 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::TargetOozesUpdateCase.
3
+ #
4
+ # Where Register::Base scopes work to a register and iterates its entries via a
5
+ # cursor search, this case takes an explicit list of target page ids from a CSV
6
+ # (`options[:source][:file]`, first column, header dropped) and processes each of
7
+ # those pages. It reproduces the SHAPE of the v2 TargetOozesUpdateCase:
8
+ # * id-batching (`batched_target_ids`)
9
+ # * a preview with duplicate detection + interactive proceed prompt
10
+ # * fetch-per-id (`graphql.pages.get`), dedup-by-id, retrieved/non-retrieved KPIs
11
+ # * hand each fetched page to `process_page` (a.k.a. `process_ooze`)
12
+ #
13
+ # As with Register::Base, the v2 batch_queue / enqueue / queue_shift machinery is
14
+ # intentionally dropped: the GraphQL update path is per-page. `update_page(page)`
15
+ # (from Page::Base) persists changes as the subclass makes them.
16
+ #
17
+ # == Subclass interface
18
+ #
19
+ # class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::GraphQL::Samples::Pages::Register::TargetOozesUpdateCase
20
+ # name 'toocs-coding'
21
+ #
22
+ # def process_page(page)
23
+ # fld = page.components.get_by_name('TOOCS Code')
24
+ # return skip('no field') unless fld
25
+ # fld.value = merge_field_values(fld, options.dig(:source, :value))
26
+ # update_page(page)
27
+ # end
28
+ # end
29
+ #
30
+ # The CSV file is provided via `options[:source][:file]`.
31
+ class Register::TargetOozesUpdateCase < Register::Base
32
+ name 'graphql-target-oozes-update-case'
33
+ type :other
34
+
35
+ attr_reader :non_retrieved_pages
36
+
37
+ # Entry point — mirrors Register::Base#process but iterates target ids rather
38
+ # than a register search.
39
+ def process
40
+ init_kpis
41
+ target_ids_preview
42
+ each_target_page do |page|
43
+ process_page(page)
44
+ @processed_pages += 1
45
+ end
46
+ log_kpis
47
+ end
48
+
49
+ protected
50
+
51
+ # Fetch and iterate each target page by id, in batches. Counts retrieved /
52
+ # non-retrieved and dedups by id (v2 parity).
53
+ def each_target_page
54
+ batched_target_ids do |ids|
55
+ ids.each do |id|
56
+ if page_result_ids[id]
57
+ @dupped_search_pages += 1
58
+ else
59
+ page_result_ids[id] = true
60
+ @total_pages += 1
61
+ end
62
+
63
+ if (page = fetch_page(id))
64
+ @retrieved_pages += 1
65
+ yield page
66
+ else
67
+ @non_retrieved_pages += 1
68
+ log(:warn) { "Could not get page #{id}" }
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Fetch one page by id via the GraphQL compat client. Wrapped so a bad id does
75
+ # not abort the whole run.
76
+ # @return [Object, nil] the page model, or nil when it could not be fetched.
77
+ def fetch_page(id)
78
+ with_rescue("page #{id}") do
79
+ graphql.pages.get(id)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Yields the target ids in slices of `batch_size`.
86
+ def batched_target_ids
87
+ msg = "Missing block. It yields in slices of #{self.class.batch_size} ids"
88
+ raise ArgumentError, msg unless block_given?
89
+
90
+ pool = []
91
+ target_ids.each do |id|
92
+ pool << id
93
+ if pool.length >= self.class.batch_size
94
+ yield(pool)
95
+ pool = []
96
+ end
97
+ end
98
+ yield(pool) unless pool.empty?
99
+ end
100
+
101
+ # Preview: total count + duplicate detection + interactive proceed prompt
102
+ # (v2-faithful — matches TargetOozesUpdateCase#target_ids_preview).
103
+ def target_ids_preview
104
+ dups = target_ids.select {|id| target_ids.count(id) > 1}
105
+ @total_search_pages = target_ids.count
106
+ @dupped_search_pages = 0
107
+
108
+ dups_str = dups.any? ? "There are #{dups.count} duplicated ids" : 'No duplicates detected'
109
+ msg = "Total target entries: #{target_ids.count} (#{dups_str})"
110
+
111
+ default_answer = session.config.run_mode_remote? ? 'Y' : 'N'
112
+
113
+ session.prompt_user(
114
+ 'Do you want to proceed (y/N):',
115
+ explanation: msg,
116
+ default: default_answer,
117
+ timeout: 10
118
+ ) do |res|
119
+ unless res.upcase.start_with?('Y')
120
+ puts '...'
121
+ log(:info) { 'Aborting script...' }
122
+ exit(0)
123
+ end
124
+ end
125
+ end
126
+
127
+ # The list of target page ids (first CSV column, header dropped).
128
+ def target_ids
129
+ @target_ids ||= input_csv.columns.first[1..]
130
+ end
131
+
132
+ def input_csv
133
+ @input_csv ||= Eco::CSV.read(options.dig(:source, :file))
134
+ end
135
+
136
+ # #each_page is register-search based (inherited from Register::Base) and is NOT
137
+ # the iteration path here — target ids come from the CSV. Guard against accidental
138
+ # use so a subclass mixing the two gets a clear error rather than a silent org scan.
139
+ def each_page(*)
140
+ raise NotImplementedError,
141
+ "#{self.class} iterates target ids from a CSV — use #each_target_page, not #each_page"
142
+ end
143
+
144
+ def init_kpis
145
+ super
146
+ @non_retrieved_pages = 0
147
+ end
148
+
149
+ def kpis_message
150
+ [
151
+ 'Run end:',
152
+ " * Target entries: #{total_search_pages}",
153
+ " * Duplicated: #{dupped_search_pages}",
154
+ " * Retrieved: #{retrieved_pages}",
155
+ " * Could not get: #{non_retrieved_pages}",
156
+ " * Processed: #{processed_pages}",
157
+ " * Updated: #{updated_pages} (of #{total_pages} unique)",
158
+ " * Skipped: #{skipped_pages}",
159
+ " * Failed: #{failed_pages}"
160
+ ].join("\n")
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,8 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module Register
3
+ end
4
+ end
5
+
6
+ require_relative 'register/base'
7
+ require_relative 'register/target_oozes_update_case'
8
+ require_relative 'register/migration_case'
@@ -0,0 +1,70 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Build-from-scratch template (workflow) construction use case.
3
+ #
4
+ # A subclass declares the desired template structure in #declare(emitter) using the
5
+ # CommandEmitter DSL; the base turns it into an ordered command batch and applies it via
6
+ # graphql.template.create / update (executeWorkflowCommands). `simulate?` previews the batch
7
+ # without writing. The emitted batch (#desired_commands) is unit-testable with no live client.
8
+ #
9
+ # class Custom::UseCase::BuildIncidentTemplate < Samples::Pages::Template::Base
10
+ # name 'build-incident-template'
11
+ #
12
+ # def declare(t)
13
+ # t.stage(name: 'Report', ordering: 0) do |stage|
14
+ # stage.section(layout: 'content') do |section|
15
+ # section.field(label: 'Description', field_type: 'plain_text')
16
+ # end
17
+ # end
18
+ # end
19
+ # end
20
+ class Template::Base < Eco::API::UseCases::GraphQL::Base
21
+ name 'graphql-template-base'
22
+ type :other
23
+
24
+ require_relative 'command_emitter'
25
+
26
+ def process
27
+ commands = desired_commands
28
+ if simulate?
29
+ log(:info) { preview_message(commands) }
30
+ return nil
31
+ end
32
+ apply(commands)
33
+ end
34
+
35
+ # == Subclass override point ================================================
36
+
37
+ # Declare the desired template structure onto the given CommandEmitter.
38
+ def declare(_emitter)
39
+ raise NotImplementedError, "Implement #declare(emitter) in #{self.class}"
40
+ end
41
+
42
+ # The ordered command batch this template would apply. Pure — no client needed.
43
+ def desired_commands
44
+ emitter = CommandEmitter.new
45
+ declare(emitter)
46
+ emitter.commands
47
+ end
48
+
49
+ protected
50
+
51
+ # Persist a fresh template from the command batch. Override to #update an existing one.
52
+ def apply(commands)
53
+ graphql.template.create(commands: commands)
54
+ end
55
+
56
+ # Update an existing template model with the command batch.
57
+ def apply_update(model, commands)
58
+ graphql.template.update(model, commands: commands)
59
+ end
60
+
61
+ private
62
+
63
+ def preview_message(commands)
64
+ [
65
+ "Simulate — would apply #{commands.size} workflow command(s):",
66
+ *commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }
67
+ ].join("\n")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,139 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module Template
3
+ # Declarative build-from-scratch emitter for template (workflow) structure.
4
+ #
5
+ # Mirrors the *shape* of the location diff->commands engine (samples/location/command), but the
6
+ # id-threading is **placeholder-based**, not post-hoc newId-remap: each new node is given a
7
+ # client-chosen `placeholderId`, and later commands in the SAME batch reference it. The gem's
8
+ # executeWorkflowCommands resolves placeholders intra-batch. See the gem's PHASE0-FINDINGS.md.
9
+ #
10
+ # Commands are emitted in dependency order via depth-first DSL evaluation:
11
+ # addStage -> (per section) addSection, addStageSection -> (per field) addField
12
+ # -> (per option) addSelectFieldOption ; then forces: addForce -> (per binding) addBinding.
13
+ #
14
+ # Usage:
15
+ # emitter = CommandEmitter.new
16
+ # emitter.stage(name: 'Report', ordering: 0) do |stage|
17
+ # stage.section(layout: 'content') do |section|
18
+ # section.field(label: 'Notes', field_type: 'plain_text')
19
+ # section.field(label: 'Risk', field_type: 'select') do |field|
20
+ # field.option(label: 'High', weight: 10)
21
+ # end
22
+ # end
23
+ # end
24
+ # emitter.commands # => ordered Array of FINAL built { commandKey => kwargs } commands
25
+ #
26
+ # The output is fed DIRECTLY to graphql.template.create(commands:) / update(model, commands:).
27
+ # It is already built via WorkflowCommand.build — do NOT re-run it through build_commands
28
+ # (that normaliser is for hand-authored raw specs and is non-idempotent for key-renaming
29
+ # commands such as addSelectFieldOption: data_field_id -> dataFieldId).
30
+ class CommandEmitter
31
+ def initialize
32
+ @commands = []
33
+ @counters = Hash.new(0)
34
+ end
35
+
36
+ # @return [Array<Hash>] ordered command specs, each { commandKey => kwargs }
37
+ attr_reader :commands
38
+
39
+ # Declare a stage. Yields a StageBuilder for nested sections.
40
+ def stage(name:, ordering: nil, &block)
41
+ placeholder = allocate(:stg)
42
+ emit(:addStage, placeholderId: placeholder, name: name, ordering: ordering)
43
+ StageBuilder.new(self, placeholder).tap { |b| block&.call(b) }
44
+ placeholder
45
+ end
46
+
47
+ # Declare a force at template level. Yields a ForceBuilder for bindings.
48
+ def force(name:, custom_script: nil, url: nil, content_b64: nil, &block)
49
+ placeholder = allocate(:frc)
50
+ emit(:addForce, placeholderId: placeholder, name: name, customScript: custom_script,
51
+ url: url, contentB64: content_b64)
52
+ ForceBuilder.new(self, placeholder).tap { |b| block&.call(b) }
53
+ placeholder
54
+ end
55
+
56
+ # --- internal API used by the nested builders ---------------------------
57
+
58
+ # Allocate the next placeholder id for a node kind (:stg, :sec, :fld, :frc, :bnd).
59
+ def allocate(kind)
60
+ "#{kind}#{@counters[kind] += 1}"
61
+ end
62
+
63
+ # Build + append one command via the gem's WorkflowCommand contract.
64
+ # The gem constant is resolved lazily (at call time, not load time) — the gem's GraphQL
65
+ # namespace is not yet loaded while this file is being required.
66
+ def emit(command_key, **kwargs)
67
+ @commands << Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
68
+ end
69
+
70
+ # Nested builders — each holds its parent's placeholder and appends in-order.
71
+
72
+ class StageBuilder
73
+ def initialize(emitter, stage_id)
74
+ @emitter = emitter
75
+ @stage_id = stage_id
76
+ end
77
+
78
+ # A section is created independently, then linked into this stage.
79
+ def section(layout: 'content', &block)
80
+ section_id = @emitter.allocate(:sec)
81
+ @emitter.emit(:addSection, placeholderId: section_id, layout: layout)
82
+ @emitter.emit(:addStageSection, stageId: @stage_id, sectionId: section_id)
83
+ SectionBuilder.new(@emitter, @stage_id, section_id).tap { |b| block&.call(b) }
84
+ section_id
85
+ end
86
+ end
87
+
88
+ class SectionBuilder
89
+ def initialize(emitter, stage_id, section_id)
90
+ @emitter = emitter
91
+ @stage_id = stage_id
92
+ @section_id = section_id
93
+ end
94
+
95
+ # `description` / `required` are accepted for forward-compatibility with the CSV-build
96
+ # identity convention (hidden-field + description). NOTE: the RELEASED gem's addField input
97
+ # (`Input::WorkflowCommand::AddField::VALID_KEYS`) currently slices to
98
+ # placeholderId/fieldType/label/stageId/sectionId/column and compacts everything else away —
99
+ # so today these pass-throughs are DROPPED by the gem contract (harmless no-op). They are wired
100
+ # here so that, once the gem's addField gains a description key, no emitter change is needed.
101
+ def field(label:, field_type:, column: 0, description: nil, required: nil, &block)
102
+ field_id = @emitter.allocate(:fld)
103
+ @emitter.emit(:addField, placeholderId: field_id, fieldType: field_type, label: label,
104
+ stageId: @stage_id, sectionId: @section_id, column: column,
105
+ description: description, required: required)
106
+ FieldBuilder.new(@emitter, field_id).tap { |b| block&.call(b) }
107
+ field_id
108
+ end
109
+ end
110
+
111
+ class FieldBuilder
112
+ def initialize(emitter, field_id)
113
+ @emitter = emitter
114
+ @field_id = field_id
115
+ end
116
+
117
+ # Select-field option. dataFieldId threads the (placeholder) field id.
118
+ def option(label:, weight: nil)
119
+ @emitter.emit(:addSelectFieldOption, data_field_id: @field_id, label: label, weight: weight)
120
+ @field_id
121
+ end
122
+ end
123
+
124
+ class ForceBuilder
125
+ def initialize(emitter, force_id)
126
+ @emitter = emitter
127
+ @force_id = force_id
128
+ end
129
+
130
+ def binding(name:, reference_id: nil, type: nil)
131
+ binding_id = @emitter.allocate(:bnd)
132
+ @emitter.emit(:addBinding, placeholderId: binding_id, forceId: @force_id, name: name,
133
+ referenceId: reference_id, type: type)
134
+ binding_id
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end