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.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
- data/lib/hecks/deploy/oidc.json +225 -0
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
- data/lib/hecks/hecks/custodian.bluebook +12 -2
- data/lib/hecks/hecks/hecks.hecksagon +57 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +8 -14
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/site/bluebook/site.bluebook +7 -1
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- 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
|