eco-helpers 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -10
- data/README.md +62 -1
- data/lib/eco/api/session/concurrency/bounded_worker_pool.rb +150 -0
- data/lib/eco/api/session/concurrency/retry_policy.rb +139 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +2 -13
- data/lib/eco/api/usecases/graphql/helpers/pages.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +9 -2
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +46 -7
- data/lib/eco/api/usecases/graphql/samples/pages/template.rb +1 -1
- data/lib/eco/language/klass/auto_loader.rb +12 -3
- data/lib/eco/version.rb +1 -1
- metadata +7 -10
- data/lib/eco/api/usecases/CLAUDE.md +0 -78
- data/lib/eco/api/usecases/graphql/CLAUDE.md +0 -120
- data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +0 -79
- data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +0 -76
- data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d846f9dd8c67e9ece8e82f76b48f57abb6f2281a827906fa0deea759793a2a4
|
|
4
|
+
data.tar.gz: 31010d6385deea25c65e005f2d056e07cc1c00e18cc3257c080863a9042c5393
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca7ad30915e09c003d99c0cf291b0ef2a1834179883de98402380d144bb231cf9f12f2fe802b5b2fed2387b8d63e6e3eee8e4363decdd3636863af0bc702edc8
|
|
7
|
+
data.tar.gz: ed532533a05a25f09b65118104a8627f7bf0fde3b212b2fcd562d499770fdd19de4588caefd388eebb5c91cd9689ab6cbfcf3456e47dafeb69acfab9231956a8
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [3.4.0] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
**MERGE ONLY AFTER `ecoportal-api-graphql` 3.0.0 IS PUBLISHED** -- this release's gemspec
|
|
10
|
+
floor (`~> 3.0`) cannot resolve against rubygems.org until then; tested here against a
|
|
11
|
+
locally built 3.0.0 CANDIDATE gem (see `docs/worklog.md`'s 2026-09-29 entry for the exact
|
|
12
|
+
build/install steps), not the published artifact.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING for consumers still on `ecoportal-api-graphql` 2.x:** requires
|
|
17
|
+
`ecoportal-api-graphql ~> 3.0` (was `~> 2.0`). That gem's own 3.0.0 removes
|
|
18
|
+
`Base::Page::DataField::ImageGallery#file_container_ids` / `#file_container_ids=` --
|
|
19
|
+
an Image Gallery image was never a `FileContainer`. Replaced by `#images` / `#image_ids` /
|
|
20
|
+
`#add_source_images([{source_id:, file_name:}, ...])` after
|
|
21
|
+
`FileUpload::Client#upload_image`. A repo-wide sweep of `eco-helpers` itself for
|
|
22
|
+
`file_container_ids` / `fileContainerIds` / `fileContainers` found **0 hits** -- no caller
|
|
23
|
+
in `lib/`, `spec/`, `docs/`, `bin/` used the removed accessors (the one look-alike,
|
|
24
|
+
`FileField#file_container_id` in `lib/eco/api/usecases/ooze_samples/helpers_migration/
|
|
25
|
+
copying.rb`, is the UNRELATED APIv2/REST `File` field type's own singular accessor, not
|
|
26
|
+
Image Gallery, not affected). No code change was needed in `eco-helpers` for this bump.
|
|
27
|
+
- `Eco::API::Session::Concurrency::BoundedWorkerPool` / `RetryPolicy`
|
|
28
|
+
(`lib/eco/api/session/concurrency/`) are now the CANONICAL, supported concurrency
|
|
29
|
+
primitives in this gem (dropped the "draft, nothing has switched to this copy yet"
|
|
30
|
+
header wording) -- see `README.md`'s new "Concurrency primitives" section. No
|
|
31
|
+
pre-existing inline `Thread.new`/`Queue.new`/subprocess-retry-loop exists anywhere under
|
|
32
|
+
`lib/eco/api/session` or `lib/eco/api/usecases` to migrate onto them (checked by grep at
|
|
33
|
+
promotion time); a future caller picks these up directly, as-is.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `Eco::Language::Klass::AutoLoader#autoload_children!` only tolerated a `TypeError` from a
|
|
38
|
+
malformed pending child (its own comment: "must be the singleton class"); a pending child
|
|
39
|
+
raising any OTHER `StandardError` while being constructed (e.g. an anonymous, never fully
|
|
40
|
+
configured `Class.new(SomeAutoloadedBase)` test double left alive in `ObjectSpace` by an
|
|
41
|
+
entirely unrelated spec elsewhere in the same process -- `Parser`/`ErrorHandler`
|
|
42
|
+
characterization specs create these on purpose) propagated out of the NEXT, unrelated
|
|
43
|
+
caller's own `autoload_children!` cycle instead. Broadened the rescue to `TypeError,
|
|
44
|
+
StandardError`, matching the existing rescue's own documented intent ("can't create from
|
|
45
|
+
this class... just ignore"). Pre-existing, GC-timing-sensitive flake -- confirmed
|
|
46
|
+
reproducible (non-deterministically) against the CURRENT `ecoportal-api-graphql` 2.2.0
|
|
47
|
+
floor too, not something the 3.0 bump introduced; surfaced while testing this release
|
|
48
|
+
against the 3.0.0 candidate, where it reproduced deterministically across three
|
|
49
|
+
consecutive runs before the fix and cleared across three consecutive runs after.
|
|
50
|
+
|
|
51
|
+
## [3.3.2] - 2026-09-02
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- Gemspec `spec.files` allowlisted by ROOT DIRECTORY (`lib/`, `exe/`) rather than by full path,
|
|
56
|
+
so any non-`.rb` file added under `lib/` shipped silently. As of 3.3.1 that was five `CLAUDE.md`
|
|
57
|
+
agent-instruction files under `lib/eco/api/usecases/`. `spec.files` is now a full-path
|
|
58
|
+
allowlist — `lib/**/*.rb`, three named JSON files genuinely read at runtime
|
|
59
|
+
(`preferences_reference.json`, `presets_integrity.json`, `presets_values.json`), and
|
|
60
|
+
`README.md` / `CHANGELOG.md` / `LICENSE` at the root — intersected with `git ls-files`.
|
|
61
|
+
`spec/packaging_spec.rb` pins the allowlist so this cannot regress silently again.
|
|
62
|
+
|
|
5
63
|
## [3.3.1] - 2026-08-13
|
|
6
64
|
|
|
7
65
|
### Fixed
|
|
@@ -11,7 +69,7 @@ All notable changes to this project will be documented in this file.
|
|
|
11
69
|
actual bytes. Both read branches (BOM and plain) now use binary mode (`mode: 'rb'`), preserving
|
|
12
70
|
the bytes exactly; BOM stripping is unchanged. Linux behaviour is identical before and after
|
|
13
71
|
(text mode never translated anything there) — this makes Windows match Linux. Surfaced by Travis
|
|
14
|
-
on
|
|
72
|
+
on a customer's tooltip CSV (reported from a downstream script repo).
|
|
15
73
|
|
|
16
74
|
**Precision note (2026-08-13, verified against the motivating file before this version was
|
|
17
75
|
tagged):** the earlier draft of this entry claimed `Eco::CSV.read` raised
|
|
@@ -51,7 +109,7 @@ depends on whether the consumer also declares graphql directly:
|
|
|
51
109
|
- A consumer declaring **only** `eco-helpers '~> 3.2'` adopts 3.3.0 on its next `bundle update`,
|
|
52
110
|
and reaches **graphql 2.0.0 transitively**.
|
|
53
111
|
- A consumer declaring **both** `eco-helpers '~> 3.2'` and `ecoportal-api-graphql '~> 1.3'` (which
|
|
54
|
-
is the case for
|
|
112
|
+
is the case for two internal script repos) does **not** silently adopt — it **fails to
|
|
55
113
|
resolve**, because 3.3.0 requires graphql `~> 2.0`.
|
|
56
114
|
|
|
57
115
|
So the "2.0.0 gates adoption for free" argument only ever covered **direct** graphql dependencies.
|
|
@@ -60,7 +118,7 @@ Both consumers were pinned deliberately on 2026-08-13 rather than left to discov
|
|
|
60
118
|
★ **`3.2.20` was tagged but deliberately NEVER PUBLISHED.** rubygems goes 3.2.19 → 3.3.0. The tag
|
|
61
119
|
marks real history (the point where the 3.2.19 hotfix line rejoined master) but the gem was not
|
|
62
120
|
pushed, and **must not be**: it predates the `spec.files` allowlist fix below and would ship
|
|
63
|
-
internal
|
|
121
|
+
internal AI-tooling and `docs/` content to rubygems. Its content is not lost — 3.3.0
|
|
64
122
|
descends from it. Anything needing the `Eco::API::Custom::Cli` reunification must use **3.3.0**,
|
|
65
123
|
not a `~> 3.2.0` pin.
|
|
66
124
|
|
|
@@ -75,7 +133,7 @@ check.
|
|
|
75
133
|
recorded, treat any `OozeRedirect` run against customer data as unproven — dry-run first.
|
|
76
134
|
|
|
77
135
|
★ **The published gem is now `lib/` only.** `spec.files` excluded just `test|spec|features`, so
|
|
78
|
-
every top-level directory added since shipped by default —
|
|
136
|
+
every top-level directory added since shipped by default — internal AI-tooling notes,
|
|
79
137
|
`docs/` (including the rolling worklog) and `.claude/`. `spec.files` is now an allowlist
|
|
80
138
|
(`lib/`, `exe/`, README, LICENSE, CHANGELOG). All `lib/` files are unchanged; nothing outside
|
|
81
139
|
`lib/` was ever loaded at runtime. Same fix as `ecoportal-api-graphql` 2.0.0.
|
|
@@ -84,7 +142,7 @@ every top-level directory added since shipped by default — `.ai-assistance/` i
|
|
|
84
142
|
|
|
85
143
|
Reunites the released `3.2.19` hotfix line with `master`. `3.2.19` was cut from the `v3.2.18` tag
|
|
86
144
|
and **never merged back**, so two halves were split across branches: `master` carried
|
|
87
|
-
`Eco::API::Custom::Cli` (unreleased since 2026-07-25, and the reason
|
|
145
|
+
`Eco::API::Custom::Cli` (unreleased since 2026-07-25, and the reason a downstream CLI could not
|
|
88
146
|
load on any published gem) while `3.2.19` carried the ooze KPI counter fix and the graphql floor.
|
|
89
147
|
This release carries both. Dependency floors are the max of each side.
|
|
90
148
|
|
|
@@ -145,7 +203,7 @@ replaces on release.
|
|
|
145
203
|
|
|
146
204
|
## [3.2.19] - 2026-07-16
|
|
147
205
|
|
|
148
|
-
|
|
206
|
+
A customer's `cans-upsert` reliability adoption + an ooze KPI counter fix. **Backwards-compatible.**
|
|
149
207
|
Cut from the `v3.2.18` tag (not `master`, which carries the native GraphQL activity/dashboard
|
|
150
208
|
readers depending on the unreleased gem `1.4.0`), so this ships needing only the published
|
|
151
209
|
`ecoportal-api-graphql 1.3.14`.
|
|
@@ -178,7 +236,7 @@ guard so the dead-fragment class of bug can't reach production again.
|
|
|
178
236
|
on 2026-07-04 (`243822b9`), then the live-crash fix `352a9657` (LocationDraft dead-fragment
|
|
179
237
|
convention) landed on 2026-07-05 — keeping the same `3.2.17` label. Because `3.2.17` is installed
|
|
180
238
|
from git/path (unpublished), a consumer bundled in that ~26h window reports `3.2.17` yet lacks the
|
|
181
|
-
fix. This crashed the **live
|
|
239
|
+
fix. This crashed the **live customer** org-structure sync again on 2026-07-09 with the exact
|
|
182
240
|
`uninitialized constant …Fragment::LocationDraft (NameError)`. Bumping to `3.2.18` makes the fixed
|
|
183
241
|
build unambiguously identifiable: any consumer on `>= 3.2.18` provably has the fix.
|
|
184
242
|
|
|
@@ -187,7 +245,7 @@ build unambiguously identifiable: any consumer on `>= 3.2.18` provably has the f
|
|
|
187
245
|
- **Regression-guard spec for the dead-fragment convention.**
|
|
188
246
|
`spec/…/helpers/location/command/end_points/optimizations_spec.rb` scans every file under
|
|
189
247
|
`usecases/graphql/` and fails if any references a fragment via the removed `___Const__Fragment` /
|
|
190
|
-
`::Fragment::<Name>` constant convention (the exact NameError that crashed
|
|
248
|
+
`::Fragment::<Name>` constant convention (the exact NameError that crashed a customer sync), and asserts
|
|
191
249
|
the three Location command payload procs still route fragments through the `spread :Name` registry
|
|
192
250
|
DSL. It is a SOURCE lint, not a full offline render: rendering needs the graphlient fork's
|
|
193
251
|
`to_query_string`/`spread` DSL, but eco-helpers' own bundle resolves stock graphlient `0.8.0` (the
|
|
@@ -212,7 +270,7 @@ to the gem's `Diff` module (gem v1.3.11), which is tagged but unpublished.
|
|
|
212
270
|
`___Const__Fragment` constant convention (`___Ecoportal__API__GraphQL__Fragment__LocationDraft` /
|
|
213
271
|
`…__LocationsError`), which the gem's registry-based fragments no longer expose as Ruby constants →
|
|
214
272
|
`uninitialized constant … Fragment::LocationDraft (NameError)` when building a locations-draft
|
|
215
|
-
`addCommands`/`create`/`publish` request. Crashed
|
|
273
|
+
`addCommands`/`create`/`publish` request. Crashed a customer's live tagtree / org-structure sync.
|
|
216
274
|
Now `spread :LocationDraft` / `spread :LocationsError`, matching the gem.
|
|
217
275
|
|
|
218
276
|
### Dependencies
|
|
@@ -220,7 +278,7 @@ to the gem's `Diff` module (gem v1.3.11), which is tagged but unpublished.
|
|
|
220
278
|
- **Raised floors to the fixed stack: `ecoportal-api-graphql >= 1.3.11`, `ecoportal-api-v2 >= 3.3.3`.**
|
|
221
279
|
Stops the stale gem 1.3.9 (missing the LocationStructure `updatedAt`/`createdAt` selection fixes from
|
|
222
280
|
1.3.10) and the buggy v2 3.3.2 (Ruby-3.x `DoubleModel` cascade `TypeError`) from resolving on
|
|
223
|
-
consumers — both crashed
|
|
281
|
+
consumers — both crashed a customer's live integration.
|
|
224
282
|
|
|
225
283
|
### Added
|
|
226
284
|
|
data/README.md
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
# API Helpers (eco-helpers)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Why this repo exists
|
|
4
|
+
|
|
5
|
+
`eco-helpers` is the team's **central integrations library** -- one of the oldest repos in the
|
|
6
|
+
fleet. It sits between the raw EcoPortal API clients (`ecoportal-api`, `ecoportal-api-v2`,
|
|
7
|
+
`ecoportal-api-graphql`) and the end scripts, adding file management/access, SFTP access, and
|
|
8
|
+
more (`net-sftp`, `net-ssh`, `aws-sdk-s3` are runtime dependencies -- see `eco-helpers.gemspec`).
|
|
9
|
+
|
|
10
|
+
> "If there is a repo that should somehow be the reference of our integrations that is the
|
|
11
|
+
> eco-helpers repo... [ep-graphql is powerful but raw] ...but if we have an intermediate layer
|
|
12
|
+
> with helpers that allow to build integrations by taking into account not just raw api access,
|
|
13
|
+
> but also file managing/access, SFTP access, etc. that is the eco-helpers for sure."
|
|
14
|
+
> -- owner, 2026-09-23
|
|
15
|
+
|
|
16
|
+
```mermaid
|
|
17
|
+
flowchart LR
|
|
18
|
+
A["Raw API clients<br/>ecoportal-api / -v2 / -graphql"] --> B["eco-helpers<br/>integrations layer"]
|
|
19
|
+
B --> C["End scripts<br/>automation, CLI, use cases"]
|
|
20
|
+
```
|
|
21
|
+
*Layering per the owner's own description (source: memory
|
|
22
|
+
`project-imports-process-and-eco-helpers-owner-truth.md`, 2026-09-24).*
|
|
23
|
+
|
|
24
|
+
### What is inside
|
|
25
|
+
|
|
26
|
+
- `lib/eco/api/` -- API integration layer (session, use-cases, microcases, policies, org resources)
|
|
27
|
+
- `lib/eco/cli/` + `lib/eco/cli_default/` -- CLI framework and default options/filters/people workflows
|
|
28
|
+
- `lib/eco/csv/` -- CSV reading, streaming, splitting
|
|
29
|
+
- `lib/eco/data/` -- data utilities (fuzzy match, hashes, locations, strings, files)
|
|
30
|
+
- `lib/eco/language/` -- logging, curry, and auxiliary utilities
|
|
31
|
+
- `lib/eco/assets/` -- static assets (language files etc.)
|
|
4
32
|
|
|
5
33
|
## Installation
|
|
6
34
|
|
|
@@ -19,6 +47,39 @@ Or install it yourself as:
|
|
|
19
47
|
$ gem install eco-helpers
|
|
20
48
|
|
|
21
49
|
|
|
50
|
+
## Concurrency primitives
|
|
51
|
+
|
|
52
|
+
`Eco::API::Session::Concurrency` (`lib/eco/api/session/concurrency/`) is the supported home
|
|
53
|
+
for session-level concurrency primitives, as of 3.4.0:
|
|
54
|
+
|
|
55
|
+
- **`BoundedWorkerPool`** -- a generic, bounded pool of OS threads (pure `Thread`/`Queue`/
|
|
56
|
+
`Mutex`, no extra gem) for running the SAME block over a list of items with a hard ceiling
|
|
57
|
+
on how many run simultaneously. Results come back in the same order as the input,
|
|
58
|
+
regardless of finish order; one item's block raising is captured as that item's own
|
|
59
|
+
`Result#error`, never re-raised, never aborts sibling work already in flight or queued
|
|
60
|
+
(unless `stop_on_error: true`). See the class's own header for the full contract and
|
|
61
|
+
thread-safety notes.
|
|
62
|
+
- **`RetryPolicy`** -- a pure decision service (never sleeps itself) for whether/how long to
|
|
63
|
+
wait before retrying a whole failed subprocess call, given its captured output and exit
|
|
64
|
+
status: rate-limit/5xx-aware, bounded exponential backoff with full jitter, honours a
|
|
65
|
+
captured `Retry-After` header. See the module's own header for why an OUTER,
|
|
66
|
+
subprocess-level retry is worth having even though the underlying GraphQL gem already
|
|
67
|
+
retries transient errors INSIDE one call.
|
|
68
|
+
|
|
69
|
+
Both are generic (no case-specific or org-specific logic) and fully specced against
|
|
70
|
+
synthetic blocks only -- no subprocess, no network, nothing case-specific. See
|
|
71
|
+
`spec/eco/api/session/concurrency/`.
|
|
72
|
+
|
|
22
73
|
## Changelog
|
|
23
74
|
|
|
24
75
|
See {file:CHANGELOG.md} for a list of changes.
|
|
76
|
+
|
|
77
|
+
<!-- audit-status:start -->
|
|
78
|
+
### Fleet audit status
|
|
79
|
+
|
|
80
|
+
This repo is under the Fleet Audit & Amendment Programme (FAAP). No audit run yet -- this is the notice MR; the first `granular`-tier run populates this block with counts by severity and status.
|
|
81
|
+
|
|
82
|
+
Guidelines (read before any change): `docs/audits/guidelines.md`
|
|
83
|
+
Deviations register: `docs/audits/deviations.md`
|
|
84
|
+
Run index: `docs/audits/README.md`
|
|
85
|
+
<!-- audit-status:end -->
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# PROMOTED FROM a downstream script repo (services/bounded_worker_pool.rb @ 41dedad)
|
|
2
|
+
# NAMESPACE: PROVISIONAL (owner ruling DSL-02) -- kept the original class/module name
|
|
3
|
+
# (`BoundedWorkerPool`) under `Eco::API::Session::Concurrency::*`, the session-level
|
|
4
|
+
# concurrency primitives location proposed by WP-6 (see
|
|
5
|
+
# the repo's internal docs, Option A).
|
|
6
|
+
#
|
|
7
|
+
# CANONICAL, 3.4.0 (release/3.4.0-prep) -- this is the supported concurrency primitive for
|
|
8
|
+
# a bounded pool of OS threads in this gem; see `README.md`'s own "Concurrency primitives"
|
|
9
|
+
# section. No PRE-EXISTING inline `Thread.new`/`Queue.new` concurrency exists anywhere else
|
|
10
|
+
# under `lib/eco/api/session` or `lib/eco/api/usecases` to migrate onto this (checked by
|
|
11
|
+
# grep, both directories, at promotion time) -- there was nothing to switch, so none of this
|
|
12
|
+
# gem's own call sites change as part of this promotion; a FUTURE caller (this gem's own
|
|
13
|
+
# batch/CLI code, or a downstream consumer such as a downstream script's wave runner, the
|
|
14
|
+
# original motivating caller) picks this up directly, as-is.
|
|
15
|
+
#
|
|
16
|
+
# the wave-concurrency work (2026-09-19) -- generic, reusable bounded-concurrency runner. Pure
|
|
17
|
+
# Ruby stdlib (`Thread`/`Queue`/`Mutex`) -- no new gem, matching this repo's own "PLAIN DATA IN/
|
|
18
|
+
# service-first" convention (see any other `Custom::Template::Services::*` file's header).
|
|
19
|
+
#
|
|
20
|
+
# WHY THIS EXISTS: `run_wave.rb` shells out to `ruby main.rb ...` once PER ITEM, per stage
|
|
21
|
+
# (build/force-install/publish/tooltips) -- ~47 items, each a live network round trip, run
|
|
22
|
+
# strictly sequentially. A pass is minutes of network WAIT, not CPU work, so a bounded pool of
|
|
23
|
+
# OS threads (each blocking on its own `Open3.capture3` call) is the right tool: Ruby (MRI)
|
|
24
|
+
# threads release the GVL during blocking I/O (`Open3.capture3` waits on the child process, a
|
|
25
|
+
# blocking syscall), so N threads genuinely overlap N in-flight subprocess calls rather than
|
|
26
|
+
# contending for CPU.
|
|
27
|
+
#
|
|
28
|
+
# CONTRACT (every point specced in isolation, synthetic blocks only -- no subprocess, no
|
|
29
|
+
# network, nothing case-specific here):
|
|
30
|
+
# - `concurrency:` is a HARD CEILING on simultaneously-RUNNING blocks -- never exceeded.
|
|
31
|
+
# - Results come back in the SAME order as `items`, regardless of which one finishes first
|
|
32
|
+
# (a slow item 1 and a fast item 2 must never swap positions in the returned Array).
|
|
33
|
+
# - One item's block RAISING is CAPTURED as that item's own `Result#error`, never re-raised
|
|
34
|
+
# to the caller, and never aborts sibling work already in flight or already queued --
|
|
35
|
+
# UNLESS `stop_on_error:` says otherwise (below).
|
|
36
|
+
# - Empty `items` returns `[]` immediately, no thread ever spawned.
|
|
37
|
+
#
|
|
38
|
+
# `stop_on_error:` (default `false`, OPT-IN, generic -- not `run_wave.rb`-specific): once ANY
|
|
39
|
+
# dispatched item's block raises, no item still WAITING in the queue is ever started (each
|
|
40
|
+
# such item's own `Result#skipped?` reads `true`, `#value`/`#error` both `nil`) -- but anything
|
|
41
|
+
# ALREADY running when the failure is observed is allowed to finish (there is no clean way to
|
|
42
|
+
# interrupt a live `Open3.capture3` mid-flight, and killing the child process would leave that
|
|
43
|
+
# item's own live state ambiguous, worse than letting it complete). At `concurrency: 1` this
|
|
44
|
+
# reproduces "stop at the first failure, never attempt the next item" EXACTLY -- with a single
|
|
45
|
+
# worker, "already running" and "already failed" can never overlap, so the second item is
|
|
46
|
+
# always still in the QUEUE (never started) the instant the first one fails. `run_wave.rb`'s
|
|
47
|
+
# own force-install/build wiring relies on this exact property for byte-for-byte parity with
|
|
48
|
+
# its pre-concurrency sequential behaviour at `--concurrency 1` (see
|
|
49
|
+
# `run_wave_concurrency_spec.rb`'s own "concurrency 1 equivalence" examples).
|
|
50
|
+
#
|
|
51
|
+
# THREAD SAFETY OF `results[index] = ...` FROM MULTIPLE THREADS: safe under MRI (this repo's
|
|
52
|
+
# only Ruby implementation, per rbenv) -- the GVL serializes bytecode execution, so concurrent
|
|
53
|
+
# writes to DISTINCT indices of one Array never race or corrupt its internal structure (a
|
|
54
|
+
# well-established MRI pattern; JRuby/TruffleRuby would need an explicit lock here too, but
|
|
55
|
+
# this repo does not target them). The ONE piece of state actually SHARED and mutated across
|
|
56
|
+
# threads (`aborted`, for `stop_on_error:`) is behind an explicit `Mutex` regardless, so this
|
|
57
|
+
# file makes no silent assumptions beyond that documented one.
|
|
58
|
+
|
|
59
|
+
module Eco
|
|
60
|
+
module API
|
|
61
|
+
class Session
|
|
62
|
+
module Concurrency
|
|
63
|
+
module BoundedWorkerPool
|
|
64
|
+
# One item's own outcome. Exactly one of `value`/`error` is non-nil unless `skipped?`
|
|
65
|
+
# is true, in which case both are `nil` -- the block was never even called for it.
|
|
66
|
+
Result = Struct.new(:item, :value, :error, :skipped, keyword_init: true) do
|
|
67
|
+
def skipped?
|
|
68
|
+
!!skipped
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] true only for a NORMAL completion -- neither raised nor skipped.
|
|
72
|
+
def ok?
|
|
73
|
+
!skipped? && error.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# @param items [Array] anything, plain data -- never mutated.
|
|
79
|
+
# @param concurrency [Integer] hard ceiling on simultaneously-RUNNING blocks. Values
|
|
80
|
+
# `<= 0` are treated as `1` (never zero threads, never negative) -- callers own
|
|
81
|
+
# their own upper-bound policy (`run_wave.rb`'s own `--concurrency` option caps at
|
|
82
|
+
# 8, enforced there, not here: this file is generic and takes no opinion on what a
|
|
83
|
+
# sane ceiling is for any particular caller).
|
|
84
|
+
# @param stop_on_error [Boolean] see the file header.
|
|
85
|
+
# @yieldparam item [Object] one element of `items`.
|
|
86
|
+
# @yieldreturn [Object] becomes that item's `Result#value`.
|
|
87
|
+
# @return [Array<Result>] one per `items` element, in `items`' own order.
|
|
88
|
+
def run(items, concurrency:, stop_on_error: false, &block)
|
|
89
|
+
list = Array(items)
|
|
90
|
+
return [] if list.empty?
|
|
91
|
+
|
|
92
|
+
worker_count = [concurrency.to_i, 1].max
|
|
93
|
+
run_state = RunState.new(
|
|
94
|
+
queue: build_queue(list, worker_count), results: Array.new(list.size),
|
|
95
|
+
abort_state: {aborted: false}, state_mutex: Mutex.new, stop_on_error: stop_on_error, block: block
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
threads = Array.new(worker_count) { Thread.new { worker_loop(run_state) } }
|
|
99
|
+
threads.each(&:join)
|
|
100
|
+
|
|
101
|
+
run_state.results
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Bundles the per-run shared state every worker thread reads/writes, so
|
|
105
|
+
# `#worker_loop`/`#run_one` stay under the 5-parameter style ceiling without losing
|
|
106
|
+
# any of it -- plain data, no behaviour of its own. Internal (declared above `private`
|
|
107
|
+
# only because Ruby constants ignore access modifiers -- rubocop Lint/UselessConstantScoping).
|
|
108
|
+
RunState = Struct.new(:queue, :results, :abort_state, :state_mutex, :stop_on_error, :block,
|
|
109
|
+
keyword_init: true)
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# One `[index, item]` pair per real item, `worker_count` `:done` sentinels at the
|
|
114
|
+
# tail (one per worker, so every worker eventually sees its own stop signal and the
|
|
115
|
+
# pool's own `Thread#join`s all return -- a `Queue` blocks `#pop` forever otherwise).
|
|
116
|
+
def build_queue(list, worker_count)
|
|
117
|
+
queue = Queue.new
|
|
118
|
+
list.each_with_index {|item, index| queue << [index, item]}
|
|
119
|
+
worker_count.times { queue << :done }
|
|
120
|
+
queue
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def worker_loop(run_state)
|
|
124
|
+
loop do
|
|
125
|
+
entry = run_state.queue.pop
|
|
126
|
+
break if entry == :done
|
|
127
|
+
|
|
128
|
+
index, item = entry
|
|
129
|
+
if run_state.stop_on_error && run_state.state_mutex.synchronize {run_state.abort_state[:aborted]}
|
|
130
|
+
run_state.results[index] = Result.new(item: item, value: nil, error: nil, skipped: true)
|
|
131
|
+
next
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
run_one(index, item, run_state)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run_one(index, item, run_state)
|
|
139
|
+
value = run_state.block.call(item)
|
|
140
|
+
run_state.results[index] = Result.new(item: item, value: value, error: nil, skipped: false)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
run_state.results[index] = Result.new(item: item, value: nil, error: e, skipped: false)
|
|
143
|
+
run_state.state_mutex.synchronize {run_state.abort_state[:aborted] = true} if run_state.stop_on_error
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# PROMOTED FROM a downstream script repo (services/retry_policy.rb @ 41dedad)
|
|
2
|
+
# NAMESPACE: PROVISIONAL (owner ruling DSL-02) -- kept the original class/module name
|
|
3
|
+
# (`RetryPolicy`) under `Eco::API::Session::Concurrency::*`, the session-level concurrency
|
|
4
|
+
# primitives location proposed by WP-6 (see
|
|
5
|
+
# the repo's internal docs, Option A).
|
|
6
|
+
#
|
|
7
|
+
# CANONICAL, 3.4.0 (release/3.4.0-prep) -- this is the supported subprocess-level retry
|
|
8
|
+
# decision primitive in this gem; see `README.md`'s own "Concurrency primitives" section.
|
|
9
|
+
# `lib/eco/api/session/batch/launcher/retry.rb`'s own `offer_retry_on`/`batch_mode_on` are a
|
|
10
|
+
# DIFFERENT, PRE-EXISTING retry mechanism (interactive Y/n prompt + a fixed
|
|
11
|
+
# `ALLOWED_RETRIES` count around the people-batch HTTP endpoint's own error classes,
|
|
12
|
+
# `Ecoportal::API::Errors::TimeOut`/`StartTimeOut`) -- not a shelled-out-subprocess retry,
|
|
13
|
+
# nothing to switch onto this file. `lib/eco/api/usecases/graphql/samples/location/service/
|
|
14
|
+
# tree_diff.rb`'s `sleep(5)` is an unconditional cooldown pause between comparisons, not a
|
|
15
|
+
# retry loop either. Neither is this file's concern; checked (grep, both directories) at
|
|
16
|
+
# promotion time -- there is no PRE-EXISTING inline concurrency/retry loop that duplicates
|
|
17
|
+
# this file's own job to wire onto it. A FUTURE caller (this gem's own batch/CLI code, or a
|
|
18
|
+
# downstream consumer such as a downstream script's wave runner, the original motivating
|
|
19
|
+
# caller) picks this up directly, as-is.
|
|
20
|
+
#
|
|
21
|
+
# the wave-concurrency work (2026-09-19) -- decides WHETHER/HOW LONG to wait before retrying
|
|
22
|
+
# one shelled-out subprocess call, never sleeps itself (pure decision service, easily specced
|
|
23
|
+
# without a slow test) and never distinguishes idempotent-vs-not (that policy is the CALLER's
|
|
24
|
+
# own job -- `run_wave.rb` only ever calls this for stages it has independently classified as
|
|
25
|
+
# safe to retry -- see that file's own PER-STAGE RETRY TABLE comment).
|
|
26
|
+
#
|
|
27
|
+
# WHY A SUBPROCESS-LEVEL RETRY EXISTS AT ALL, GIVEN THE GEM ALREADY RETRIES: confirmed by
|
|
28
|
+
# reading `ecoportal-api-graphql` (2.2.0) `lib/ecoportal/api/common/graphql/http_client.rb`
|
|
29
|
+
# (`#execute`'s own comment, verbatim): every GraphQL call already routes through the
|
|
30
|
+
# inherited `Common::Client` pipeline ("instrument -> with_retry { rate_throttling }"), which
|
|
31
|
+
# already retries 429/Cloudflare-1015/5xx/connection errors INSIDE one call, transparently, long
|
|
32
|
+
# before either the case code or this file ever sees anything. So by the time a shelled-out
|
|
33
|
+
# `ruby main.rb ...` subprocess actually EXITS non-zero for an HTTP reason, that gem-level
|
|
34
|
+
# budget has ALREADY been exhausted (or the whole process crashed before ever making a call,
|
|
35
|
+
# e.g. a DNS blip at startup). Retrying the WHOLE subprocess is therefore a coarser, OUTER
|
|
36
|
+
# layer of defence -- a fresh process gets a FRESH inner retry budget too (useful for
|
|
37
|
+
# transient token/DNS issues an in-process retry cannot fix) -- not a duplicate of the gem's
|
|
38
|
+
# own retries.
|
|
39
|
+
#
|
|
40
|
+
# THE MARKER THIS FILE READS -- NO CASE-FILE CHANGE NEEDED: grepped every
|
|
41
|
+
# downstream template case file for an HTTP-status-distinguishing marker
|
|
42
|
+
# (429/Retry-After/5xx) -- none add one themselves; they let the gem's own exception surface
|
|
43
|
+
# verbatim on an uncaught crash. That exception already IS a stable, distinguishing marker:
|
|
44
|
+
# `Ecoportal::API::Common::Client::Error::UnexpectedServerError#initialize` (ecoportal-api
|
|
45
|
+
# 0.10.18, lib/ecoportal/api/common/client/error.rb) formats its message
|
|
46
|
+
# `"Code: #{code} -- Error: #{msg}"` -- printed verbatim to STDERR (captured into `execute`'s
|
|
47
|
+
# own combined stdout+stderr) whenever a subprocess crashes uncaught on a non-2xx HTTP
|
|
48
|
+
# response. `HTTP_CODE_PATTERN` below matches THAT exact, already-existing text -- nothing was
|
|
49
|
+
# added to any case file for this to work. Recorded here as an INFERRED-FROM-STATIC-READ
|
|
50
|
+
# finding, not live-verified against a real 429 -- if a live retry never actually fires when
|
|
51
|
+
# expected, re-check this pattern against a real crash's captured output first.
|
|
52
|
+
module Eco
|
|
53
|
+
module API
|
|
54
|
+
class Session
|
|
55
|
+
module Concurrency
|
|
56
|
+
module RetryPolicy
|
|
57
|
+
# HTTP statuses worth retrying the WHOLE subprocess for: rate limiting (429) and the
|
|
58
|
+
# standard 5xx server-error family. Never 4xx other than 429 (a 400/403/404/422 is a
|
|
59
|
+
# REQUEST defect -- retrying it verbatim would just fail the same way every time).
|
|
60
|
+
RETRYABLE_HTTP_CODES = [429, 500, 502, 503, 504].freeze
|
|
61
|
+
|
|
62
|
+
# See the file header -- `Ecoportal::API::Common::Client::Error::UnexpectedServerError`'s
|
|
63
|
+
# own message format, verbatim.
|
|
64
|
+
HTTP_CODE_PATTERN = /Code:\s*(\d+)\s*--\s*Error:/
|
|
65
|
+
|
|
66
|
+
# Case-insensitive: HTTP header names are conventionally capitalised in a raw dump, but
|
|
67
|
+
# nothing here guarantees a subprocess's captured text preserves that casing exactly.
|
|
68
|
+
RETRY_AFTER_PATTERN = /Retry-After:\s*(\d+)/i
|
|
69
|
+
|
|
70
|
+
BASE_DELAY_SECONDS = 1.0
|
|
71
|
+
MAX_DELAY_SECONDS = 30.0
|
|
72
|
+
|
|
73
|
+
# One decision, after ONE attempt has already run and been captured.
|
|
74
|
+
Decision = Struct.new(:should_retry, :delay_seconds, :reason, keyword_init: true) do
|
|
75
|
+
def retry?
|
|
76
|
+
!!should_retry
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# @param output [String] combined stdout+stderr from the attempt just completed.
|
|
82
|
+
# @param status [Process::Status, nil] `nil` when the subprocess itself could not be
|
|
83
|
+
# spawned at all (see `run_wave.rb#execute`'s own `rescue StandardError` branch) --
|
|
84
|
+
# treated as a hard failure, never retryable (nothing about a spawn failure is an
|
|
85
|
+
# HTTP status).
|
|
86
|
+
# @param attempt [Integer] 1-based -- the attempt that JUST ran.
|
|
87
|
+
# @param max_attempts [Integer] total attempts allowed, INCLUDING the first -- so
|
|
88
|
+
# `max_attempts: 3` permits at most 2 retries after the initial attempt.
|
|
89
|
+
# @return [Decision]
|
|
90
|
+
def decide(output:, status:, attempt:, max_attempts: 3)
|
|
91
|
+
return Decision.new(should_retry: false, delay_seconds: 0, reason: :ok) if status&.success?
|
|
92
|
+
if attempt >= max_attempts
|
|
93
|
+
return Decision.new(should_retry: false, delay_seconds: 0,
|
|
94
|
+
reason: :max_attempts_reached)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
code = retryable_http_code(output)
|
|
98
|
+
return Decision.new(should_retry: false, delay_seconds: 0, reason: :not_retryable) unless code
|
|
99
|
+
|
|
100
|
+
delay = retry_after_seconds(output) || backoff_delay_seconds(attempt)
|
|
101
|
+
Decision.new(should_retry: true, delay_seconds: delay, reason: :"http_#{code}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Integer, nil] the HTTP status code found in `output`, only when it is one
|
|
105
|
+
# of {RETRYABLE_HTTP_CODES} -- `nil` for anything else (including a code that WAS
|
|
106
|
+
# found but is not on the retryable list, e.g. a 400).
|
|
107
|
+
def retryable_http_code(output)
|
|
108
|
+
match = HTTP_CODE_PATTERN.match(output.to_s)
|
|
109
|
+
return nil unless match
|
|
110
|
+
|
|
111
|
+
code = match[1].to_i
|
|
112
|
+
RETRYABLE_HTTP_CODES.include?(code) ? code : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Integer, nil] the server-requested wait, in seconds, when a `Retry-After`
|
|
116
|
+
# header value was captured in the output -- takes priority over the computed
|
|
117
|
+
# backoff below when present (the server knows better than a guess).
|
|
118
|
+
def retry_after_seconds(output)
|
|
119
|
+
match = RETRY_AFTER_PATTERN.match(output.to_s)
|
|
120
|
+
match && match[1].to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Bounded exponential backoff with FULL jitter (AWS Architecture Blog's own
|
|
124
|
+
# "Exponential Backoff And Jitter", the well-established shape): a uniform random
|
|
125
|
+
# pick in `[0, capped)`, `capped = min(BASE * 2**(attempt-1), MAX)`. Full jitter
|
|
126
|
+
# (not merely +/- a percentage) is deliberate -- it is what actually de-correlates N
|
|
127
|
+
# concurrent workers that all hit a 429 in the SAME instant (`--concurrency N`'s own
|
|
128
|
+
# reason to exist) from retrying in lockstep and re-triggering the same rate limit.
|
|
129
|
+
# @return [Float] seconds, `0 <= delay < capped`.
|
|
130
|
+
def backoff_delay_seconds(attempt)
|
|
131
|
+
capped = [BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_DELAY_SECONDS].min
|
|
132
|
+
rand * capped
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -62,19 +62,8 @@ module Eco::API::UseCases::GraphQL::Compat
|
|
|
62
62
|
# force.custom_script = new_script → write the LISP script
|
|
63
63
|
# force.script → raw script content (alias)
|
|
64
64
|
#
|
|
65
|
-
# Affected cases currently blocked
|
|
66
|
-
#
|
|
67
|
-
# act-gov: 5 x 20240130_act_*_case, rearrage_page_sites_case
|
|
68
|
-
# briscoes: remove_induction_sections, 310524_Briscoes_Remove_Tasks
|
|
69
|
-
# chorus: 4 x audit_update cases
|
|
70
|
-
# hcc: update_enterprise_risk_case
|
|
71
|
-
# lic: update_life_cycle_force_case
|
|
72
|
-
# mitre10: rich_text_update, update_location_force, updating_template
|
|
73
|
-
# npdc: contractor_title_force, risk_titile_force, fix_title_syncing,
|
|
74
|
-
# reminder_date_fields, 10092024_NPDC_CP_Add_Force
|
|
75
|
-
# profile-group: int_training_review, 20231026_profile_wellness
|
|
76
|
-
# turners-growers: event_changes, inj_cost_calc, remove_line_force
|
|
77
|
-
# twg: hide_attached_risks, add_new_force
|
|
65
|
+
# Affected cases currently blocked: roughly half of all ooze cases in the internal script repos'
|
|
66
|
+
# survey (per-customer breakdown kept in the internal migration notes, not in this gem).
|
|
78
67
|
#
|
|
79
68
|
# Implementation sketch (to be built when the endpoint lands):
|
|
80
69
|
#
|
|
@@ -7,7 +7,7 @@ module Eco::API::UseCases::GraphQL::Helpers
|
|
|
7
7
|
# with duck-typing so they work against the GraphQL page/section/field models.
|
|
8
8
|
#
|
|
9
9
|
# This is Phase 1 of the ooze -> native GraphQL migration (build the shared substrate before
|
|
10
|
-
# any case). See ecoportal-api-graphql
|
|
10
|
+
# any case). See the ecoportal-api-graphql repo's internal docs.
|
|
11
11
|
module Pages
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -115,8 +115,15 @@ module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# Select-field option. dataFieldId threads the (placeholder) field id.
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
#
|
|
119
|
+
# `value:` is a REQUIRED keyword -- the backend's AddSelectFieldOptionInput declares
|
|
120
|
+
# `argument :value, String, required: true`, and it is the option's unique key (cast to a
|
|
121
|
+
# number for numeric select fields). There is no default and it is never derived here from
|
|
122
|
+
# label/weight/position: a plausible-looking derived value would silently store WRONG data on
|
|
123
|
+
# exactly the scored fields this feature is used for. Callers must supply an explicit value.
|
|
124
|
+
def option(label:, value:, weight: nil)
|
|
125
|
+
@emitter.emit(:addSelectFieldOption, data_field_id: @field_id, label: label, value: value,
|
|
126
|
+
weight: weight)
|
|
120
127
|
@field_id
|
|
121
128
|
end
|
|
122
129
|
end
|
|
@@ -111,7 +111,7 @@ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
|
111
111
|
description: field.description
|
|
112
112
|
) do |field_builder|
|
|
113
113
|
Array(field.options).each do |opt|
|
|
114
|
-
field_builder.option(label: opt[:label], weight: opt[:weight])
|
|
114
|
+
field_builder.option(label: opt[:label], value: opt[:value], weight: opt[:weight])
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|
|
@@ -15,8 +15,17 @@ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
|
15
15
|
#
|
|
16
16
|
# * `field_type` — a template field type accepted by addField (e.g. plain_text, select,
|
|
17
17
|
# date, number, gauge, rich_text, people, ...). Passed through verbatim.
|
|
18
|
-
# * `field_options` — for select-type fields: pipe-separated `label:weight`
|
|
19
|
-
# e.g. "High:10|Medium:5|Low:0".
|
|
18
|
+
# * `field_options` — for select-type fields: pipe-separated `label:value:weight`
|
|
19
|
+
# triples, e.g. "High:10:10|Medium:5:5|Low:0:0". `value` is the
|
|
20
|
+
# backend's unique, REQUIRED select-option key (cast to a number for
|
|
21
|
+
# numeric select fields) -- NEVER derived from label/weight/position
|
|
22
|
+
# here, because a derived value can collide (duplicates are rejected
|
|
23
|
+
# server-side) or silently store the WRONG number on a scored field.
|
|
24
|
+
# Weight optional via a trailing empty part, e.g. "High:H:|Low:L:". A
|
|
25
|
+
# cell resolving to exactly two parts (the OLD `label:weight`
|
|
26
|
+
# shorthand -- now ambiguous with a value-only `label:value`) or a
|
|
27
|
+
# bare label RAISES ArgumentError naming the field, option label, and
|
|
28
|
+
# offending cell -- see `#options` below.
|
|
20
29
|
# * `field_description` — carries the SECTION/FIELD IDENTITY convention (see below).
|
|
21
30
|
#
|
|
22
31
|
# SECTION / FIELD IDENTITY CONVENTION (per the CSV-pipeline project notes): stable identity for a
|
|
@@ -68,20 +77,50 @@ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
|
68
77
|
field_required: truthy?(row, :field_required),
|
|
69
78
|
field_column: integer(row, :field_column) || 0,
|
|
70
79
|
field_description: value(row, :field_description),
|
|
71
|
-
field_options: options(value(row, :field_options))
|
|
80
|
+
field_options: options(value(row, :field_options), field_label: value(row, :field_label))
|
|
72
81
|
)
|
|
73
82
|
end
|
|
74
83
|
|
|
75
|
-
# Parse the options cell into [ { label:, weight: }, ... ]. Empty → [].
|
|
76
|
-
|
|
84
|
+
# Parse the options cell into [ { label:, value:, weight: }, ... ]. Empty → [].
|
|
85
|
+
#
|
|
86
|
+
# `field_label:` is ONLY used for the ArgumentError message below -- it identifies which field's
|
|
87
|
+
# options cell is malformed, since a builder raising mid-batch on a 500-row CSV is otherwise
|
|
88
|
+
# nearly impossible to trace back to the offending row.
|
|
89
|
+
def options(cell, field_label: nil)
|
|
77
90
|
return [] if cell.nil? || cell.to_s.strip.empty?
|
|
78
91
|
|
|
79
92
|
cell.to_s.split(OPTIONS_DELIMITER).map do |token|
|
|
80
|
-
|
|
81
|
-
{ label: label, weight: (weight && !weight.empty? ? Integer(weight, exception: false) : nil) }
|
|
93
|
+
option_from_token(token, field_label: field_label, cell: cell)
|
|
82
94
|
end
|
|
83
95
|
end
|
|
84
96
|
|
|
97
|
+
# A token must resolve to exactly `label:value` or `label:value:weight` (weight optional, via a
|
|
98
|
+
# present-but-empty third part). Anything else -- a bare label, or exactly two parts -- means no
|
|
99
|
+
# explicit `value` was given. Because a `label:X` two-part cell is indistinguishable from the OLD
|
|
100
|
+
# `label:weight` shorthand, it is REJECTED rather than guessed at: see the module doc for why a
|
|
101
|
+
# derived value is worse than a loud failure.
|
|
102
|
+
def option_from_token(token, field_label:, cell:)
|
|
103
|
+
parts = token.split(OPTION_WEIGHT_SEPARATOR, 3).map(&:strip)
|
|
104
|
+
label = parts[0].to_s.empty? ? nil : parts[0]
|
|
105
|
+
value = parts[1]
|
|
106
|
+
|
|
107
|
+
if parts.size < 3 || value.nil? || value.empty?
|
|
108
|
+
raise ArgumentError, missing_option_value_message(field_label, label || token, cell)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
weight_raw = parts[2]
|
|
112
|
+
{ label: label, value: value,
|
|
113
|
+
weight: (weight_raw && !weight_raw.empty? ? Integer(weight_raw, exception: false) : nil) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def missing_option_value_message(field_label, option_label, cell)
|
|
117
|
+
"select option is missing an explicit `value` (field: #{field_label.inspect}, " \
|
|
118
|
+
"option: #{option_label.inspect}, cell: #{cell.to_s.inspect}). The old `label:weight` " \
|
|
119
|
+
'two-part shorthand is no longer accepted -- addSelectFieldOption requires a unique, ' \
|
|
120
|
+
'non-derived `value` per option. Add one explicitly, e.g. ' \
|
|
121
|
+
"\"#{option_label}:VALUE\" or \"#{option_label}:VALUE:WEIGHT\"."
|
|
122
|
+
end
|
|
123
|
+
|
|
85
124
|
def value(row, logical)
|
|
86
125
|
header = COLUMNS.fetch(logical)
|
|
87
126
|
raw = row[header]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
2
|
# Template (workflow) build-from-scratch + (later) diff-and-update samples.
|
|
3
|
-
# See ecoportal-api-graphql
|
|
3
|
+
# See the ecoportal-api-graphql repo's internal docs.
|
|
4
4
|
module Template
|
|
5
5
|
end
|
|
6
6
|
end
|
|
@@ -21,9 +21,18 @@ module Eco::Language::Klass
|
|
|
21
21
|
|
|
22
22
|
pending_children.each do |klass|
|
|
23
23
|
@child = klass.new(object)
|
|
24
|
-
rescue
|
|
25
|
-
# Can't create from this class (must be the singleton class)
|
|
26
|
-
#
|
|
24
|
+
rescue StandardError => _e
|
|
25
|
+
# Can't create from this class (must be the singleton class), OR the class was
|
|
26
|
+
# never fully configured for a real registration -- e.g. an anonymous
|
|
27
|
+
# `Class.new(SomeAutoloadedBase)` test double left behind, un-configured, by an
|
|
28
|
+
# ENTIRELY UNRELATED spec elsewhere in the same process (this scan walks
|
|
29
|
+
# `ObjectSpace`, a process-wide, cross-spec-file concern -- see
|
|
30
|
+
# `spec/eco/language/klass/auto_loader_spec.rb`'s own "malformed pending child"
|
|
31
|
+
# example, and `spec/support/fakes/c7_person_entry_fakes.rb`'s header, for two
|
|
32
|
+
# already-documented instances of this exact class of fragility). Best-effort
|
|
33
|
+
# discovery: one malformed/unrelated pending child must never abort loading the
|
|
34
|
+
# REST of the pending children, still less raise into a caller that has nothing
|
|
35
|
+
# to do with it. Just ignore, same as the original TypeError case above.
|
|
27
36
|
ensure
|
|
28
37
|
autoloaded_children.push(klass)
|
|
29
38
|
end
|
data/lib/eco/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eco-helpers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oscar Segura
|
|
@@ -259,20 +259,20 @@ dependencies:
|
|
|
259
259
|
requirements:
|
|
260
260
|
- - "~>"
|
|
261
261
|
- !ruby/object:Gem::Version
|
|
262
|
-
version: '
|
|
262
|
+
version: '3.0'
|
|
263
263
|
- - ">="
|
|
264
264
|
- !ruby/object:Gem::Version
|
|
265
|
-
version:
|
|
265
|
+
version: 3.0.0
|
|
266
266
|
type: :runtime
|
|
267
267
|
prerelease: false
|
|
268
268
|
version_requirements: !ruby/object:Gem::Requirement
|
|
269
269
|
requirements:
|
|
270
270
|
- - "~>"
|
|
271
271
|
- !ruby/object:Gem::Version
|
|
272
|
-
version: '
|
|
272
|
+
version: '3.0'
|
|
273
273
|
- - ">="
|
|
274
274
|
- !ruby/object:Gem::Version
|
|
275
|
-
version:
|
|
275
|
+
version: 3.0.0
|
|
276
276
|
- !ruby/object:Gem::Dependency
|
|
277
277
|
name: ecoportal-api-v2
|
|
278
278
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -713,6 +713,8 @@ files:
|
|
|
713
713
|
- lib/eco/api/session/batch/policies.rb
|
|
714
714
|
- lib/eco/api/session/batch/searcher.rb
|
|
715
715
|
- lib/eco/api/session/batch/status.rb
|
|
716
|
+
- lib/eco/api/session/concurrency/bounded_worker_pool.rb
|
|
717
|
+
- lib/eco/api/session/concurrency/retry_policy.rb
|
|
716
718
|
- lib/eco/api/session/config.rb
|
|
717
719
|
- lib/eco/api/session/config/api.rb
|
|
718
720
|
- lib/eco/api/session/config/apis.rb
|
|
@@ -731,7 +733,6 @@ files:
|
|
|
731
733
|
- lib/eco/api/session/config/tagtree.rb
|
|
732
734
|
- lib/eco/api/session/config/workflow.rb
|
|
733
735
|
- lib/eco/api/usecases.rb
|
|
734
|
-
- lib/eco/api/usecases/CLAUDE.md
|
|
735
736
|
- lib/eco/api/usecases/base_case.rb
|
|
736
737
|
- lib/eco/api/usecases/base_case/model.rb
|
|
737
738
|
- lib/eco/api/usecases/base_case/type.rb
|
|
@@ -818,7 +819,6 @@ files:
|
|
|
818
819
|
- lib/eco/api/usecases/default_cases/update_case.rb
|
|
819
820
|
- lib/eco/api/usecases/default_cases/upsert_case.rb
|
|
820
821
|
- lib/eco/api/usecases/graphql.rb
|
|
821
|
-
- lib/eco/api/usecases/graphql/CLAUDE.md
|
|
822
822
|
- lib/eco/api/usecases/graphql/base.rb
|
|
823
823
|
- lib/eco/api/usecases/graphql/compat.rb
|
|
824
824
|
- lib/eco/api/usecases/graphql/compat/ooze_redirect.rb
|
|
@@ -829,7 +829,6 @@ files:
|
|
|
829
829
|
- lib/eco/api/usecases/graphql/compat/parity/harness.rb
|
|
830
830
|
- lib/eco/api/usecases/graphql/compat/parity/run_result.rb
|
|
831
831
|
- lib/eco/api/usecases/graphql/helpers.rb
|
|
832
|
-
- lib/eco/api/usecases/graphql/helpers/CLAUDE.md
|
|
833
832
|
- lib/eco/api/usecases/graphql/helpers/access_logs.rb
|
|
834
833
|
- lib/eco/api/usecases/graphql/helpers/access_logs/base.rb
|
|
835
834
|
- lib/eco/api/usecases/graphql/helpers/access_logs/base/reader.rb
|
|
@@ -879,7 +878,6 @@ files:
|
|
|
879
878
|
- lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb
|
|
880
879
|
- lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb
|
|
881
880
|
- lib/eco/api/usecases/graphql/samples.rb
|
|
882
|
-
- lib/eco/api/usecases/graphql/samples/CLAUDE.md
|
|
883
881
|
- lib/eco/api/usecases/graphql/samples/contractors.rb
|
|
884
882
|
- lib/eco/api/usecases/graphql/samples/contractors/dsl.rb
|
|
885
883
|
- lib/eco/api/usecases/graphql/samples/location.rb
|
|
@@ -907,7 +905,6 @@ files:
|
|
|
907
905
|
- lib/eco/api/usecases/graphql/samples/location/service/tree_to_list/converter/parser.rb
|
|
908
906
|
- lib/eco/api/usecases/graphql/samples/location/service/tree_to_list/output.rb
|
|
909
907
|
- lib/eco/api/usecases/graphql/samples/pages.rb
|
|
910
|
-
- lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md
|
|
911
908
|
- lib/eco/api/usecases/graphql/samples/pages/org_page.rb
|
|
912
909
|
- lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb
|
|
913
910
|
- lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# usecases
|
|
2
|
-
|
|
3
|
-
The use-case registry and all built-in case base classes for scripting against EcoPortal.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## What a use case is
|
|
8
|
-
|
|
9
|
-
A use case is a self-contained, named, runnable unit of work. It registers itself with
|
|
10
|
-
the CLI framework, receives `session`, `options`, and `usecase` from the runner, and
|
|
11
|
-
executes its `process` (or `process_ooze` / `process_page`) method.
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
CLI invokes rake → rake finds registered case → UseCase#launch → main() → process()
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## Directory structure
|
|
20
|
-
|
|
21
|
-
| Path | What lives there |
|
|
22
|
-
|------|-----------------|
|
|
23
|
-
| `graphql/` | GraphQL-native base cases + samples (see `graphql/CLAUDE.md`) |
|
|
24
|
-
| `ooze_samples/` | APIv2/REST base cases: `OozeBaseCase`, `RegisterUpdateCase` |
|
|
25
|
-
| `ooze_cases/` | Concrete built-in ooze cases (export register, etc.) |
|
|
26
|
-
| `default/` | Built-in people, location, and utility cases |
|
|
27
|
-
| `default_cases/` | Loader and samples for default cases |
|
|
28
|
-
| `samples/` | Misc driver samples |
|
|
29
|
-
| `graphql.rb` | GraphQL use case loader |
|
|
30
|
-
| `ooze_samples.rb` | Ooze/REST use case loader |
|
|
31
|
-
| `default.rb` | Default use case loader |
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## Adding a new use case
|
|
36
|
-
|
|
37
|
-
1. Subclass the appropriate base:
|
|
38
|
-
|
|
39
|
-
| Your use case | Inherit from |
|
|
40
|
-
|---|---|
|
|
41
|
-
| Process pages in a register (update workflow) | `Eco::API::UseCases::GraphQL::PageCase` |
|
|
42
|
-
| Process pages org-wide (cross-register, audit) | `Eco::API::UseCases::GraphQL::OrgPageCase` |
|
|
43
|
-
| Custom GraphQL script (export, report, one-off) | `Eco::API::UseCases::GraphQL::Base` |
|
|
44
|
-
| Legacy APIv2 register update | `Eco::API::UseCases::OozeSamples::RegisterUpdateCase` |
|
|
45
|
-
|
|
46
|
-
2. Set `name` and `type`:
|
|
47
|
-
```ruby
|
|
48
|
-
name 'my-case-name' # CLI identifier: called with -my-case-name
|
|
49
|
-
type :other # :people | :contractors | :other
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
3. Override the entry point (`process_page`, `process`, or `process_ooze`).
|
|
53
|
-
|
|
54
|
-
4. Register in the org's `config/cli.rb`:
|
|
55
|
-
```ruby
|
|
56
|
-
cases.add('-my-case-name', :other, 'Description')
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## How cases are launched
|
|
62
|
-
|
|
63
|
-
`Eco::API::UseCases::UseCase#launch` calls `callback.call(*uio.params)` where the
|
|
64
|
-
callback is bound to `method(:main)`. Before launch, `@session` and `@options` are
|
|
65
|
-
injected into the instance — subclasses access them via the `attr_reader` in `CaseEnv`.
|
|
66
|
-
|
|
67
|
-
The `:other` type passes `(session, options, usecase)` positionally to `main`.
|
|
68
|
-
For `GraphQL::Base` subclasses the signature is `main(*_args)` — `session` and
|
|
69
|
-
`options` are already available via the helpers module before `main` is called.
|
|
70
|
-
|
|
71
|
-
---
|
|
72
|
-
|
|
73
|
-
## Related
|
|
74
|
-
|
|
75
|
-
- `graphql/CLAUDE.md` — GraphQL case hierarchy, PageCase/OrgPageCase
|
|
76
|
-
- `ooze_samples/` — legacy v2 cases (RegisterUpdateCase, OozeBaseCase)
|
|
77
|
-
- `eco-helpers/CLAUDE.md` — top-level gem context
|
|
78
|
-
- `ecoportal-api-graphql` — upstream gem providing `SearchConf`, `Compat::Pages`, etc.
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# usecases/graphql
|
|
2
|
-
|
|
3
|
-
GraphQL-native use case base classes and helpers. All cases here work directly with
|
|
4
|
-
`ecoportal-api-graphql` — no v2 REST layer, no ooze objects.
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Class hierarchy
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
Eco::API::Common::Loaders::UseCase (registration + launch)
|
|
12
|
-
↓
|
|
13
|
-
Eco::API::UseCases::GraphQL::Base ← universal GraphQL env
|
|
14
|
-
├── GraphQL::Samples::Pages::Page::Base ← register-scoped pages
|
|
15
|
-
│ ├── GraphQL::Samples::Pages::OrgPage::Base ← org-wide pages
|
|
16
|
-
│ └── your subclass (process_page, search_conf)
|
|
17
|
-
└── your subclass directly (custom scripts: exports, reports, one-offs)
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Samples live under `samples/pages/` — NOT in the `graphql/` root. The root only
|
|
21
|
-
has `base.rb`, `helpers.rb`, `utils.rb`, and `samples.rb`.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Base — `graphql/base.rb`
|
|
26
|
-
|
|
27
|
-
Universal GraphQL environment. Provides `graphql`, `session`, `options`, `config`,
|
|
28
|
-
`simulate?`, `log`, `backup` via `Helpers::Base` (see `helpers/CLAUDE.md`).
|
|
29
|
-
|
|
30
|
-
Override `process` to write your script:
|
|
31
|
-
```ruby
|
|
32
|
-
class MyCase < Eco::API::UseCases::GraphQL::Base
|
|
33
|
-
name 'my-case'
|
|
34
|
-
def process
|
|
35
|
-
graphql.currentOrganization.contractorEntities.each { |c| puts c.name }
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
## Pages — `samples/pages/`
|
|
43
|
-
|
|
44
|
-
Page processing base cases. Follow the hierarchy: `page/base` → `org_page/base`.
|
|
45
|
-
|
|
46
|
-
### `samples/pages/page/base.rb` — `Samples::Pages::Page::Base`
|
|
47
|
-
|
|
48
|
-
For **register-scoped** page update workflows.
|
|
49
|
-
|
|
50
|
-
**Class methods:** `register_id 'REG_ID'`, `batch_size 50` (default)
|
|
51
|
-
|
|
52
|
-
**Override points:**
|
|
53
|
-
- `process_page(page)` — **required** — transformation for one page
|
|
54
|
-
- `search_conf` — optional — call `super` to keep register scope, then add filters
|
|
55
|
-
|
|
56
|
-
**Protected helpers:** `update_page`, `skip(reason)`, `each_page`
|
|
57
|
-
|
|
58
|
-
**KPI readers:** `total_pages`, `processed_pages`, `updated_pages`, `skipped_pages`, `failed_pages`
|
|
59
|
-
|
|
60
|
-
**DSL (via `samples/pages/page/dsl.rb`):** `sc`, `in_register`, `state_is`, `external_id_eq`, `updated_since`
|
|
61
|
-
|
|
62
|
-
```ruby
|
|
63
|
-
class Custom::UseCase::UpdateStatus < Eco::API::UseCases::GraphQL::Samples::Pages::Page::Base
|
|
64
|
-
name 'update-status'
|
|
65
|
-
register_id 'REG_ABC'
|
|
66
|
-
|
|
67
|
-
def search_conf
|
|
68
|
-
super.filter(state_is(:active))
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def process_page(page)
|
|
72
|
-
page.name = page.name.upcase
|
|
73
|
-
update_page(page)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### `samples/pages/org_page/base.rb` — `Samples::Pages::OrgPage::Base`
|
|
79
|
-
|
|
80
|
-
Inherits `Page::Base`. `search_conf` starts empty (org-wide, no register scope).
|
|
81
|
-
Use for: archive sweeps, cross-register audits, bulk org operations.
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
## Samples — `graphql/samples/`
|
|
86
|
-
|
|
87
|
-
Built-in ready-to-use case implementations:
|
|
88
|
-
- `samples/location.rb` — location structure management cases
|
|
89
|
-
- `samples/contractors.rb` — contractor entity cases
|
|
90
|
-
|
|
91
|
-
See `samples/CLAUDE.md` for details.
|
|
92
|
-
|
|
93
|
-
---
|
|
94
|
-
|
|
95
|
-
## Helpers — `graphql/helpers/`
|
|
96
|
-
|
|
97
|
-
Mixins providing domain-specific access patterns. See `helpers/CLAUDE.md`.
|
|
98
|
-
|
|
99
|
-
---
|
|
100
|
-
|
|
101
|
-
## Loader order in `graphql.rb`
|
|
102
|
-
|
|
103
|
-
```ruby
|
|
104
|
-
require 'graphql/helpers' # environment mixins (graphql, session, simulate? etc.)
|
|
105
|
-
require 'graphql/utils' # utility modules (SFTP etc.)
|
|
106
|
-
require 'graphql/base' # GraphQL::Base — universal foundation
|
|
107
|
-
require 'graphql/samples' # sample cases: location, contractors, pages, ...
|
|
108
|
-
# └─ graphql/samples/pages.rb
|
|
109
|
-
# └─ pages/page.rb → page/dsl.rb, page/base.rb
|
|
110
|
-
# └─ pages/org_page.rb → org_page/dsl.rb, org_page/base.rb
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Page base cases are in `samples/pages/` — NOT in the `graphql/` root.
|
|
114
|
-
Custom org cases are NOT loaded here — they live in the implementation repo.
|
|
115
|
-
|
|
116
|
-
## default/pages/
|
|
117
|
-
|
|
118
|
-
CLI-integrated page use cases go in `default/pages/` (mirroring `default/locations/`
|
|
119
|
-
and `default/people/`). Currently empty — add cases there when a pattern is common
|
|
120
|
-
enough to expose to all org environments. See `default/pages.rb` for the convention.
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# usecases/graphql/helpers
|
|
2
|
-
|
|
3
|
-
Mixin modules providing domain-specific helper methods for GraphQL use cases.
|
|
4
|
-
All modules are ultimately included via `Helpers::Base` into `GraphQL::Base`.
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Include chain
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
GraphQL::Base
|
|
12
|
-
includes Helpers::Base
|
|
13
|
-
includes CaseEnv → session, options, config, simulate?, log, ErrorHandling
|
|
14
|
-
includes GraphQLEnv → graphql (lazy, memoized)
|
|
15
|
-
includes Helpers (loader)
|
|
16
|
-
includes Helpers::Location → location tree helpers
|
|
17
|
-
includes Helpers::Contractors → contractor entity helpers
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## Helpers::Base (`helpers/base.rb`)
|
|
23
|
-
|
|
24
|
-
Core environment — included in every GraphQL use case.
|
|
25
|
-
|
|
26
|
-
| Method | Source | Description |
|
|
27
|
-
|--------|--------|-------------|
|
|
28
|
-
| `session` | `CaseEnv` | Current `Eco::API::Session` |
|
|
29
|
-
| `options` | `CaseEnv` | Options hash from CLI/runner |
|
|
30
|
-
| `config` | `CaseEnv` | `session.config` shortcut |
|
|
31
|
-
| `simulate?` | `CaseEnv` | `options[:simulate] \|\| options[:dry_run]` |
|
|
32
|
-
| `log(level)` | `CaseEnv` | Logger proxy |
|
|
33
|
-
| `graphql` | `GraphQLEnv` | Lazy-loaded `Ecoportal::API::GraphQL` instance |
|
|
34
|
-
| `backup(data, type:)` | `Helpers::Base` | Save JSON to requests folder |
|
|
35
|
-
| `exit_error(msg)` | `Helpers::Base` | Log error and `exit(1)` |
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## Helpers::Location (`helpers/location/`)
|
|
40
|
-
|
|
41
|
-
Location tree access, tag remapping, classification parsing.
|
|
42
|
-
|
|
43
|
-
- `helpers/location/base.rb` — `Location::Base` mixin
|
|
44
|
-
- `helpers/location/base/tree_tracking.rb` — track tree mutations
|
|
45
|
-
- `helpers/location/base/classifications_parser.rb` — parse location classifications
|
|
46
|
-
- `helpers/location/tags_remap/` — remapping tags across location changes
|
|
47
|
-
- `helpers/location/command/` — apply/diff location structure commands
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## Helpers::Contractors (`helpers/contractors/`)
|
|
52
|
-
|
|
53
|
-
Contractor entity loading helpers.
|
|
54
|
-
|
|
55
|
-
- `helpers/contractors/base.rb` — base contractor helpers
|
|
56
|
-
- `helpers/contractors/base/load.rb` — batch load contractor entities
|
|
57
|
-
|
|
58
|
-
---
|
|
59
|
-
|
|
60
|
-
## Adding a new helper
|
|
61
|
-
|
|
62
|
-
1. Create `helpers/my_domain/base.rb`:
|
|
63
|
-
```ruby
|
|
64
|
-
module Eco::API::UseCases::GraphQL::Helpers
|
|
65
|
-
module MyDomain
|
|
66
|
-
module Base
|
|
67
|
-
private
|
|
68
|
-
def my_helper_method
|
|
69
|
-
graphql.myDomainQuery(...)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
```
|
|
75
|
-
2. Create `helpers/my_domain.rb` as a loader that includes `Base`
|
|
76
|
-
3. Add `require_relative 'my_domain'` to `helpers.rb`
|
|
77
|
-
|
|
78
|
-
The helper is then available in all cases that include `Helpers::Base` (i.e., all
|
|
79
|
-
subclasses of `GraphQL::Base` including `PageCase` and `OrgPageCase`).
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# usecases/graphql/samples
|
|
2
|
-
|
|
3
|
-
Built-in GraphQL sample base classes shipped with the gem.
|
|
4
|
-
These are abstract/semi-abstract classes that org scripts inherit from.
|
|
5
|
-
Concrete, CLI-integrated cases go in `usecases/default/` instead.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Hierarchy convention
|
|
10
|
-
|
|
11
|
-
Each domain follows:
|
|
12
|
-
```
|
|
13
|
-
samples/<domain>.rb ← namespace loader (may also BE the base class)
|
|
14
|
-
samples/<domain>/
|
|
15
|
-
<level>/
|
|
16
|
-
dsl.rb ← DSL concern — include in base, available to subclasses
|
|
17
|
-
base.rb ← Base class (inherits from GraphQL::Base or parent level)
|
|
18
|
-
<functional_level>.rb ← Optional: opinionated subclass, inherit directly
|
|
19
|
-
<functional_level>/
|
|
20
|
-
dsl.rb ← Further DSL for that functional level
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
DSLs are always **concerns (modules)** to include — never classes.
|
|
24
|
-
Bases are **classes** with the scaffolding logic.
|
|
25
|
-
Functional levels add opinionated defaults on top of base.
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
## Contents
|
|
30
|
-
|
|
31
|
-
| File | Class | Purpose |
|
|
32
|
-
|------|-------|---------|
|
|
33
|
-
| `samples/location.rb` | `Samples::Location` | Location structure management |
|
|
34
|
-
| `samples/location/command.rb` | `Samples::Location::Command` | Apply location tree commands |
|
|
35
|
-
| `samples/location/service.rb` | `Samples::Location::Service` | Tree diffing and conversion service |
|
|
36
|
-
| `samples/contractors.rb` | `Samples::Contractors` | Contractor entity base case |
|
|
37
|
-
| `samples/contractors/dsl.rb` | `Contractors::DSL` | Contractor helper mixin |
|
|
38
|
-
| `samples/pages.rb` | `Samples::Pages` (namespace) | Page processing cases loader |
|
|
39
|
-
| `samples/pages/page/dsl.rb` | `Pages::Page::DSL` | SearchConf helpers mixin |
|
|
40
|
-
| `samples/pages/page/base.rb` | `Pages::Page::Base` | Register-scoped page iteration |
|
|
41
|
-
| `samples/pages/org_page/dsl.rb` | `Pages::OrgPage::DSL` | Org-page DSL (extends Page::DSL) |
|
|
42
|
-
| `samples/pages/org_page/base.rb` | `Pages::OrgPage::Base` | Org-wide page iteration |
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## Difference: samples vs org-specific cases vs default
|
|
47
|
-
|
|
48
|
-
| Layer | Location | Purpose |
|
|
49
|
-
|---|---|---|
|
|
50
|
-
| **samples** (here) | `eco-helpers/lib/.../graphql/samples/` | Abstract base classes — org scripts inherit |
|
|
51
|
-
| **default** | `eco-helpers/lib/.../usecases/default/` | Concrete CLI-integrated cases for ALL orgs |
|
|
52
|
-
| **org-specific** | `multi_org_api/{org}/config/graphql_cases/` | Org-specific implementations |
|
|
53
|
-
|
|
54
|
-
Org scripts inherit from `samples/`, optionally via `default/` as an intermediate layer.
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## Adding a new built-in sample
|
|
59
|
-
|
|
60
|
-
1. Create the case file in `samples/`:
|
|
61
|
-
```ruby
|
|
62
|
-
# lib/eco/api/usecases/graphql/samples/my_domain.rb
|
|
63
|
-
class Eco::API::UseCases::GraphQL::Samples::MyDomain < Eco::API::UseCases::GraphQL::Base
|
|
64
|
-
name 'my-domain-case'
|
|
65
|
-
type :other
|
|
66
|
-
|
|
67
|
-
def process
|
|
68
|
-
# ...
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
2. Add `require_relative 'samples/my_domain'` to `samples.rb`.
|
|
74
|
-
|
|
75
|
-
If the case is page-centric, inherit from `PageCase` instead of `Base` to get
|
|
76
|
-
pagination, KPI tracking, and `update_page` for free.
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# samples/pages
|
|
2
|
-
|
|
3
|
-
Base classes for GraphQL-native page processing use cases.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Structure
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
pages/
|
|
11
|
-
page/
|
|
12
|
-
dsl.rb ← Page::DSL — SearchConf helpers mixin (sc, in_register, state_is, ...)
|
|
13
|
-
base.rb ← Page::Base — register-scoped page iteration + KPI scaffolding
|
|
14
|
-
org_page/
|
|
15
|
-
dsl.rb ← OrgPage::DSL — extends Page::DSL (org-wide helpers)
|
|
16
|
-
base.rb ← OrgPage::Base — org-wide iteration (no default register scope)
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Inheritance
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
GraphQL::Base
|
|
25
|
-
↓
|
|
26
|
-
Samples::Pages::Page::Base (register-scoped, inherits Page::DSL)
|
|
27
|
-
↓
|
|
28
|
-
Samples::Pages::OrgPage::Base (org-wide, overrides search_conf)
|
|
29
|
-
↓
|
|
30
|
-
your org subclass
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## Page::Base — register-scoped scripts
|
|
36
|
-
|
|
37
|
-
Override `process_page(page)` and optionally `search_conf`.
|
|
38
|
-
Set `register_id` and `batch_size` on the class.
|
|
39
|
-
|
|
40
|
-
## OrgPage::Base — org-wide scripts
|
|
41
|
-
|
|
42
|
-
Same as Page::Base but `search_conf` starts empty.
|
|
43
|
-
Add your own filters via `super.filter(state_is(:active))` etc.
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## Adding a functional level
|
|
48
|
-
|
|
49
|
-
If a common pattern emerges (e.g., "stage-submit scripts"), add:
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
pages/page/
|
|
53
|
-
stage_submitter.rb ← Page::StageSubmitter < Page::Base
|
|
54
|
-
stage_submitter/
|
|
55
|
-
dsl.rb ← StageSubmitter::DSL
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Keep base.rb for the pure iteration scaffolding; put opinionated defaults in
|
|
59
|
-
the functional level so scripts can choose their entry point.
|