hecks 3.4.2 → 3.5.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
  4. data/lib/hecks/deploy/oidc.json +225 -0
  5. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  6. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  7. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
  8. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  9. data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
  10. data/lib/hecks/hecks/custodian.bluebook +12 -2
  11. data/lib/hecks/hecks/hecks.hecksagon +57 -0
  12. data/lib/hecks/hecks/hecks.world +5 -1
  13. data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
  14. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  16. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  17. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  18. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  19. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  20. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
  21. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  22. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  23. data/lib/hecks/projections/deploy/box.rb +8 -14
  24. data/lib/hecks/projections/site/admin.rb +140 -0
  25. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  26. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  27. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  28. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  29. data/lib/hecks/projections/site/payload/model.rb +128 -0
  30. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  31. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  32. data/lib/hecks/projections/site/root_rows.rb +59 -0
  33. data/lib/hecks/projections/site/site_admin.rb +292 -0
  34. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  35. data/lib/hecks/projections/site/site_host.rb +58 -0
  36. data/lib/hecks/projections/site/site_root.rb +179 -0
  37. data/lib/hecks/site/bluebook/site.bluebook +7 -1
  38. data/lib/hecks/tools/site_routes.rb +78 -8
  39. data/lib/hecks/version.rb +1 -1
  40. data/rust/host/HECKS_RELEASE +1 -1
  41. metadata +17 -1
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Site
8
+ module Payload
9
+ # The TypeScript that ties a Payload collection to an aggregate: the collection's field
10
+ # definitions, one per attribute, and the function that reads a saved document back into the
11
+ # aggregate's input.
12
+ #
13
+ # Both come from the attribute's shape and, where the editor needs something the shape
14
+ # cannot
15
+ # say (a date picker, a choice list, an upload, a relation), from a `PayloadField` row.
16
+ # They are
17
+ # produced together so a field and the code that reads it cannot disagree.
18
+ module Fields
19
+ BANNER = "// Generated by hecks site site_projection.project_site. Do not edit."
20
+
21
+ # The kinds a row may name, with the Payload field type each is.
22
+ KINDS = { "text" => "text", "textarea" => "textarea", "select" => "select", "date" => "date", "day" => "date",
23
+ "number" => "number", "upload" => "upload", "relationship" => "relationship" }.freeze
24
+
25
+ module_function
26
+
27
+ # @param aggregates [Array<Model::Aggregate>] the aggregates to drive
28
+ # @param rows [Array<Hash{Symbol => Object}>] the checked `PayloadField` rows
29
+ # @param specs [String] the import path of the generated specs module
30
+ # @param helpers [String] the import path of the module that holds `nonBlank` and its kin
31
+ # @return [String] the module
32
+ # @raise [ArgumentError] when a row names an aggregate or an attribute the domain lacks
33
+ def render(aggregates, rows:, specs:, helpers:)
34
+ check(aggregates, rows)
35
+ body = aggregates.map { |agg| aggregate(agg, rows.select { |row| row[:aggregate] == agg.noun }) }
36
+ [BANNER, header(aggregates, specs, helpers), shared, *body].join("\n\n") << "\n"
37
+ end
38
+
39
+ def check(aggregates, rows)
40
+ rows.each do |row|
41
+ agg = aggregates.find { |candidate| candidate.noun == row[:aggregate] }
42
+ raise ArgumentError, "PayloadField names #{row[:aggregate]}, which it does not drive" unless agg
43
+
44
+ check_attribute(agg, row)
45
+ check_kind(row)
46
+ end
47
+ end
48
+
49
+ def check_attribute(agg, row)
50
+ top, part = row[:attribute].split(".", 2)
51
+ attr = agg.attrs.find { |candidate| candidate.name == top }
52
+ where = "PayloadField #{row[:aggregate]}.#{row[:attribute]}"
53
+ raise ArgumentError, "#{where} is not an attribute" unless attr
54
+ raise ArgumentError, "#{where} is not a part" if part && !attr.parts&.any? { |each| each.name == part }
55
+ end
56
+
57
+ def check_kind(row)
58
+ kind = row[:kind]
59
+ if kind && !KINDS.key?(kind)
60
+ raise ArgumentError,
61
+ "PayloadField kind #{kind.inspect} is one of #{KINDS.keys.join(', ')}"
62
+ end
63
+ return unless %w[upload relationship].include?(kind) && !row[:relation]
64
+
65
+ raise ArgumentError, "PayloadField #{row[:attribute]} is #{kind} and needs a relation"
66
+ end
67
+
68
+ def header(aggregates, specs, helpers)
69
+ types = aggregates.map { |agg| "type #{agg.noun}Input" }.join(", ")
70
+ <<~TS.chomp
71
+ import type { Field, PayloadRequest } from "payload";
72
+
73
+ import { isoOrNull, nonBlank, related, type Doc } from #{helpers.to_json};
74
+ import { #{types} } from #{specs.to_json};
75
+ TS
76
+ end
77
+
78
+ def shared
79
+ <<~TS.chomp
80
+ const valuesOf = (value: unknown, pick: (raw: unknown) => string | null): string[] =>
81
+ (Array.isArray(value) ? value : []).map((row) => pick((row as Doc)?.value)).filter((item): item is string => !!item);
82
+ TS
83
+ end
84
+
85
+ def aggregate(agg, rows)
86
+ var = "#{agg.noun[0].downcase}#{agg.noun[1..]}"
87
+ fields = fields_of(agg, rows).map { |name, text| " #{name}: #{text}," }.join("\n")
88
+ inputs = agg.attrs.map { |attr| " #{attr.ts}: #{input(attr, row_for(rows, attr.name), rows)}," }.join("\n")
89
+ <<~TS.chomp
90
+ // ---- #{agg.noun} ----
91
+
92
+ export const #{var}Fields = {
93
+ #{fields}
94
+ } satisfies Record<string, Field>;
95
+
96
+ export const #{var}FromDoc = async (req: PayloadRequest, doc: Doc): Promise<#{agg.noun}Input> => ({
97
+ #{inputs}
98
+ });
99
+ TS
100
+ end
101
+
102
+ def row_for(rows, attribute, part = nil)
103
+ wanted = part ? "#{attribute}.#{part}" : attribute
104
+ rows.find { |row| row[:attribute] == wanted } || {}
105
+ end
106
+
107
+ def fields_of(agg, rows)
108
+ agg.attrs.map do |attr|
109
+ row = row_for(rows, attr.name)
110
+ [field_name(attr, row), field(agg, attr, row, rows)]
111
+ end
112
+ end
113
+
114
+ def field_name(attr, row) = row[:field] || attr.ts
115
+
116
+ def field(_agg, attr, row, rows)
117
+ name = field_name(attr, row)
118
+ return composite(attr, row, name, rows) if attr.shape == :composite
119
+ return array(attr, row, name) if attr.list
120
+
121
+ literal(base(attr, row, name, !attr.optional))
122
+ end
123
+
124
+ def kind_of(attr, row)
125
+ row[:kind] || (attr.shape == :integer ? "number" : "text")
126
+ end
127
+
128
+ def base(attr, row, name, required)
129
+ kind = kind_of(attr, row)
130
+ required = row[:required] if row.key?(:required)
131
+ pairs = [["name", name.to_json], ["type", KINDS.fetch(kind).to_json]]
132
+ pairs << ["required", "true"] if required
133
+ pairs << ["unique", "true"] if attr.name == "slug"
134
+ pairs << ["defaultValue", row[:default].to_json] if row[:default]
135
+ pairs.concat(extras(kind, row))
136
+ end
137
+
138
+ def extras(kind, row, describe: true)
139
+ pairs = []
140
+ pairs << ["options", options(row[:options])] if kind == "select" && row[:options]
141
+ pairs << ["relationTo", row[:relation].to_json] if row[:relation]
142
+ admin = []
143
+ admin << ["description", row[:description].to_json] if describe && row[:description]
144
+ if KINDS[kind] == "date"
145
+ admin << ["date",
146
+ "{ pickerAppearance: #{(kind == 'day' ? 'dayOnly' : 'dayAndTime').to_json} }"]
147
+ end
148
+ pairs << ["admin", "{ #{admin.map { |key, value| "#{key}: #{value}" }.join(', ')} }"] if admin.any?
149
+ pairs
150
+ end
151
+
152
+ def options(text)
153
+ items = text.split(",").map(&:strip).reject(&:empty?).map do |item|
154
+ value, label = item.split("=", 2)
155
+ label ? "{ label: #{label.to_json}, value: #{value.to_json} }" : value.to_json
156
+ end
157
+ "[#{items.join(', ')}]"
158
+ end
159
+
160
+ def literal(pairs) = "{ #{pairs.map { |key, value| "#{key}: #{value}" }.join(', ')} }"
161
+
162
+ def array(_attr, row, name)
163
+ kind = row[:kind] || "text"
164
+ element = literal([["name", '"value"'], ["type", KINDS.fetch(kind).to_json], ["required", "true"],
165
+ *extras(kind, row, describe: false)])
166
+ pairs = [["name", name.to_json], ["type", '"array"']]
167
+ pairs << ["labels", labels(row[:label])] if row[:label]
168
+ pairs << ["admin", "{ description: #{row[:description].to_json} }"] if row[:description]
169
+ pairs << ["fields", "[#{element}]"]
170
+ literal(pairs)
171
+ end
172
+
173
+ def labels(text)
174
+ singular, plural = text.split("|", 2)
175
+ "{ singular: #{singular.to_json}, plural: #{(plural || "#{singular}s").to_json} }"
176
+ end
177
+
178
+ def composite(attr, row, name, rows)
179
+ subs = attr.parts.map do |part|
180
+ part_row = row_for(rows, attr.name, part.name)
181
+ literal(base(part, part_row.merge(required: part_row.fetch(:required, true)), part.ts, true))
182
+ end
183
+ pairs = [["name", name.to_json], ["type", '"array"']]
184
+ pairs << ["labels", labels(row[:label])] if row[:label]
185
+ pairs << ["admin", "{ description: #{row[:description].to_json} }"] if row[:description]
186
+ pairs << ["fields", "[#{subs.join(', ')}]"]
187
+ literal(pairs)
188
+ end
189
+
190
+ def input(attr, row, rows)
191
+ return composite_input(attr, rows) if attr.shape == :composite
192
+
193
+ name = row[:field] || attr.ts
194
+ kind = kind_of(attr, row)
195
+ return list_input(name, kind) if attr.list
196
+
197
+ scalar_input(attr, row, name, kind)
198
+ end
199
+
200
+ def list_input(name, kind) = "valuesOf(doc.#{name}, #{kind == 'date' ? 'isoOrNull' : 'nonBlank'})"
201
+
202
+ def scalar_input(attr, row, name, kind)
203
+ case kind
204
+ when "upload", "relationship"
205
+ via = row[:via] || (kind == "upload" ? "url" : "slug")
206
+ "await related(req, #{row[:relation].to_json}, doc.#{name}, #{via.to_json})"
207
+ when "date", "day" then date_input(attr, name, kind)
208
+ when "number" then attr.optional ? "doc.#{name} == null ? null : Number(doc.#{name})" : "Number(doc.#{name})"
209
+ else attr.optional ? "nonBlank(doc.#{name})" : "String(doc.#{name})"
210
+ end
211
+ end
212
+
213
+ def date_input(attr, name, kind)
214
+ return "isoOrNull(doc.#{name})#{' ?? ""' unless attr.optional}" unless attr.shape == :integer || kind == "day"
215
+
216
+ stamp = "doc.#{name} ? new Date(doc.#{name} as string).getTime() : Date.now()"
217
+ "Math.floor((#{stamp}) / 1000)"
218
+ end
219
+
220
+ def composite_input(attr, rows)
221
+ parts = attr.parts.map { |part| "#{part.ts}: #{part_input(part, row_for(rows, attr.name, part.name))}" }.join(", ")
222
+ "(Array.isArray(doc.#{attr.ts}) ? doc.#{attr.ts} : []).map((raw) => { const s = raw as Doc; return { #{parts} }; })"
223
+ end
224
+
225
+ def part_input(part, row)
226
+ return "isoOrNull(s.#{part.ts}) ?? \"\"" if KINDS[row[:kind]] == "date"
227
+
228
+ "nonBlank(s.#{part.ts}) ?? \"\""
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Site
8
+ module Payload
9
+ # The module that turns a spec and a saved document into the commands between the host's
10
+ # state and
11
+ # the wanted one. It is the same for every project but for the role its commands declare
12
+ # and the
13
+ # path of the module that talks to the host.
14
+ module Lifecycle
15
+ BANNER = "// Generated by hecks site site_projection.project_site. Do not edit."
16
+
17
+ module_function
18
+
19
+ # @param role [String, nil] the role every driven command declares
20
+ # @param hecks [String] the import path of the module that talks to the host
21
+ # @return [String] the module
22
+ def render(role:, hecks:)
23
+ [BANNER, TEXT.sub("__ROLE__", role.to_json).sub("__HECKS__", hecks.to_json)].join("\n\n")
24
+ end
25
+
26
+ TEXT = <<~TS
27
+ // Driving one aggregate from a save: what every driven collection shares.
28
+ //
29
+ // An editor's save says what the document should look like and where it should
30
+ // be (live, withdrawn, cancelled). The domain answers with where the aggregate
31
+ // is now. This works out the commands between the two: create it if the domain
32
+ // has never seen it, Revise when its facts differ, then walk its lifecycle to
33
+ // the wanted status along the shortest path the bluebook allows.
34
+ //
35
+ // The pure parts (the plan) are separate from `drive`, the one function that
36
+ // talks to the domain, so the decisions test without a network.
37
+ import { domain, instancesOf, refusalOf, type Answer } from __HECKS__;
38
+
39
+ /** The role every driven command declares; the host compares the role as a plain string. */
40
+ const ROLE = __ROLE__;
41
+
42
+ export type Wire = Record<string, unknown>;
43
+
44
+ export interface Command<S> {
45
+ verb: string;
46
+ to?: string;
47
+ with: Wire;
48
+ /** True when the instances that came back show the command took effect. */
49
+ confirm: (after: S[]) => boolean;
50
+ }
51
+
52
+ export interface Instance {
53
+ slug: string;
54
+ status: string;
55
+ }
56
+
57
+ export interface Spec<S extends Instance, I extends { slug: string }> {
58
+ /** The aggregate as the host names it, chapter first: "Club::Event". */
59
+ aggregate: string;
60
+ /** Its instances in an answer, keyed by slug. */
61
+ read: (answer: Answer) => S[];
62
+ /** What the host holds, in the shape a save produces, so the two compare. */
63
+ toInput: (state: S) => Omit<I, "status">;
64
+ /** Every attribute but the slug, as `Revise` takes it. */
65
+ fields: (input: Omit<I, "status">) => Wire;
66
+ /** The command that creates one (it takes the slug and the fields) and the status it leaves it in. */
67
+ create: { verb: string; status: string };
68
+ /** The lifecycle: from a status, the commands that leave it and where they lead. */
69
+ edges: Record<string, Record<string, string>>;
70
+ }
71
+
72
+ /** A refusal-free plan: nothing is sent for a status the domain cannot reach from where it is. */
73
+ export class UnreachableStatus extends Error {}
74
+
75
+ export const wrapped = (value: string) => ({ value });
76
+ export const optionalValue = (name: string, value: string | null | undefined) => (value ? { [name]: wrapped(value) } : {});
77
+
78
+ /** The text inside a one-attribute value object (`{value}`, `{url}`, `{address}`), or null. */
79
+ export function unwrap(value: unknown): string | null {
80
+ if (typeof value === "string") return value || null;
81
+ if (value && typeof value === "object") {
82
+ for (const key of ["value", "url", "address"]) {
83
+ const inner = (value as Record<string, unknown>)[key];
84
+ if (typeof inner === "string" && inner) return inner;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ export const unwrapList = (value: unknown): string[] =>
91
+ Array.isArray(value) ? value.map(unwrap).filter((item): item is string => !!item) : [];
92
+
93
+ export const statusOf = (state: Record<string, unknown>): string => (typeof state.status === "string" ? state.status : "");
94
+
95
+ /** The verbs, in order, that take an aggregate from one status to another; null when none does. */
96
+ export function pathBetween(edges: Record<string, Record<string, string>>, from: string, to: string): string[] | null {
97
+ if (from === to) return [];
98
+ const seen = new Set([from]);
99
+ let frontier: { status: string; verbs: string[] }[] = [{ status: from, verbs: [] }];
100
+ while (frontier.length) {
101
+ const next: typeof frontier = [];
102
+ for (const { status, verbs } of frontier) {
103
+ for (const [verb, target] of Object.entries(edges[status] ?? {})) {
104
+ if (seen.has(target)) continue;
105
+ if (target === to) return [...verbs, verb];
106
+ seen.add(target);
107
+ next.push({ status: target, verbs: [...verbs, verb] });
108
+ }
109
+ }
110
+ frontier = next;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ const stateOf = <S extends Instance>(after: S[], slug: string) => after.find((s) => s.slug === slug);
116
+
117
+ /**
118
+ * The commands that make the domain's aggregate match `input` and sit in
119
+ * `desired`. `desired` null means "not on the site": a document the domain has
120
+ * never seen sends nothing; one it holds is walked to `withdrawn`.
121
+ */
122
+ export function planCommands<S extends Instance, I extends { slug: string }>(
123
+ spec: Spec<S, I>,
124
+ current: S | undefined,
125
+ input: Omit<I, "status">,
126
+ desired: string,
127
+ ): Command<S>[] {
128
+ const slug = input.slug;
129
+ const fields = spec.fields(input);
130
+ const commands: Command<S>[] = [];
131
+ let status = current?.status;
132
+
133
+ if (!current) {
134
+ if (spec.create.status !== desired && !pathBetween(spec.edges, spec.create.status, desired)) {
135
+ throw new UnreachableStatus(`${spec.aggregate} cannot reach "${desired}" from "${spec.create.status}".`);
136
+ }
137
+ commands.push({
138
+ verb: spec.create.verb,
139
+ with: { slug: wrapped(slug), ...fields },
140
+ confirm: (after) => stateOf(after, slug)?.status === spec.create.status,
141
+ });
142
+ status = spec.create.status;
143
+ } else if (JSON.stringify(spec.fields(spec.toInput(current))) !== JSON.stringify(fields)) {
144
+ commands.push({
145
+ verb: "Revise",
146
+ to: slug,
147
+ with: fields,
148
+ confirm: (after) => {
149
+ const now = stateOf(after, slug);
150
+ return !!now && JSON.stringify(spec.fields(spec.toInput(now))) === JSON.stringify(fields);
151
+ },
152
+ });
153
+ }
154
+
155
+ const path = pathBetween(spec.edges, status ?? "", desired);
156
+ if (path === null) throw new UnreachableStatus(`${spec.aggregate} "${slug}" cannot go from "${status}" to "${desired}".`);
157
+ let at = status ?? "";
158
+ for (const verb of path) {
159
+ const to = spec.edges[at][verb];
160
+ commands.push({ verb, to: slug, with: {}, confirm: (after) => stateOf(after, slug)?.status === to });
161
+ at = to;
162
+ }
163
+ return commands;
164
+ }
165
+
166
+ /** Runs the plan against the domain; a command whose result does not show it took effect throws the domain's own words. */
167
+ export async function drive<S extends Instance, I extends { slug: string }>(
168
+ spec: Spec<S, I>,
169
+ input: Omit<I, "status">,
170
+ desired: string | null,
171
+ /** The status that means "off the site", used when `desired` is null and the domain holds the aggregate. */
172
+ offSite: string,
173
+ ): Promise<void> {
174
+ const current = spec.read(await domain().read()).find((s) => s.slug === input.slug);
175
+ // Not on the site and never was (a draft): the domain hears nothing about it.
176
+ if (desired === null && !current) return;
177
+ for (const command of planCommands(spec, current, input, desired ?? offSite)) {
178
+ const verb = `${spec.aggregate.split("::")[1]}.${command.verb}`;
179
+ const answer = await domain().dispatch(verb, command.with, command.to, ROLE);
180
+ if (!command.confirm(spec.read(answer))) throw refusalOf(answer, verb);
181
+ }
182
+ }
183
+
184
+ /** Reads an aggregate's instances, slug first, from an answer. */
185
+ export const instancesBySlug = (answer: Answer, aggregate: string) => instancesOf(answer, aggregate);
186
+ TS
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ module Payload
7
+ # What the generators read from a domain chapter about one aggregate the content system
8
+ # drives:
9
+ # its attributes with the shape each takes over the wire, the command that creates it, the
10
+ # lifecycle edges, and the role its commands declare.
11
+ #
12
+ # The model is pure data. It refuses an aggregate it cannot drive, naming why, so a project
13
+ # learns at generation time rather than from a runtime refusal.
14
+ class Model
15
+ # One attribute: `name` is the domain's (snake case), `ts` the camel case the content
16
+ # system
17
+ # uses, `shape` one of :value, :url, :address, :integer or :composite, and `parts` the
18
+ # attributes of a composite value object.
19
+ Attr = Struct.new(:name, :ts, :shape, :wire_key, :optional, :list, :type, :parts, keyword_init: true)
20
+
21
+ # One aggregate: `noun` ("Event"), `fqn` ("Club::Event"), `create` ({verb:, status:}),
22
+ # `edges` (status => { verb => status }) and `attrs` (the identity first).
23
+ Aggregate = Struct.new(:noun, :fqn, :create, :edges, :attrs, :identity, :role, keyword_init: true)
24
+
25
+ # The single attribute names a one-attribute value object may carry, with the shape each
26
+ # is.
27
+ SINGLE = { "value" => :value, "url" => :url, "address" => :address }.freeze
28
+
29
+ # @param chapter [Bluebook::Chapter] the domain chapter
30
+ # @param skip [Array<String>] aggregates to leave out
31
+ # @return [Array<Aggregate>] each aggregate with a lifecycle and a creating command
32
+ # @raise [ArgumentError] when such an aggregate cannot be driven
33
+ def self.read(chapter, skip: [])
34
+ chapter.aggregates.select { |agg| agg.lifecycle && !skip.include?(agg.hecks_name) }
35
+ .map { |agg| new(chapter, agg).aggregate }
36
+ end
37
+
38
+ # @return [Aggregate] the aggregate's model
39
+ attr_reader :aggregate
40
+
41
+ def initialize(chapter, agg)
42
+ @chapter = chapter
43
+ @agg = agg
44
+ @aggregate = Aggregate.new(noun: agg.hecks_name, fqn: "#{chapter.name}::#{agg.hecks_name}", create: create,
45
+ edges: edges, attrs: attrs, identity: agg.identified_by.to_s, role: role)
46
+ end
47
+
48
+ private
49
+
50
+ def create
51
+ command = @agg.commands.find(&:creates?)
52
+ raise ArgumentError, "#{@agg.hecks_name} has no command that creates it" unless command
53
+ raise ArgumentError, "#{@agg.hecks_name}'s lifecycle has no default status" unless @agg.lifecycle.default
54
+
55
+ { verb: command.hecks_name, status: @agg.lifecycle.default }
56
+ end
57
+
58
+ def edges
59
+ creating = @agg.commands.select(&:creates?).map(&:hecks_name)
60
+ map = Hash.new { |hash, key| hash[key] = {} }
61
+ @agg.lifecycle.transitions.each do |verb, move|
62
+ next if creating.include?(verb)
63
+
64
+ Array(move.from).each { |from| map[from][verb] = move.target }
65
+ map[move.target]
66
+ end
67
+ map.transform_values(&:dup)
68
+ end
69
+
70
+ def role
71
+ roles = @agg.commands.filter_map { |command| command.role if command.respond_to?(:role) }.uniq
72
+ if roles.size > 1
73
+ raise ArgumentError,
74
+ "#{@agg.hecks_name}'s commands declare #{roles.size} roles; the driver acts as one"
75
+ end
76
+
77
+ roles.first
78
+ end
79
+
80
+ def attrs
81
+ @agg.attributes.reject { |attribute| attribute.name == @agg.lifecycle.field }.map { |attribute| attr(attribute) }
82
+ end
83
+
84
+ def attr(attribute)
85
+ object = value_object(attribute.type)
86
+ unless object
87
+ raise ArgumentError,
88
+ "#{@agg.hecks_name}.#{attribute.name} is a #{attribute.type}; the driver needs a value object"
89
+ end
90
+
91
+ members = object.attributes
92
+ shape, key, parts = shape_of(attribute, members)
93
+ Attr.new(name: attribute.name.to_s, ts: camel(attribute.name), shape: shape, wire_key: key,
94
+ optional: attribute.optional?, list: attribute.list?, type: attribute.type, parts: parts)
95
+ end
96
+
97
+ def shape_of(attribute, members)
98
+ if members.size == 1 && SINGLE.key?(members.first.name.to_s)
99
+ key = members.first.name.to_s
100
+ [members.first.type.to_s == "Integer" ? :integer : SINGLE.fetch(key), key, nil]
101
+ elsif attribute.list? && members.size > 1
102
+ [:composite, nil, members.map { |member| part(member) }]
103
+ else
104
+ raise ArgumentError, "#{@agg.hecks_name}.#{attribute.name} is #{attribute.type}, which the driver cannot carry"
105
+ end
106
+ end
107
+
108
+ def part(member)
109
+ unless member.type.to_s == "String"
110
+ raise ArgumentError, "#{@agg.hecks_name}'s #{member.name} is a #{member.type}; a composite carries text only"
111
+ end
112
+
113
+ Attr.new(name: member.name.to_s, ts: camel(member.name), shape: :value, type: member.type.to_s, optional: false)
114
+ end
115
+
116
+ def value_object(type)
117
+ @agg.value_objects.find { |object| object.hecks_name == type.to_s }
118
+ end
119
+
120
+ def camel(name)
121
+ first, *rest = name.to_s.split("_")
122
+ first + rest.map(&:capitalize).join
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end