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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bf8d3ba40b3f6731a9790d3068ecfd8396b13af79fd5a553aed7dafa5514236
4
- data.tar.gz: 386c5c215aef1afb8e5ef4c8fb72aeddd6ec4c66bb89d569e9c579063128900f
3
+ metadata.gz: 9a51d3b328b3fda96aa2fa153f47481bad73417bc59b45000c7bea49d83d963f
4
+ data.tar.gz: 46a7268158b4d9d6695868ab2c89eb737764ee9896b53e956c2b08ffadb9c8a5
5
5
  SHA512:
6
- metadata.gz: 9eaaaf9ecff40b6eb646adeceb8cc7115dacf998357206f9192ff4d6bf912067a90a915b93649ef49c99332c5655459fc599fec3a516f11c49951442bc836219
7
- data.tar.gz: 0020a6441d4da68b5a3b80b82494197b2f4c21656dd5babbd143e8ac6302fcd09599eb68d68b9a32895dcd0bd76f6cf76ba96ae66f17706b30a9dbd5f1d61131
6
+ metadata.gz: 7ad1185638b499866d8052480b013cb46e2fd89d2cadf622bbe3e8392bdba50334019ca5ce08e969e8e53dc3f32d71eb7dfcf4246aec8524d9708862ab9ce5f2
7
+ data.tar.gz: ee15599fc00631f1e4f659df324f3b5a587d62b893f35ed436bd01b8c3d23c5ee1b8a72c55e615b890afce94a3bc80f51e4e2389449f89ba46a0b01031d93625
data/CHANGELOG.md CHANGED
@@ -2,6 +2,123 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.2.17] - 2026-07-04
6
+
7
+ Native GraphQL template build+maintenance + ooze-native migration (strangler-fig): pure,
8
+ non-breaking additions built ALONGSIDE the OozeRedirect shim. No `OozeSamples::*` name is
9
+ flipped/redirected and the shim is untouched. **Not yet published** — the template Deploy loop binds
10
+ to the gem's `Diff` module (gem v1.3.11), which is tagged but unpublished.
11
+
12
+ ### Fixed
13
+
14
+ - **`-no-email` double-send guard typo.** `Location::Command::Service::TreeUpdate#email_digest` set
15
+ `{worfklow: {no_email: true}}` (misspelled key) after sending, so the "don't send the digest twice"
16
+ guard never took effect. Corrected to `workflow`. (Note: the primary `-no-email` check on the same
17
+ method already used the correct key, so a single `-no-email` run was already suppressed.)
18
+ - **Location command optimizations — dead fragment convention (live crash).**
19
+ `Helpers::Location::Command::EndPoints::Optimizations` referenced fragments via the removed
20
+ `___Const__Fragment` constant convention (`___Ecoportal__API__GraphQL__Fragment__LocationDraft` /
21
+ `…__LocationsError`), which the gem's registry-based fragments no longer expose as Ruby constants →
22
+ `uninitialized constant … Fragment::LocationDraft (NameError)` when building a locations-draft
23
+ `addCommands`/`create`/`publish` request. Crashed the live act-gov tagtree / org-structure sync.
24
+ Now `spread :LocationDraft` / `spread :LocationsError`, matching the gem.
25
+
26
+ ### Dependencies
27
+
28
+ - **Raised floors to the fixed stack: `ecoportal-api-graphql >= 1.3.11`, `ecoportal-api-v2 >= 3.3.3`.**
29
+ Stops the stale gem 1.3.9 (missing the LocationStructure `updatedAt`/`createdAt` selection fixes from
30
+ 1.3.10) and the buggy v2 3.3.2 (Ruby-3.x `DoubleModel` cascade `TypeError`) from resolving on
31
+ consumers — both crashed the live act-gov integration.
32
+
33
+ ### Added
34
+
35
+ - **`Helpers::Pages::OozeHandlers`** — native re-expression of `OozeSamples::Helpers::OozeHandlers`.
36
+ `merge_values(origin, append, type:, delimiter:)` dispatches on the GraphQL DataField TYPE STRING
37
+ (`PlainText`/`RichText` → newline; `Select`/`CrossReference`/`People`/`Checklist`/`ActionsList`/
38
+ `File`/`ImageGallery`/`Geo`/`Law` → delimiter join; `Date`/`Number`/`Gauge` → keep origin) instead
39
+ of `Ecoportal::API::V2::Page::Component::*` classes. `merge_arrays`/`array_indexes` ported verbatim.
40
+ Included into `Samples::Pages::Register::Base`, which also gains `#merge_field_values(field, append)`
41
+ (reads the field's own `#type` + current `#value`).
42
+ - **`Samples::Pages::Register::TargetOozesUpdateCase`** — native counterpart of
43
+ `OozeSamples::TargetOozesUpdateCase`, subclassing the native `Register::Base`. Reads target page ids
44
+ from `options[:source][:file]` (first CSV column, header dropped), batches them, previews with
45
+ duplicate detection + proceed prompt, fetches each via `graphql.pages.get`, dedups by id, and hands
46
+ each page to `process_page`. `#each_page` (register-search) is disabled here to prevent an
47
+ accidental org scan.
48
+ - **`Compat::Parity`** A/B parity harness — `RunResult` (canonical KPI + normalised per-page update
49
+ payload snapshot), `Comparison` (KPI/page-set/payload diffs + `equivalent?` verdict + report), and
50
+ `Harness` (orchestrates a legacy vs native run and compares; `.record` captures a run's observables).
51
+ Comparison logic is pure/offline-runnable; live capture of the two runs needs a test org + creds.
52
+ - **`Helpers::Pages::TypedFieldsPairing`** — native re-expression of
53
+ `OozeSamples::HelpersMigration::TypedFieldsPairing`. Pairs data fields across two page models by
54
+ SAME type + SAME label, in two screenings (exact label, then mild: case-insensitive a-z with
55
+ bracketed content removed). Groups/dispatches by the GraphQL DataField TYPE STRING
56
+ (`PlainText`/`Select`/`Date`/... per `DataField::TYPE_MAP`) rather than v2 snake_case component
57
+ types; `EXCLUDED_TYPES` = `%w[TagField Chart FrequencyRateChart]` (never-pair). Tracks unpaired
58
+ src/dst fields + multi-candidate sources, with `resolve`/report helpers. Legacy class untouched.
59
+ This is the cross-template field-pairing (migration), DISTINCT from `OozeHandlers#merge_values`
60
+ (value-merge of one already-paired field).
61
+ - **`Helpers::Pages::Copying`** — native counterpart of
62
+ `OozeSamples::HelpersMigration::Copying`. `copy_generic_paired_fields(src, dst)` runs
63
+ `TypedFieldsPairing` and copies each pair's content; `copy_field_content(src, dst)` dispatches on
64
+ the GraphQL DataField type string (`PlainText`/`Number`/`Date`/`Gauge` → `value=`, `RichText` →
65
+ `content=`, `Select` → `select_option`, `CrossReference` → `page_ids=`; other types left untouched).
66
+ The v2 regex/JSON hooked-field mapping layer (`copy_hooked_fields`) is NOT ported (later increment).
67
+ - **`Samples::Pages::Register::MigrationCase`** — native counterpart of
68
+ `OozeSamples::RegisterMigrationCase`, subclassing the native `Register::Base` and mixing in
69
+ `Copying`. Iterates a source register, drafts a NEW entry of `TEMPLATE_ID` (via native `Creatable`),
70
+ pairs the source fields to the draft's fields and copies content across, runs the unpaired /
71
+ multi-pair reports, and hands the draft to `#custom_processing` for finalising. Per-page persistence
72
+ is the native create path (not the v2 batch queue). Native-only; no OozeSamples name flipped.
73
+
74
+ Template automatic build & maintenance (Phase 5): native, offline-safe orchestration on top of the
75
+ released gem's `Diff::Deploy` / `Diff::VersionDiff` / `Builder::Template`. Additive, no case flipped.
76
+
77
+ - **`Samples::Pages::Template::Deploy`** — deploy → verify → monitor loop for template (workflow)
78
+ changes, driven by a gem `Diff::Deploy` command batch. Dry-run is the DEFAULT; live apply is gated
79
+ behind an explicit `commit: true` + executor. All components are session-less and offline-runnable
80
+ (the apply / re-read seams are injected).
81
+ - `Deploy::Applier` — wraps a gem `Diff::Deploy` plan. Dry-run records the batch it WOULD send;
82
+ `commit: true` applies it via any `execute_workflow_commands`-shaped executor (gem `Builder::Page`
83
+ facade adapted by `TargetBoundExecutor`). Refuses to apply while the plan has `unsupported`
84
+ changes unless `allow_partial: true` — nothing is guessed.
85
+ - `Deploy::RecordingExecutor` — offline stand-in that records batches and (with a `mutator`) replays
86
+ them onto an in-memory doc, so the pre/post drift check is runnable with no API call.
87
+ - `Deploy::DriftReport` — compares the APPLIED delta (a pre/post self-version `Diff::VersionDiff` of
88
+ the re-read target) to the INTENDED delta (the source diff the batch came from) on an id-free shape
89
+ signature; reports `missing` (under-applied) and `unexpected` (side-effect) changes honestly.
90
+ - `Deploy::Verifier` — pluggable post-deploy verification. `QaVerifier` wires the `ecoportal-qa`
91
+ check framework through a single seam when the gem is on the load path (NOT a hard dependency);
92
+ otherwise `NullVerifier` keeps the loop runnable and is honest that qa was unavailable.
93
+ - `Deploy::SyncReadiness` — given a register subset + its active template doc, reports which entries
94
+ CAN sync (all REQUIRED fields present, non-empty, and correctly typed) vs not, with per-entry
95
+ reasons.
96
+ - `Deploy::Loop` — orchestrates apply → drift → verify → sync-readiness and yields an aggregate
97
+ `Result` (`ok?` gate + human `report`). The applied-diff builder defaults to the gem's
98
+ `Diff::VersionDiff` (resolved lazily) and is injectable for offline use.
99
+ - **`Samples::Pages::Template::CsvBuild`** — CSV → template BUILD pipeline reusing the existing
100
+ `Template::CommandEmitter` + the released gem `Builder::Template#create(commands:)`.
101
+ - `CsvBuild::FormatMap` — the SINGLE, isolated place the assumed columnar format lives (column names
102
+ + option delimiters), mapping a CSV row to a format-agnostic `RowSpec`. ⚠ The exact ~300-template
103
+ CSV column format is due ~mid-July 2026 and is NOT yet confirmed — only this file (and its spec)
104
+ changes when the real format lands.
105
+ - `CsvBuild::Parser` — groups rows into a stage → section → field tree preserving first-seen order.
106
+ - `CsvBuild::Builder` — replays the tree onto `CommandEmitter` (same placeholder-id threading), emits
107
+ the ordered `WorkflowCommand` batch (select options → `addSelectFieldOption`), and stamps a hidden
108
+ per-section anchor field for the identity convention (hidden-field + description). Offline/dry-run
109
+ by default (`#commands`, `#preview`); `#create!(template_facade)` is the live path (needs creds).
110
+ - `Template::CommandEmitter#field` gains optional `description:`/`required:` pass-throughs (additive).
111
+ - **HONEST LIMITATIONS / deferred:**
112
+ - The released gem (1.3.9) does NOT yet ship `Diff::Deploy` / `Diff::VersionDiff` (unreleased on the
113
+ gem's main). The deploy loop is written against those APIs but touches them only via lazy/injected
114
+ seams, so it loads and its specs run offline today; a live drift check needs those classes released.
115
+ - The released gem's `addField` input drops any key outside
116
+ placeholderId/fieldType/label/stageId/sectionId/column, so the `description` identity token is NOT
117
+ yet persisted through addField — only the hidden anchor field's presence survives. Wiring is in
118
+ place so identity flows end-to-end once the gem's addField gains a description key.
119
+ - Live deploy/apply and live template creation both need credentials + a test org (the sandbox
120
+ cannot reach live). qa verification is stubbed behind `QaVerifier`'s single seam until qa is pinned.
121
+
5
122
  ## [3.2.16] - 2026-07-02
6
123
 
7
124
  APIv2→GraphQL cutover — the OozeRedirect compat layer that runs the maintained ooze integrations
data/eco-helpers.gemspec CHANGED
@@ -42,8 +42,8 @@ Gem::Specification.new do |spec|
42
42
  spec.add_dependency 'docx', '>= 0.8.0', '< 0.9'
43
43
  spec.add_dependency 'dotenv', '~> 3'
44
44
  spec.add_dependency 'ecoportal-api', '~> 0.10', '>= 0.10.14'
45
- spec.add_dependency 'ecoportal-api-graphql', '~> 1.3', '>= 1.3.9'
46
- spec.add_dependency 'ecoportal-api-v2', '~> 3.3', '>= 3.3.1'
45
+ spec.add_dependency 'ecoportal-api-graphql', '~> 1.3', '>= 1.3.11'
46
+ spec.add_dependency 'ecoportal-api-v2', '~> 3.3', '>= 3.3.3'
47
47
  spec.add_dependency 'ed25519', '~> 1.2'
48
48
  spec.add_dependency 'fast_excel', '>= 0.5.0', '< 0.6'
49
49
  spec.add_dependency 'fuzzy_match', '>= 2.1.0', '< 2.2'
@@ -0,0 +1,70 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # The result of comparing a legacy RunResult (A) against a native RunResult (B).
4
+ #
5
+ # Pure value object: given two RunResults it computes the KPI deltas, the set of
6
+ # page ids only one side touched, and the per-page payload diffs. `equivalent?`
7
+ # is the go/no-go signal the migration flips a case name on.
8
+ class Comparison
9
+ attr_reader :a, :b
10
+
11
+ def initialize(run_a, run_b)
12
+ @a = run_a
13
+ @b = run_b
14
+ end
15
+
16
+ # True when the two runs are indistinguishable on the canonical KPI set and on
17
+ # every per-page update payload.
18
+ def equivalent?
19
+ kpi_diff.empty? && only_in_a.empty? && only_in_b.empty? && payload_diffs.empty?
20
+ end
21
+
22
+ # Canonical KPI names whose values differ, => [a_value, b_value].
23
+ def kpi_diff
24
+ keys = a.kpis.keys | b.kpis.keys
25
+ keys.each_with_object({}) do |key, out|
26
+ av = a.kpis[key].to_i
27
+ bv = b.kpis[key].to_i
28
+ out[key] = [av, bv] if av != bv
29
+ end
30
+ end
31
+
32
+ # Page ids only the legacy run acted on.
33
+ def only_in_a
34
+ a.page_ids - b.page_ids
35
+ end
36
+
37
+ # Page ids only the native run acted on.
38
+ def only_in_b
39
+ b.page_ids - a.page_ids
40
+ end
41
+
42
+ # Per-page payload diffs for the ids BOTH runs touched, => [a_payload, b_payload].
43
+ def payload_diffs
44
+ (a.page_ids & b.page_ids).each_with_object({}) do |id, out|
45
+ ap = a.updates[id]
46
+ bp = b.updates[id]
47
+ out[id] = [ap, bp] if ap != bp
48
+ end
49
+ end
50
+
51
+ # Human-readable report of the divergences (empty when equivalent).
52
+ def report # rubocop:disable Metrics/AbcSize
53
+ return "PARITY OK — #{a.label} ≡ #{b.label}" if equivalent?
54
+
55
+ lines = ["PARITY MISMATCH — #{a.label} vs #{b.label}:"]
56
+ kpi_diff.each do |key, (av, bv)|
57
+ lines << " * KPI #{key}: #{a.label}=#{av} #{b.label}=#{bv}"
58
+ end
59
+ lines << " * only in #{a.label}: #{only_in_a.join(', ')}" unless only_in_a.empty?
60
+ lines << " * only in #{b.label}: #{only_in_b.join(', ')}" unless only_in_b.empty?
61
+ payload_diffs.each do |id, (ap, bp)|
62
+ lines << " * payload #{id}:"
63
+ lines << " #{a.label}: #{ap.inspect}"
64
+ lines << " #{b.label}: #{bp.inspect}"
65
+ end
66
+ lines.join("\n")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,102 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # A/B parity harness for the ooze -> native GraphQL strangler-fig migration.
4
+ #
5
+ # Runs the SAME logical register-processing case through the two code paths —
6
+ # A: legacy (v2 / OozeRedirect shim), B: native GraphQL — and asserts that the
7
+ # observable outcome (KPI counters + per-page update payloads) is equivalent.
8
+ #
9
+ # The comparison logic (RunResult / Comparison) is PURE and offline-runnable.
10
+ # The harness itself is a thin orchestrator: you hand it two callables, each of
11
+ # which runs its side and returns a RunResult. In a live run each callable would
12
+ # execute the case in `-simulate` mode against the same test org, using a
13
+ # Recorder to capture what each side WOULD write (no data is mutated). Wiring the
14
+ # two live runs needs credentials + a test org; the structural machinery here is
15
+ # verified offline.
16
+ #
17
+ # == Usage (live)
18
+ #
19
+ # harness = Parity::Harness.new(
20
+ # legacy: -> { Parity::Harness.record('legacy') { |rec| run_legacy_case(rec) } },
21
+ # native: -> { Parity::Harness.record('native') { |rec| run_native_case(rec) } }
22
+ # )
23
+ # comparison = harness.run
24
+ # raise comparison.report unless comparison.equivalent?
25
+ #
26
+ # == Usage (offline / spec)
27
+ #
28
+ # a = Parity::RunResult.new(label: 'legacy', kpis: {...}, updates: {...})
29
+ # b = Parity::RunResult.new(label: 'native', kpis: {...}, updates: {...})
30
+ # Parity::Harness.new(legacy: -> { a }, native: -> { b }).run.equivalent?
31
+ class Harness
32
+ # @param legacy [#call] returns the legacy-path RunResult.
33
+ # @param native [#call] returns the native-path RunResult.
34
+ def initialize(legacy:, native:)
35
+ @legacy = legacy
36
+ @native = native
37
+ end
38
+
39
+ # Execute both sides and compare. Returns a Parity::Comparison.
40
+ def run
41
+ a = @legacy.call
42
+ b = @native.call
43
+ assert_run_result!(a, 'legacy')
44
+ assert_run_result!(b, 'native')
45
+ a.compare_to(b)
46
+ end
47
+
48
+ # Capture the observable outcome of a case run into a RunResult.
49
+ #
50
+ # Yields a Recorder. The caller runs the case, feeding each page's would-be
51
+ # update payload to `recorder.record_update(page_id, payload)` (typically wired
52
+ # by stubbing the case's `update_page` to call `graphql.pages.get_body(page)`
53
+ # instead of persisting). After the run, pass the case's KPI hash to
54
+ # `recorder.kpis = case.kpis_hash` (or set counters individually).
55
+ #
56
+ # @param label [String] 'legacy' or 'native'.
57
+ # @yieldparam recorder [Recorder]
58
+ # @return [RunResult]
59
+ def self.record(label)
60
+ recorder = Recorder.new(label)
61
+ yield(recorder) if block_given?
62
+ recorder.to_run_result
63
+ end
64
+
65
+ private
66
+
67
+ def assert_run_result!(obj, side)
68
+ return if obj.is_a?(RunResult)
69
+
70
+ raise TypeError, "The #{side} callable must return a Parity::RunResult, got #{obj.class}"
71
+ end
72
+
73
+ # Collects a run's KPIs + per-page update payloads, then bakes a RunResult.
74
+ class Recorder
75
+ attr_accessor :kpis
76
+
77
+ def initialize(label)
78
+ @label = label
79
+ @kpis = {}
80
+ @updates = {}
81
+ end
82
+
83
+ # Record what a run WOULD send for `page_id`. Later ids for the same page
84
+ # overwrite (the last state wins — matching a real save of the final page).
85
+ def record_update(page_id, payload)
86
+ @updates[page_id.to_s] = payload
87
+ self
88
+ end
89
+
90
+ # Set a single canonical/native KPI counter by name.
91
+ def kpi(name, value)
92
+ @kpis[name.to_sym] = value
93
+ self
94
+ end
95
+
96
+ def to_run_result
97
+ RunResult.new(label: @label, kpis: @kpis, updates: @updates)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,96 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # A normalised, comparable snapshot of ONE use-case run.
4
+ #
5
+ # The A/B parity harness reduces a run — whether it went through the legacy
6
+ # (v2 / OozeRedirect) path or the native GraphQL path — to just its OBSERVABLE
7
+ # outcome:
8
+ # * `kpis` — the KPI counter hash (search/retrieved/updated/created/… )
9
+ # * `updates` — the per-page update payload the run WOULD send, keyed by page id
10
+ #
11
+ # Two runs are "equivalent" when, after normalisation, both are identical. This
12
+ # is the unit the strangler-fig migration is gated on: flip a case name only once
13
+ # its native RunResult matches the legacy RunResult over the same input.
14
+ #
15
+ # Everything here is PURE (no session, no network) so equivalence can be asserted
16
+ # offline in specs; the live capture step (feeding real runs in) is the only part
17
+ # that needs credentials.
18
+ class RunResult
19
+ attr_reader :label, :kpis, :updates
20
+
21
+ # @param label [String] human tag for this side ('legacy' / 'native').
22
+ # @param kpis [Hash] KPI counters. Keys are symbolised; values coerced to Integer.
23
+ # @param updates [Hash{String=>Object}] page_id => update payload (any Hash-ish).
24
+ def initialize(label:, kpis: {}, updates: {})
25
+ @label = label.to_s
26
+ @kpis = normalize_kpis(kpis)
27
+ @updates = normalize_updates(updates)
28
+ end
29
+
30
+ # The set of page ids this run acted on.
31
+ def page_ids
32
+ updates.keys
33
+ end
34
+
35
+ # Compare against another RunResult, returning a Comparison value object.
36
+ def compare_to(other)
37
+ Comparison.new(self, other)
38
+ end
39
+
40
+ # Convenience: are the two runs equivalent?
41
+ def equivalent_to?(other)
42
+ compare_to(other).equivalent?
43
+ end
44
+
45
+ # Merge/normalise into a plain hash (handy for golden-file capture).
46
+ def to_h
47
+ { label: label, kpis: kpis, updates: updates }
48
+ end
49
+
50
+ # KPI names differ between the legacy and native cases (e.g. `updated_oozes`
51
+ # vs `updated_pages`). We compare on a CANONICAL subset of counters that both
52
+ # sides expose the same way, mapping each side's names onto the canon.
53
+ CANON_KPI_ALIASES = {
54
+ search: %i[total_search_oozes total_search_pages search],
55
+ duplicated: %i[dupped_search_oozes dupped_search_pages duplicated],
56
+ retrieved: %i[retrieved_oozes retrieved_pages retrieved],
57
+ updated: %i[updated_oozes updated_pages updated],
58
+ created: %i[created_oozes created_pages created],
59
+ skipped: %i[skipped_pages skipped],
60
+ failed: %i[failed_update_oozes failed_pages failed]
61
+ }.freeze
62
+
63
+ private
64
+
65
+ def normalize_kpis(raw)
66
+ source = raw.each_with_object({}) do |(k, v), out|
67
+ out[k.to_sym] = v
68
+ end
69
+
70
+ CANON_KPI_ALIASES.each_with_object({}) do |(canon, aliases), out|
71
+ key = aliases.find {|a| source.key?(a)}
72
+ out[canon] = key ? source[key].to_i : 0
73
+ end
74
+ end
75
+
76
+ # Normalise each update payload so a superficial key-order / string-vs-symbol
77
+ # difference between the two paths does not read as a real divergence.
78
+ def normalize_updates(raw)
79
+ raw.each_with_object({}) do |(id, payload), out|
80
+ out[id.to_s] = deep_normalize(payload)
81
+ end
82
+ end
83
+
84
+ def deep_normalize(value)
85
+ case value
86
+ when Hash
87
+ value.map {|k, v| [k.to_s, deep_normalize(v)]}.sort_by(&:first).to_h
88
+ when Array
89
+ value.map {|v| deep_normalize(v)}
90
+ else
91
+ value
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,6 +1,11 @@
1
1
  module Eco::API::UseCases::GraphQL
2
2
  module Compat
3
+ module Parity
4
+ end
3
5
  end
4
6
  end
5
7
 
6
8
  require_relative 'compat/ooze_redirect'
9
+ require_relative 'compat/parity/run_result'
10
+ require_relative 'compat/parity/comparison'
11
+ require_relative 'compat/parity/harness'
@@ -29,7 +29,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
29
29
  messages
30
30
  }
31
31
  draft { # rubocop:disable Style/BlockDelimiters
32
- ___Ecoportal__API__GraphQL__Fragment__LocationDraft
32
+ spread :LocationDraft
33
33
  }
34
34
  }
35
35
  end
@@ -44,7 +44,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
44
44
  messages
45
45
  }
