ecoportal-api-graphql 2.2.1 → 3.0.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 +405 -9
- data/README.gem.md +53 -0
- data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
- data/lib/ecoportal/api/common/graphql/client.rb +38 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
- data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
- data/lib/ecoportal/api/graphql/base.rb +1 -0
- data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
- data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
- data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
- data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
- data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
- data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
- data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
- data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
- data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
- data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
- data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
- data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
- data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
- data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
- data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
- data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
- data/lib/ecoportal/api/graphql/model/template.rb +33 -0
- data/lib/ecoportal/api/graphql/model.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
- data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
- data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
- data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
- data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
- data/lib/ecoportal/api/graphql/mutation.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +35 -2
- data/README.md +0 -24
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# Shared behaviour for every mutable node in the editable template tree (Stage,
|
|
7
|
+
# Section, Field, Option, Force, Binding, Helper). A node is either:
|
|
8
|
+
#
|
|
9
|
+
# * LOADED — `id` is a real server id, carried in from `Read.call`. `snapshot`
|
|
10
|
+
# freezes the mutable attributes as they were at load time, so `#dirty?` /
|
|
11
|
+
# `#changed_attributes` can diff "now" against "as loaded" later.
|
|
12
|
+
# * NEW — `id` is nil; the node was created in memory (`Stage#add_section`, etc.)
|
|
13
|
+
# and has no server counterpart yet. `#ref` mints a client-chosen `placeholderId`
|
|
14
|
+
# the first time it is asked (deterministic, `"ph_<prefix>_<n>"`, matching the
|
|
15
|
+
# scheme `Builder::TemplateBuilder`/`Diff::CommandSynthesizer` already use,
|
|
16
|
+
# counter owned by the root `Instance` so two loaded templates never share
|
|
17
|
+
# counters) and memoises it — every command in the same `#as_commands` batch
|
|
18
|
+
# that addresses this node reuses the SAME token.
|
|
19
|
+
#
|
|
20
|
+
# `#ref` is the ONE thing every command-emission method should call to address a
|
|
21
|
+
# node — never `id` directly — because it transparently upgrades from placeholder to
|
|
22
|
+
# real id the moment `StagedExecutor` resolves this node against a live re-read
|
|
23
|
+
# (`#resolve!`), without the caller needing to know which phase it is in.
|
|
24
|
+
#
|
|
25
|
+
# Including class MUST define `PLACEHOLDER_PREFIX` (a short String, e.g. `'sec'`)
|
|
26
|
+
# and `#template` (the root `Instance`, which owns the placeholder counters).
|
|
27
|
+
module Node
|
|
28
|
+
attr_reader :id
|
|
29
|
+
|
|
30
|
+
def new?
|
|
31
|
+
id.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def removed?
|
|
35
|
+
!!@removed
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Flags this node for a `remove*` command. Only meaningful for a LOADED node — a
|
|
39
|
+
# NEW node that is removed before ever being saved should simply be dropped from
|
|
40
|
+
# its parent collection instead (there is nothing server-side to remove yet).
|
|
41
|
+
def remove!
|
|
42
|
+
if new?
|
|
43
|
+
raise ArgumentError, "#{self.class}: cannot remove a node that was never saved " \
|
|
44
|
+
'(drop it from its parent collection instead)'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@removed = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The token every command that addresses this node must use: the real id once
|
|
51
|
+
# known, otherwise a memoised placeholder. `StagedExecutor#resolve!` is the only
|
|
52
|
+
# code that ever calls `#resolve!` — everything downstream just calls `#ref` again
|
|
53
|
+
# and transparently gets the real id from then on.
|
|
54
|
+
def ref
|
|
55
|
+
id || placeholder
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def placeholder
|
|
59
|
+
@placeholder ||= template.next_placeholder(self.class::PLACEHOLDER_PREFIX)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Called once a live re-read has matched this NEW node to its real server id.
|
|
63
|
+
# Raises if called on an already-resolved node with a DIFFERENT id (a resolver
|
|
64
|
+
# bug, not a transient condition) — silently overwriting a resolved id would hide
|
|
65
|
+
# a mismatched cross-check upstream.
|
|
66
|
+
def resolve!(real_id)
|
|
67
|
+
raise ArgumentError, "#{self.class}: resolve! requires a real id" if real_id.nil?
|
|
68
|
+
return if id == real_id
|
|
69
|
+
raise "#{self.class}: already resolved to #{id.inspect}, cannot re-resolve to #{real_id.inspect}" if id
|
|
70
|
+
|
|
71
|
+
@id = real_id
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# One option of a Select field. Read-side field name is `name` (see the gem's own
|
|
7
|
+
# `selectField` fragment, `fragment/pages/common_page_union.rb`); write-side commands
|
|
8
|
+
# (`addSelectFieldOption`/`editSelectFieldOption`) name the same thing `label` — this
|
|
9
|
+
# class always uses `label`, mapping the read-side `name` -> `label` at load time
|
|
10
|
+
# (`Read.call`), so callers never see the read/write naming split.
|
|
11
|
+
class Option
|
|
12
|
+
include Node
|
|
13
|
+
|
|
14
|
+
PLACEHOLDER_PREFIX = 'opt'.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :template, :field
|
|
17
|
+
attr_accessor :label, :value, :weight
|
|
18
|
+
|
|
19
|
+
def initialize(template:, field:, id: nil, label: nil, value: nil, weight: nil)
|
|
20
|
+
@template = template
|
|
21
|
+
@field = field
|
|
22
|
+
@id = id
|
|
23
|
+
@label = label
|
|
24
|
+
@value = value
|
|
25
|
+
@weight = weight
|
|
26
|
+
@snapshot = snapshot_attrs.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dirty?
|
|
30
|
+
!removed? && !new? && snapshot_attrs != @snapshot
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def changed_attributes
|
|
34
|
+
return {} if new? || removed?
|
|
35
|
+
|
|
36
|
+
snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def snapshot_attrs
|
|
42
|
+
{ label: label, value: value, weight: weight }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# Live read for `Instance.load`/`StagedExecutor`'s between-phase re-reads.
|
|
7
|
+
#
|
|
8
|
+
# *** PLATFORM FACT — workflow-first, DO NOT collapse onto `page.stages` alone ***
|
|
9
|
+
#
|
|
10
|
+
# Ported verbatim (query shape) from a downstream script repo's
|
|
11
|
+
# template-fields read service, itself verified live 2026-08-01 and
|
|
12
|
+
# re-confirmed 2026-08-09: a command-bus-built template's PAGE projection can be
|
|
13
|
+
# EMPTY while its WORKFLOW carries the full structure (Workflow Builder / this gem's
|
|
14
|
+
# own `updatePageTemplate` writes workflow stage/section/field docs; the page
|
|
15
|
+
# projection does not always materialise from them), whereas a web-editor-built
|
|
16
|
+
# template DOES project page stages. Read `workflow { stages { sections } }` FIRST;
|
|
17
|
+
# fall back to the page projection only when the workflow yields nothing — never the
|
|
18
|
+
# other way round.
|
|
19
|
+
#
|
|
20
|
+
# SplitSection has NO `dataFields` — `leftDataFields`/`rightDataFields` only. Forces
|
|
21
|
+
# are read from `workflow.stages.forces` only (`18_template_editor_save_path.md`
|
|
22
|
+
# section A.1, `TemplateStageFields.forces`); the page projection carries no forces
|
|
23
|
+
# at all, so there is no fallback for that part of the read.
|
|
24
|
+
#
|
|
25
|
+
# `Helper`'s own five fields -- `id name filePath script contentB64` -- are LIVE-
|
|
26
|
+
# VERIFIED (not merely mirrored from the write side): a downstream script repo's
|
|
27
|
+
# template force-read case selects
|
|
28
|
+
# `helpers { id name filePath script contentB64 }` on exactly this same
|
|
29
|
+
# `forces` field, and that read has run live against pre_prod across 47 templates
|
|
30
|
+
# (that file's own header, lines 29-31/90). Use these five with confidence.
|
|
31
|
+
#
|
|
32
|
+
# UNVERIFIED against a live schema this session: any `Force` field beyond the
|
|
33
|
+
# `Helper` sub-fields cited above (e.g. a top-level `script` alongside
|
|
34
|
+
# `customScript`, or a plain `url`/`contentB64` on the force itself) -- cite before
|
|
35
|
+
# relying on anything not confirmed above.
|
|
36
|
+
module Read
|
|
37
|
+
STRUCTURE_QUERY = <<~GRAPHQL.freeze
|
|
38
|
+
query TemplateModelRead($id: ID!) {
|
|
39
|
+
currentOrganization {
|
|
40
|
+
page(id: $id, showHiddenData: true) {
|
|
41
|
+
__typename
|
|
42
|
+
... on BasicPage {
|
|
43
|
+
id
|
|
44
|
+
name
|
|
45
|
+
patchVer
|
|
46
|
+
workflow { stages { ...wfStage } }
|
|
47
|
+
sections { ...sectionFields }
|
|
48
|
+
}
|
|
49
|
+
... on PhasedPage {
|
|
50
|
+
id
|
|
51
|
+
name
|
|
52
|
+
patchVer
|
|
53
|
+
workflow { stages { ...wfStage } }
|
|
54
|
+
stages { id name ordering sections { ...sectionFields } }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fragment wfStage on Stage {
|
|
61
|
+
id
|
|
62
|
+
name
|
|
63
|
+
ordering
|
|
64
|
+
sections { ...sectionFields }
|
|
65
|
+
forces {
|
|
66
|
+
id
|
|
67
|
+
name
|
|
68
|
+
customScript
|
|
69
|
+
url
|
|
70
|
+
contentB64
|
|
71
|
+
bindings { name referenceId }
|
|
72
|
+
helpers { id name filePath script contentB64 }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fragment sectionFields on SectionUnion {
|
|
77
|
+
__typename
|
|
78
|
+
... on ContentSection {
|
|
79
|
+
id
|
|
80
|
+
heading
|
|
81
|
+
dataFields { ...tf }
|
|
82
|
+
}
|
|
83
|
+
... on SplitSection {
|
|
84
|
+
id
|
|
85
|
+
heading
|
|
86
|
+
leftHeading
|
|
87
|
+
rightHeading
|
|
88
|
+
leftDataFields { ...tf }
|
|
89
|
+
rightDataFields { ...tf }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fragment tf on DataFieldUnion {
|
|
94
|
+
__typename
|
|
95
|
+
... on DataFieldsInterface {
|
|
96
|
+
id
|
|
97
|
+
label
|
|
98
|
+
description
|
|
99
|
+
tooltip
|
|
100
|
+
hidden
|
|
101
|
+
required
|
|
102
|
+
}
|
|
103
|
+
... on Select {
|
|
104
|
+
options { id value name }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
GRAPHQL
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
# @param client [Ecoportal::API::GraphQL] the top-level gem client (has
|
|
111
|
+
# `#client` / `.http_client`, matching every other read in this gem).
|
|
112
|
+
# @param id [String] the template's page id.
|
|
113
|
+
# @return [Hash] the raw `page` doc (string keys, as the server sent it).
|
|
114
|
+
def call(client, id)
|
|
115
|
+
raw = http(client).execute(STRUCTURE_QUERY, variables: { id: id })
|
|
116
|
+
page = raw.dig('data', 'currentOrganization', 'page')
|
|
117
|
+
raise "Template '#{id}' not readable as page" unless page
|
|
118
|
+
|
|
119
|
+
page
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Fields in document order, workflow-first, exactly `TemplateFieldsRead.fields`'s
|
|
123
|
+
# own semantics (kept as a module method so `StagedExecutor`'s re-read
|
|
124
|
+
# cross-checks can call it against a freshly re-fetched `page` doc without going
|
|
125
|
+
# through the whole `Instance` construction).
|
|
126
|
+
#
|
|
127
|
+
# @return [Array<Hash>] each entry carries stage_id/section_id/section_side
|
|
128
|
+
# alongside the field's own doc, in document order.
|
|
129
|
+
def stages(page)
|
|
130
|
+
from_workflow = Array(page.dig('workflow', 'stages'))
|
|
131
|
+
return from_workflow unless from_workflow.empty?
|
|
132
|
+
|
|
133
|
+
fallback_stages(page)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Accepts, in order of preference: the top-level `Ecoportal::API::GraphQL`
|
|
139
|
+
# object (`client.client.http_client`), the inner
|
|
140
|
+
# `Common::GraphQL::Client` directly (`client.http_client`), or a bare
|
|
141
|
+
# `#execute(query, variables:) -> Hash` duck (a spec double) as-is.
|
|
142
|
+
def http(client)
|
|
143
|
+
return client.client.http_client if client.respond_to?(:client) && client.client.respond_to?(:http_client)
|
|
144
|
+
return client.http_client if client.respond_to?(:http_client)
|
|
145
|
+
|
|
146
|
+
client
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fallback_stages(page)
|
|
150
|
+
case page['__typename']
|
|
151
|
+
when 'PhasedPage' then Array(page['stages'])
|
|
152
|
+
when 'BasicPage'
|
|
153
|
+
[{ 'id' => nil, 'name' => nil, 'ordering' => 0, 'sections' => Array(page['sections']), 'forces' => [] }]
|
|
154
|
+
else
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# One section. `kind` is `:content` (single `fields` list) or `:split` (`left_fields`
|
|
7
|
+
# / `right_fields`, each independently addressable — a `SplitSection` has NO
|
|
8
|
+
# `dataFields` of its own, see `Read.call`'s header). `#fields` always returns the
|
|
9
|
+
# flattened, document-ordered list (left then right, for a split section) — the ONE
|
|
10
|
+
# place callers should iterate unless they specifically need a side.
|
|
11
|
+
class Section
|
|
12
|
+
include Node
|
|
13
|
+
|
|
14
|
+
PLACEHOLDER_PREFIX = 'sec'.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :template, :stage, :kind, :layout, :left_fields, :right_fields, :pending_move
|
|
17
|
+
attr_accessor :heading, :left_heading, :right_heading
|
|
18
|
+
|
|
19
|
+
def initialize(template:, stage:, id: nil, kind: :content, layout: nil,
|
|
20
|
+
heading: nil, left_heading: nil, right_heading: nil)
|
|
21
|
+
@template = template
|
|
22
|
+
@stage = stage
|
|
23
|
+
@id = id
|
|
24
|
+
@kind = kind
|
|
25
|
+
@layout = layout
|
|
26
|
+
@heading = heading
|
|
27
|
+
@left_heading = left_heading
|
|
28
|
+
@right_heading = right_heading
|
|
29
|
+
@left_fields = []
|
|
30
|
+
@right_fields = []
|
|
31
|
+
@pending_move = nil
|
|
32
|
+
@snapshot = snapshot_attrs.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Document-ordered fields. For a :content section this IS `left_fields`; for a
|
|
36
|
+
# :split section it is left-then-right (matches `Read.call`'s own document-order
|
|
37
|
+
# flattening, so `#fields` here and the loaded order agree).
|
|
38
|
+
def fields
|
|
39
|
+
kind == :split ? left_fields + right_fields : left_fields
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_field(field_type:, label: nil, column: nil, side: nil, **kargs)
|
|
43
|
+
field = Field.new(template: template, section: self, stage: stage,
|
|
44
|
+
field_type: field_type, label: label, column: column, **kargs)
|
|
45
|
+
(side == :right ? right_fields : left_fields) << field
|
|
46
|
+
field
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Records the intent to reposition this (existing) section relative to `anchor`
|
|
50
|
+
# (another Section) — emitted as ONE `reorderSection` command as a per-stage
|
|
51
|
+
# TRAILER (after every structural add/remove in the same stage), because the
|
|
52
|
+
# server rewrites EVERY sibling's weight on a reorder
|
|
53
|
+
# (`18_template_editor_save_path.md` section C.1) — running it before a same-batch
|
|
54
|
+
# `addStageSection` would have its effect immediately overwritten by the next
|
|
55
|
+
# section's own weight assignment.
|
|
56
|
+
def move(anchor:, position:)
|
|
57
|
+
raise ArgumentError, "position must be 'BEFORE' or 'AFTER'" unless %w[BEFORE AFTER].include?(position)
|
|
58
|
+
|
|
59
|
+
@pending_move = { anchor: anchor, position: position }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def dirty?
|
|
63
|
+
!removed? && !new? && snapshot_attrs != @snapshot
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def changed_attributes
|
|
67
|
+
return {} if new? || removed?
|
|
68
|
+
|
|
69
|
+
snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def snapshot_attrs
|
|
75
|
+
{ heading: heading, left_heading: left_heading, right_heading: right_heading }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# One workflow stage. `editStage`/`moveStage` exist server-side but are not wired to
|
|
7
|
+
# a mutable accessor here yet — out of scope for this pass (see the model's own
|
|
8
|
+
# design doc, "not yet implemented"); `name`/`ordering` are captured read-only for
|
|
9
|
+
# display and cross-checks.
|
|
10
|
+
class Stage
|
|
11
|
+
include Node
|
|
12
|
+
|
|
13
|
+
PLACEHOLDER_PREFIX = 'stg'.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :template, :sections, :forces, :name, :ordering
|
|
16
|
+
|
|
17
|
+
def initialize(template:, id: nil, name: nil, ordering: nil)
|
|
18
|
+
@template = template
|
|
19
|
+
@id = id
|
|
20
|
+
@name = name
|
|
21
|
+
@ordering = ordering
|
|
22
|
+
@sections = []
|
|
23
|
+
@forces = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_section(kind: :content, layout: nil, heading: nil, left_heading: nil, right_heading: nil)
|
|
27
|
+
section = Section.new(template: template, stage: self, kind: kind, layout: layout,
|
|
28
|
+
heading: heading, left_heading: left_heading, right_heading: right_heading)
|
|
29
|
+
sections << section
|
|
30
|
+
section
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_force(name: nil, custom_script: nil, url: nil, content_b64: nil)
|
|
34
|
+
force = Force.new(template: template, stage: self, name: name, custom_script: custom_script,
|
|
35
|
+
url: url, content_b64: content_b64)
|
|
36
|
+
forces << force
|
|
37
|
+
force
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Every field across every (non-removed) section of this stage, document order.
|
|
41
|
+
def fields
|
|
42
|
+
sections.reject(&:removed?).flat_map(&:fields)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
3
|
+
module Ecoportal
|
|
4
|
+
module API
|
|
5
|
+
class GraphQL
|
|
6
|
+
module Model
|
|
7
|
+
module Template
|
|
8
|
+
# Ports a downstream script repo's staged `-template-force-install` install (sections ->
|
|
9
|
+
# re-read -> fields+options -> re-read -> forces -> re-read -> helpers+bindings) into
|
|
10
|
+
# a GENERIC, four-phase executor that works for ANY `Instance`, any number of
|
|
11
|
+
# stages/sections/fields — not one template's shape. Each phase is built FRESH from
|
|
12
|
+
# the CURRENT tree (`CommandSynthesis`'s phase-scoped methods), so a node resolved by
|
|
13
|
+
# an earlier phase (`Node#resolve!`) is addressed by its real id — never its stale
|
|
14
|
+
# placeholder — the moment the next phase's commands are synthesised (`Node#ref`
|
|
15
|
+
# itself does the upgrade; nothing here has to rewrite a command hash by hand).
|
|
16
|
+
#
|
|
17
|
+
# Sends through `Builder::Template#update` (`updatePageTemplate`) — NEVER
|
|
18
|
+
# `executeWorkflowCommands` — per `18_template_editor_save_path.md` section D.4/B.3's
|
|
19
|
+
# own recommendation (the SAME safety invariants the template editor enforces should
|
|
20
|
+
# apply to bulk/automated writes). Every call carries a fresh `clientMutationId` for
|
|
21
|
+
# tracing/correlation ONLY — the server does NOT dedupe on it (confirmed: neither
|
|
22
|
+
# `UpdateForm` nor `ExecuteCommandsForm` reference it in
|
|
23
|
+
# `18_template_editor_save_path.md` section B).
|
|
24
|
+
class StagedExecutor
|
|
25
|
+
DEFAULT_CHUNK_SIZE = 200
|
|
26
|
+
|
|
27
|
+
PHASES = %i[stage_phase section_phase field_phase force_phase helper_binding_phase].freeze
|
|
28
|
+
|
|
29
|
+
# Placeholder prefixes that MUST already be resolved to real ids by the time this
|
|
30
|
+
# phase's commands are built — a leftover token here means an earlier phase's
|
|
31
|
+
# resolution silently failed to cover this node, and sending anyway would either
|
|
32
|
+
# get rejected server-side or (worse) silently misfire. See `#guard_resolved!`.
|
|
33
|
+
REQUIRES_RESOLVED = {
|
|
34
|
+
section_phase: %w[stg],
|
|
35
|
+
field_phase: %w[stg sec],
|
|
36
|
+
force_phase: %w[stg],
|
|
37
|
+
helper_binding_phase: %w[stg sec frc fld]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
Plan = Struct.new(:stages, keyword_init: true)
|
|
41
|
+
Result = Struct.new(:ok, :patch_ver, :stages, :error, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
def initialize(instance, client:, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
44
|
+
@instance = instance
|
|
45
|
+
@client = client
|
|
46
|
+
@chunk_size = chunk_size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# SIMULATE — no call is made. Returns the plan every phase WOULD send: command
|
|
50
|
+
# counts, chunk sizes, and every placeholder token appearing in that phase (a
|
|
51
|
+
# caller can eyeball this before ever touching the server).
|
|
52
|
+
def plan
|
|
53
|
+
synth = CommandSynthesis.new(instance)
|
|
54
|
+
Plan.new(stages: PHASES.map { |phase| phase_plan(synth, phase) })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# EXECUTE — actually sends the staged batches. Raises ArgumentError up front if no
|
|
58
|
+
# client was given (a `simulate: false` call with no client is a caller mistake,
|
|
59
|
+
# not a runtime condition to swallow).
|
|
60
|
+
def execute!
|
|
61
|
+
raise ArgumentError, 'StagedExecutor#execute! requires a client (got nil)' if client.nil?
|
|
62
|
+
|
|
63
|
+
patch_ver = instance.patch_ver
|
|
64
|
+
report = []
|
|
65
|
+
|
|
66
|
+
PHASES.each do |phase|
|
|
67
|
+
outcome = execute_phase(phase, patch_ver)
|
|
68
|
+
report << outcome
|
|
69
|
+
return abort(report, outcome[:error]) unless outcome[:ok]
|
|
70
|
+
|
|
71
|
+
patch_ver = outcome[:patch_ver]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Result.new(ok: true, patch_ver: patch_ver, stages: report, error: nil)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
attr_reader :instance, :client, :chunk_size
|
|
80
|
+
|
|
81
|
+
def phase_plan(synth, phase)
|
|
82
|
+
commands = synth.public_send(phase)
|
|
83
|
+
chunks = CommandGrouper.chunk(commands, chunk_size)
|
|
84
|
+
{ phase: phase, command_count: commands.size, chunk_sizes: chunks.map(&:size),
|
|
85
|
+
placeholders: CommandGrouper.tokens_in(commands) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def execute_phase(phase, patch_ver)
|
|
89
|
+
commands = CommandSynthesis.new(instance).public_send(phase)
|
|
90
|
+
return { phase: phase, ok: true, patch_ver: patch_ver, sent: 0 } if commands.empty?
|
|
91
|
+
|
|
92
|
+
guard_resolved!(phase, commands)
|
|
93
|
+
|
|
94
|
+
chunks = CommandGrouper.chunk(commands, chunk_size)
|
|
95
|
+
chunks.each do |chunk|
|
|
96
|
+
response = send_chunk(chunk, patch_ver)
|
|
97
|
+
return { phase: phase, ok: false, patch_ver: patch_ver, error: response[:error] } unless response[:ok]
|
|
98
|
+
|
|
99
|
+
patch_ver = response[:patch_ver]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
resolve_phase!(phase)
|
|
103
|
+
{ phase: phase, ok: true, patch_ver: patch_ver, sent: commands.size }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def abort(report, error)
|
|
107
|
+
Result.new(ok: false, patch_ver: report.last[:patch_ver], stages: report, error: error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Refuses to send a batch that still references a placeholder an EARLIER phase
|
|
111
|
+
# should already have resolved — see `REQUIRES_RESOLVED`'s own doc.
|
|
112
|
+
def guard_resolved!(phase, commands)
|
|
113
|
+
prefixes = REQUIRES_RESOLVED[phase]
|
|
114
|
+
return if prefixes.nil? || prefixes.empty?
|
|
115
|
+
|
|
116
|
+
stale = CommandGrouper.tokens_in(commands).select { |tok| prefixes.any? { |p| tok.start_with?("ph_#{p}_") } }
|
|
117
|
+
return if stale.empty?
|
|
118
|
+
|
|
119
|
+
raise "StagedExecutor: refusing to send #{phase} — it still references " \
|
|
120
|
+
"#{stale.size} unresolved placeholder(s) from an earlier phase: #{stale.join(', ')}. " \
|
|
121
|
+
'An earlier phase\'s resolution step did not cover every new node it created.'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def send_chunk(chunk, patch_ver)
|
|
125
|
+
payload = builder.update(instance, commands: chunk, patch_ver: patch_ver,
|
|
126
|
+
client_mutation_id: "tmpl-model-#{SecureRandom.hex(6)}")
|
|
127
|
+
return { ok: false, error: 'no payload returned' } if payload.nil?
|
|
128
|
+
return { ok: false, error: payload.error_doc } if payload.error?
|
|
129
|
+
|
|
130
|
+
{ ok: true, patch_ver: payload.item.patchVer }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def builder
|
|
134
|
+
@builder ||= client.template
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- between-phase resolution -------------------------------------------
|
|
138
|
+
|
|
139
|
+
def resolve_phase!(phase)
|
|
140
|
+
case phase
|
|
141
|
+
when :stage_phase then resolve_stages! if instance.stages.any?(&:new?)
|
|
142
|
+
when :section_phase then resolve_sections! if instance.sections.any?(&:new?)
|
|
143
|
+
when :field_phase then resolve_fields! if instance.fields.any?(&:new?)
|
|
144
|
+
when :force_phase then resolve_forces! if instance.forces.any?(&:new?)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fresh_page
|
|
149
|
+
Read.call(client, instance.id)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolve_stages!
|
|
153
|
+
live = Read.stages(fresh_page)
|
|
154
|
+
resolve_new_children!(instance.stages, live, cross_check: :name, live_key: 'name', label: 'stage')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def resolve_sections!
|
|
158
|
+
live_stages = Read.stages(fresh_page)
|
|
159
|
+
instance.stages.each_with_index do |stage, i|
|
|
160
|
+
live_sections = Array(live_stages.dig(i, 'sections'))
|
|
161
|
+
resolve_new_children!(stage.sections, live_sections, cross_check: :heading, live_key: 'heading', label: "stage[#{i}] section")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolve_fields!
|
|
166
|
+
live_stages = Read.stages(fresh_page)
|
|
167
|
+
instance.stages.each_with_index do |stage, si|
|
|
168
|
+
stage.sections.each_with_index do |section, sci|
|
|
169
|
+
live_section = live_stages.dig(si, 'sections', sci)
|
|
170
|
+
resolve_section_fields!(section, live_section)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_section_fields!(section, live_section)
|
|
176
|
+
if section.kind == :split
|
|
177
|
+
resolve_new_children!(section.left_fields, Array(live_section&.dig('leftDataFields')),
|
|
178
|
+
cross_check: :label, live_key: 'label', label: 'split left field')
|
|
179
|
+
resolve_new_children!(section.right_fields, Array(live_section&.dig('rightDataFields')),
|
|
180
|
+
cross_check: :label, live_key: 'label', label: 'split right field')
|
|
181
|
+
else
|
|
182
|
+
resolve_new_children!(section.left_fields, Array(live_section&.dig('dataFields')),
|
|
183
|
+
cross_check: :label, live_key: 'label', label: 'content field')
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def resolve_forces!
|
|
188
|
+
live_stages = Read.stages(fresh_page)
|
|
189
|
+
instance.stages.each_with_index do |stage, i|
|
|
190
|
+
live_forces = Array(live_stages.dig(i, 'forces'))
|
|
191
|
+
resolve_new_children!(stage.forces, live_forces, cross_check: :name, live_key: 'name', label: "stage[#{i}] force")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Generic positional-plus-cross-check resolver: `nodes` is the CURRENT in-memory
|
|
196
|
+
# sibling list (loaded ones keep their relative order; new ones are appended, so
|
|
197
|
+
# `nodes.select(&:new?)` is exactly "the ones the last-sent batch just created, in
|
|
198
|
+
# creation order"). `live_docs` is the FRESH re-read of the SAME sibling collection.
|
|
199
|
+
#
|
|
200
|
+
# count guard — live count minus the pre-existing count must equal exactly the
|
|
201
|
+
# number of new nodes this batch created (never "roughly matches").
|
|
202
|
+
# cross-check — the new server docs are assumed appended at the tail, matched
|
|
203
|
+
# 1:1 in creation order; when the node's own `cross_check`
|
|
204
|
+
# attribute is non-nil, it must equal the live doc's `live_key`
|
|
205
|
+
# value, or resolution raises rather than silently mis-mapping an
|
|
206
|
+
# id to the wrong node.
|
|
207
|
+
def resolve_new_children!(nodes, live_docs, cross_check:, live_key:, label:)
|
|
208
|
+
new_nodes = nodes.select(&:new?)
|
|
209
|
+
return if new_nodes.empty?
|
|
210
|
+
|
|
211
|
+
before_count = nodes.size - new_nodes.size
|
|
212
|
+
delta = live_docs.size - before_count
|
|
213
|
+
if delta != new_nodes.size
|
|
214
|
+
raise "StagedExecutor: #{label} count guard failed -- expected #{new_nodes.size} new, " \
|
|
215
|
+
"live delta is #{delta} (before=#{before_count}, live=#{live_docs.size})"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
matched = live_docs.last(delta)
|
|
219
|
+
new_nodes.each_with_index do |node, idx|
|
|
220
|
+
live = matched[idx]
|
|
221
|
+
expected = node.public_send(cross_check)
|
|
222
|
+
actual = live[live_key]
|
|
223
|
+
if !expected.nil? && expected != actual
|
|
224
|
+
raise "StagedExecutor: #{label} cross-check failed at position #{idx} -- " \
|
|
225
|
+
"expected #{cross_check}=#{expected.inspect}, live #{live_key}=#{actual.inspect}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
node.resolve!(live['id'])
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|