eco-helpers 3.2.16 → 3.2.18

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 +145 -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: 9238296c1185b73d2dfa18af81387f01e7b913af4fa3732491dfeed4a7ba6caf
4
+ data.tar.gz: 4b8a882badc3d22c2ba166e45284e1b4d1e3912afa8a2452072765917165fe3b
5
5
  SHA512:
6
- metadata.gz: 9eaaaf9ecff40b6eb646adeceb8cc7115dacf998357206f9192ff4d6bf912067a90a915b93649ef49c99332c5655459fc599fec3a516f11c49951442bc836219
7
- data.tar.gz: 0020a6441d4da68b5a3b80b82494197b2f4c21656dd5babbd143e8ac6302fcd09599eb68d68b9a32895dcd0bd76f6cf76ba96ae66f17706b30a9dbd5f1d61131
6
+ metadata.gz: 1b0abd929fdb1827a544d18ec6c2a2a49b0807caedae00603f6d656c8e8f4498b5ea500f8f3845fcd44187f156125916456326513da21134c16a73ba7eee32ee
7
+ data.tar.gz: 755a6f6efa59752b4897506be1bdc2b408a2e7f84c26330b7c192dd805ff3851b13966e8516da6e61517d86b3cb267a6e8884a26069ea20f8be962d5e1886f18
data/CHANGELOG.md CHANGED
@@ -2,6 +2,151 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.2.18] - 2026-07-10
6
+
7
+ Version-identity + regression-guard release. **No behaviour change** vs the fixed `3.2.17` build —
8
+ this bump exists so the LocationDraft fix can be pinned/verified by version, and adds an offline
9
+ guard so the dead-fragment class of bug can't reach production again.
10
+
11
+ ### Why this bump
12
+
13
+ `3.2.17` shipped as **two different builds under one version**: the version was bumped to `3.2.17`
14
+ on 2026-07-04 (`243822b9`), then the live-crash fix `352a9657` (LocationDraft dead-fragment
15
+ convention) landed on 2026-07-05 — keeping the same `3.2.17` label. Because `3.2.17` is installed
16
+ from git/path (unpublished), a consumer bundled in that ~26h window reports `3.2.17` yet lacks the
17
+ fix. This crashed the **live act-gov** org-structure sync again on 2026-07-09 with the exact
18
+ `uninitialized constant …Fragment::LocationDraft (NameError)`. Bumping to `3.2.18` makes the fixed
19
+ build unambiguously identifiable: any consumer on `>= 3.2.18` provably has the fix.
20
+
21
+ ### Added
22
+
23
+ - **Regression-guard spec for the dead-fragment convention.**
24
+ `spec/…/helpers/location/command/end_points/optimizations_spec.rb` scans every file under
25
+ `usecases/graphql/` and fails if any references a fragment via the removed `___Const__Fragment` /
26
+ `::Fragment::<Name>` constant convention (the exact NameError that crashed act-gov), and asserts
27
+ the three Location command payload procs still route fragments through the `spread :Name` registry
28
+ DSL. It is a SOURCE lint, not a full offline render: rendering needs the graphlient fork's
29
+ `to_query_string`/`spread` DSL, but eco-helpers' own bundle resolves stock graphlient `0.8.0` (the
30
+ fork is pinned only in the deployment/gem bundles). If eco-helpers later pins the fork in dev/test,
31
+ promote this to a real render guard like the gem's `tests/validate_queries.rb`.
32
+
33
+ ## [3.2.17] - 2026-07-04
34
+
35
+ Native GraphQL template build+maintenance + ooze-native migration (strangler-fig): pure,
36
+ non-breaking additions built ALONGSIDE the OozeRedirect shim. No `OozeSamples::*` name is
37
+ flipped/redirected and the shim is untouched. **Not yet published** — the template Deploy loop binds
38
+ to the gem's `Diff` module (gem v1.3.11), which is tagged but unpublished.
39
+
40
+ ### Fixed
41
+
42
+ - **`-no-email` double-send guard typo.** `Location::Command::Service::TreeUpdate#email_digest` set
43
+ `{worfklow: {no_email: true}}` (misspelled key) after sending, so the "don't send the digest twice"
44
+ guard never took effect. Corrected to `workflow`. (Note: the primary `-no-email` check on the same
45
+ method already used the correct key, so a single `-no-email` run was already suppressed.)
46
+ - **Location command optimizations — dead fragment convention (live crash).**
47
+ `Helpers::Location::Command::EndPoints::Optimizations` referenced fragments via the removed
48
+ `___Const__Fragment` constant convention (`___Ecoportal__API__GraphQL__Fragment__LocationDraft` /
49
+ `…__LocationsError`), which the gem's registry-based fragments no longer expose as Ruby constants →
50
+ `uninitialized constant … Fragment::LocationDraft (NameError)` when building a locations-draft
51
+ `addCommands`/`create`/`publish` request. Crashed the live act-gov tagtree / org-structure sync.
52
+ Now `spread :LocationDraft` / `spread :LocationsError`, matching the gem.
53
+
54
+ ### Dependencies
55
+
56
+ - **Raised floors to the fixed stack: `ecoportal-api-graphql >= 1.3.11`, `ecoportal-api-v2 >= 3.3.3`.**
57
+ Stops the stale gem 1.3.9 (missing the LocationStructure `updatedAt`/`createdAt` selection fixes from
58
+ 1.3.10) and the buggy v2 3.3.2 (Ruby-3.x `DoubleModel` cascade `TypeError`) from resolving on
59
+ consumers — both crashed the live act-gov integration.
60
+
61
+ ### Added
62
+
63
+ - **`Helpers::Pages::OozeHandlers`** — native re-expression of `OozeSamples::Helpers::OozeHandlers`.
64
+ `merge_values(origin, append, type:, delimiter:)` dispatches on the GraphQL DataField TYPE STRING
65
+ (`PlainText`/`RichText` → newline; `Select`/`CrossReference`/`People`/`Checklist`/`ActionsList`/
66
+ `File`/`ImageGallery`/`Geo`/`Law` → delimiter join; `Date`/`Number`/`Gauge` → keep origin) instead
67
+ of `Ecoportal::API::V2::Page::Component::*` classes. `merge_arrays`/`array_indexes` ported verbatim.
68
+ Included into `Samples::Pages::Register::Base`, which also gains `#merge_field_values(field, append)`
69
+ (reads the field's own `#type` + current `#value`).
70
+ - **`Samples::Pages::Register::TargetOozesUpdateCase`** — native counterpart of
71
+ `OozeSamples::TargetOozesUpdateCase`, subclassing the native `Register::Base`. Reads target page ids
72
+ from `options[:source][:file]` (first CSV column, header dropped), batches them, previews with
73
+ duplicate detection + proceed prompt, fetches each via `graphql.pages.get`, dedups by id, and hands
74
+ each page to `process_page`. `#each_page` (register-search) is disabled here to prevent an
75
+ accidental org scan.
76
+ - **`Compat::Parity`** A/B parity harness — `RunResult` (canonical KPI + normalised per-page update
77
+ payload snapshot), `Comparison` (KPI/page-set/payload diffs + `equivalent?` verdict + report), and
78
+ `Harness` (orchestrates a legacy vs native run and compares; `.record` captures a run's observables).
79
+ Comparison logic is pure/offline-runnable; live capture of the two runs needs a test org + creds.
80
+ - **`Helpers::Pages::TypedFieldsPairing`** — native re-expression of
81
+ `OozeSamples::HelpersMigration::TypedFieldsPairing`. Pairs data fields across two page models by
82
+ SAME type + SAME label, in two screenings (exact label, then mild: case-insensitive a-z with
83
+ bracketed content removed). Groups/dispatches by the GraphQL DataField TYPE STRING
84
+ (`PlainText`/`Select`/`Date`/... per `DataField::TYPE_MAP`) rather than v2 snake_case component
85
+ types; `EXCLUDED_TYPES` = `%w[TagField Chart FrequencyRateChart]` (never-pair). Tracks unpaired
86
+ src/dst fields + multi-candidate sources, with `resolve`/report helpers. Legacy class untouched.
87
+ This is the cross-template field-pairing (migration), DISTINCT from `OozeHandlers#merge_values`
88
+ (value-merge of one already-paired field).
89
+ - **`Helpers::Pages::Copying`** — native counterpart of
90
+ `OozeSamples::HelpersMigration::Copying`. `copy_generic_paired_fields(src, dst)` runs
91
+ `TypedFieldsPairing` and copies each pair's content; `copy_field_content(src, dst)` dispatches on
92
+ the GraphQL DataField type string (`PlainText`/`Number`/`Date`/`Gauge` → `value=`, `RichText` →
93
+ `content=`, `Select` → `select_option`, `CrossReference` → `page_ids=`; other types left untouched).
94
+ The v2 regex/JSON hooked-field mapping layer (`copy_hooked_fields`) is NOT ported (later increment).
95
+ - **`Samples::Pages::Register::MigrationCase`** — native counterpart of
96
+ `OozeSamples::RegisterMigrationCase`, subclassing the native `Register::Base` and mixing in
97
+ `Copying`. Iterates a source register, drafts a NEW entry of `TEMPLATE_ID` (via native `Creatable`),
98
+ pairs the source fields to the draft's fields and copies content across, runs the unpaired /
99
+ multi-pair reports, and hands the draft to `#custom_processing` for finalising. Per-page persistence
100
+ is the native create path (not the v2 batch queue). Native-only; no OozeSamples name flipped.
101
+
102
+ Template automatic build & maintenance (Phase 5): native, offline-safe orchestration on top of the
103
+ released gem's `Diff::Deploy` / `Diff::VersionDiff` / `Builder::Template`. Additive, no case flipped.
104
+
105
+ - **`Samples::Pages::Template::Deploy`** — deploy → verify → monitor loop for template (workflow)
106
+ changes, driven by a gem `Diff::Deploy` command batch. Dry-run is the DEFAULT; live apply is gated
107
+ behind an explicit `commit: true` + executor. All components are session-less and offline-runnable
108
+ (the apply / re-read seams are injected).
109
+ - `Deploy::Applier` — wraps a gem `Diff::Deploy` plan. Dry-run records the batch it WOULD send;
110
+ `commit: true` applies it via any `execute_workflow_commands`-shaped executor (gem `Builder::Page`
111
+ facade adapted by `TargetBoundExecutor`). Refuses to apply while the plan has `unsupported`
112
+ changes unless `allow_partial: true` — nothing is guessed.
113
+ - `Deploy::RecordingExecutor` — offline stand-in that records batches and (with a `mutator`) replays
114
+ them onto an in-memory doc, so the pre/post drift check is runnable with no API call.
115
+ - `Deploy::DriftReport` — compares the APPLIED delta (a pre/post self-version `Diff::VersionDiff` of
116
+ the re-read target) to the INTENDED delta (the source diff the batch came from) on an id-free shape
117
+ signature; reports `missing` (under-applied) and `unexpected` (side-effect) changes honestly.
118
+ - `Deploy::Verifier` — pluggable post-deploy verification. `QaVerifier` wires the `ecoportal-qa`
119
+ check framework through a single seam when the gem is on the load path (NOT a hard dependency);
120
+ otherwise `NullVerifier` keeps the loop runnable and is honest that qa was unavailable.
121
+ - `Deploy::SyncReadiness` — given a register subset + its active template doc, reports which entries
122
+ CAN sync (all REQUIRED fields present, non-empty, and correctly typed) vs not, with per-entry
123
+ reasons.
124
+ - `Deploy::Loop` — orchestrates apply → drift → verify → sync-readiness and yields an aggregate
125
+ `Result` (`ok?` gate + human `report`). The applied-diff builder defaults to the gem's
126
+ `Diff::VersionDiff` (resolved lazily) and is injectable for offline use.
127
+ - **`Samples::Pages::Template::CsvBuild`** — CSV → template BUILD pipeline reusing the existing
128
+ `Template::CommandEmitter` + the released gem `Builder::Template#create(commands:)`.
129
+ - `CsvBuild::FormatMap` — the SINGLE, isolated place the assumed columnar format lives (column names
130
+ + option delimiters), mapping a CSV row to a format-agnostic `RowSpec`. ⚠ The exact ~300-template
131
+ CSV column format is due ~mid-July 2026 and is NOT yet confirmed — only this file (and its spec)
132
+ changes when the real format lands.
133
+ - `CsvBuild::Parser` — groups rows into a stage → section → field tree preserving first-seen order.
134
+ - `CsvBuild::Builder` — replays the tree onto `CommandEmitter` (same placeholder-id threading), emits
135
+ the ordered `WorkflowCommand` batch (select options → `addSelectFieldOption`), and stamps a hidden
136
+ per-section anchor field for the identity convention (hidden-field + description). Offline/dry-run
137
+ by default (`#commands`, `#preview`); `#create!(template_facade)` is the live path (needs creds).
138
+ - `Template::CommandEmitter#field` gains optional `description:`/`required:` pass-throughs (additive).
139
+ - **HONEST LIMITATIONS / deferred:**
140
+ - The released gem (1.3.9) does NOT yet ship `Diff::Deploy` / `Diff::VersionDiff` (unreleased on the
141
+ gem's main). The deploy loop is written against those APIs but touches them only via lazy/injected
142
+ seams, so it loads and its specs run offline today; a live drift check needs those classes released.
143
+ - The released gem's `addField` input drops any key outside
144
+ placeholderId/fieldType/label/stageId/sectionId/column, so the `description` identity token is NOT
145
+ yet persisted through addField — only the hidden anchor field's presence survives. Wiring is in
146
+ place so identity flows end-to-end once the gem's addField gains a description key.
147
+ - Live deploy/apply and live template creation both need credentials + a test org (the sandbox
148
+ cannot reach live). qa verification is stubbed behind `QaVerifier`'s single seam until qa is pinned.
149
+
5
150
  ## [3.2.16] - 2026-07-02
6
151
 
7
152
  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