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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +405 -9
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  8. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  9. data/lib/ecoportal/api/graphql/base.rb +1 -0
  10. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  11. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  12. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  13. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
  14. data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
  15. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  16. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  17. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  18. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  19. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  20. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  21. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  22. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  23. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  24. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  25. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  26. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  27. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  28. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  29. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  30. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  31. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  32. data/lib/ecoportal/api/graphql/model.rb +1 -0
  33. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  34. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  35. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  36. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  37. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  38. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  39. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  40. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  41. data/lib/ecoportal/api/graphql_version.rb +1 -1
  42. metadata +35 -2
  43. 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