46
46
  draft { # rubocop:disable Style/BlockDelimiters
47
- ___Ecoportal__API__GraphQL__Fragment__LocationDraft
47
+ spread :LocationDraft
48
48
  }
49
49
  }
50
50
  end
@@ -54,7 +54,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
54
54
  clientMutationId
55
55
  ok
56
56
  error { # rubocop:disable Style/BlockDelimiters
57
- ___Ecoportal__API__GraphQL__Fragment__LocationsError
57
+ spread :LocationsError
58
58
  }
59
59
  errors { # rubocop:disable Style/BlockDelimiters
60
60
  details
@@ -69,7 +69,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
69
69
  __typename
70
70
  }
71
71
  error { # rubocop:disable Style/BlockDelimiters
72
- ___Ecoportal__API__GraphQL__Fragment__LocationsError
72
+ spread :LocationsError
73
73
  }
74
74
  }
75
75
  }
@@ -0,0 +1,71 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of OozeSamples::HelpersMigration::Copying.
4
+ #
5
+ # Provides the "pair source fields to destination fields and copy their
6
+ # content across" flow used by the native migration case. Pairing is delegated
7
+ # to `TypedFieldsPairing`; the per-field content copy (`copy_field_content`)
8
+ # dispatches on the GraphQL DataField TYPE STRING (PlainText/Select/Date/...),
9
+ # mirroring how OozeHandlers#merge_values ports the v2 type-class dispatch.
10
+ #
11
+ # Only the generic-typed-pairing flow is ported (the v2 regex/JSON hooked-field
12
+ # mapping — `copy_hooked_fields` / `with_src_dst_field_pair` — is a later
13
+ # increment and is intentionally omitted here).
14
+ module Copying
15
+ include Eco::Language::AuxiliarLogger
16
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
17
+
18
+ def session
19
+ defined?(super) ? super : @session
20
+ end
21
+
22
+ # Pair the same-type/same-label fields of `src` and `dst`, and copy the
23
+ # content of each pair from source to destination.
24
+ #
25
+ # @param src [Object] the source page model.
26
+ # @param dst [Object] the destination (draft) page model.
27
+ # @param src_excluded [Array] source fields to exclude from pairing.
28
+ # @param dst_excluded [Array] destination fields to exclude from pairing.
29
+ # @param exclude_ximport [Boolean] skip ximport-marked fields.
30
+ # @yield [src_fld, dst_fld, pairing] optional post-copy callback per pair.
31
+ # @return [TypedFieldsPairing] the tracker (unpaired / multi-pair inspection).
32
+ def copy_generic_paired_fields(src, dst, src_excluded: [], dst_excluded: [], exclude_ximport: false)
33
+ TypedFieldsPairing.new(
34
+ src,
35
+ dst,
36
+ src_excluded: src_excluded,
37
+ dst_excluded: dst_excluded,
38
+ exclude_ximport: exclude_ximport
39
+ ).tap do |pairing|
40
+ pairing.each_pair do |src_fld, dst_fld|
41
+ copy_field_content(src_fld, dst_fld)
42
+ yield(src_fld, dst_fld, pairing) if block_given?
43
+ end
44
+ end
45
+ end
46
+
47
+ # Copy the content of `src` into `dst`, dispatching on the GraphQL DataField
48
+ # TYPE STRING. `src` and `dst` are guaranteed to be the same type by the
49
+ # pairing. Types with no meaningful/copyable content (or whose native setter
50
+ # is not yet supported) are left untouched.
51
+ #
52
+ # @param src [#type] the source data field.
53
+ # @param dst [#type] the destination data field.
54
+ # @return [Object] result of the per-type copy (or a "won't copy" marker).
55
+ def copy_field_content(src, dst)
56
+ case src.type.to_s
57
+ when 'PlainText', 'Number', 'Date', 'Gauge'
58
+ dst.value = src.value
59
+ when 'RichText'
60
+ dst.content = src.content
61
+ when 'Select'
62
+ dst.select_option(*src.selected_options.map { |opt| opt['id'] })
63
+ when 'CrossReference'
64
+ dst.page_ids = src.page_ids
65
+ else
66
+ "won't copy"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,78 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of OozeSamples::Helpers::Creatable.
4
+ #
5
+ # Provides the "create a new entry (page) from a template" flow used by
6
+ # register-processing use cases. Mirrors the v2 Creatable structure
7
+ # (creating_new_page -> drafting_entry -> create_entry -> create_ooze) but
8
+ # talks to the native GraphQL stack (`graphql.pages.get_new` / `graphql.pages.create`)
9
+ # and respects `simulate?` for dry-run feedback.
10
+ #
11
+ # Included via the base-type guard: it can only be mixed into the native
12
+ # register case (Samples::Pages::Register::Base), which supplies the
13
+ # `create_ooze` hook + KPI counters it drives.
14
+ module Creatable
15
+ module InstanceMethods
16
+ private
17
+
18
+ # Draft a page from `template_id`, let the caller fill it, then persist it.
19
+ # @param draft_reference [String] human-readable label for logs.
20
+ # @param template_id [String] the template to build the draft from.
21
+ # @yield [draft] the built (still unsaved) draft page to mutate.
22
+ # @return [String, nil] the created page id, or nil on dry-run / failure.
23
+ def creating_new_page(draft_reference = 'new entry', template_id:)
24
+ page_id = nil
25
+ drafting_entry(template_id) do |draft|
26
+ yield(draft) if block_given?
27
+
28
+ if (page_id = create_entry(draft, reference: draft_reference))
29
+ log(:info) { "Page '#{page_id}' created successfully -- #{draft_reference}" }
30
+ elsif simulate?
31
+ log(:info) { "Simulated launch for #{draft_reference}" }
32
+ end
33
+ end
34
+ page_id
35
+ end
36
+
37
+ # @return [Object] a draft page of `template_id` (still not saved).
38
+ def drafting_entry(template_id)
39
+ raise ArgumentError, 'Expecting block, but not given' unless block_given?
40
+
41
+ draft = graphql.pages.get_new(template_id)
42
+ yield(draft)
43
+ end
44
+
45
+ # Does the actual creation of the entry (wrapped so a per-item error does
46
+ # not abort the whole run).
47
+ # @return [String, nil] the created page id, or nil.
48
+ def create_entry(draft, reference: "new entry from #{draft&.template_id}")
49
+ with_rescue(reference) do
50
+ if (result = create_ooze(draft, template_id: draft.template_id))
51
+ return result.page_id
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ class << self
58
+ def included(base)
59
+ super
60
+
61
+ validate_base_type!(base)
62
+ base.include(InstanceMethods)
63
+ end
64
+
65
+ def validate_base_type!(base)
66
+ return super if defined?(super)
67
+
68
+ register_base = Eco::API::UseCases::GraphQL::Samples::Pages::Register::Base
69
+ msg = "#{self} can only be included in #{register_base}"
70
+ msg << "\nCan't be included in #{base}"
71
+ raise LoadError, msg unless base <= register_base
72
+
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end