@shortlink-org/portolan 0.2.2 → 0.2.4
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.
- package/README.md +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Adr,
|
|
3
|
+
Aggregate,
|
|
4
|
+
Catalog,
|
|
5
|
+
Flow,
|
|
6
|
+
FlowNode,
|
|
7
|
+
FlowTrigger,
|
|
8
|
+
Lifecycle,
|
|
9
|
+
Service,
|
|
10
|
+
Step,
|
|
11
|
+
} from "./catalog-model.ts";
|
|
12
|
+
import {
|
|
13
|
+
CLASSIFICATIONS,
|
|
14
|
+
COMPONENT_KINDS,
|
|
15
|
+
GROUP_KINDS,
|
|
16
|
+
REDIS_OPERATIONS,
|
|
17
|
+
STORE_KINDS,
|
|
18
|
+
STREAMING,
|
|
19
|
+
TABLE_OPERATIONS,
|
|
20
|
+
TABLE_ROLES,
|
|
21
|
+
aggregateBlocks,
|
|
22
|
+
allAggregates,
|
|
23
|
+
allExternals,
|
|
24
|
+
allModules,
|
|
25
|
+
allRepos,
|
|
26
|
+
allServices,
|
|
27
|
+
allStores,
|
|
28
|
+
allTerms,
|
|
29
|
+
columnNameOfId,
|
|
30
|
+
enumsOf,
|
|
31
|
+
relationOfColumnId,
|
|
32
|
+
rootEntity,
|
|
33
|
+
storeViews,
|
|
34
|
+
walkSteps,
|
|
35
|
+
} from "./catalog-model.ts";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Validation. Throws on the first violation with a message that names the
|
|
39
|
+
// offending flow / step / field, so a bad generator run fails loudly.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export class CatalogError extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* Where the violation is, as a reader would name it: "flow checkout-happy /
|
|
45
|
+
* step s4", "aggregate shop.oms.order". The message already says what is
|
|
46
|
+
* wrong; this says which line of the generator run to go and look at, and it
|
|
47
|
+
* is what the error page prints under the message.
|
|
48
|
+
*/
|
|
49
|
+
readonly path: string | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(message: string, path?: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "CatalogError";
|
|
54
|
+
this.path = path;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fail(message: string, path?: string): never {
|
|
59
|
+
throw new CatalogError(message, path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertUniqueSlugs(
|
|
63
|
+
slugs: string[],
|
|
64
|
+
parent: string,
|
|
65
|
+
what: string,
|
|
66
|
+
): void {
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
for (const slug of slugs) {
|
|
69
|
+
if (seen.has(slug))
|
|
70
|
+
fail(`${what} slug "${slug}" is not unique within ${parent}`, parent);
|
|
71
|
+
seen.add(slug);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a service says about the bus.
|
|
77
|
+
*
|
|
78
|
+
* The address is the whole of a channel's identity - there is no id, because a
|
|
79
|
+
* channel is not a page and nothing links to one - so two channels sharing an
|
|
80
|
+
* address in one service is the same mistake as two aggregates sharing a slug.
|
|
81
|
+
* A message with no name is worse than no message at all: the name is what an
|
|
82
|
+
* event's wire is compared against, and a blank one matches everything.
|
|
83
|
+
*/
|
|
84
|
+
function validateChannels(service: Service): void {
|
|
85
|
+
const addresses = new Set<string>();
|
|
86
|
+
|
|
87
|
+
for (const channel of service.channels ?? []) {
|
|
88
|
+
if (typeof channel.address !== "string" || channel.address === "") {
|
|
89
|
+
fail(
|
|
90
|
+
`service "${service.id}" declares a channel with no address; the address is what a channel is`,
|
|
91
|
+
`service ${service.id}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (addresses.has(channel.address)) {
|
|
95
|
+
fail(
|
|
96
|
+
`service "${service.id}" declares channel "${channel.address}" twice; one channel says both directions`,
|
|
97
|
+
`service ${service.id}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
addresses.add(channel.address);
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
channel.kind !== undefined &&
|
|
104
|
+
channel.kind !== "event" &&
|
|
105
|
+
channel.kind !== "job" &&
|
|
106
|
+
channel.kind !== "message"
|
|
107
|
+
) {
|
|
108
|
+
fail(
|
|
109
|
+
`channel "${channel.address}" of service "${service.id}" has kind "${channel.kind}", which is neither event, job, nor message`,
|
|
110
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
for (const message of channel.messages) {
|
|
116
|
+
if (typeof message.name !== "string" || message.name === "") {
|
|
117
|
+
fail(
|
|
118
|
+
`channel "${channel.address}" of service "${service.id}" carries a message with no name; the name is what a subscriber dispatches on`,
|
|
119
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (message.direction !== "send" && message.direction !== "receive") {
|
|
123
|
+
fail(
|
|
124
|
+
`message "${message.name}" on channel "${channel.address}" travels "${message.direction}", which is neither send nor receive`,
|
|
125
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (message.encoding !== undefined && (typeof message.encoding !== "string" || message.encoding === "")) {
|
|
129
|
+
fail(
|
|
130
|
+
`message "${message.name}" on channel "${channel.address}" has an empty encoding`,
|
|
131
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (message.contentType !== undefined && (typeof message.contentType !== "string" || message.contentType === "")) {
|
|
135
|
+
fail(
|
|
136
|
+
`message "${message.name}" on channel "${channel.address}" has an empty content type`,
|
|
137
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const key = `${message.direction} ${message.name}`;
|
|
141
|
+
if (seen.has(key)) {
|
|
142
|
+
fail(
|
|
143
|
+
`channel "${channel.address}" of service "${service.id}" declares "${message.name}" twice in the same direction`,
|
|
144
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
seen.add(key);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function validateCatalog(catalog: Catalog): Catalog {
|
|
153
|
+
if (!catalog.generatedAt) fail("catalog.generatedAt is missing", "catalog");
|
|
154
|
+
if (!catalog.commit) fail("catalog.commit is missing", "catalog");
|
|
155
|
+
|
|
156
|
+
const eventIds = new Set<string>();
|
|
157
|
+
const rpcIds = new Set<string>();
|
|
158
|
+
const providedRpcRefs = new Set(
|
|
159
|
+
allExternals(catalog).flatMap((external) =>
|
|
160
|
+
external.provides.flatMap((provided) =>
|
|
161
|
+
provided.methods.map(
|
|
162
|
+
(method) => `${external.id}|${provided.id}/${method.name}`,
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
168
|
+
|
|
169
|
+
assertUniqueSlugs(
|
|
170
|
+
catalog.contexts.map((c) => c.id),
|
|
171
|
+
"catalog",
|
|
172
|
+
"context",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
for (const context of catalog.contexts) {
|
|
176
|
+
// A context is a root, so it has nothing to be a slug relative to: id and
|
|
177
|
+
// slug are the same string, and holding them equal here keeps every route
|
|
178
|
+
// built from `context.id` addressing the same thing the slug names.
|
|
179
|
+
if (context.slug !== context.id) {
|
|
180
|
+
fail(
|
|
181
|
+
`context "${context.id}" has slug "${context.slug}"; a context sits at the root, so its slug must equal its id`,
|
|
182
|
+
`context ${context.id}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (context.kind !== undefined && !GROUP_KINDS.includes(context.kind)) {
|
|
186
|
+
fail(
|
|
187
|
+
`context "${context.id}" has kind "${context.kind}"; expected one of ${GROUP_KINDS.join(", ")}`,
|
|
188
|
+
`context ${context.id}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
context.classification !== undefined &&
|
|
193
|
+
!CLASSIFICATIONS.includes(context.classification)
|
|
194
|
+
) {
|
|
195
|
+
fail(
|
|
196
|
+
`context "${context.id}" has classification "${context.classification}"; expected one of ${CLASSIFICATIONS.join(", ")}`,
|
|
197
|
+
`context ${context.id}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
assertUniqueSlugs(
|
|
201
|
+
context.services.map((s) => s.slug),
|
|
202
|
+
`context "${context.id}"`,
|
|
203
|
+
"service",
|
|
204
|
+
);
|
|
205
|
+
for (const service of context.services) {
|
|
206
|
+
if (service.id !== `${context.id}.${service.slug}`) {
|
|
207
|
+
fail(
|
|
208
|
+
`service "${service.id}" in context "${context.id}" must have id "${context.id}.${service.slug}"`,
|
|
209
|
+
`service ${service.id}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
service.kind !== undefined &&
|
|
214
|
+
!COMPONENT_KINDS.includes(service.kind)
|
|
215
|
+
) {
|
|
216
|
+
fail(
|
|
217
|
+
`service "${service.id}" has kind "${service.kind}"; expected one of ${COMPONENT_KINDS.join(", ")}`,
|
|
218
|
+
`service ${service.id}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
// Owners are opaque - the estate's business is who to ask, not what a
|
|
222
|
+
// handle resolves to - so only the two things that would render as a
|
|
223
|
+
// hole are checked: a blank chip, and one name shown twice.
|
|
224
|
+
const handles = new Set<string>();
|
|
225
|
+
for (const handle of service.owners ?? []) {
|
|
226
|
+
if (!handle.trim()) {
|
|
227
|
+
fail(
|
|
228
|
+
`service "${service.id}" has an owner with no name`,
|
|
229
|
+
`service ${service.id}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (handles.has(handle)) {
|
|
233
|
+
fail(
|
|
234
|
+
`service "${service.id}" names owner "${handle}" twice`,
|
|
235
|
+
`service ${service.id}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
handles.add(handle);
|
|
239
|
+
}
|
|
240
|
+
const technologies = new Set<string>();
|
|
241
|
+
for (const technology of service.technologies ?? []) {
|
|
242
|
+
if (!technology.trim()) {
|
|
243
|
+
fail(
|
|
244
|
+
`service "${service.id}" has a technology with no name`,
|
|
245
|
+
`service ${service.id}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (technologies.has(technology)) {
|
|
249
|
+
fail(
|
|
250
|
+
`service "${service.id}" names technology "${technology}" twice`,
|
|
251
|
+
`service ${service.id}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
technologies.add(technology);
|
|
255
|
+
}
|
|
256
|
+
for (const call of service.consumes) rpcIds.add(call.id);
|
|
257
|
+
for (const provided of service.provides) {
|
|
258
|
+
for (const method of provided.methods) {
|
|
259
|
+
providedRpcRefs.add(
|
|
260
|
+
`${service.id}|${provided.id}/${method.name}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const provided of [
|
|
265
|
+
...service.provides,
|
|
266
|
+
...(service.copies ?? []),
|
|
267
|
+
]) {
|
|
268
|
+
// A duplicate method name is not a cosmetic problem: `exposedBy` names
|
|
269
|
+
// a method by name alone, and `rpcProviderByMethod` is keyed by it, so
|
|
270
|
+
// two methods called the same thing make one of them unreachable.
|
|
271
|
+
assertUniqueSlugs(
|
|
272
|
+
provided.methods.map((method) => method.name),
|
|
273
|
+
`interface "${provided.id}"`,
|
|
274
|
+
"method",
|
|
275
|
+
);
|
|
276
|
+
for (const method of provided.methods) {
|
|
277
|
+
if (
|
|
278
|
+
method.streaming !== undefined &&
|
|
279
|
+
!STREAMING.includes(method.streaming)
|
|
280
|
+
) {
|
|
281
|
+
fail(
|
|
282
|
+
`method "${provided.id}/${method.name}" streams "${method.streaming}"; expected one of ${STREAMING.join(", ")}`,
|
|
283
|
+
`service ${service.id}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
method.soap?.version !== undefined &&
|
|
288
|
+
method.soap.version !== "1.1" &&
|
|
289
|
+
method.soap.version !== "1.2"
|
|
290
|
+
) {
|
|
291
|
+
fail(
|
|
292
|
+
`method "${provided.id}/${method.name}" uses SOAP ${method.soap.version}; expected 1.1 or 1.2`,
|
|
293
|
+
`service ${service.id}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const messageNames = new Set(
|
|
298
|
+
(provided.messages ?? []).map((message) => message.name),
|
|
299
|
+
);
|
|
300
|
+
for (const message of provided.messages ?? []) {
|
|
301
|
+
if (message.discriminator !== undefined) {
|
|
302
|
+
const discriminator = message.discriminator;
|
|
303
|
+
if (discriminator.property === "") {
|
|
304
|
+
fail(
|
|
305
|
+
`rpc message "${provided.id}.${message.name}" has an empty discriminator property`,
|
|
306
|
+
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const values = new Set<string>();
|
|
310
|
+
for (const variant of discriminator.variants) {
|
|
311
|
+
if (variant.value === "" || variant.message === "") {
|
|
312
|
+
fail(
|
|
313
|
+
`rpc message "${provided.id}.${message.name}" has an incomplete discriminator variant`,
|
|
314
|
+
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (values.has(variant.value)) {
|
|
318
|
+
fail(
|
|
319
|
+
`rpc message "${provided.id}.${message.name}" maps discriminator value "${variant.value}" more than once`,
|
|
320
|
+
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
values.add(variant.value);
|
|
324
|
+
if (!messageNames.has(variant.message)) {
|
|
325
|
+
fail(
|
|
326
|
+
`rpc message "${provided.id}.${message.name}" discriminator references unknown message "${variant.message}"`,
|
|
327
|
+
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const field of message.fields) {
|
|
333
|
+
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
334
|
+
fail(
|
|
335
|
+
`field "${field.name}" of rpc message "${provided.id}.${message.name}" references unknown def "${field.ref}"`,
|
|
336
|
+
`service ${service.id} / rpc ${provided.id}.${message.name} / field ${field.name}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Every method this service answers on, whichever interface declares it.
|
|
344
|
+
// An operation says which of them expose it, and a name that matches none
|
|
345
|
+
// of them is a link into nothing.
|
|
346
|
+
const methods = new Set(
|
|
347
|
+
service.provides.flatMap((provided) =>
|
|
348
|
+
provided.methods.map((method) => method.name),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
validateChannels(service);
|
|
353
|
+
|
|
354
|
+
assertUniqueSlugs(
|
|
355
|
+
service.aggregates.map((a) => a.slug),
|
|
356
|
+
`service "${service.id}"`,
|
|
357
|
+
"aggregate",
|
|
358
|
+
);
|
|
359
|
+
for (const aggregate of service.aggregates) {
|
|
360
|
+
for (const operation of aggregate.operations) {
|
|
361
|
+
for (const method of operation.exposedBy ?? []) {
|
|
362
|
+
if (!methods.has(method)) {
|
|
363
|
+
fail(
|
|
364
|
+
`operation "${operation.id}" of aggregate "${aggregate.id}" says it is exposed by "${method}", which no interface of service "${service.id}" declares`,
|
|
365
|
+
`aggregate ${aggregate.id} / operation ${operation.id}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
validateBlocks(catalog, aggregate);
|
|
371
|
+
assertUniqueSlugs(
|
|
372
|
+
aggregate.events.map((e) => e.slug),
|
|
373
|
+
`aggregate "${aggregate.id}"`,
|
|
374
|
+
"event",
|
|
375
|
+
);
|
|
376
|
+
for (const event of aggregate.events) {
|
|
377
|
+
if (event.versions.length === 0) {
|
|
378
|
+
fail(
|
|
379
|
+
`event "${event.id}" has no versions; at least one is required`,
|
|
380
|
+
`event ${event.id}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
eventIds.add(event.id);
|
|
384
|
+
if (event.wire !== undefined) {
|
|
385
|
+
if (typeof event.wire.name !== "string" || event.wire.name === "") {
|
|
386
|
+
fail(
|
|
387
|
+
`event "${event.id}" has a wire with no name; the name on the message is what a wire is`,
|
|
388
|
+
`event ${event.id}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (event.wire.channel !== undefined && event.wire.channel === "") {
|
|
392
|
+
fail(
|
|
393
|
+
`event "${event.id}" names an empty channel; leave it out when the source does not say`,
|
|
394
|
+
`event ${event.id}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const version of event.versions) {
|
|
399
|
+
for (const field of version.fields) {
|
|
400
|
+
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
401
|
+
fail(
|
|
402
|
+
`field "${field.name}" of ${event.id}@${version.version} references unknown def "${field.ref}"`,
|
|
403
|
+
`event ${event.id}@${version.version} / field ${field.name}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const [defId, def] of Object.entries(catalog.defs)) {
|
|
414
|
+
for (const field of def.fields) {
|
|
415
|
+
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
416
|
+
fail(
|
|
417
|
+
`field "${field.name}" of def "${defId}" references unknown def "${field.ref}"`,
|
|
418
|
+
`def ${defId} / field ${field.name}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
assertUniqueSlugs(
|
|
425
|
+
catalog.flows.map((f) => f.slug),
|
|
426
|
+
"catalog",
|
|
427
|
+
"flow",
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const flowGroupIds = new Set(catalog.contexts.map((c) => c.id));
|
|
431
|
+
|
|
432
|
+
const triggerKinds = new Set<FlowTrigger["kind"]>([
|
|
433
|
+
"http",
|
|
434
|
+
"callback",
|
|
435
|
+
"event",
|
|
436
|
+
"message",
|
|
437
|
+
"job",
|
|
438
|
+
"startup",
|
|
439
|
+
"scheduled",
|
|
440
|
+
"manual",
|
|
441
|
+
"unproven",
|
|
442
|
+
]);
|
|
443
|
+
const triggerConfidence = new Set<FlowTrigger["confidence"]>([
|
|
444
|
+
"high",
|
|
445
|
+
"medium",
|
|
446
|
+
"low",
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
for (const flow of catalog.flows) {
|
|
450
|
+
const lanes = new Set(flow.participants.map((p) => p.id));
|
|
451
|
+
if (lanes.size !== flow.participants.length) {
|
|
452
|
+
fail(
|
|
453
|
+
`flow "${flow.slug}" has duplicate participant ids`,
|
|
454
|
+
`flow ${flow.id}`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
// Whatever derived the flow knew which service's tree it was reading, so
|
|
458
|
+
// there is no case where the owner is unknowable. Without it the flow has
|
|
459
|
+
// no group to sit under and the tree files it as a defect.
|
|
460
|
+
if (flow.owner === undefined) {
|
|
461
|
+
fail(
|
|
462
|
+
`flow "${flow.slug}" names no owner; a flow must state the group it belongs to`,
|
|
463
|
+
`flow ${flow.id}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (flow.owner !== undefined && !flowGroupIds.has(flow.owner)) {
|
|
467
|
+
fail(
|
|
468
|
+
`flow "${flow.slug}" names owner "${flow.owner}", which is not a top-level group`,
|
|
469
|
+
`flow ${flow.id}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (flow.trigger && !triggerKinds.has(flow.trigger.kind)) {
|
|
473
|
+
fail(
|
|
474
|
+
`flow "${flow.slug}" has unknown trigger kind "${flow.trigger.kind}"`,
|
|
475
|
+
`flow ${flow.id}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (flow.trigger && !triggerConfidence.has(flow.trigger.confidence)) {
|
|
479
|
+
fail(
|
|
480
|
+
`flow "${flow.slug}" has unknown trigger confidence "${flow.trigger.confidence}"`,
|
|
481
|
+
`flow ${flow.id}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (flow.includes) {
|
|
485
|
+
const included = new Set<string>();
|
|
486
|
+
for (const slug of flow.includes) {
|
|
487
|
+
if (!slug || slug === flow.slug || included.has(slug)) {
|
|
488
|
+
fail(
|
|
489
|
+
`flow "${flow.slug}" has an invalid or duplicate included flow "${slug}"`,
|
|
490
|
+
`flow ${flow.id}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
included.add(slug);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
validateFlowFrames(flow, flow.steps);
|
|
497
|
+
|
|
498
|
+
const steps = walkSteps(flow.steps);
|
|
499
|
+
const stepIds = new Set<string>();
|
|
500
|
+
const stepById = new Map<string, Step>();
|
|
501
|
+
for (const step of steps) {
|
|
502
|
+
if (stepIds.has(step.id)) {
|
|
503
|
+
fail(
|
|
504
|
+
`flow "${flow.slug}" has duplicate step id "${step.id}"`,
|
|
505
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
stepIds.add(step.id);
|
|
509
|
+
stepById.set(step.id, step);
|
|
510
|
+
|
|
511
|
+
if (
|
|
512
|
+
!(["rpc", "event", "call", "response"] as const).includes(step.kind)
|
|
513
|
+
) {
|
|
514
|
+
fail(
|
|
515
|
+
`flow "${flow.slug}" step "${step.id}" has unknown kind "${step.kind}"`,
|
|
516
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (step.reaches?.some((entrypoint) => entrypoint.length === 0)) {
|
|
521
|
+
fail(
|
|
522
|
+
`flow "${flow.slug}" step "${step.id}" has an empty reached source function`,
|
|
523
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (step.handoff) {
|
|
527
|
+
if (!(["message", "job"] as const).includes(step.handoff.kind)) {
|
|
528
|
+
fail(
|
|
529
|
+
`flow "${flow.slug}" step "${step.id}" has unknown handoff kind "${step.handoff.kind}"`,
|
|
530
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (!step.handoff.transport) {
|
|
534
|
+
fail(
|
|
535
|
+
`flow "${flow.slug}" step "${step.id}" has a handoff with no transport`,
|
|
536
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (!step.handoff.channel) {
|
|
540
|
+
fail(
|
|
541
|
+
`flow "${flow.slug}" step "${step.id}" has a handoff with no channel`,
|
|
542
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
if (step.handoff.kind === "job" && !step.handoff.message) {
|
|
546
|
+
fail(
|
|
547
|
+
`flow "${flow.slug}" step "${step.id}" has a job handoff with no message`,
|
|
548
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (!(["send", "receive"] as const).includes(step.handoff.direction)) {
|
|
552
|
+
fail(
|
|
553
|
+
`flow "${flow.slug}" step "${step.id}" has unknown handoff direction "${step.handoff.direction}"`,
|
|
554
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (step.storeAccess) {
|
|
559
|
+
if (step.kind !== "call") {
|
|
560
|
+
fail(
|
|
561
|
+
`flow "${flow.slug}" step "${step.id}" has store access metadata but is not a call`,
|
|
562
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (!storeIds.has(step.storeAccess.store)) {
|
|
566
|
+
fail(
|
|
567
|
+
`flow "${flow.slug}" step "${step.id}" names unknown store "${step.storeAccess.store}"`,
|
|
568
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (
|
|
572
|
+
step.storeAccess.operation !== undefined &&
|
|
573
|
+
!REDIS_OPERATIONS.includes(step.storeAccess.operation)
|
|
574
|
+
) {
|
|
575
|
+
fail(
|
|
576
|
+
`flow "${flow.slug}" step "${step.id}" has unknown store operation "${step.storeAccess.operation}"`,
|
|
577
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (step.http) {
|
|
582
|
+
if (step.kind !== "response") {
|
|
583
|
+
fail(
|
|
584
|
+
`flow "${flow.slug}" step "${step.id}" has HTTP response metadata but is not a response`,
|
|
585
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (
|
|
589
|
+
step.http.status !== undefined &&
|
|
590
|
+
(step.http.status < 100 || step.http.status > 599)
|
|
591
|
+
) {
|
|
592
|
+
fail(
|
|
593
|
+
`flow "${flow.slug}" response "${step.id}" has invalid HTTP status ${step.http.status}`,
|
|
594
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (
|
|
598
|
+
step.http.outcome !== undefined &&
|
|
599
|
+
!(["success", "error"] as const).includes(step.http.outcome)
|
|
600
|
+
) {
|
|
601
|
+
fail(
|
|
602
|
+
`flow "${flow.slug}" response "${step.id}" has unknown HTTP outcome "${step.http.outcome}"`,
|
|
603
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!lanes.has(step.from)) {
|
|
609
|
+
fail(
|
|
610
|
+
`flow "${flow.slug}" step "${step.id}": from "${step.from}" is not a declared participant`,
|
|
611
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (!lanes.has(step.to)) {
|
|
615
|
+
fail(
|
|
616
|
+
`flow "${flow.slug}" step "${step.id}": to "${step.to}" is not a declared participant`,
|
|
617
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (step.ref !== undefined && step.status !== "unresolved") {
|
|
621
|
+
const resolves =
|
|
622
|
+
eventIds.has(step.ref) ||
|
|
623
|
+
rpcIds.has(step.ref) ||
|
|
624
|
+
(step.kind === "rpc" &&
|
|
625
|
+
providedRpcRefs.has(`${step.to}|${step.ref}`));
|
|
626
|
+
if (!resolves) {
|
|
627
|
+
fail(
|
|
628
|
+
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
|
|
629
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const step of steps) {
|
|
636
|
+
if (step.kind !== "response") {
|
|
637
|
+
if (step.replyTo !== undefined) {
|
|
638
|
+
fail(
|
|
639
|
+
`flow "${flow.slug}" step "${step.id}" is not a response but names replyTo "${step.replyTo}"`,
|
|
640
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!step.replyTo) {
|
|
646
|
+
fail(
|
|
647
|
+
`flow "${flow.slug}" response "${step.id}" names no request in replyTo`,
|
|
648
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const request = stepById.get(step.replyTo);
|
|
652
|
+
if (!request || request.kind !== "rpc") {
|
|
653
|
+
fail(
|
|
654
|
+
`flow "${flow.slug}" response "${step.id}" replies to "${step.replyTo}", which is not an rpc request`,
|
|
655
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (step.from !== request.to || step.to !== request.from) {
|
|
659
|
+
fail(
|
|
660
|
+
`flow "${flow.slug}" response "${step.id}" does not reverse request "${request.id}"`,
|
|
661
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
validateExternals(catalog);
|
|
668
|
+
validateStores(catalog);
|
|
669
|
+
validateModules(catalog);
|
|
670
|
+
validateAdrs(catalog, eventIds);
|
|
671
|
+
validateTerms(catalog);
|
|
672
|
+
validateRepos(catalog);
|
|
673
|
+
|
|
674
|
+
return catalog;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* A module reference may only point at a module that exists.
|
|
679
|
+
*
|
|
680
|
+
* `deps` are the exception, and deliberately NOT checked. A module's
|
|
681
|
+
* dependencies come from its own lock file and routinely name modules this
|
|
682
|
+
* estate never vendored - the same kind of fact as an `RpcCall` to a peer
|
|
683
|
+
* outside the catalog. Requiring them to resolve would mean a module could only
|
|
684
|
+
* be recorded once everything it transitively depends on had been vendored too,
|
|
685
|
+
* which is a rule about the estate's homework rather than about the catalog
|
|
686
|
+
* being coherent. A dangling dep is shown as a name the catalog does not hold.
|
|
687
|
+
*
|
|
688
|
+
* Also NOT checked: whether a method's `request` names a message the interface
|
|
689
|
+
* actually lists, and whether a module is pinned to a commit. Both are
|
|
690
|
+
* legitimate mid-migration states - a copy vendored before the producer
|
|
691
|
+
* published, a module tracked by label - and refusing to render the catalog
|
|
692
|
+
* over either would be refusing to describe the estate as it is. They belong on
|
|
693
|
+
* the Problems page, which is where the rest of that judgement already lives.
|
|
694
|
+
*/
|
|
695
|
+
function validateModules(catalog: Catalog): void {
|
|
696
|
+
const modules = allModules(catalog);
|
|
697
|
+
const ids = new Set(modules.map((m) => m.id));
|
|
698
|
+
const serviceIds = new Set(allServices(catalog).map((s) => s.id));
|
|
699
|
+
|
|
700
|
+
assertUniqueSlugs(
|
|
701
|
+
modules.map((m) => m.id),
|
|
702
|
+
"catalog",
|
|
703
|
+
"module",
|
|
704
|
+
);
|
|
705
|
+
// Slugs are what the URL uses, so two modules sharing one would put two
|
|
706
|
+
// entities at the same address.
|
|
707
|
+
assertUniqueSlugs(
|
|
708
|
+
modules.map((m) => m.slug),
|
|
709
|
+
"catalog",
|
|
710
|
+
"module slug",
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
for (const module of modules) {
|
|
714
|
+
if (module.owner !== undefined && !serviceIds.has(module.owner)) {
|
|
715
|
+
fail(
|
|
716
|
+
`module "${module.id}" is owned by "${module.owner}", which is not a service in this catalog`,
|
|
717
|
+
`module ${module.id}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const refers = (module: string, where: string, path: string) => {
|
|
723
|
+
if (!ids.has(module)) {
|
|
724
|
+
fail(
|
|
725
|
+
`${where} names module "${module}", which is not in this catalog`,
|
|
726
|
+
path,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
for (const service of allServices(catalog)) {
|
|
732
|
+
for (const module of service.modules ?? []) {
|
|
733
|
+
refers(module, `service "${service.id}"`, `service ${service.id}`);
|
|
734
|
+
}
|
|
735
|
+
for (const provided of service.provides) {
|
|
736
|
+
if (provided.module !== undefined) {
|
|
737
|
+
refers(
|
|
738
|
+
provided.module,
|
|
739
|
+
`interface "${provided.id}"`,
|
|
740
|
+
`service ${service.id}`,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const copy of service.copies ?? []) {
|
|
745
|
+
if (copy.module !== undefined) {
|
|
746
|
+
refers(
|
|
747
|
+
copy.module,
|
|
748
|
+
`vendored interface "${copy.id}"`,
|
|
749
|
+
`service ${service.id}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const call of service.consumes) {
|
|
754
|
+
if (call.module !== undefined) {
|
|
755
|
+
refers(call.module, `call "${call.id}"`, `service ${service.id}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* An external sits at the root beside the contexts, so it is held to the same
|
|
763
|
+
* shape: id equal to slug, no dot, and a name nothing else at the root uses.
|
|
764
|
+
* The last rule is the one that matters - a flow lane, a call's `peer` and a
|
|
765
|
+
* LikeC4 node all address the root by a bare id, and an external called
|
|
766
|
+
* `shop` beside a context called `shop` would land every arrow on the wrong one.
|
|
767
|
+
*
|
|
768
|
+
* What is NOT checked: whether any service calls it. An external nobody calls
|
|
769
|
+
* is a copy vendored ahead of the adapter, which is a legitimate mid-migration
|
|
770
|
+
* state and shows on its page as "called by nobody" rather than failing the
|
|
771
|
+
* build.
|
|
772
|
+
*/
|
|
773
|
+
function validateExternals(catalog: Catalog): void {
|
|
774
|
+
const externals = allExternals(catalog);
|
|
775
|
+
if (externals.length === 0) return;
|
|
776
|
+
|
|
777
|
+
assertUniqueSlugs(
|
|
778
|
+
externals.map((e) => e.id),
|
|
779
|
+
"catalog",
|
|
780
|
+
"external",
|
|
781
|
+
);
|
|
782
|
+
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
783
|
+
|
|
784
|
+
for (const external of externals) {
|
|
785
|
+
if (external.slug !== external.id) {
|
|
786
|
+
fail(
|
|
787
|
+
`external "${external.id}" has slug "${external.slug}"; an external sits at the root, so its slug must equal its id`,
|
|
788
|
+
`external ${external.id}`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (external.id.includes(".")) {
|
|
792
|
+
fail(
|
|
793
|
+
`external "${external.id}" has a dot in its id; an external sits at the root and is addressed by a bare name`,
|
|
794
|
+
`external ${external.id}`,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (contextIds.has(external.id)) {
|
|
798
|
+
fail(
|
|
799
|
+
`external "${external.id}" has the id of a bounded context; the root cannot hold both`,
|
|
800
|
+
`external ${external.id}`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
assertUniqueSlugs(
|
|
804
|
+
external.provides.map((p) => p.id),
|
|
805
|
+
`external "${external.id}"`,
|
|
806
|
+
"interface",
|
|
807
|
+
);
|
|
808
|
+
for (const provided of external.provides) {
|
|
809
|
+
assertUniqueSlugs(
|
|
810
|
+
provided.methods.map((method) => method.name),
|
|
811
|
+
`interface "${provided.id}"`,
|
|
812
|
+
"method",
|
|
813
|
+
);
|
|
814
|
+
for (const method of provided.methods) {
|
|
815
|
+
if (
|
|
816
|
+
method.soap?.version !== undefined &&
|
|
817
|
+
method.soap.version !== "1.1" &&
|
|
818
|
+
method.soap.version !== "1.2"
|
|
819
|
+
) {
|
|
820
|
+
fail(
|
|
821
|
+
`method "${provided.id}/${method.name}" uses SOAP ${method.soap.version}; expected 1.1 or 1.2`,
|
|
822
|
+
`external ${external.id}`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* A schema may only point at things that exist. A foreign key into a table
|
|
832
|
+
* nobody declared, or a `persists` naming an aggregate that is not in the
|
|
833
|
+
* catalog, would draw an edge into open water on a canvas whose whole job is
|
|
834
|
+
* to show where the edges land — so both fail the build.
|
|
835
|
+
*
|
|
836
|
+
* What is NOT checked here: whether an outbox actually carries a payload, and
|
|
837
|
+
* whether a table's columns still match the aggregate it claims to persist.
|
|
838
|
+
* Those are judgements about a model that is allowed to be mid-migration, and
|
|
839
|
+
* they are reported on the Problems page as warnings rather than refusing to
|
|
840
|
+
* render the catalog at all.
|
|
841
|
+
*/
|
|
842
|
+
function validateStores(catalog: Catalog): void {
|
|
843
|
+
const stores = allStores(catalog);
|
|
844
|
+
if (stores.length === 0) return;
|
|
845
|
+
|
|
846
|
+
const services = new Map(allServices(catalog).map((s) => [s.id, s]));
|
|
847
|
+
const serviceIds = new Set(services.keys());
|
|
848
|
+
const aggregates = new Map(allAggregates(catalog).map((a) => [a.id, a]));
|
|
849
|
+
|
|
850
|
+
assertUniqueSlugs(
|
|
851
|
+
stores.map((s) => s.id),
|
|
852
|
+
"catalog",
|
|
853
|
+
"store",
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
// Every table id first: a foreign key may point forwards, at a table in a
|
|
857
|
+
// store declared later in the file.
|
|
858
|
+
const columnsOfTable = new Map<string, Set<string>>();
|
|
859
|
+
for (const store of stores) {
|
|
860
|
+
for (const table of store.tables) {
|
|
861
|
+
if (columnsOfTable.has(table.id)) {
|
|
862
|
+
fail(`table id "${table.id}" is not unique`, `store ${store.id}`);
|
|
863
|
+
}
|
|
864
|
+
columnsOfTable.set(table.id, new Set(table.columns.map((c) => c.name)));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Views join the same namespace: a database will not let a view and a table
|
|
869
|
+
// share a name, and lineage points at both, so one map answers "does this id
|
|
870
|
+
// exist, and does it have that column" for either.
|
|
871
|
+
const columnsOfRelation = new Map(columnsOfTable);
|
|
872
|
+
for (const store of stores) {
|
|
873
|
+
for (const view of storeViews(store)) {
|
|
874
|
+
if (columnsOfRelation.has(view.id)) {
|
|
875
|
+
fail(
|
|
876
|
+
`view id "${view.id}" collides with another table or view`,
|
|
877
|
+
`store ${store.id}`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
columnsOfRelation.set(view.id, new Set(view.columns.map((c) => c.name)));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** A column reference — "<relation id>.<column>" — that has to resolve. */
|
|
885
|
+
const checkColumnRef = (ref: string, where: string, what: string): void => {
|
|
886
|
+
const relation = relationOfColumnId(ref);
|
|
887
|
+
const columns = columnsOfRelation.get(relation);
|
|
888
|
+
if (!columns) {
|
|
889
|
+
fail(
|
|
890
|
+
`${what} names "${ref}", and "${relation}" is not a table or view in the catalog`,
|
|
891
|
+
where,
|
|
892
|
+
);
|
|
893
|
+
} else if (!columns.has(columnNameOfId(ref))) {
|
|
894
|
+
fail(
|
|
895
|
+
`${what} names "${ref}", and "${relation}" has no column "${columnNameOfId(ref)}"`,
|
|
896
|
+
where,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
for (const store of stores) {
|
|
902
|
+
if (store.id !== `${store.owner}.${store.slug}`) {
|
|
903
|
+
fail(
|
|
904
|
+
`store "${store.id}" is owned by "${store.owner}", so its id must be "${store.owner}.${store.slug}"`,
|
|
905
|
+
`store ${store.id}`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
if (!serviceIds.has(store.owner)) {
|
|
909
|
+
fail(
|
|
910
|
+
`store "${store.id}" is owned by "${store.owner}", which is not a service in the catalog`,
|
|
911
|
+
`store ${store.id}`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
// A store is drawn inside the service that owns it, beside that service's
|
|
915
|
+
// aggregates, so the two share one namespace in the architecture model. A
|
|
916
|
+
// store whose slug is also an aggregate's would be one box standing for
|
|
917
|
+
// two things, and the id it is clicked by would answer with whichever was
|
|
918
|
+
// registered first.
|
|
919
|
+
if (
|
|
920
|
+
services.get(store.owner)?.aggregates.some((a) => a.slug === store.slug)
|
|
921
|
+
) {
|
|
922
|
+
fail(
|
|
923
|
+
`store "${store.id}" has the slug of an aggregate of "${store.owner}"`,
|
|
924
|
+
`store ${store.id}`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
if (!STORE_KINDS.includes(store.kind)) {
|
|
928
|
+
fail(
|
|
929
|
+
`store "${store.id}" has kind "${store.kind}"; expected one of ${STORE_KINDS.join(", ")}`,
|
|
930
|
+
`store ${store.id}`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const keyPatterns = new Set<string>();
|
|
935
|
+
for (const keyspace of store.keyspaces ?? []) {
|
|
936
|
+
const where = `store ${store.id} / Redis key ${keyspace.pattern}`;
|
|
937
|
+
if (store.kind !== "redis") {
|
|
938
|
+
fail(
|
|
939
|
+
`store "${store.id}" declares Redis key patterns but has kind "${store.kind}"`,
|
|
940
|
+
where,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
if (!keyspace.pattern) {
|
|
944
|
+
fail(`store "${store.id}" has an empty Redis key pattern`, where);
|
|
945
|
+
}
|
|
946
|
+
if (keyPatterns.has(keyspace.pattern)) {
|
|
947
|
+
fail(
|
|
948
|
+
`store "${store.id}" repeats Redis key pattern "${keyspace.pattern}"`,
|
|
949
|
+
where,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
keyPatterns.add(keyspace.pattern);
|
|
953
|
+
if (keyspace.operations.length === 0) {
|
|
954
|
+
fail(
|
|
955
|
+
`Redis key pattern "${keyspace.pattern}" has no operations`,
|
|
956
|
+
where,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
const operations = new Set<string>();
|
|
960
|
+
for (const operation of keyspace.operations) {
|
|
961
|
+
if (!REDIS_OPERATIONS.includes(operation)) {
|
|
962
|
+
fail(
|
|
963
|
+
`Redis key pattern "${keyspace.pattern}" has operation "${operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
|
|
964
|
+
where,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
if (operations.has(operation)) {
|
|
968
|
+
fail(
|
|
969
|
+
`Redis key pattern "${keyspace.pattern}" repeats operation "${operation}"`,
|
|
970
|
+
where,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
operations.add(operation);
|
|
974
|
+
}
|
|
975
|
+
const aggregateId = keyspace.persists?.aggregate;
|
|
976
|
+
if (aggregateId && !aggregates.has(aggregateId)) {
|
|
977
|
+
fail(
|
|
978
|
+
`Redis key pattern "${keyspace.pattern}" persists unknown aggregate "${aggregateId}"`,
|
|
979
|
+
where,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const blockId = keyspace.persists?.block;
|
|
983
|
+
if (blockId) {
|
|
984
|
+
const blockAggregate =
|
|
985
|
+
aggregates.get(blockId.split(".").slice(0, -1).join("."));
|
|
986
|
+
const belongs = blockAggregate
|
|
987
|
+
? aggregateBlocks(blockAggregate).some(({ block }) => block.id === blockId)
|
|
988
|
+
: false;
|
|
989
|
+
if (!belongs) {
|
|
990
|
+
fail(
|
|
991
|
+
`Redis key pattern "${keyspace.pattern}" persists unknown block "${blockId}"`,
|
|
992
|
+
where,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
for (const access of keyspace.accesses ?? []) {
|
|
997
|
+
if (!REDIS_OPERATIONS.includes(access.operation)) {
|
|
998
|
+
fail(
|
|
999
|
+
`Redis key pattern "${keyspace.pattern}" has access operation "${access.operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
|
|
1000
|
+
where,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
if (!operations.has(access.operation)) {
|
|
1004
|
+
fail(
|
|
1005
|
+
`Redis key pattern "${keyspace.pattern}" has ${access.operation} access absent from its operations summary`,
|
|
1006
|
+
where,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
for (const table of store.tables) {
|
|
1013
|
+
const where = `store ${store.id} / table ${table.name}`;
|
|
1014
|
+
if (table.id !== `${store.id}.${table.name}`) {
|
|
1015
|
+
fail(
|
|
1016
|
+
`table "${table.id}" in store "${store.id}" must have id "${store.id}.${table.name}"`,
|
|
1017
|
+
where,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
if (table.role !== undefined && !TABLE_ROLES.includes(table.role)) {
|
|
1021
|
+
fail(
|
|
1022
|
+
`table "${table.id}" has role "${table.role}"; expected one of ${TABLE_ROLES.join(", ")}`,
|
|
1023
|
+
where,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
for (const access of table.accesses ?? []) {
|
|
1027
|
+
if (!TABLE_OPERATIONS.includes(access.operation)) {
|
|
1028
|
+
fail(
|
|
1029
|
+
`table "${table.id}" has access operation "${access.operation}"; expected one of ${TABLE_OPERATIONS.join(", ")}`,
|
|
1030
|
+
where,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const own = columnsOfTable.get(table.id) ?? new Set<string>();
|
|
1036
|
+
if (own.size !== table.columns.length) {
|
|
1037
|
+
fail(`table "${table.id}" has duplicate column names`, where);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const aggregateId = table.persists?.aggregate;
|
|
1041
|
+
const aggregate = aggregateId ? aggregates.get(aggregateId) : undefined;
|
|
1042
|
+
if (aggregateId && !aggregate) {
|
|
1043
|
+
fail(
|
|
1044
|
+
`table "${table.id}" persists unknown aggregate "${aggregateId}"`,
|
|
1045
|
+
where,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const blockId = table.persists?.block;
|
|
1049
|
+
if (blockId) {
|
|
1050
|
+
// A block is named "<aggregate id>.<slug>", so a block belonging to
|
|
1051
|
+
// another aggregate than the one the table persists is a contradiction
|
|
1052
|
+
// the id itself spells out.
|
|
1053
|
+
const owner =
|
|
1054
|
+
aggregate ??
|
|
1055
|
+
aggregates.get(blockId.split(".").slice(0, -1).join("."));
|
|
1056
|
+
const found = owner
|
|
1057
|
+
? aggregateBlocks(owner).some((b) => b.block.id === blockId)
|
|
1058
|
+
: false;
|
|
1059
|
+
if (!found) {
|
|
1060
|
+
fail(
|
|
1061
|
+
`table "${table.id}" persists block "${blockId}", which is not a block of ${aggregateId ? `aggregate "${aggregateId}"` : "any aggregate in the catalog"}`,
|
|
1062
|
+
where,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
for (const index of table.indexes ?? []) {
|
|
1068
|
+
for (const column of index.columns) {
|
|
1069
|
+
if (!own.has(column)) {
|
|
1070
|
+
fail(
|
|
1071
|
+
`index "${index.name}" on table "${table.id}" names column "${column}", which the table does not have`,
|
|
1072
|
+
where,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (const column of table.columns) {
|
|
1079
|
+
for (const ref of column.from ?? []) {
|
|
1080
|
+
const self = `${table.id}.${column.name}`;
|
|
1081
|
+
if (ref === self) {
|
|
1082
|
+
fail(
|
|
1083
|
+
`column "${self}" is declared as derived from itself`,
|
|
1084
|
+
`${where} / column ${column.name}`,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
checkColumnRef(
|
|
1088
|
+
ref,
|
|
1089
|
+
`${where} / column ${column.name}`,
|
|
1090
|
+
`column "${column.name}" of table "${table.id}" is derived from a column that`,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
if (!column.fk) continue;
|
|
1094
|
+
const target = columnsOfTable.get(column.fk.table);
|
|
1095
|
+
if (!target) {
|
|
1096
|
+
fail(
|
|
1097
|
+
`column "${column.name}" of table "${table.id}" has a foreign key into "${column.fk.table}", which is not a table in the catalog`,
|
|
1098
|
+
`${where} / column ${column.name}`,
|
|
1099
|
+
);
|
|
1100
|
+
} else if (!target.has(column.fk.column)) {
|
|
1101
|
+
fail(
|
|
1102
|
+
`column "${column.name}" of table "${table.id}" has a foreign key into "${column.fk.table}.${column.fk.column}", and that table has no such column`,
|
|
1103
|
+
`${where} / column ${column.name}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
for (const view of storeViews(store)) {
|
|
1110
|
+
const where = `store ${store.id} / view ${view.name}`;
|
|
1111
|
+
if (view.id !== `${store.id}.${view.name}`) {
|
|
1112
|
+
fail(
|
|
1113
|
+
`view "${view.id}" in store "${store.id}" must have id "${store.id}.${view.name}"`,
|
|
1114
|
+
where,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const own = columnsOfRelation.get(view.id) ?? new Set<string>();
|
|
1119
|
+
if (own.size !== view.columns.length) {
|
|
1120
|
+
fail(`view "${view.id}" has duplicate column names`, where);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const aggregateId = view.persists?.aggregate;
|
|
1124
|
+
if (aggregateId && !aggregates.has(aggregateId)) {
|
|
1125
|
+
fail(
|
|
1126
|
+
`view "${view.id}" presents unknown aggregate "${aggregateId}"`,
|
|
1127
|
+
where,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
for (const readId of view.reads ?? []) {
|
|
1132
|
+
if (readId === view.id) {
|
|
1133
|
+
fail(`view "${view.id}" is declared as reading itself`, where);
|
|
1134
|
+
}
|
|
1135
|
+
if (!columnsOfRelation.has(readId)) {
|
|
1136
|
+
fail(
|
|
1137
|
+
`view "${view.id}" reads "${readId}", which is not a table or view in the catalog`,
|
|
1138
|
+
where,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
for (const column of view.columns) {
|
|
1144
|
+
const at = `${where} / column ${column.name}`;
|
|
1145
|
+
// A view has no rows of its own, so it has no key of its own either.
|
|
1146
|
+
// Saying otherwise would put a key glyph on a card that cannot enforce
|
|
1147
|
+
// one, which is the sort of small lie a schema browser exists to stop.
|
|
1148
|
+
if (column.pk) {
|
|
1149
|
+
fail(
|
|
1150
|
+
`column "${column.name}" of view "${view.id}" is marked as a primary key; a view has no key of its own`,
|
|
1151
|
+
at,
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (column.fk) {
|
|
1155
|
+
fail(
|
|
1156
|
+
`column "${column.name}" of view "${view.id}" declares a foreign key; a view states what it reads through lineage, not through constraints`,
|
|
1157
|
+
at,
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
for (const ref of column.from ?? []) {
|
|
1161
|
+
if (ref === `${view.id}.${column.name}`) {
|
|
1162
|
+
fail(
|
|
1163
|
+
`column "${view.id}.${column.name}" is declared as derived from itself`,
|
|
1164
|
+
at,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
checkColumnRef(
|
|
1168
|
+
ref,
|
|
1169
|
+
at,
|
|
1170
|
+
`column "${column.name}" of view "${view.id}" is derived from a column that`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const storeIds = new Set(stores.map((s) => s.id));
|
|
1178
|
+
for (const service of allServices(catalog)) {
|
|
1179
|
+
for (const storeId of service.stores ?? []) {
|
|
1180
|
+
if (!storeIds.has(storeId)) {
|
|
1181
|
+
fail(
|
|
1182
|
+
`service "${service.id}" lists unknown store "${storeId}"`,
|
|
1183
|
+
`service ${service.id}`,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Frames have to mean what they say. An alt with one branch is not a choice, an
|
|
1192
|
+
* untitled branch states no condition, and steps written after an alt whose
|
|
1193
|
+
* every branch is terminal can never run — each of those would be drawn as a
|
|
1194
|
+
* perfectly ordinary sequence, which is exactly the reading we are trying to
|
|
1195
|
+
* stop, so they fail the build instead.
|
|
1196
|
+
*/
|
|
1197
|
+
function validateFlowFrames(flow: Flow, nodes: FlowNode[]): void {
|
|
1198
|
+
nodes.forEach((node, i) => {
|
|
1199
|
+
switch (node.type) {
|
|
1200
|
+
case "step":
|
|
1201
|
+
break;
|
|
1202
|
+
case "parallel":
|
|
1203
|
+
for (const branch of node.branches) validateFlowFrames(flow, branch);
|
|
1204
|
+
break;
|
|
1205
|
+
case "loop":
|
|
1206
|
+
if (!node.title) {
|
|
1207
|
+
fail(
|
|
1208
|
+
`flow "${flow.slug}" loop "${node.id}" has no title, so the diagram cannot say what it repeats until`,
|
|
1209
|
+
`flow ${flow.id} / loop ${node.id}`,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
validateFlowFrames(flow, node.steps);
|
|
1213
|
+
break;
|
|
1214
|
+
case "alt": {
|
|
1215
|
+
if (node.branches.length < 2) {
|
|
1216
|
+
fail(
|
|
1217
|
+
`flow "${flow.slug}" alt "${node.id}" has ${node.branches.length} branch(es); an alt states a choice and needs at least two`,
|
|
1218
|
+
`flow ${flow.id} / alt ${node.id}`,
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
const titles = new Set<string>();
|
|
1222
|
+
for (const branch of node.branches) {
|
|
1223
|
+
if (!branch.title) {
|
|
1224
|
+
fail(
|
|
1225
|
+
`flow "${flow.slug}" alt "${node.id}" has a branch with no title, so nothing says when it runs`,
|
|
1226
|
+
`flow ${flow.id} / alt ${node.id}`,
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
if (titles.has(branch.title)) {
|
|
1230
|
+
fail(
|
|
1231
|
+
`flow "${flow.slug}" alt "${node.id}" has two branches titled "${branch.title}"`,
|
|
1232
|
+
`flow ${flow.id} / alt ${node.id}`,
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
titles.add(branch.title);
|
|
1236
|
+
validateFlowFrames(flow, branch.steps);
|
|
1237
|
+
}
|
|
1238
|
+
if (node.branches.every((b) => b.terminal) && i < nodes.length - 1) {
|
|
1239
|
+
fail(
|
|
1240
|
+
`flow "${flow.slug}" alt "${node.id}": every branch is terminal, so the ${nodes.length - 1 - i} node(s) after it can never run`,
|
|
1241
|
+
`flow ${flow.id} / alt ${node.id}`,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* An aggregate is a root entity plus the entities and value objects it owns.
|
|
1252
|
+
* The root has to be one of those entities: an aggregate that names a root it
|
|
1253
|
+
* does not list is a modelling mistake, not a rendering one, and the tree would
|
|
1254
|
+
* quietly print a line pointing at nothing.
|
|
1255
|
+
*/
|
|
1256
|
+
/**
|
|
1257
|
+
* An enum is a set: its slug is unique among the aggregate's enums, its id
|
|
1258
|
+
* is spelled from the aggregate's, and its values are named once each and
|
|
1259
|
+
* are at least one. An empty enum is not a fact about a closed set, it is a
|
|
1260
|
+
* reader that found the type and none of its members.
|
|
1261
|
+
*/
|
|
1262
|
+
function validateEnums(aggregate: Aggregate): void {
|
|
1263
|
+
if (aggregate.enums !== undefined && !Array.isArray(aggregate.enums)) {
|
|
1264
|
+
fail(
|
|
1265
|
+
`aggregate "${aggregate.id}" has an enums list that is not a list`,
|
|
1266
|
+
`aggregate ${aggregate.id}`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
const enums = enumsOf(aggregate);
|
|
1270
|
+
assertUniqueSlugs(
|
|
1271
|
+
enums.map((e) => e.slug),
|
|
1272
|
+
`aggregate "${aggregate.id}"`,
|
|
1273
|
+
"enum",
|
|
1274
|
+
);
|
|
1275
|
+
for (const item of enums) {
|
|
1276
|
+
const where = `aggregate ${aggregate.id} / enum ${item.slug}`;
|
|
1277
|
+
if (item.id !== `${aggregate.id}.${item.slug}`) {
|
|
1278
|
+
fail(
|
|
1279
|
+
`enum "${item.id}" in aggregate "${aggregate.id}" must have id "${aggregate.id}.${item.slug}"`,
|
|
1280
|
+
where,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
if (!Array.isArray(item.values) || item.values.length === 0) {
|
|
1284
|
+
fail(`enum "${item.id}" has no values`, where);
|
|
1285
|
+
}
|
|
1286
|
+
const seen = new Set<string>();
|
|
1287
|
+
for (const value of item.values) {
|
|
1288
|
+
if (!value.name) {
|
|
1289
|
+
fail(`enum "${item.id}" has a value with no name`, where);
|
|
1290
|
+
}
|
|
1291
|
+
if (seen.has(value.name)) {
|
|
1292
|
+
fail(`enum "${item.id}" lists value "${value.name}" twice`, where);
|
|
1293
|
+
}
|
|
1294
|
+
seen.add(value.name);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
|
|
1300
|
+
for (const [what, list] of [
|
|
1301
|
+
["entities", aggregate.entities],
|
|
1302
|
+
["valueObjects", aggregate.valueObjects],
|
|
1303
|
+
] as const) {
|
|
1304
|
+
if (!Array.isArray(list)) {
|
|
1305
|
+
fail(
|
|
1306
|
+
`aggregate "${aggregate.id}" is missing its ${what} list`,
|
|
1307
|
+
`aggregate ${aggregate.id}`,
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
assertUniqueSlugs(
|
|
1313
|
+
aggregate.entities.map((e) => e.slug),
|
|
1314
|
+
`aggregate "${aggregate.id}"`,
|
|
1315
|
+
"entity",
|
|
1316
|
+
);
|
|
1317
|
+
assertUniqueSlugs(
|
|
1318
|
+
aggregate.valueObjects.map((v) => v.slug),
|
|
1319
|
+
`aggregate "${aggregate.id}"`,
|
|
1320
|
+
"value object",
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
for (const { kind, block } of aggregateBlocks(aggregate)) {
|
|
1324
|
+
const what = kind === "vo" ? "value object" : "entity";
|
|
1325
|
+
if (block.id !== `${aggregate.id}.${block.slug}`) {
|
|
1326
|
+
fail(
|
|
1327
|
+
`${what} "${block.id}" in aggregate "${aggregate.id}" must have id "${aggregate.id}.${block.slug}"`,
|
|
1328
|
+
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
if (block.ref !== undefined && !(block.ref in catalog.defs)) {
|
|
1332
|
+
fail(
|
|
1333
|
+
`${what} "${block.id}" references unknown def "${block.ref}"`,
|
|
1334
|
+
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
if (block.ref === undefined && (block.fields ?? []).length === 0) {
|
|
1338
|
+
fail(
|
|
1339
|
+
`${what} "${block.id}" has neither a def ref nor any fields of its own`,
|
|
1340
|
+
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
for (const field of block.fields ?? []) {
|
|
1344
|
+
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
1345
|
+
fail(
|
|
1346
|
+
`field "${field.name}" of ${what} "${block.id}" references unknown def "${field.ref}"`,
|
|
1347
|
+
`aggregate ${aggregate.id} / ${what} ${block.slug} / field ${field.name}`,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
validateEnums(aggregate);
|
|
1354
|
+
|
|
1355
|
+
if (!aggregate.root) {
|
|
1356
|
+
fail(
|
|
1357
|
+
`aggregate "${aggregate.id}" names no root entity`,
|
|
1358
|
+
`aggregate ${aggregate.id}`,
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
if (!rootEntity(aggregate)) {
|
|
1362
|
+
fail(
|
|
1363
|
+
`aggregate "${aggregate.id}" names root "${aggregate.root}", which is not one of its entities`,
|
|
1364
|
+
`aggregate ${aggregate.id}`,
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
if (aggregate.lifecycle) validateLifecycle(aggregate, aggregate.lifecycle);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* A lifecycle names only states it lists and events the aggregate owns. A
|
|
1372
|
+
* transition into a state nobody listed is a typo that would draw a box the
|
|
1373
|
+
* code never reaches; a transition emitting an event of another aggregate is
|
|
1374
|
+
* a claim the aggregate's own page could not follow.
|
|
1375
|
+
*/
|
|
1376
|
+
function validateLifecycle(aggregate: Aggregate, lifecycle: Lifecycle): void {
|
|
1377
|
+
const where = `aggregate ${aggregate.id} / lifecycle`;
|
|
1378
|
+
if (lifecycle.states.length === 0) {
|
|
1379
|
+
fail(`aggregate "${aggregate.id}" has a lifecycle with no states`, where);
|
|
1380
|
+
}
|
|
1381
|
+
const states = new Set<string>();
|
|
1382
|
+
for (const state of lifecycle.states) {
|
|
1383
|
+
if (states.has(state)) {
|
|
1384
|
+
fail(`aggregate "${aggregate.id}" lists state "${state}" twice`, where);
|
|
1385
|
+
}
|
|
1386
|
+
states.add(state);
|
|
1387
|
+
}
|
|
1388
|
+
const events = new Set(aggregate.events.map((e) => e.id));
|
|
1389
|
+
for (const t of lifecycle.transitions) {
|
|
1390
|
+
for (const end of [t.from, t.to]) {
|
|
1391
|
+
if (!states.has(end)) {
|
|
1392
|
+
fail(
|
|
1393
|
+
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} on ${t.on}, and "${end}" is not one of its states`,
|
|
1394
|
+
where,
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (!t.on) {
|
|
1399
|
+
fail(
|
|
1400
|
+
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} on nothing`,
|
|
1401
|
+
where,
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
if (t.emits !== undefined && !events.has(t.emits)) {
|
|
1405
|
+
fail(
|
|
1406
|
+
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} emitting "${t.emits}", which is not one of its events`,
|
|
1407
|
+
where,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* A term belongs to a context that exists, and its id says which one.
|
|
1415
|
+
*
|
|
1416
|
+
* The composition check is the one that earns its place. A glossary sits
|
|
1417
|
+
* beside a SERVICE and the words in it belong to a context, so the extractor
|
|
1418
|
+
* has to be told which - and told nothing, it falls back to the directory's
|
|
1419
|
+
* name. Left that way, `examples/shop/oms/GLOSSARY.md` produces `oms.order`
|
|
1420
|
+
* in a context called `oms` that nothing else in the estate has heard of, and
|
|
1421
|
+
* every one of its terms is a word the reader can never find from the page
|
|
1422
|
+
* that uses it. Failing here names the step; the alternative is a vocabulary
|
|
1423
|
+
* that loads and answers nothing.
|
|
1424
|
+
*/
|
|
1425
|
+
function validateTerms(catalog: Catalog): void {
|
|
1426
|
+
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
1427
|
+
const ids = new Set<string>();
|
|
1428
|
+
|
|
1429
|
+
for (const term of allTerms(catalog)) {
|
|
1430
|
+
const where = `term ${term.id}`;
|
|
1431
|
+
if (term.id !== `${term.context}.${term.slug}`) {
|
|
1432
|
+
fail(
|
|
1433
|
+
`term "${term.id}" must have id "${term.context}.${term.slug}"`,
|
|
1434
|
+
where,
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
if (ids.has(term.id)) fail(`term id "${term.id}" is not unique`, where);
|
|
1438
|
+
ids.add(term.id);
|
|
1439
|
+
if (!term.name) fail(`term "${term.id}" has no name`, where);
|
|
1440
|
+
if (!term.definition) {
|
|
1441
|
+
fail(`term "${term.id}" says nothing about what it is`, where);
|
|
1442
|
+
}
|
|
1443
|
+
if (!contextIds.has(term.context)) {
|
|
1444
|
+
fail(
|
|
1445
|
+
`term "${term.id}" belongs to context "${term.context}", which the catalog does not declare`,
|
|
1446
|
+
where,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* A decision record may only point at things that exist. A dangling relates
|
|
1454
|
+
* entry or a half-written supersession would let the UI draw a link to
|
|
1455
|
+
* nowhere, so both fail the build instead.
|
|
1456
|
+
*/
|
|
1457
|
+
function validateAdrs(catalog: Catalog, eventIds: Set<string>): void {
|
|
1458
|
+
if (!Array.isArray(catalog.adrs)) fail("catalog.adrs is missing", "catalog");
|
|
1459
|
+
|
|
1460
|
+
const serviceIds = new Set(allServices(catalog).map((s) => s.id));
|
|
1461
|
+
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
1462
|
+
const flowSlugs = new Set(catalog.flows.map((f) => f.slug));
|
|
1463
|
+
|
|
1464
|
+
assertUniqueSlugs(
|
|
1465
|
+
catalog.adrs.map((a) => a.slug),
|
|
1466
|
+
"catalog",
|
|
1467
|
+
"adr",
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
const byId = new Map<string, Adr>();
|
|
1471
|
+
for (const adr of catalog.adrs) {
|
|
1472
|
+
if (byId.has(adr.id))
|
|
1473
|
+
fail(`adr id "${adr.id}" is not unique`, `decision ${adr.id}`);
|
|
1474
|
+
byId.set(adr.id, adr);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
for (const adr of catalog.adrs) {
|
|
1478
|
+
const padded = String(adr.number).padStart(4, "0");
|
|
1479
|
+
if (!adr.id.endsWith(`.${padded}`)) {
|
|
1480
|
+
fail(
|
|
1481
|
+
`adr "${adr.id}" must end with its number, "${padded}"`,
|
|
1482
|
+
`decision ${adr.id}`,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
if (Number.isNaN(new Date(adr.date).getTime())) {
|
|
1486
|
+
fail(
|
|
1487
|
+
`adr "${adr.id}" has an unparseable date "${adr.date}"`,
|
|
1488
|
+
`decision ${adr.id}`,
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
switch (adr.scope.kind) {
|
|
1493
|
+
case "context":
|
|
1494
|
+
if (!contextIds.has(adr.scope.context)) {
|
|
1495
|
+
fail(
|
|
1496
|
+
`adr "${adr.id}" is scoped to unknown context "${adr.scope.context}"`,
|
|
1497
|
+
`decision ${adr.id}`,
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
break;
|
|
1501
|
+
case "service":
|
|
1502
|
+
if (!serviceIds.has(adr.scope.service)) {
|
|
1503
|
+
fail(
|
|
1504
|
+
`adr "${adr.id}" is scoped to unknown service "${adr.scope.service}"`,
|
|
1505
|
+
`decision ${adr.id}`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
break;
|
|
1509
|
+
case "org":
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
for (const serviceId of adr.relates.services ?? []) {
|
|
1514
|
+
if (!serviceIds.has(serviceId)) {
|
|
1515
|
+
fail(
|
|
1516
|
+
`adr "${adr.id}" relates to unknown service "${serviceId}"`,
|
|
1517
|
+
`decision ${adr.id}`,
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
for (const eventId of adr.relates.events ?? []) {
|
|
1522
|
+
if (!eventIds.has(eventId)) {
|
|
1523
|
+
fail(
|
|
1524
|
+
`adr "${adr.id}" relates to unknown event "${eventId}"`,
|
|
1525
|
+
`decision ${adr.id}`,
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
for (const flowSlug of adr.relates.flows ?? []) {
|
|
1530
|
+
if (!flowSlugs.has(flowSlug)) {
|
|
1531
|
+
fail(
|
|
1532
|
+
`adr "${adr.id}" relates to unknown flow "${flowSlug}"`,
|
|
1533
|
+
`decision ${adr.id}`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Supersession is a two-way fact. Recording one half of it is a bug in
|
|
1539
|
+
// whatever wrote the catalog, not a display problem to paper over.
|
|
1540
|
+
if (adr.status === "superseded" && !adr.supersededBy) {
|
|
1541
|
+
fail(
|
|
1542
|
+
`adr "${adr.id}" is superseded but names no supersededBy`,
|
|
1543
|
+
`decision ${adr.id}`,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
if (adr.supersededBy !== undefined) {
|
|
1547
|
+
if (adr.status !== "superseded") {
|
|
1548
|
+
fail(
|
|
1549
|
+
`adr "${adr.id}" names supersededBy "${adr.supersededBy}" but its status is "${adr.status}", not "superseded"`,
|
|
1550
|
+
`decision ${adr.id}`,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
const successor = byId.get(adr.supersededBy);
|
|
1554
|
+
if (!successor) {
|
|
1555
|
+
fail(
|
|
1556
|
+
`adr "${adr.id}" is superseded by unknown adr "${adr.supersededBy}"`,
|
|
1557
|
+
`decision ${adr.id}`,
|
|
1558
|
+
);
|
|
1559
|
+
} else if (!(successor.supersedes ?? []).includes(adr.id)) {
|
|
1560
|
+
fail(
|
|
1561
|
+
`adr "${adr.id}" is superseded by "${successor.id}", but "${successor.id}" does not list it in supersedes`,
|
|
1562
|
+
`decision ${adr.id}`,
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
for (const supersededId of adr.supersedes ?? []) {
|
|
1567
|
+
const predecessor = byId.get(supersededId);
|
|
1568
|
+
if (!predecessor) {
|
|
1569
|
+
fail(
|
|
1570
|
+
`adr "${adr.id}" supersedes unknown adr "${supersededId}"`,
|
|
1571
|
+
`decision ${adr.id}`,
|
|
1572
|
+
);
|
|
1573
|
+
} else if (predecessor.supersededBy !== adr.id) {
|
|
1574
|
+
fail(
|
|
1575
|
+
`adr "${adr.id}" supersedes "${supersededId}", but "${supersededId}" is not marked superseded by it`,
|
|
1576
|
+
`decision ${adr.id}`,
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* A pin names a repository and a commit, and names each repository once.
|
|
1585
|
+
*
|
|
1586
|
+
* Nothing else is checked here, and one omission is deliberate: a pin for a
|
|
1587
|
+
* repository no service claims to live in is NOT an error. The merge unions
|
|
1588
|
+
* sources that do not know each other, and a repository fetched for its protos
|
|
1589
|
+
* before anything reads its code is a normal intermediate state - the pin is
|
|
1590
|
+
* simply never looked up. What would be a real problem is one repository
|
|
1591
|
+
* pinned to two commits, and that is caught in the merge, where both sources
|
|
1592
|
+
* are still known and the reader can be told which file lost.
|
|
1593
|
+
*/
|
|
1594
|
+
function validateRepos(catalog: Catalog): void {
|
|
1595
|
+
const seen = new Set<string>();
|
|
1596
|
+
|
|
1597
|
+
for (const pin of allRepos(catalog)) {
|
|
1598
|
+
const where = `repo ${pin.repo || "?"}`;
|
|
1599
|
+
if (!pin.repo) fail("a repo pin names no repository", where);
|
|
1600
|
+
if (!pin.commit) {
|
|
1601
|
+
fail(
|
|
1602
|
+
`repo "${pin.repo}" is pinned to nothing; a pin without a commit is not a place a link can point at`,
|
|
1603
|
+
where,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
if (seen.has(pin.repo)) {
|
|
1607
|
+
fail(`repo "${pin.repo}" is pinned twice in one catalog`, where);
|
|
1608
|
+
}
|
|
1609
|
+
seen.add(pin.repo);
|
|
1610
|
+
}
|
|
1611
|
+
}
|