@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
package/src/catalog.ts
CHANGED
|
@@ -1,3307 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
// and
|
|
1
|
+
// Stable public surface for the catalog contract. The data model, derived
|
|
2
|
+
// indexes, and validation rules live separately so changing one concern does
|
|
3
|
+
// not make every other concern part of the same file.
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export const STATUSES: readonly Status[] = [
|
|
8
|
-
"verified",
|
|
9
|
-
"declared",
|
|
10
|
-
"unresolved",
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
export interface Catalog {
|
|
14
|
-
generatedAt: string; // ISO 8601
|
|
15
|
-
commit: string; // short sha
|
|
16
|
-
contexts: BoundedContext[];
|
|
17
|
-
defs: Record<string, TypeDef>; // shared type definitions by id
|
|
18
|
-
flows: Flow[];
|
|
19
|
-
adrs: Adr[];
|
|
20
|
-
/**
|
|
21
|
-
* Where the estate keeps its state. Optional in the file and never optional
|
|
22
|
-
* downstream: a catalog written before the extractor learned to read
|
|
23
|
-
* migrations still loads, and every reader sees an empty list rather than an
|
|
24
|
-
* undefined one.
|
|
25
|
-
*/
|
|
26
|
-
stores?: Store[];
|
|
27
|
-
/**
|
|
28
|
-
* The schema modules the estate publishes and vendors. Optional in the file
|
|
29
|
-
* and never optional downstream, exactly like `stores`: a catalog written
|
|
30
|
-
* before anything read a proto still loads.
|
|
31
|
-
*/
|
|
32
|
-
modules?: ProtoModule[];
|
|
33
|
-
/**
|
|
34
|
-
* The vocabulary each context speaks, read out of its `GLOSSARY.md`.
|
|
35
|
-
* Optional in the file and never optional downstream, exactly like `stores`
|
|
36
|
-
* and `modules`: an estate that has written no glossary renders as it did
|
|
37
|
-
* before there was one to read.
|
|
38
|
-
*/
|
|
39
|
-
terms?: Term[];
|
|
40
|
-
/**
|
|
41
|
-
* Where the estate's code was read, for the repositories that are not this
|
|
42
|
-
* one. Optional in the file and never optional downstream, exactly like
|
|
43
|
-
* `stores` and `modules`: an estate whose services all live here has nothing
|
|
44
|
-
* to pin and renders as it did before there was anything to pin.
|
|
45
|
-
*/
|
|
46
|
-
repos?: RepoPin[];
|
|
47
|
-
/**
|
|
48
|
-
* The systems outside the estate that a service calls on a contract: a
|
|
49
|
-
* payment provider, a tax API, a carrier. Nobody here builds one, so it has
|
|
50
|
-
* no context, no aggregates and no repository - only the interfaces it
|
|
51
|
-
* answers on, read from the copy of its document vendored beside the adapter
|
|
52
|
-
* that calls it. Optional in the file and never optional downstream, like
|
|
53
|
-
* `stores`: an estate that calls nobody outside renders as it did before.
|
|
54
|
-
*/
|
|
55
|
-
externals?: External[];
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* A system outside the estate, with a contract.
|
|
59
|
-
*
|
|
60
|
-
* The difference from a service is what the catalog may claim about it: what it
|
|
61
|
-
* answers on, and nothing else. The difference from an `unknown` participant
|
|
62
|
-
* is that the calls land: a step to an external names an operation its
|
|
63
|
-
* document declares, so the arrow is `declared`, not `unresolved` - and the
|
|
64
|
-
* catalog still does not pretend to own the far end.
|
|
65
|
-
*/
|
|
66
|
-
export interface External {
|
|
67
|
-
/** Sits at the root beside the contexts, so its id is its slug and has no dot. */
|
|
68
|
-
id: string;
|
|
69
|
-
slug: string;
|
|
70
|
-
name: string;
|
|
71
|
-
summary: string;
|
|
72
|
-
/** Where the third party documents itself, for a reader who needs more than the copy. */
|
|
73
|
-
url?: string;
|
|
74
|
-
provides: RpcService[];
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* A bounded context: the estate's top grouping level, and nothing more. It owns
|
|
78
|
-
* services; it states no relationships to its neighbours. The map of who talks
|
|
79
|
-
* to whom is already drawn from the calls and events themselves.
|
|
80
|
-
*/
|
|
81
|
-
export interface BoundedContext {
|
|
82
|
-
/** A context sits at the root, so its id is its slug. The validator holds them equal. */
|
|
83
|
-
id: string;
|
|
84
|
-
slug: string;
|
|
85
|
-
name: string;
|
|
86
|
-
summary: string;
|
|
87
|
-
/** Semantic role of this top-level group. Absent preserves the historical bounded-context meaning (portolan.0004). */
|
|
88
|
-
kind?: GroupKind;
|
|
89
|
-
/**
|
|
90
|
-
* How strategically the domain is rated. A badge, and only a badge: it never
|
|
91
|
-
* orders, groups or filters anything. Absent means the estate has not made
|
|
92
|
-
* the call, which renders as nothing at all rather than a default.
|
|
93
|
-
*/
|
|
94
|
-
classification?: Classification;
|
|
95
|
-
/** LikeC4 view to embed on the context page, when the derived `ctx_<id>` is not the one wanted. */
|
|
96
|
-
viewId?: string;
|
|
97
|
-
services: Service[];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export type GroupKind =
|
|
101
|
-
"bounded-context" | "system" | "product" | "team" | "namespace";
|
|
102
|
-
|
|
103
|
-
export const GROUP_KINDS: readonly GroupKind[] = [
|
|
104
|
-
"bounded-context",
|
|
105
|
-
"system",
|
|
106
|
-
"product",
|
|
107
|
-
"team",
|
|
108
|
-
"namespace",
|
|
109
|
-
] as const;
|
|
110
|
-
|
|
111
|
-
export type Classification = "core" | "supporting" | "generic";
|
|
112
|
-
|
|
113
|
-
export const CLASSIFICATIONS: readonly Classification[] = [
|
|
114
|
-
"core",
|
|
115
|
-
"supporting",
|
|
116
|
-
"generic",
|
|
117
|
-
] as const;
|
|
118
|
-
export interface Service {
|
|
119
|
-
id: string; // "<context>.<slug>", e.g. "shop.oms"
|
|
120
|
-
slug: string;
|
|
121
|
-
name: string;
|
|
122
|
-
repo: string;
|
|
123
|
-
path: string;
|
|
124
|
-
readme: string; // markdown
|
|
125
|
-
/** Runtime or code role. Absent preserves the historical service meaning. */
|
|
126
|
-
kind?: ComponentKind;
|
|
127
|
-
/** Technology names discovered from build and deployment manifests. */
|
|
128
|
-
technologies?: string[];
|
|
129
|
-
provides: RpcService[];
|
|
130
|
-
consumes: RpcCall[];
|
|
131
|
-
aggregates: Aggregate[];
|
|
132
|
-
/**
|
|
133
|
-
* Stores this service touches, by id — the ones it owns and the ones it only
|
|
134
|
-
* reads. Ownership is not stated here: a store names its own owner, so a
|
|
135
|
-
* service listing a store it does not own is reading it, and the pages say
|
|
136
|
-
* so rather than guessing.
|
|
137
|
-
*/
|
|
138
|
-
stores?: string[];
|
|
139
|
-
/**
|
|
140
|
-
* Schema modules this service publishes or vendors, by id. Which of the two
|
|
141
|
-
* is not stated here: a module names its own owner, so a module in this list
|
|
142
|
-
* that does not call this service its owner is one the service reads.
|
|
143
|
-
*/
|
|
144
|
-
modules?: string[];
|
|
145
|
-
/**
|
|
146
|
-
* Channels this service declares it publishes on or listens to, read out of
|
|
147
|
-
* an AsyncAPI document. Absent for a service with no such document, which is
|
|
148
|
-
* not the same as a service that speaks to nobody.
|
|
149
|
-
*/
|
|
150
|
-
channels?: Channel[];
|
|
151
|
-
/**
|
|
152
|
-
* Who to ask about it, as CODEOWNERS spells them: `@acme/oms-team`,
|
|
153
|
-
* `@someone`, `dev@acme.io`.
|
|
154
|
-
*
|
|
155
|
-
* Handles, and deliberately nothing more. Resolving one to the people in it
|
|
156
|
-
* is a call to a forge's API, which needs a credential, answers differently
|
|
157
|
-
* tomorrow, and would put the estate's documentation behind an outage. A
|
|
158
|
-
* handle is what the reviewer types and what the file says, so a handle is
|
|
159
|
-
* what the page shows.
|
|
160
|
-
*
|
|
161
|
-
* Absent means nobody was named, which is not the same as nobody owning it -
|
|
162
|
-
* an estate that keeps no CODEOWNERS has an owner for everything and has
|
|
163
|
-
* written it down nowhere.
|
|
164
|
-
*/
|
|
165
|
-
owners?: string[];
|
|
166
|
-
/**
|
|
167
|
-
* What a developer types against the checkout: the make targets, npm
|
|
168
|
-
* scripts, just recipes and task-runner tasks the repository declares. Read
|
|
169
|
-
* from the runner files, never from the README, so the list is the one the
|
|
170
|
-
* runner would accept. Absent when nothing declares any, which is not the
|
|
171
|
-
* same as a service that cannot be built.
|
|
172
|
-
*/
|
|
173
|
-
commands?: Command[];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* One entry of a task runner's file: a make target, an npm script, a just
|
|
178
|
-
* recipe, a Taskfile task, a poe or pdm task.
|
|
179
|
-
*
|
|
180
|
-
* `run` is the whole point - the line a reader copies - and it is spelled
|
|
181
|
-
* here rather than rebuilt from the runner and the name, because `npm test`
|
|
182
|
-
* and `npm run typecheck` are two spellings of one runner and the page should
|
|
183
|
-
* not have to know which scripts npm treats specially.
|
|
184
|
-
*/
|
|
185
|
-
export interface Command {
|
|
186
|
-
/** The tool the line is typed at: make, npm, pnpm, yarn, bun, just, task, poe, pdm. */
|
|
187
|
-
runner: string;
|
|
188
|
-
/** The target, script, recipe or task as its file spells it. */
|
|
189
|
-
name: string;
|
|
190
|
-
/** The line to type at a shell in the service's directory. */
|
|
191
|
-
run: string;
|
|
192
|
-
/**
|
|
193
|
-
* What the file says the command is for, when it says anything: a `##`
|
|
194
|
-
* comment on a make target, a `#` line over a just recipe, a task's `desc`,
|
|
195
|
-
* a poe task's `help`. Most files say nothing.
|
|
196
|
-
*/
|
|
197
|
-
doc?: string;
|
|
198
|
-
/**
|
|
199
|
-
* What the runner executes for it: the recipe, the script line, the cmds.
|
|
200
|
-
* Carried so a name that says nothing can still be read, and shown folded,
|
|
201
|
-
* because a build script is not a sentence.
|
|
202
|
-
*/
|
|
203
|
-
body?: string;
|
|
204
|
-
/** The file and line the entry was read at. */
|
|
205
|
-
source?: string;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export type ComponentKind =
|
|
209
|
-
| "service"
|
|
210
|
-
| "application"
|
|
211
|
-
| "webapp"
|
|
212
|
-
| "worker"
|
|
213
|
-
| "job"
|
|
214
|
-
| "function"
|
|
215
|
-
| "cli"
|
|
216
|
-
| "library"
|
|
217
|
-
| "data-pipeline";
|
|
218
|
-
|
|
219
|
-
export const COMPONENT_KINDS: readonly ComponentKind[] = [
|
|
220
|
-
"service",
|
|
221
|
-
"application",
|
|
222
|
-
"webapp",
|
|
223
|
-
"worker",
|
|
224
|
-
"job",
|
|
225
|
-
"function",
|
|
226
|
-
"cli",
|
|
227
|
-
"library",
|
|
228
|
-
"data-pipeline",
|
|
229
|
-
] as const;
|
|
230
|
-
|
|
231
|
-
/** Neutral vocabulary for consumers that do not assume DDD. */
|
|
232
|
-
export type Group = BoundedContext;
|
|
233
|
-
export type Component = Service;
|
|
234
|
-
export interface RpcService {
|
|
235
|
-
id: string;
|
|
236
|
-
methods: RpcMethod[];
|
|
237
|
-
source: string;
|
|
238
|
-
/**
|
|
239
|
-
* Request and response shapes, when the generator could read them. Optional:
|
|
240
|
-
* a service whose protos were not parsed still lists its methods.
|
|
241
|
-
*/
|
|
242
|
-
messages?: RpcMessage[];
|
|
243
|
-
/**
|
|
244
|
-
* The enums the messages' fields name, reached the way `messages` are:
|
|
245
|
-
* from the methods, through the fields, as far as the document declares.
|
|
246
|
-
*/
|
|
247
|
-
enums?: RpcEnum[];
|
|
248
|
-
/** The schema module declaring this interface, by `ProtoModule.id`. */
|
|
249
|
-
module?: string;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* One method of one interface.
|
|
254
|
-
*
|
|
255
|
-
* A string would have done for the name, and did until protos were read. What
|
|
256
|
-
* a string could not carry is the shapes on either side: an endpoint whose
|
|
257
|
-
* request and response are named is one a reader can follow without opening
|
|
258
|
-
* the source, and a streaming method drawn as a unary call is a lie about how
|
|
259
|
-
* the two ends are coupled.
|
|
260
|
-
*
|
|
261
|
-
* Only `name` is required. An interface read from an OpenAPI document supplies
|
|
262
|
-
* nothing else, and must keep reading the way it always did.
|
|
263
|
-
*
|
|
264
|
-
* There is no id here. `<rpcServiceId>/<name>` is already how the app spells
|
|
265
|
-
* one, everywhere it needs one, and a stored copy would be a second place for
|
|
266
|
-
* it to be wrong.
|
|
267
|
-
*/
|
|
268
|
-
export interface RpcMethod {
|
|
269
|
-
/**
|
|
270
|
-
* The name as the interface declares it - a proto method, an OpenAPI
|
|
271
|
-
* `operationId`. This is what `Operation.exposedBy` names.
|
|
272
|
-
*/
|
|
273
|
-
name: string;
|
|
274
|
-
doc?: string;
|
|
275
|
-
/**
|
|
276
|
-
* The request and response messages, by the name they carry in
|
|
277
|
-
* `RpcService.messages`. `ref` keys `catalog.defs` when the shape is shared -
|
|
278
|
-
* the same pairing, for the same reason, as `Field.type` and `Field.ref`.
|
|
279
|
-
*/
|
|
280
|
-
request?: string;
|
|
281
|
-
requestRef?: string;
|
|
282
|
-
response?: string;
|
|
283
|
-
responseRef?: string;
|
|
284
|
-
/** How the method streams. Absent is unary, which is most of them. */
|
|
285
|
-
streaming?: Streaming;
|
|
286
|
-
deprecated?: boolean;
|
|
287
|
-
/**
|
|
288
|
-
* The route, for a method read from an OpenAPI document: the verb and the
|
|
289
|
-
* path template as the document writes them. This is what lets a request
|
|
290
|
-
* seen on the wire be read back to the operation it ran.
|
|
291
|
-
*/
|
|
292
|
-
http?: HttpRoute;
|
|
293
|
-
/** Concrete SOAP binding read from a WSDL operation. */
|
|
294
|
-
soap?: SoapRoute;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export interface HttpRoute {
|
|
298
|
-
/** Upper case: `POST`. */
|
|
299
|
-
method: string;
|
|
300
|
-
/** As templated in the document: `/v1/users/{id}`. */
|
|
301
|
-
path: string;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export interface SoapRoute {
|
|
305
|
-
action?: string;
|
|
306
|
-
version?: "1.1" | "1.2";
|
|
307
|
-
style?: string;
|
|
308
|
-
endpoint?: string;
|
|
309
|
-
binding?: string;
|
|
310
|
-
faults?: string[];
|
|
311
|
-
headers?: string[];
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export type Streaming = "client" | "server" | "bidi";
|
|
315
|
-
|
|
316
|
-
export const STREAMING: readonly Streaming[] = [
|
|
317
|
-
"client",
|
|
318
|
-
"server",
|
|
319
|
-
"bidi",
|
|
320
|
-
] as const;
|
|
321
|
-
/** An enum a proto declares. The number is what a binary message carries. */
|
|
322
|
-
export interface RpcEnum {
|
|
323
|
-
name: string;
|
|
324
|
-
doc?: string;
|
|
325
|
-
values: RpcEnumValue[];
|
|
326
|
-
}
|
|
327
|
-
export interface RpcEnumValue {
|
|
328
|
-
name: string;
|
|
329
|
-
number: number;
|
|
330
|
-
doc?: string;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export interface RpcMessage {
|
|
334
|
-
name: string; // "PlaceOrderRequest"
|
|
335
|
-
fields: Field[];
|
|
336
|
-
/** How this OpenAPI message selects one concrete variant on the wire. */
|
|
337
|
-
discriminator?: RpcDiscriminator;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export interface RpcDiscriminator {
|
|
341
|
-
/** JSON property carrying the discriminator value. */
|
|
342
|
-
property: string;
|
|
343
|
-
variants: RpcVariant[];
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export interface RpcVariant {
|
|
347
|
-
/** Value carried in `property`. */
|
|
348
|
-
value: string;
|
|
349
|
-
/** Concrete message selected by that value. */
|
|
350
|
-
message: string;
|
|
351
|
-
}
|
|
352
|
-
/**
|
|
353
|
-
* Where a derived edge was read from: the flow step that implies it. A
|
|
354
|
-
* consumer or a call carrying `via` was not declared by any source; it is
|
|
355
|
-
* what a flow already said, written where the graph can read it. It is kept
|
|
356
|
-
* as a field rather than a note because the UI links back to the step.
|
|
357
|
-
*/
|
|
358
|
-
export interface EdgeVia {
|
|
359
|
-
flow: string; // Flow.slug
|
|
360
|
-
step: string; // Step.id
|
|
361
|
-
}
|
|
362
|
-
export interface RpcCall {
|
|
363
|
-
id: string; // "<proto.package.Service>/<Method>"
|
|
364
|
-
peer: string; // service id if resolved, else raw name
|
|
365
|
-
status: Status;
|
|
366
|
-
source: string;
|
|
367
|
-
note?: string;
|
|
368
|
-
/** The module the vendored copy this call was read from belongs to. */
|
|
369
|
-
module?: string;
|
|
370
|
-
/** Set when the call was derived from a flow step rather than declared. */
|
|
371
|
-
via?: EdgeVia;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* A schema module: a set of .proto files with a name, a version and a
|
|
376
|
-
* publisher - `buf.build/acme/shop`.
|
|
377
|
-
*
|
|
378
|
-
* It sits at the top level rather than inside the service that publishes it,
|
|
379
|
-
* because the interesting fact about a module is usually who ELSE reads it.
|
|
380
|
-
*
|
|
381
|
-
* Its id is the module's own registry-global name and NOT `<owner>.<slug>` the
|
|
382
|
-
* way a store's is. A store is declared by exactly one source - the service
|
|
383
|
-
* that owns it - so deriving its id from its owner is safe. A module is
|
|
384
|
-
* declared by several sources that do not know each other: the producer's
|
|
385
|
-
* extractor knows which service publishes it, and the consumer's extractor,
|
|
386
|
-
* reading a vendored copy in another repository, knows only the module name.
|
|
387
|
-
* Since the merge unions top-level entities BY ID, an owner-derived id would
|
|
388
|
-
* grow one module per consumer.
|
|
389
|
-
*
|
|
390
|
-
* What it carries is identity and inventory, not schema. The interfaces are
|
|
391
|
-
* found through `RpcService.module` and the shapes live in `RpcService.messages`
|
|
392
|
-
* and `catalog.defs`, in one place rather than two that can disagree.
|
|
393
|
-
*/
|
|
394
|
-
export interface ProtoModule {
|
|
395
|
-
/** "buf.build/acme/shop", or "local:proto/shop" for a set never published. */
|
|
396
|
-
id: string;
|
|
397
|
-
/** Unique across the catalog, and what the URL uses: "acme-shop". */
|
|
398
|
-
slug: string;
|
|
399
|
-
name: string; // "acme/shop"
|
|
400
|
-
/** "buf.build". Absent when the module was never published to one. */
|
|
401
|
-
registry?: string;
|
|
402
|
-
/**
|
|
403
|
-
* The service that publishes it, by id, when the estate knows.
|
|
404
|
-
*
|
|
405
|
-
* Optional on purpose, and the first entity where "nobody here owns this" is
|
|
406
|
-
* an honest answer rather than a defect: a module published by a team, or by
|
|
407
|
-
* a repository outside the estate, is the ordinary case.
|
|
408
|
-
*/
|
|
409
|
-
owner?: string;
|
|
410
|
-
/** The commit this catalog was built from. */
|
|
411
|
-
commit?: string;
|
|
412
|
-
/** The registry's content digest of that commit - what makes a copy checkable. */
|
|
413
|
-
digest?: string;
|
|
414
|
-
/** Proto packages declared inside it, sorted. */
|
|
415
|
-
packages: string[];
|
|
416
|
-
/** Files, module-relative and sorted. */
|
|
417
|
-
files: string[];
|
|
418
|
-
/** Modules it depends on, by id. */
|
|
419
|
-
deps?: string[];
|
|
420
|
-
/** Where the copy in this repository lives, as a reader would type it. */
|
|
421
|
-
source: string;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* A repository the estate was read at, and the commit it was read at.
|
|
426
|
-
*
|
|
427
|
-
* It exists so a source path can be a link when the code is not in this
|
|
428
|
-
* repository. A service says which repository it lives in; only whoever
|
|
429
|
-
* fetched that repository knows which commit the copy is of, and by the time
|
|
430
|
-
* an extractor runs, that fact is in a lock file no page ever reads.
|
|
431
|
-
*
|
|
432
|
-
* It is a list on the catalog rather than a field on `Service` because the pin
|
|
433
|
-
* is a fact about the estate and not about one service: a repository holding
|
|
434
|
-
* three services is fetched once, at one commit, and writing that commit three
|
|
435
|
-
* times would be three places for it to disagree with itself.
|
|
436
|
-
*/
|
|
437
|
-
export interface RepoPin {
|
|
438
|
-
/** The repository, spelled the way `Service.repo` spells it: "github.com/acme/shop". */
|
|
439
|
-
repo: string;
|
|
440
|
-
/** The commit the copy was made of. Full sha: it is not resolved locally, so there is nothing to expand it against. */
|
|
441
|
-
commit: string;
|
|
442
|
-
}
|
|
443
|
-
export interface Aggregate {
|
|
444
|
-
id: string;
|
|
445
|
-
slug: string;
|
|
446
|
-
name: string;
|
|
447
|
-
readme: string;
|
|
448
|
-
/** Name of the entity that is the aggregate root; must be one of `entities`. */
|
|
449
|
-
root: string;
|
|
450
|
-
entities: Entity[];
|
|
451
|
-
valueObjects: ValueObject[];
|
|
452
|
-
operations: Operation[];
|
|
453
|
-
events: Event[];
|
|
454
|
-
/**
|
|
455
|
-
* The closed sets the aggregate's fields take values from: a reason, a
|
|
456
|
-
* status, a code. Read through `enumsOf`, which answers [] for a source
|
|
457
|
-
* that declared none - the same shape every other optional list here has.
|
|
458
|
-
*/
|
|
459
|
-
enums?: Enum[];
|
|
460
|
-
/**
|
|
461
|
-
* Where the root can go from where it is, when the aggregate has a status
|
|
462
|
-
* and the code writes its transitions down as one table. Absent means the
|
|
463
|
-
* aggregate has no lifecycle worth the name, or the extractor found none.
|
|
464
|
-
*/
|
|
465
|
-
lifecycle?: Lifecycle;
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* A state machine read off the aggregate: the states in the order the code
|
|
469
|
-
* lists them, the first being the one a new root starts in, and every move
|
|
470
|
-
* between them. A state nothing leads out of is terminal; that is derived,
|
|
471
|
-
* never declared.
|
|
472
|
-
*/
|
|
473
|
-
export interface Lifecycle {
|
|
474
|
-
states: string[];
|
|
475
|
-
transitions: Transition[];
|
|
476
|
-
}
|
|
477
|
-
export interface Transition {
|
|
478
|
-
from: string;
|
|
479
|
-
to: string;
|
|
480
|
-
/** The method on the root that makes the move, as written: `checkout`. */
|
|
481
|
-
on: string;
|
|
482
|
-
/** The event the method hands back for it, by id, when it hands one back. */
|
|
483
|
-
emits?: string;
|
|
484
|
-
/** Where the move is made, `file:line`. */
|
|
485
|
-
source?: string;
|
|
486
|
-
}
|
|
487
|
-
export interface Operation {
|
|
488
|
-
id: string;
|
|
489
|
-
kind: "command" | "query";
|
|
490
|
-
doc?: string;
|
|
491
|
-
/** Still callable, but the source says not to: a JSDoc `@deprecated`. */
|
|
492
|
-
deprecated?: boolean;
|
|
493
|
-
/**
|
|
494
|
-
* The interface methods that expose this operation, by the name they carry
|
|
495
|
-
* in `RpcService.methods` - an OpenAPI `operationId`, a proto method.
|
|
496
|
-
*
|
|
497
|
-
* A method rather than a full `<service>/<method>` id, because the two ends
|
|
498
|
-
* are read by different generators out of different files: one reads the
|
|
499
|
-
* handlers and knows which use case an endpoint runs, the other reads the
|
|
500
|
-
* document and knows what the interface is called. Neither can state the
|
|
501
|
-
* other's half, and the pairing resolves once they are merged.
|
|
502
|
-
*
|
|
503
|
-
* Empty is a fact, not an omission: an operation nothing exposes is one the
|
|
504
|
-
* estate can only reach from inside, which is sometimes exactly the point.
|
|
505
|
-
*/
|
|
506
|
-
exposedBy?: string[];
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* A DDD building block held inside an aggregate. Entities have identity and
|
|
511
|
-
* value objects do not, but both are named shapes, so they share a structure
|
|
512
|
-
* and are told apart by the list they sit in.
|
|
513
|
-
*
|
|
514
|
-
* The shape is either NAMED - `ref` points at a shared `catalog.defs` entry, and
|
|
515
|
-
* every other block, event field or RPC message naming that same def is
|
|
516
|
-
* knowably the same type - or INLINE, when the type is local to the aggregate.
|
|
517
|
-
*/
|
|
518
|
-
export interface Block {
|
|
519
|
-
id: string; // "<aggregate id>.<slug>"
|
|
520
|
-
slug: string;
|
|
521
|
-
name: string;
|
|
522
|
-
doc: string;
|
|
523
|
-
/** The shape is on its way out, per a `@deprecated` on its class. */
|
|
524
|
-
deprecated?: boolean;
|
|
525
|
-
ref?: string; // key into catalog.defs
|
|
526
|
-
fields?: Field[]; // inline shape, used when there is no ref
|
|
527
|
-
}
|
|
528
|
-
export type ValueObject = Block;
|
|
529
|
-
export type Entity = Block;
|
|
530
|
-
export type BlockKind = "vo" | "entity";
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* A closed set of values a field can hold. What a consumer switches on: an
|
|
534
|
-
* order hearing `PaymentDeclined` reads `reason` and does one thing for
|
|
535
|
-
* CARD_REFUSED and another for ORDER_CANCELLED, and this is the list it has
|
|
536
|
-
* to handle. Not a Block - it has no fields - and told apart from a status
|
|
537
|
-
* the lifecycle already knows by nothing: the lifecycle keeps the moves, the
|
|
538
|
-
* enum keeps the doc on each value, and a page may draw both.
|
|
539
|
-
*/
|
|
540
|
-
export interface Enum {
|
|
541
|
-
id: string; // "<aggregate id>.<slug>"
|
|
542
|
-
slug: string;
|
|
543
|
-
name: string;
|
|
544
|
-
doc: string;
|
|
545
|
-
/** The whole set is on its way out. */
|
|
546
|
-
deprecated?: boolean;
|
|
547
|
-
values: EnumValue[];
|
|
548
|
-
}
|
|
549
|
-
export interface EnumValue {
|
|
550
|
-
/**
|
|
551
|
-
* What a consumer sees on the wire when the source says so - a Go
|
|
552
|
-
* constant's literal, a Rust `as_str` arm - and the variant's own name
|
|
553
|
-
* otherwise.
|
|
554
|
-
*/
|
|
555
|
-
name: string;
|
|
556
|
-
doc: string;
|
|
557
|
-
deprecated?: boolean;
|
|
558
|
-
}
|
|
559
|
-
export interface Event {
|
|
560
|
-
id: string; // "<service id>.<aggregate>.<Name>"
|
|
561
|
-
slug: string;
|
|
562
|
-
name: string;
|
|
563
|
-
versions: EventVersion[]; // >=1, oldest first
|
|
564
|
-
consumers: EventConsumer[];
|
|
565
|
-
/**
|
|
566
|
-
* How the event leaves the service. Optional because a hand-written catalog
|
|
567
|
-
* may not know, and an extractor only says what the source declares.
|
|
568
|
-
*/
|
|
569
|
-
wire?: EventWire;
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* The event as the bus sees it: its name on the message and the channel it
|
|
573
|
-
* is published on. The two are different facts - one topic carries every
|
|
574
|
-
* event of an aggregate, and a subscriber dispatches on the name - and a
|
|
575
|
-
* trace carries both, as `event.name` and `messaging.destination.name`.
|
|
576
|
-
* This is the one place the catalog and a running system meet by string.
|
|
577
|
-
*/
|
|
578
|
-
export interface EventWire {
|
|
579
|
-
/** "cart.BasketCreated" - the name on the message, as a trace's event.name. */
|
|
580
|
-
name: string;
|
|
581
|
-
/**
|
|
582
|
-
* "cart_basket" - the topic, subject or stream it is published on. Absent
|
|
583
|
-
* when the source names the event but does not say where it goes.
|
|
584
|
-
*/
|
|
585
|
-
channel?: string;
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* A topic, subject or stream a service says it uses, and the messages that
|
|
589
|
-
* travel on it. This is what an AsyncAPI document declares - the async half of
|
|
590
|
-
* what an OpenAPI document says about routes.
|
|
591
|
-
*
|
|
592
|
-
* The catalog knew about channels before this, but only by inference: an event
|
|
593
|
-
* carries a wire, and a channel was whatever the events happened to name. A
|
|
594
|
-
* declaration is a different fact, and it says two things inference could not.
|
|
595
|
-
* What the service means to put on the bus, whether or not an extractor found
|
|
596
|
-
* an event saying so - and what it listens for, which nothing in a publisher's
|
|
597
|
-
* source could ever say.
|
|
598
|
-
*/
|
|
599
|
-
export interface Channel {
|
|
600
|
-
/**
|
|
601
|
-
* "shop.cart.basket" - the channel as the broker knows it. The same string an
|
|
602
|
-
* event's `wire.channel` carries, and comparing the two is how a document and
|
|
603
|
-
* the code beside it are held against each other.
|
|
604
|
-
*/
|
|
605
|
-
address: string;
|
|
606
|
-
/** Domain event by default; jobs are work queues and messages are generic streams. */
|
|
607
|
-
kind?: "event" | "job" | "message";
|
|
608
|
-
title?: string;
|
|
609
|
-
doc?: string;
|
|
610
|
-
messages: ChannelMessage[];
|
|
611
|
-
/** The document this was read out of. */
|
|
612
|
-
source?: string;
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* Which way a message travels, from this service's side.
|
|
616
|
-
*
|
|
617
|
-
* It decides ownership: a service that sends on a channel publishes on it, and
|
|
618
|
-
* a channel has one publisher. A service that only receives is a subscriber,
|
|
619
|
-
* and any number of those is the point of a bus.
|
|
620
|
-
*/
|
|
621
|
-
export type ChannelDirection = "send" | "receive";
|
|
622
|
-
export interface ChannelMessage {
|
|
623
|
-
/** "cart.BasketCreated" - the name on the message, as an event's wire.name. */
|
|
624
|
-
name: string;
|
|
625
|
-
title?: string;
|
|
626
|
-
doc?: string;
|
|
627
|
-
direction: ChannelDirection;
|
|
628
|
-
}
|
|
629
|
-
export interface EventConsumer {
|
|
630
|
-
service: string;
|
|
631
|
-
status: Status;
|
|
632
|
-
note?: string;
|
|
633
|
-
/** Set when the consumer was derived from a flow step rather than declared. */
|
|
634
|
-
via?: EdgeVia;
|
|
635
|
-
}
|
|
636
|
-
export interface EventVersion {
|
|
637
|
-
version: string;
|
|
638
|
-
doc: string;
|
|
639
|
-
/** This version is superseded, per a `@deprecated` on the class that carries it. */
|
|
640
|
-
deprecated?: boolean;
|
|
641
|
-
source: string;
|
|
642
|
-
fields: Field[];
|
|
643
|
-
}
|
|
644
|
-
export interface Field {
|
|
645
|
-
name: string;
|
|
646
|
-
type: string;
|
|
647
|
-
doc: string;
|
|
648
|
-
/** Still on the wire, but not to be written or read anew: a `@deprecated` on the field. */
|
|
649
|
-
deprecated?: boolean;
|
|
650
|
-
ref?: string;
|
|
651
|
-
} // ref -> defs key
|
|
652
|
-
export interface TypeDef {
|
|
653
|
-
fields: Field[];
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// ---------------------------------------------------------------------------
|
|
657
|
-
// Persistence. Where an aggregate actually lives when nothing is running.
|
|
658
|
-
//
|
|
659
|
-
// This axis is deliberately shallow: a store, its tables, their columns, and
|
|
660
|
-
// the foreign keys between them. It says nothing about how the rows got there.
|
|
661
|
-
// What it does say — through `persists` and `maps` — is which domain object a
|
|
662
|
-
// table holds and which domain field a column carries, which is the only
|
|
663
|
-
// question that makes a schema readable next to a model rather than beside it.
|
|
664
|
-
// ---------------------------------------------------------------------------
|
|
665
|
-
|
|
666
|
-
export type StoreKind =
|
|
667
|
-
| "postgres"
|
|
668
|
-
| "mysql"
|
|
669
|
-
| "sqlite"
|
|
670
|
-
| "redis"
|
|
671
|
-
| "mongodb"
|
|
672
|
-
| "clickhouse"
|
|
673
|
-
| "s3"
|
|
674
|
-
| "other";
|
|
675
|
-
|
|
676
|
-
export const STORE_KINDS: readonly StoreKind[] = [
|
|
677
|
-
"postgres",
|
|
678
|
-
"mysql",
|
|
679
|
-
"sqlite",
|
|
680
|
-
"redis",
|
|
681
|
-
"mongodb",
|
|
682
|
-
"clickhouse",
|
|
683
|
-
"s3",
|
|
684
|
-
"other",
|
|
685
|
-
] as const;
|
|
686
|
-
|
|
687
|
-
export interface Store {
|
|
688
|
-
id: string; // "shop.oms.pg"
|
|
689
|
-
slug: string;
|
|
690
|
-
name: string;
|
|
691
|
-
kind: StoreKind;
|
|
692
|
-
/** Service id. Exactly one service owns a store; everyone else reads it. */
|
|
693
|
-
owner: string;
|
|
694
|
-
tables: Table[];
|
|
695
|
-
/**
|
|
696
|
-
* Views declared over those tables. Optional in the file for the same reason
|
|
697
|
-
* `stores` is: a catalog written before the extractor learned to read
|
|
698
|
-
* `CREATE VIEW` still loads, and every reader sees an empty list.
|
|
699
|
-
*/
|
|
700
|
-
views?: View[];
|
|
701
|
-
/** Redis key families proved by client calls. Dynamic parts use `{name}`. */
|
|
702
|
-
keyspaces?: RedisKeyspace[];
|
|
703
|
-
/** Migrations directory or config path, as a reader would open it. */
|
|
704
|
-
source?: string;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
export type RedisOperation =
|
|
708
|
-
"read" | "write" | "delete" | "exists" | "expire" | "count";
|
|
709
|
-
|
|
710
|
-
export const REDIS_OPERATIONS: readonly RedisOperation[] = [
|
|
711
|
-
"read",
|
|
712
|
-
"write",
|
|
713
|
-
"delete",
|
|
714
|
-
"exists",
|
|
715
|
-
"expire",
|
|
716
|
-
"count",
|
|
717
|
-
] as const;
|
|
718
|
-
|
|
719
|
-
/** A source-backed family of Redis keys, not a relational table. */
|
|
720
|
-
export interface RedisKeyspace {
|
|
721
|
-
pattern: string;
|
|
722
|
-
operations: RedisOperation[];
|
|
723
|
-
/** Source spelling of a fixed, configured or caller-provided expiry. */
|
|
724
|
-
ttl?: string;
|
|
725
|
-
/** Value type where a write or marshal call proves it. */
|
|
726
|
-
value?: string;
|
|
727
|
-
source?: string;
|
|
728
|
-
/** Aggregate or block whose value this key family holds, when provable. */
|
|
729
|
-
persists?: { aggregate?: string; block?: string };
|
|
730
|
-
/** Individual client calls, before they are folded into `operations`. */
|
|
731
|
-
accesses?: RedisAccess[];
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
export interface RedisAccess {
|
|
735
|
-
operation: RedisOperation;
|
|
736
|
-
/** Enclosing adapter method, for example `Store.Get`. */
|
|
737
|
-
method?: string;
|
|
738
|
-
ttl?: string;
|
|
739
|
-
value?: string;
|
|
740
|
-
source?: string;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* What a table is FOR. The role is not decoration: an outbox and a projection
|
|
745
|
-
* are read completely differently from the table that holds the aggregate, and
|
|
746
|
-
* a canvas that draws all three the same way hides the only structural fact a
|
|
747
|
-
* reader came for.
|
|
748
|
-
*/
|
|
749
|
-
export type TableRole =
|
|
750
|
-
"aggregate-root" | "child" | "outbox" | "projection" | "lookup" | "other";
|
|
751
|
-
|
|
752
|
-
export const TABLE_ROLES: readonly TableRole[] = [
|
|
753
|
-
"aggregate-root",
|
|
754
|
-
"child",
|
|
755
|
-
"outbox",
|
|
756
|
-
"projection",
|
|
757
|
-
"lookup",
|
|
758
|
-
"other",
|
|
759
|
-
] as const;
|
|
760
|
-
|
|
761
|
-
export interface Table {
|
|
762
|
-
id: string; // "<store id>.<table>"
|
|
763
|
-
name: string;
|
|
764
|
-
doc?: string;
|
|
765
|
-
columns: Column[];
|
|
766
|
-
indexes?: TableIndex[];
|
|
767
|
-
/** The domain object this table holds: an aggregate id, and optionally a block id. */
|
|
768
|
-
persists?: { aggregate?: string; block?: string };
|
|
769
|
-
role?: TableRole;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
export interface TableIndex {
|
|
773
|
-
name: string;
|
|
774
|
-
columns: string[];
|
|
775
|
-
unique: boolean;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
export interface Column {
|
|
779
|
-
name: string;
|
|
780
|
-
/** The db type as declared — uuid, timestamptz, jsonb — not a normalised one. */
|
|
781
|
-
type: string;
|
|
782
|
-
nullable: boolean;
|
|
783
|
-
pk?: boolean;
|
|
784
|
-
/** `table` is a Table.id, so a foreign key names its target unambiguously. */
|
|
785
|
-
fk?: { table: string; column: string; onDelete?: string };
|
|
786
|
-
/**
|
|
787
|
-
* The columns this one is computed from, as "<table or view id>.<column>".
|
|
788
|
-
*
|
|
789
|
-
* A foreign key says which row this value points AT; lineage says where the
|
|
790
|
-
* value CAME FROM, which is a different question and the only one that can
|
|
791
|
-
* be asked of a view column or of a projection rebuilt from an event. It is
|
|
792
|
-
* declared on the derived end because that is the end that knows: a source
|
|
793
|
-
* table has no idea who reads it.
|
|
794
|
-
*/
|
|
795
|
-
from?: string[];
|
|
796
|
-
/** Domain field path, e.g. "Order.CustomerID". */
|
|
797
|
-
maps?: string;
|
|
798
|
-
doc?: string;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* A view: a query the database has a name for.
|
|
803
|
-
*
|
|
804
|
-
* It is kept apart from Table rather than folded in behind a flag because the
|
|
805
|
-
* two answer different questions. A table is where rows live; a view is a
|
|
806
|
-
* reading of rows that live somewhere else, so it has no primary key, no
|
|
807
|
-
* foreign keys, and no migrations of its own — what it has instead is the list
|
|
808
|
-
* of things it reads, which is the only reason it is on the canvas at all.
|
|
809
|
-
*/
|
|
810
|
-
export interface View {
|
|
811
|
-
id: string; // "<store id>.<view name>"
|
|
812
|
-
name: string;
|
|
813
|
-
doc?: string;
|
|
814
|
-
/**
|
|
815
|
-
* True when the database keeps the rows rather than recomputing them. A
|
|
816
|
-
* matview can be stale, which is the one fact a reader has to have before
|
|
817
|
-
* believing a row, so it is drawn differently rather than noted in prose.
|
|
818
|
-
*/
|
|
819
|
-
materialized?: boolean;
|
|
820
|
-
columns: Column[];
|
|
821
|
-
/**
|
|
822
|
-
* Tables and views this one is defined over, by id. Column lineage already
|
|
823
|
-
* implies most of them; this is what a view whose columns nobody has mapped
|
|
824
|
-
* still says out loud, and it is what the canvas draws when a column-level
|
|
825
|
-
* edge would be a guess.
|
|
826
|
-
*/
|
|
827
|
-
reads?: string[];
|
|
828
|
-
/** The SELECT, as the migration declares it. Shown, never parsed. */
|
|
829
|
-
definition?: string;
|
|
830
|
-
/** The domain object this view presents, when it presents exactly one. */
|
|
831
|
-
persists?: { aggregate?: string; block?: string };
|
|
832
|
-
/** Migration or model file, as a reader would open it. */
|
|
833
|
-
source?: string;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
/**
|
|
837
|
-
* A sequence read out of source.
|
|
838
|
-
*
|
|
839
|
-
* Extractors may attach the execution trigger they proved. Authored flows omit
|
|
840
|
-
* it when that evidence is not part of the document.
|
|
841
|
-
*/
|
|
842
|
-
export interface Flow {
|
|
843
|
-
id: string;
|
|
844
|
-
slug: string;
|
|
845
|
-
name: string;
|
|
846
|
-
summary: string;
|
|
847
|
-
source?: string; // the file the flow was read out of
|
|
848
|
-
/** Source-backed execution root and the strength of that evidence. */
|
|
849
|
-
trigger?: FlowTrigger;
|
|
850
|
-
/** Source function this flow expands, used for evidence-backed composition. */
|
|
851
|
-
entrypoint?: string;
|
|
852
|
-
/** Source-backed flow fragments composed into this root flow. */
|
|
853
|
-
includes?: string[];
|
|
854
|
-
/**
|
|
855
|
-
* The top-level group this flow belongs to. Whatever derived the flow read
|
|
856
|
-
* one component's tree to find it and therefore knows the answer, so the flow
|
|
857
|
-
* states it instead of leaving a reader to recover it from `source` - and the
|
|
858
|
-
* validator holds every flow to it, because a flow with no owner has nowhere
|
|
859
|
-
* to sit in the tree.
|
|
860
|
-
*/
|
|
861
|
-
owner: string;
|
|
862
|
-
participants: Participant[]; // order is significant - it is the lane order
|
|
863
|
-
steps: FlowNode[];
|
|
864
|
-
}
|
|
865
|
-
export interface FlowTrigger {
|
|
866
|
-
kind:
|
|
867
|
-
| "http"
|
|
868
|
-
| "callback"
|
|
869
|
-
| "event"
|
|
870
|
-
| "message"
|
|
871
|
-
| "job"
|
|
872
|
-
| "startup"
|
|
873
|
-
| "scheduled"
|
|
874
|
-
| "manual"
|
|
875
|
-
| "unproven";
|
|
876
|
-
label?: string;
|
|
877
|
-
confidence: "high" | "medium" | "low";
|
|
878
|
-
}
|
|
879
|
-
export interface Participant {
|
|
880
|
-
id: string;
|
|
881
|
-
kind: "actor" | "service" | "broker" | "store" | "external" | "unknown";
|
|
882
|
-
context: string | null; // null for actors and brokers
|
|
883
|
-
label?: string;
|
|
884
|
-
}
|
|
885
|
-
export type FlowNode = Step | Parallel | Alt | Loop;
|
|
886
|
-
export interface Step {
|
|
887
|
-
type: "step";
|
|
888
|
-
id: string;
|
|
889
|
-
from: string;
|
|
890
|
-
to: string; // participant ids; from === to is a self-message
|
|
891
|
-
kind: "rpc" | "event" | "call" | "response";
|
|
892
|
-
ref?: string; // Event.id or RpcCall.id - resolvable, or status must be unresolved
|
|
893
|
-
label?: string;
|
|
894
|
-
status: Status;
|
|
895
|
-
note?: string;
|
|
896
|
-
line?: string;
|
|
897
|
-
/** Synchronous request step this synthesized response returns from. */
|
|
898
|
-
replyTo?: string;
|
|
899
|
-
/** Proven HTTP wire contract for a response step. */
|
|
900
|
-
http?: HTTPResponse;
|
|
901
|
-
/** Source function execution enters here, when an extractor can prove it. */
|
|
902
|
-
continuesAt?: string;
|
|
903
|
-
/** Source functions proven to execute on the path represented by this step. */
|
|
904
|
-
reaches?: string[];
|
|
905
|
-
/** Exact asynchronous send/receive evidence used for flow composition. */
|
|
906
|
-
handoff?: FlowHandoff;
|
|
907
|
-
/** Repository call resolved to a concrete store operation after merge. */
|
|
908
|
-
storeAccess?: FlowStoreAccess;
|
|
909
|
-
}
|
|
910
|
-
export interface HTTPResponse {
|
|
911
|
-
status?: number;
|
|
912
|
-
contentType?: string;
|
|
913
|
-
body?: string;
|
|
914
|
-
/** RPC method whose response value is serialized into this body. */
|
|
915
|
-
bodyRef?: string;
|
|
916
|
-
encoding?: string;
|
|
917
|
-
outcome?: "success" | "error";
|
|
918
|
-
warning?: string;
|
|
919
|
-
source?: string;
|
|
920
|
-
/** Shape recovered directly from a literal response body. */
|
|
921
|
-
fields?: Field[];
|
|
922
|
-
}
|
|
923
|
-
export interface FlowHandoff {
|
|
924
|
-
kind: "message" | "job";
|
|
925
|
-
transport: string;
|
|
926
|
-
channel: string;
|
|
927
|
-
message?: string;
|
|
928
|
-
direction: "send" | "receive";
|
|
929
|
-
}
|
|
930
|
-
export interface FlowStoreAccess {
|
|
931
|
-
store: string;
|
|
932
|
-
method?: string;
|
|
933
|
-
operation?: RedisOperation;
|
|
934
|
-
keyspace?: string;
|
|
935
|
-
/** Concrete adapter call rather than the use-case-side repository call. */
|
|
936
|
-
source?: string;
|
|
937
|
-
}
|
|
938
|
-
export interface Parallel {
|
|
939
|
-
type: "parallel";
|
|
940
|
-
id: string;
|
|
941
|
-
title?: string;
|
|
942
|
-
branches: FlowNode[][];
|
|
943
|
-
}
|
|
944
|
-
/**
|
|
945
|
-
* A choice. Exactly one branch runs, so the branches are not a sequence and
|
|
946
|
-
* nothing that reads a flow may treat them as one.
|
|
947
|
-
*
|
|
948
|
-
* `terminal` marks a branch that ENDS the flow rather than rejoining it — the
|
|
949
|
-
* cancel arm of a risk check, say. Without it a reader has no way to tell that
|
|
950
|
-
* the steps drawn after the alt do not follow that branch, and the sequence
|
|
951
|
-
* reads as "the order was cancelled and then charged".
|
|
952
|
-
*/
|
|
953
|
-
export interface Alt {
|
|
954
|
-
type: "alt";
|
|
955
|
-
id: string;
|
|
956
|
-
branches: AltBranch[];
|
|
957
|
-
}
|
|
958
|
-
export interface AltBranch {
|
|
959
|
-
/** The condition under which this branch runs, in words. */
|
|
960
|
-
title: string;
|
|
961
|
-
steps: FlowNode[];
|
|
962
|
-
/** True when the flow stops here instead of continuing past the alt. */
|
|
963
|
-
terminal?: boolean;
|
|
964
|
-
}
|
|
965
|
-
export interface Loop {
|
|
966
|
-
type: "loop";
|
|
967
|
-
id: string;
|
|
968
|
-
title: string;
|
|
969
|
-
steps: FlowNode[];
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// ---------------------------------------------------------------------------
|
|
973
|
-
// The ubiquitous language. One meaning per word inside a context, written down
|
|
974
|
-
// where the code that uses the word lives, and the leaf everything else points
|
|
975
|
-
// to: a term links nowhere, and nothing here is derived from the model.
|
|
976
|
-
//
|
|
977
|
-
// A word and a sentence, and nothing else. What the sentence says is the
|
|
978
|
-
// author's business - the one thing in this catalog that no extractor could
|
|
979
|
-
// have worked out from the code, and the one thing a parser has no business
|
|
980
|
-
// taking apart.
|
|
981
|
-
// ---------------------------------------------------------------------------
|
|
982
|
-
|
|
983
|
-
export interface Term {
|
|
984
|
-
/** "<context>.<slug>" - auth.session. A word means one thing per context. */
|
|
985
|
-
id: string;
|
|
986
|
-
slug: string;
|
|
987
|
-
/** The context whose vocabulary this is, never the service the file sat in. */
|
|
988
|
-
context: string;
|
|
989
|
-
/** As the glossary spells it, which is how the code spells it: "Email address". */
|
|
990
|
-
name: string;
|
|
991
|
-
/** What it means, as the glossary's own paragraph: markdown, one line. */
|
|
992
|
-
definition: string;
|
|
993
|
-
/** `path:line` of the entry, as everything else in the catalog spells a source. */
|
|
994
|
-
source: string;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// ---------------------------------------------------------------------------
|
|
998
|
-
// Decision records. An ADR is frozen history: it says what was decided and
|
|
999
|
-
// when, not what the model looks like now. Nothing here is regenerated from
|
|
1000
|
-
// the current catalog, and nothing on an ADR page redraws from it.
|
|
1001
|
-
// ---------------------------------------------------------------------------
|
|
1002
|
-
|
|
1003
|
-
export type AdrStatus =
|
|
1004
|
-
"proposed" | "accepted" | "superseded" | "deprecated" | "rejected";
|
|
1005
|
-
|
|
1006
|
-
export type AdrScope =
|
|
1007
|
-
| { kind: "org" }
|
|
1008
|
-
| { kind: "context"; context: string }
|
|
1009
|
-
| { kind: "service"; service: string };
|
|
1010
|
-
|
|
1011
|
-
export interface Adr {
|
|
1012
|
-
id: string; // "shop.oms.0007" - scope prefix plus zero-padded number
|
|
1013
|
-
slug: string;
|
|
1014
|
-
number: number; // 7
|
|
1015
|
-
title: string;
|
|
1016
|
-
status: AdrStatus;
|
|
1017
|
-
date: string; // decision date, ISO
|
|
1018
|
-
scope: AdrScope;
|
|
1019
|
-
body: string; // markdown, MADR structure
|
|
1020
|
-
// Prose about the record that no other field holds - most often that part of
|
|
1021
|
-
// it was decided again elsewhere without the whole of it being superseded.
|
|
1022
|
-
// It sits in the header, above the frozen body, because it is the thing to
|
|
1023
|
-
// read before the decision rather than after it.
|
|
1024
|
-
note?: string;
|
|
1025
|
-
supersededBy?: string; // Adr.id
|
|
1026
|
-
supersedes?: string[];
|
|
1027
|
-
relates: { services?: string[]; events?: string[]; flows?: string[] };
|
|
1028
|
-
source: string; // path to the .md in its repo
|
|
1029
|
-
// What git says about the file: the commit that first added it, and the one
|
|
1030
|
-
// that last touched it when that is a different commit. Absent when the
|
|
1031
|
-
// tree had no history to read.
|
|
1032
|
-
created?: AdrCommit;
|
|
1033
|
-
revised?: AdrCommit;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
export interface AdrCommit {
|
|
1037
|
-
commit: string; // full sha
|
|
1038
|
-
author: string; // author name as git records it
|
|
1039
|
-
date: string; // committer date, ISO
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// ---------------------------------------------------------------------------
|
|
1043
|
-
// Traversal helpers. Everything here is DERIVED from the steps, never stored in
|
|
1044
|
-
// the JSON.
|
|
1045
|
-
//
|
|
1046
|
-
// There is deliberately no flow-level score. How far a flow can be trusted is
|
|
1047
|
-
// said step by step, by each `Step.status`; a ratio over those averaged claims
|
|
1048
|
-
// that are not comparable, hid the only actionable one (`unresolved`), and —
|
|
1049
|
-
// once alt branches are counted — divided by a number no single execution ever
|
|
1050
|
-
// reaches.
|
|
1051
|
-
// ---------------------------------------------------------------------------
|
|
1052
|
-
|
|
1053
|
-
/** Depth-first walk over every Step in a node list, in numbering order. */
|
|
1054
|
-
export function walkSteps(nodes: FlowNode[]): Step[] {
|
|
1055
|
-
const out: Step[] = [];
|
|
1056
|
-
const visit = (list: FlowNode[]): void => {
|
|
1057
|
-
for (const node of list) {
|
|
1058
|
-
switch (node.type) {
|
|
1059
|
-
case "step":
|
|
1060
|
-
out.push(node);
|
|
1061
|
-
break;
|
|
1062
|
-
case "parallel":
|
|
1063
|
-
for (const branch of node.branches) visit(branch);
|
|
1064
|
-
break;
|
|
1065
|
-
case "alt":
|
|
1066
|
-
for (const branch of node.branches) visit(branch.steps);
|
|
1067
|
-
break;
|
|
1068
|
-
case "loop":
|
|
1069
|
-
visit(node.steps);
|
|
1070
|
-
break;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
};
|
|
1074
|
-
visit(nodes);
|
|
1075
|
-
return out;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
/**
|
|
1079
|
-
* One frame enclosing a step: the alt, parallel or loop it sits inside.
|
|
1080
|
-
*
|
|
1081
|
-
* This is what the rail and the detail panel need in order to say *under what
|
|
1082
|
-
* condition* a step runs. Without it a step is just a line in a sequence, and
|
|
1083
|
-
* a reader cannot tell an alternative apart from a consequence.
|
|
1084
|
-
*/
|
|
1085
|
-
export interface StepFrame {
|
|
1086
|
-
kind: "parallel" | "alt" | "loop";
|
|
1087
|
-
/** Id of the Parallel / Alt / Loop node. */
|
|
1088
|
-
id: string;
|
|
1089
|
-
/** Loop or parallel title. An alt carries its condition on the branch. */
|
|
1090
|
-
title?: string;
|
|
1091
|
-
/** Alt: the branch condition. Parallel: the 1-based branch number. */
|
|
1092
|
-
branch?: string;
|
|
1093
|
-
/** Alt only: this branch ends the flow rather than rejoining it. */
|
|
1094
|
-
terminal?: boolean;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/**
|
|
1098
|
-
* The frames around every step, outermost first. Steps not inside any frame
|
|
1099
|
-
* map to an empty list, so callers never have to special-case the flat case.
|
|
1100
|
-
*/
|
|
1101
|
-
export function stepFrames(nodes: FlowNode[]): Map<string, StepFrame[]> {
|
|
1102
|
-
const out = new Map<string, StepFrame[]>();
|
|
1103
|
-
const visit = (list: FlowNode[], stack: StepFrame[]): void => {
|
|
1104
|
-
for (const node of list) {
|
|
1105
|
-
switch (node.type) {
|
|
1106
|
-
case "step":
|
|
1107
|
-
out.set(node.id, stack);
|
|
1108
|
-
break;
|
|
1109
|
-
case "parallel":
|
|
1110
|
-
node.branches.forEach((branch, i) =>
|
|
1111
|
-
visit(branch, [
|
|
1112
|
-
...stack,
|
|
1113
|
-
{
|
|
1114
|
-
kind: "parallel",
|
|
1115
|
-
id: node.id,
|
|
1116
|
-
title: node.title,
|
|
1117
|
-
branch: String(i + 1),
|
|
1118
|
-
},
|
|
1119
|
-
]),
|
|
1120
|
-
);
|
|
1121
|
-
break;
|
|
1122
|
-
case "alt":
|
|
1123
|
-
for (const branch of node.branches) {
|
|
1124
|
-
visit(branch.steps, [
|
|
1125
|
-
...stack,
|
|
1126
|
-
{
|
|
1127
|
-
kind: "alt",
|
|
1128
|
-
id: node.id,
|
|
1129
|
-
branch: branch.title,
|
|
1130
|
-
terminal: branch.terminal,
|
|
1131
|
-
},
|
|
1132
|
-
]);
|
|
1133
|
-
}
|
|
1134
|
-
break;
|
|
1135
|
-
case "loop":
|
|
1136
|
-
visit(node.steps, [
|
|
1137
|
-
...stack,
|
|
1138
|
-
{ kind: "loop", id: node.id, title: node.title },
|
|
1139
|
-
]);
|
|
1140
|
-
break;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
};
|
|
1144
|
-
visit(nodes, []);
|
|
1145
|
-
return out;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* The conditions a step runs under, outermost first — the alt branches around
|
|
1150
|
-
* it and nothing else. A step with none of these runs on every path.
|
|
1151
|
-
*/
|
|
1152
|
-
export function stepConditions(frames: readonly StepFrame[]): StepFrame[] {
|
|
1153
|
-
return frames.filter((f) => f.kind === "alt");
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
export function allServices(catalog: Catalog): Service[] {
|
|
1157
|
-
return catalog.contexts.flatMap((c) => c.services);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/** Neutral alias for allServices; both names intentionally address the same wire model. */
|
|
1161
|
-
export function allComponents(catalog: Catalog): Component[] {
|
|
1162
|
-
return allServices(catalog);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
export function groupKind(group: Group): GroupKind {
|
|
1166
|
-
return group.kind ?? "bounded-context";
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
export function componentKind(component: Component): ComponentKind {
|
|
1170
|
-
return component.kind ?? "service";
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/** Every system outside the estate with a contract, in catalog order. */
|
|
1174
|
-
export function allExternals(catalog: Catalog): External[] {
|
|
1175
|
-
return catalog.externals ?? [];
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
export function allEvents(catalog: Catalog): Event[] {
|
|
1179
|
-
return allServices(catalog).flatMap((s) =>
|
|
1180
|
-
s.aggregates.flatMap((a) => a.events),
|
|
1181
|
-
);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
export function allAggregates(catalog: Catalog): Aggregate[] {
|
|
1185
|
-
return allServices(catalog).flatMap((s) => s.aggregates);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/** Every store, whether or not any service lists it. Absent means none. */
|
|
1189
|
-
export function allStores(catalog: Catalog): Store[] {
|
|
1190
|
-
return catalog.stores ?? [];
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
export function allModules(catalog: Catalog): ProtoModule[] {
|
|
1194
|
-
return catalog.modules ?? [];
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/** Every term in every glossary. Absent means none, exactly as with modules. */
|
|
1198
|
-
export function allTerms(catalog: Catalog): Term[] {
|
|
1199
|
-
return catalog.terms ?? [];
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
/** Every repository the estate was read at. Absent means none, exactly as with modules. */
|
|
1203
|
-
export function allRepos(catalog: Catalog): RepoPin[] {
|
|
1204
|
-
return catalog.repos ?? [];
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
/** Who to ask about a service, without the caller having to know the field is optional. */
|
|
1208
|
-
export function ownersOf(service: Service): string[] {
|
|
1209
|
-
return service.owners ?? [];
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
export function technologiesOf(component: Component): string[] {
|
|
1213
|
-
return component.technologies ?? [];
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
export function commandsOf(component: Component): Command[] {
|
|
1217
|
-
return component.commands ?? [];
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/** Every view in every store. Absent means none, exactly as with tables. */
|
|
1221
|
-
export function allViews(catalog: Catalog): View[] {
|
|
1222
|
-
return allStores(catalog).flatMap((s) => s.views ?? []);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/** The views of one store, without the caller having to know the field is optional. */
|
|
1226
|
-
export function storeViews(store: Store): View[] {
|
|
1227
|
-
return store.views ?? [];
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
/**
|
|
1231
|
-
* What a view reads, table by table: what it declares, then everything its
|
|
1232
|
-
* columns point at that it forgot to declare. A view is allowed to state only
|
|
1233
|
-
* one of the two — the coarse list is easier to write by hand, the column
|
|
1234
|
-
* lineage is what an extractor produces — and readers should not have to know
|
|
1235
|
-
* which of the two the catalog happened to carry.
|
|
1236
|
-
*/
|
|
1237
|
-
export function viewReads(view: View): string[] {
|
|
1238
|
-
const out: string[] = [];
|
|
1239
|
-
const add = (id: string) => {
|
|
1240
|
-
if (!out.includes(id)) out.push(id);
|
|
1241
|
-
};
|
|
1242
|
-
for (const id of view.reads ?? []) add(id);
|
|
1243
|
-
for (const column of view.columns) {
|
|
1244
|
-
for (const ref of column.from ?? []) add(relationOfColumnId(ref));
|
|
1245
|
-
}
|
|
1246
|
-
return out;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/**
|
|
1250
|
-
* The relation half of a column id. Ids are dotted all the way down and only
|
|
1251
|
-
* the last segment is the column name, so this is a right split, not a left
|
|
1252
|
-
* one: "shop.oms.pg.orders.status" is the `status` column of `shop.oms.pg.orders`.
|
|
1253
|
-
*/
|
|
1254
|
-
export function relationOfColumnId(id: string): string {
|
|
1255
|
-
return id.split(".").slice(0, -1).join(".");
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/** The column half of a column id — everything after the last dot. */
|
|
1259
|
-
export function columnNameOfId(id: string): string {
|
|
1260
|
-
return id.split(".").at(-1) ?? "";
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* A column's id. Columns are not addressed in the JSON, but the selection layer
|
|
1265
|
-
* needs one identifier per selectable thing, and "<table id>.<column>" is the
|
|
1266
|
-
* spelling a reader would type.
|
|
1267
|
-
*/
|
|
1268
|
-
export function columnId(tableId: string, column: string): string {
|
|
1269
|
-
return `${tableId}.${column}`;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
/** The columns a collapsed table card shows: its key, then everything it points at. */
|
|
1273
|
-
export function keyColumns(table: Table): Column[] {
|
|
1274
|
-
return table.columns.filter((c) => c.pk || c.fk);
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
/**
|
|
1278
|
-
* The fields a block actually has: its own when written inline, otherwise the
|
|
1279
|
-
* shared def it names. An empty list means the catalog knows the block by name
|
|
1280
|
-
* only, which pages say out loud rather than drawing a blank table.
|
|
1281
|
-
*/
|
|
1282
|
-
/** The enums an aggregate declares; [] for a source that wrote none. */
|
|
1283
|
-
export function enumsOf(aggregate: Aggregate): Enum[] {
|
|
1284
|
-
return aggregate.enums ?? [];
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
export function blockFields(catalog: Catalog, block: Block): Field[] {
|
|
1288
|
-
if (block.fields) return block.fields;
|
|
1289
|
-
if (block.ref) return catalog.defs[block.ref]?.fields ?? [];
|
|
1290
|
-
return [];
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/** Value objects and entities of one aggregate, tagged with which they are. */
|
|
1294
|
-
export function aggregateBlocks(
|
|
1295
|
-
aggregate: Aggregate,
|
|
1296
|
-
): { kind: BlockKind; block: Block }[] {
|
|
1297
|
-
return [
|
|
1298
|
-
...aggregate.valueObjects.map((block) => ({ kind: "vo" as const, block })),
|
|
1299
|
-
...aggregate.entities.map((block) => ({ kind: "entity" as const, block })),
|
|
1300
|
-
];
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/** The entity an aggregate names as its root, if the catalog lists it. */
|
|
1304
|
-
export function rootEntity(aggregate: Aggregate): Entity | undefined {
|
|
1305
|
-
return aggregate.entities.find((e) => e.name === aggregate.root);
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
export interface BlockCounts {
|
|
1309
|
-
entities: number;
|
|
1310
|
-
valueObjects: number;
|
|
1311
|
-
enums: number;
|
|
1312
|
-
events: number;
|
|
1313
|
-
commands: number;
|
|
1314
|
-
queries: number;
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
export function blockCounts(aggregate: Aggregate): BlockCounts {
|
|
1318
|
-
return {
|
|
1319
|
-
entities: aggregate.entities.length,
|
|
1320
|
-
valueObjects: aggregate.valueObjects.length,
|
|
1321
|
-
enums: enumsOf(aggregate).length,
|
|
1322
|
-
events: aggregate.events.length,
|
|
1323
|
-
commands: aggregate.operations.filter((o) => o.kind === "command").length,
|
|
1324
|
-
queries: aggregate.operations.filter((o) => o.kind === "query").length,
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/** Contexts touched by a flow, in participant order, ignoring null-context lanes. */
|
|
1329
|
-
export function flowContexts(flow: Flow): string[] {
|
|
1330
|
-
const seen: string[] = [];
|
|
1331
|
-
for (const p of flow.participants) {
|
|
1332
|
-
if (p.context && !seen.includes(p.context)) seen.push(p.context);
|
|
1333
|
-
}
|
|
1334
|
-
return seen;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// ---------------------------------------------------------------------------
|
|
1338
|
-
// Indexes
|
|
1339
|
-
// ---------------------------------------------------------------------------
|
|
1340
|
-
|
|
1341
|
-
/** Everything needed to render or link a block without walking the tree again. */
|
|
1342
|
-
export interface BlockOwner {
|
|
1343
|
-
block: Block;
|
|
1344
|
-
kind: BlockKind;
|
|
1345
|
-
aggregate: Aggregate;
|
|
1346
|
-
service: Service;
|
|
1347
|
-
context: BoundedContext;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/** An enum and everything that owns it, so a value can be drawn without a lookup. */
|
|
1351
|
-
export interface EnumOwner {
|
|
1352
|
-
enum: Enum;
|
|
1353
|
-
aggregate: Aggregate;
|
|
1354
|
-
service: Service;
|
|
1355
|
-
context: BoundedContext;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/** A column and everything holding it, so a row can be drawn without a lookup. */
|
|
1359
|
-
export interface ColumnOwner {
|
|
1360
|
-
column: Column;
|
|
1361
|
-
table: Table;
|
|
1362
|
-
store: Store;
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
/** The same, for a column of a view. Kept apart so `owner.table` never lies. */
|
|
1366
|
-
export interface ViewColumnOwner {
|
|
1367
|
-
column: Column;
|
|
1368
|
-
view: View;
|
|
1369
|
-
store: Store;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
/**
|
|
1373
|
-
* The block a `maps` path points into, by name. A path is "<Type>.<Field>", and
|
|
1374
|
-
* the type is resolved inside the aggregate the table already says it persists
|
|
1375
|
-
* — the only scope in which a bare type name is unambiguous.
|
|
1376
|
-
*/
|
|
1377
|
-
export function mapsBlockId(
|
|
1378
|
-
aggregate: Aggregate | undefined,
|
|
1379
|
-
maps: string | undefined,
|
|
1380
|
-
): string | null {
|
|
1381
|
-
if (!aggregate || !maps) return null;
|
|
1382
|
-
const head = maps.split(".")[0];
|
|
1383
|
-
if (!head) return null;
|
|
1384
|
-
const found = aggregateBlocks(aggregate).find((b) => b.block.name === head);
|
|
1385
|
-
return found ? found.block.id : null;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
/** The field half of a `maps` path — everything after the type name. */
|
|
1389
|
-
export function mapsFieldPath(maps: string): string {
|
|
1390
|
-
const at = maps.indexOf(".");
|
|
1391
|
-
return at < 0 ? maps : maps.slice(at + 1);
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
export interface CatalogIndex {
|
|
1395
|
-
catalog: Catalog;
|
|
1396
|
-
serviceById: Map<string, Service>;
|
|
1397
|
-
serviceContext: Map<string, BoundedContext>;
|
|
1398
|
-
aggregateById: Map<string, Aggregate>;
|
|
1399
|
-
aggregateOwner: Map<string, Service>;
|
|
1400
|
-
eventById: Map<string, Event>;
|
|
1401
|
-
eventOwner: Map<string, { service: Service; aggregate: Aggregate }>;
|
|
1402
|
-
/**
|
|
1403
|
-
* wire name -> the event that goes out under it.
|
|
1404
|
-
*
|
|
1405
|
-
* The one lookup that starts from the bus rather than from the catalog. A
|
|
1406
|
-
* subscriber names a message and knows nothing else about it, and this is
|
|
1407
|
-
* what turns that name back into the event, its aggregate and its owner.
|
|
1408
|
-
* Only events are in here: a channel declared by a document is a promise,
|
|
1409
|
-
* and a promise is not a page anything can link to.
|
|
1410
|
-
*/
|
|
1411
|
-
eventByWireName: Map<string, Event>;
|
|
1412
|
-
/** value object and entity id -> the block and everything that owns it */
|
|
1413
|
-
blockById: Map<string, BlockOwner>;
|
|
1414
|
-
/** enum id -> the enum and everything that owns it */
|
|
1415
|
-
enumById: Map<string, EnumOwner>;
|
|
1416
|
-
/** defs key -> ids of the blocks that name it */
|
|
1417
|
-
blocksByDef: Map<string, string[]>;
|
|
1418
|
-
rpcById: Map<string, RpcCall>;
|
|
1419
|
-
rpcProviderByMethod: Map<string, Service>;
|
|
1420
|
-
externalById: Map<string, External>;
|
|
1421
|
-
/**
|
|
1422
|
-
* "<interface>/<method>" -> the external answering on it. Kept apart from
|
|
1423
|
-
* `rpcProviderByMethod` rather than widened into it: every reader of that map
|
|
1424
|
-
* follows the provider to a service page, and an external has none.
|
|
1425
|
-
*/
|
|
1426
|
-
externalProviderByMethod: Map<string, External>;
|
|
1427
|
-
storeById: Map<string, Store>;
|
|
1428
|
-
/** table id -> the table and the store holding it */
|
|
1429
|
-
tableById: Map<string, { table: Table; store: Store }>;
|
|
1430
|
-
/** view id -> the view and the store declaring it */
|
|
1431
|
-
viewById: Map<string, { view: View; store: Store }>;
|
|
1432
|
-
/** column id -> the column and everything that owns it */
|
|
1433
|
-
columnById: Map<string, ColumnOwner>;
|
|
1434
|
-
/** column id -> the view column and everything that owns it */
|
|
1435
|
-
viewColumnById: Map<string, ViewColumnOwner>;
|
|
1436
|
-
/** table or view id -> the views reading it, in catalog order */
|
|
1437
|
-
viewsReading: Map<string, View[]>;
|
|
1438
|
-
/** column id -> the column ids it is computed from, in declaration order */
|
|
1439
|
-
lineageFrom: Map<string, string[]>;
|
|
1440
|
-
/** column id -> the column ids computed from it, in catalog order */
|
|
1441
|
-
lineageInto: Map<string, string[]>;
|
|
1442
|
-
/** service id -> stores it owns, in catalog order */
|
|
1443
|
-
storesOwnedBy: Map<string, Store[]>;
|
|
1444
|
-
/** aggregate id -> tables naming it in `persists`, in catalog order */
|
|
1445
|
-
tablesByAggregate: Map<string, Table[]>;
|
|
1446
|
-
/** aggregate id -> views naming it in `persists`, in catalog order */
|
|
1447
|
-
viewsByAggregate: Map<string, View[]>;
|
|
1448
|
-
/** aggregate id -> Redis key families holding it, in catalog order */
|
|
1449
|
-
keyspacesByAggregate: Map<string, RedisKeyspaceOwner[]>;
|
|
1450
|
-
/** block id -> columns whose `maps` path lands in that block */
|
|
1451
|
-
columnsByBlock: Map<string, ColumnOwner[]>;
|
|
1452
|
-
/** table id -> the columns pointing at it through a foreign key */
|
|
1453
|
-
fkIntoTable: Map<string, ColumnOwner[]>;
|
|
1454
|
-
flowBySlug: Map<string, Flow>;
|
|
1455
|
-
/** event id -> flow slugs that reference it in a step */
|
|
1456
|
-
flowsByEvent: Map<string, string[]>;
|
|
1457
|
-
moduleById: Map<string, ProtoModule>;
|
|
1458
|
-
moduleBySlug: Map<string, ProtoModule>;
|
|
1459
|
-
/** module id -> the interfaces declaring themselves part of it, with their service */
|
|
1460
|
-
interfacesByModule: Map<string, InterfaceOwner[]>;
|
|
1461
|
-
/**
|
|
1462
|
-
* module id -> services that publish it, vendor it, or name it on a call.
|
|
1463
|
-
*
|
|
1464
|
-
* The interesting fact about a module is usually who ELSE reads it, and no
|
|
1465
|
-
* single field says so: a producer names it on an interface, a consumer on a
|
|
1466
|
-
* call, and either may list it under `Service.modules`. One map answers it.
|
|
1467
|
-
*/
|
|
1468
|
-
servicesUsingModule: Map<string, Service[]>;
|
|
1469
|
-
adrById: Map<string, Adr>;
|
|
1470
|
-
adrBySlug: Map<string, Adr>;
|
|
1471
|
-
/** event id -> ADRs that name it in relates.events, newest first */
|
|
1472
|
-
adrsByEvent: Map<string, Adr[]>;
|
|
1473
|
-
termById: Map<string, Term>;
|
|
1474
|
-
/** context id -> its vocabulary, alphabetical, as the glossary was written */
|
|
1475
|
-
termsByContext: Map<string, Term[]>;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
/** An interface and the service that answers on it. */
|
|
1479
|
-
export interface InterfaceOwner {
|
|
1480
|
-
service: Service;
|
|
1481
|
-
provided: RpcService;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
export interface RedisKeyspaceOwner {
|
|
1485
|
-
keyspace: RedisKeyspace;
|
|
1486
|
-
store: Store;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
export function buildIndex(catalog: Catalog): CatalogIndex {
|
|
1490
|
-
const serviceById = new Map<string, Service>();
|
|
1491
|
-
const serviceContext = new Map<string, BoundedContext>();
|
|
1492
|
-
const moduleById = new Map<string, ProtoModule>();
|
|
1493
|
-
const moduleBySlug = new Map<string, ProtoModule>();
|
|
1494
|
-
const interfacesByModule = new Map<string, InterfaceOwner[]>();
|
|
1495
|
-
const servicesUsingModule = new Map<string, Service[]>();
|
|
1496
|
-
const aggregateById = new Map<string, Aggregate>();
|
|
1497
|
-
const aggregateOwner = new Map<string, Service>();
|
|
1498
|
-
const eventById = new Map<string, Event>();
|
|
1499
|
-
const eventByWireName = new Map<string, Event>();
|
|
1500
|
-
const eventOwner = new Map<
|
|
1501
|
-
string,
|
|
1502
|
-
{ service: Service; aggregate: Aggregate }
|
|
1503
|
-
>();
|
|
1504
|
-
const blockById = new Map<string, BlockOwner>();
|
|
1505
|
-
const enumById = new Map<string, EnumOwner>();
|
|
1506
|
-
const blocksByDef = new Map<string, string[]>();
|
|
1507
|
-
const rpcById = new Map<string, RpcCall>();
|
|
1508
|
-
const rpcProviderByMethod = new Map<string, Service>();
|
|
1509
|
-
const externalById = new Map<string, External>();
|
|
1510
|
-
const externalProviderByMethod = new Map<string, External>();
|
|
1511
|
-
for (const external of allExternals(catalog)) {
|
|
1512
|
-
externalById.set(external.id, external);
|
|
1513
|
-
for (const provided of external.provides) {
|
|
1514
|
-
for (const method of provided.methods) {
|
|
1515
|
-
externalProviderByMethod.set(`${provided.id}/${method.name}`, external);
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
const flowBySlug = new Map<string, Flow>();
|
|
1520
|
-
const flowsByEvent = new Map<string, string[]>();
|
|
1521
|
-
const adrById = new Map<string, Adr>();
|
|
1522
|
-
const adrBySlug = new Map<string, Adr>();
|
|
1523
|
-
const adrsByEvent = new Map<string, Adr[]>();
|
|
1524
|
-
const termById = new Map<string, Term>();
|
|
1525
|
-
const termsByContext = new Map<string, Term[]>();
|
|
1526
|
-
const storeById = new Map<string, Store>();
|
|
1527
|
-
const tableById = new Map<string, { table: Table; store: Store }>();
|
|
1528
|
-
const viewById = new Map<string, { view: View; store: Store }>();
|
|
1529
|
-
const columnById = new Map<string, ColumnOwner>();
|
|
1530
|
-
const viewColumnById = new Map<string, ViewColumnOwner>();
|
|
1531
|
-
const viewsReading = new Map<string, View[]>();
|
|
1532
|
-
const lineageFrom = new Map<string, string[]>();
|
|
1533
|
-
const lineageInto = new Map<string, string[]>();
|
|
1534
|
-
const storesOwnedBy = new Map<string, Store[]>();
|
|
1535
|
-
const tablesByAggregate = new Map<string, Table[]>();
|
|
1536
|
-
const viewsByAggregate = new Map<string, View[]>();
|
|
1537
|
-
const keyspacesByAggregate = new Map<string, RedisKeyspaceOwner[]>();
|
|
1538
|
-
const columnsByBlock = new Map<string, ColumnOwner[]>();
|
|
1539
|
-
const fkIntoTable = new Map<string, ColumnOwner[]>();
|
|
1540
|
-
|
|
1541
|
-
for (const context of catalog.contexts) {
|
|
1542
|
-
for (const service of context.services) {
|
|
1543
|
-
serviceById.set(service.id, service);
|
|
1544
|
-
serviceContext.set(service.id, context);
|
|
1545
|
-
for (const call of service.consumes) rpcById.set(call.id, call);
|
|
1546
|
-
for (const provided of service.provides) {
|
|
1547
|
-
for (const method of provided.methods) {
|
|
1548
|
-
rpcProviderByMethod.set(`${provided.id}/${method.name}`, service);
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
for (const aggregate of service.aggregates) {
|
|
1552
|
-
aggregateById.set(aggregate.id, aggregate);
|
|
1553
|
-
aggregateOwner.set(aggregate.id, service);
|
|
1554
|
-
for (const event of aggregate.events) {
|
|
1555
|
-
eventById.set(event.id, event);
|
|
1556
|
-
eventOwner.set(event.id, { service, aggregate });
|
|
1557
|
-
// First one wins, and two events sharing a wire name is a problem
|
|
1558
|
-
// the Problems page is the place to say so about, not this.
|
|
1559
|
-
if (event.wire && !eventByWireName.has(event.wire.name)) {
|
|
1560
|
-
eventByWireName.set(event.wire.name, event);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
for (const item of enumsOf(aggregate)) {
|
|
1564
|
-
enumById.set(item.id, { enum: item, aggregate, service, context });
|
|
1565
|
-
}
|
|
1566
|
-
for (const { kind, block } of aggregateBlocks(aggregate)) {
|
|
1567
|
-
blockById.set(block.id, { block, kind, aggregate, service, context });
|
|
1568
|
-
if (block.ref) {
|
|
1569
|
-
const list = blocksByDef.get(block.ref) ?? [];
|
|
1570
|
-
list.push(block.id);
|
|
1571
|
-
blocksByDef.set(block.ref, list);
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
// Lineage is recorded from the derived end, which is the only end that
|
|
1579
|
-
// declares it, and both directions are kept: "where did this come from" and
|
|
1580
|
-
// "who reads this" are asked as often as each other, and answering the
|
|
1581
|
-
// second by scanning every column in the catalog is what an index is for.
|
|
1582
|
-
const recordLineage = (id: string, column: Column): void => {
|
|
1583
|
-
const sources = column.from ?? [];
|
|
1584
|
-
if (sources.length === 0) return;
|
|
1585
|
-
lineageFrom.set(id, [...sources]);
|
|
1586
|
-
for (const source of sources) {
|
|
1587
|
-
const list = lineageInto.get(source) ?? [];
|
|
1588
|
-
if (!list.includes(id)) list.push(id);
|
|
1589
|
-
lineageInto.set(source, list);
|
|
1590
|
-
}
|
|
1591
|
-
};
|
|
1592
|
-
|
|
1593
|
-
// Modules are collected from both ends: the top-level list says what exists,
|
|
1594
|
-
// and the services say who touches it. A module named by a service the
|
|
1595
|
-
// catalog has no entry for is refused by the validator, so nothing here has
|
|
1596
|
-
// to guess.
|
|
1597
|
-
for (const module of allModules(catalog)) {
|
|
1598
|
-
moduleById.set(module.id, module);
|
|
1599
|
-
moduleBySlug.set(module.slug, module);
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
const uses = (moduleId: string, service: Service) => {
|
|
1603
|
-
const list = servicesUsingModule.get(moduleId) ?? [];
|
|
1604
|
-
if (!list.includes(service)) list.push(service);
|
|
1605
|
-
servicesUsingModule.set(moduleId, list);
|
|
1606
|
-
};
|
|
1607
|
-
|
|
1608
|
-
for (const context of catalog.contexts) {
|
|
1609
|
-
for (const service of context.services) {
|
|
1610
|
-
for (const moduleId of service.modules ?? []) uses(moduleId, service);
|
|
1611
|
-
for (const provided of service.provides) {
|
|
1612
|
-
if (provided.module === undefined) continue;
|
|
1613
|
-
const list = interfacesByModule.get(provided.module) ?? [];
|
|
1614
|
-
list.push({ service, provided });
|
|
1615
|
-
interfacesByModule.set(provided.module, list);
|
|
1616
|
-
uses(provided.module, service);
|
|
1617
|
-
}
|
|
1618
|
-
for (const call of service.consumes) {
|
|
1619
|
-
if (call.module !== undefined) uses(call.module, service);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
// Stores come after the domain tree because they point into it: a table says
|
|
1625
|
-
// which aggregate it persists, and a column which block it maps to, so both
|
|
1626
|
-
// are resolved against maps that are already full.
|
|
1627
|
-
for (const store of allStores(catalog)) {
|
|
1628
|
-
storeById.set(store.id, store);
|
|
1629
|
-
const owned = storesOwnedBy.get(store.owner) ?? [];
|
|
1630
|
-
owned.push(store);
|
|
1631
|
-
storesOwnedBy.set(store.owner, owned);
|
|
1632
|
-
|
|
1633
|
-
for (const keyspace of store.keyspaces ?? []) {
|
|
1634
|
-
const aggregateId = keyspace.persists?.aggregate;
|
|
1635
|
-
if (!aggregateId) continue;
|
|
1636
|
-
const list = keyspacesByAggregate.get(aggregateId) ?? [];
|
|
1637
|
-
list.push({ keyspace, store });
|
|
1638
|
-
keyspacesByAggregate.set(aggregateId, list);
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
for (const table of store.tables) {
|
|
1642
|
-
tableById.set(table.id, { table, store });
|
|
1643
|
-
// A table may name only the block it holds. That block belongs to an
|
|
1644
|
-
// aggregate, and an aggregate's Persistence section has to list it, so
|
|
1645
|
-
// the owner is filled in here rather than asked for twice in the JSON.
|
|
1646
|
-
const aggregateId =
|
|
1647
|
-
table.persists?.aggregate ??
|
|
1648
|
-
(table.persists?.block
|
|
1649
|
-
? blockById.get(table.persists.block)?.aggregate.id
|
|
1650
|
-
: undefined);
|
|
1651
|
-
if (aggregateId) {
|
|
1652
|
-
const list = tablesByAggregate.get(aggregateId) ?? [];
|
|
1653
|
-
list.push(table);
|
|
1654
|
-
tablesByAggregate.set(aggregateId, list);
|
|
1655
|
-
}
|
|
1656
|
-
const aggregate = aggregateId
|
|
1657
|
-
? aggregateById.get(aggregateId)
|
|
1658
|
-
: undefined;
|
|
1659
|
-
|
|
1660
|
-
for (const column of table.columns) {
|
|
1661
|
-
const owner: ColumnOwner = { column, table, store };
|
|
1662
|
-
columnById.set(columnId(table.id, column.name), owner);
|
|
1663
|
-
recordLineage(columnId(table.id, column.name), column);
|
|
1664
|
-
if (column.fk) {
|
|
1665
|
-
const into = fkIntoTable.get(column.fk.table) ?? [];
|
|
1666
|
-
into.push(owner);
|
|
1667
|
-
fkIntoTable.set(column.fk.table, into);
|
|
1668
|
-
}
|
|
1669
|
-
// `persists.block` names the block outright; otherwise the head of the
|
|
1670
|
-
// maps path is resolved inside the aggregate the table persists.
|
|
1671
|
-
const blockId =
|
|
1672
|
-
table.persists?.block ?? mapsBlockId(aggregate, column.maps);
|
|
1673
|
-
if (blockId && column.maps) {
|
|
1674
|
-
const list = columnsByBlock.get(blockId) ?? [];
|
|
1675
|
-
list.push(owner);
|
|
1676
|
-
columnsByBlock.set(blockId, list);
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
// Views after the tables of the same store: a view reads tables, and the
|
|
1682
|
-
// ones it reads are usually its neighbours in the same file.
|
|
1683
|
-
for (const view of storeViews(store)) {
|
|
1684
|
-
viewById.set(view.id, { view, store });
|
|
1685
|
-
const aggregateId =
|
|
1686
|
-
view.persists?.aggregate ??
|
|
1687
|
-
(view.persists?.block
|
|
1688
|
-
? blockById.get(view.persists.block)?.aggregate.id
|
|
1689
|
-
: undefined);
|
|
1690
|
-
if (aggregateId) {
|
|
1691
|
-
const list = viewsByAggregate.get(aggregateId) ?? [];
|
|
1692
|
-
list.push(view);
|
|
1693
|
-
viewsByAggregate.set(aggregateId, list);
|
|
1694
|
-
}
|
|
1695
|
-
for (const readId of viewReads(view)) {
|
|
1696
|
-
const list = viewsReading.get(readId) ?? [];
|
|
1697
|
-
if (!list.includes(view)) list.push(view);
|
|
1698
|
-
viewsReading.set(readId, list);
|
|
1699
|
-
}
|
|
1700
|
-
for (const column of view.columns) {
|
|
1701
|
-
const id = columnId(view.id, column.name);
|
|
1702
|
-
viewColumnById.set(id, { column, view, store });
|
|
1703
|
-
recordLineage(id, column);
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
for (const flow of catalog.flows) {
|
|
1709
|
-
flowBySlug.set(flow.slug, flow);
|
|
1710
|
-
for (const step of walkSteps(flow.steps)) {
|
|
1711
|
-
if (step.ref && eventById.has(step.ref)) {
|
|
1712
|
-
const list = flowsByEvent.get(step.ref) ?? [];
|
|
1713
|
-
if (!list.includes(flow.slug)) list.push(flow.slug);
|
|
1714
|
-
flowsByEvent.set(step.ref, list);
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
for (const adr of [...catalog.adrs].sort(byDateDesc)) {
|
|
1720
|
-
adrById.set(adr.id, adr);
|
|
1721
|
-
adrBySlug.set(adr.slug, adr);
|
|
1722
|
-
for (const eventId of adr.relates.events ?? []) {
|
|
1723
|
-
const list = adrsByEvent.get(eventId) ?? [];
|
|
1724
|
-
list.push(adr);
|
|
1725
|
-
adrsByEvent.set(eventId, list);
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
for (const term of allTerms(catalog)) {
|
|
1730
|
-
termById.set(term.id, term);
|
|
1731
|
-
const list = termsByContext.get(term.context) ?? [];
|
|
1732
|
-
list.push(term);
|
|
1733
|
-
termsByContext.set(term.context, list);
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
return {
|
|
1737
|
-
catalog,
|
|
1738
|
-
serviceById,
|
|
1739
|
-
serviceContext,
|
|
1740
|
-
aggregateById,
|
|
1741
|
-
aggregateOwner,
|
|
1742
|
-
eventById,
|
|
1743
|
-
eventOwner,
|
|
1744
|
-
eventByWireName,
|
|
1745
|
-
blockById,
|
|
1746
|
-
enumById,
|
|
1747
|
-
blocksByDef,
|
|
1748
|
-
rpcById,
|
|
1749
|
-
rpcProviderByMethod,
|
|
1750
|
-
externalById,
|
|
1751
|
-
externalProviderByMethod,
|
|
1752
|
-
flowBySlug,
|
|
1753
|
-
flowsByEvent,
|
|
1754
|
-
adrById,
|
|
1755
|
-
adrBySlug,
|
|
1756
|
-
adrsByEvent,
|
|
1757
|
-
termById,
|
|
1758
|
-
termsByContext,
|
|
1759
|
-
storeById,
|
|
1760
|
-
tableById,
|
|
1761
|
-
viewById,
|
|
1762
|
-
columnById,
|
|
1763
|
-
viewColumnById,
|
|
1764
|
-
viewsReading,
|
|
1765
|
-
lineageFrom,
|
|
1766
|
-
lineageInto,
|
|
1767
|
-
storesOwnedBy,
|
|
1768
|
-
moduleById,
|
|
1769
|
-
moduleBySlug,
|
|
1770
|
-
interfacesByModule,
|
|
1771
|
-
servicesUsingModule,
|
|
1772
|
-
tablesByAggregate,
|
|
1773
|
-
viewsByAggregate,
|
|
1774
|
-
keyspacesByAggregate,
|
|
1775
|
-
columnsByBlock,
|
|
1776
|
-
fkIntoTable,
|
|
1777
|
-
};
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
/** Newest decision first; ties broken by number so the order is total. */
|
|
1781
|
-
export function byDateDesc(a: Adr, b: Adr): number {
|
|
1782
|
-
return b.date.localeCompare(a.date) || b.number - a.number;
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// ---------------------------------------------------------------------------
|
|
1786
|
-
// Validation. Throws on the first violation with a message that names the
|
|
1787
|
-
// offending flow / step / field, so a bad generator run fails loudly.
|
|
1788
|
-
// ---------------------------------------------------------------------------
|
|
1789
|
-
|
|
1790
|
-
export class CatalogError extends Error {
|
|
1791
|
-
/**
|
|
1792
|
-
* Where the violation is, as a reader would name it: "flow checkout-happy /
|
|
1793
|
-
* step s4", "aggregate shop.oms.order". The message already says what is
|
|
1794
|
-
* wrong; this says which line of the generator run to go and look at, and it
|
|
1795
|
-
* is what the error page prints under the message.
|
|
1796
|
-
*/
|
|
1797
|
-
readonly path: string | undefined;
|
|
1798
|
-
|
|
1799
|
-
constructor(message: string, path?: string) {
|
|
1800
|
-
super(message);
|
|
1801
|
-
this.name = "CatalogError";
|
|
1802
|
-
this.path = path;
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
function fail(message: string, path?: string): never {
|
|
1807
|
-
throw new CatalogError(message, path);
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
function assertUniqueSlugs(
|
|
1811
|
-
slugs: string[],
|
|
1812
|
-
parent: string,
|
|
1813
|
-
what: string,
|
|
1814
|
-
): void {
|
|
1815
|
-
const seen = new Set<string>();
|
|
1816
|
-
for (const slug of slugs) {
|
|
1817
|
-
if (seen.has(slug))
|
|
1818
|
-
fail(`${what} slug "${slug}" is not unique within ${parent}`, parent);
|
|
1819
|
-
seen.add(slug);
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
/**
|
|
1824
|
-
* What a service says about the bus.
|
|
1825
|
-
*
|
|
1826
|
-
* The address is the whole of a channel's identity - there is no id, because a
|
|
1827
|
-
* channel is not a page and nothing links to one - so two channels sharing an
|
|
1828
|
-
* address in one service is the same mistake as two aggregates sharing a slug.
|
|
1829
|
-
* A message with no name is worse than no message at all: the name is what an
|
|
1830
|
-
* event's wire is compared against, and a blank one matches everything.
|
|
1831
|
-
*/
|
|
1832
|
-
function validateChannels(service: Service): void {
|
|
1833
|
-
const addresses = new Set<string>();
|
|
1834
|
-
|
|
1835
|
-
for (const channel of service.channels ?? []) {
|
|
1836
|
-
if (typeof channel.address !== "string" || channel.address === "") {
|
|
1837
|
-
fail(
|
|
1838
|
-
`service "${service.id}" declares a channel with no address; the address is what a channel is`,
|
|
1839
|
-
`service ${service.id}`,
|
|
1840
|
-
);
|
|
1841
|
-
}
|
|
1842
|
-
if (addresses.has(channel.address)) {
|
|
1843
|
-
fail(
|
|
1844
|
-
`service "${service.id}" declares channel "${channel.address}" twice; one channel says both directions`,
|
|
1845
|
-
`service ${service.id}`,
|
|
1846
|
-
);
|
|
1847
|
-
}
|
|
1848
|
-
addresses.add(channel.address);
|
|
1849
|
-
|
|
1850
|
-
if (
|
|
1851
|
-
channel.kind !== undefined &&
|
|
1852
|
-
channel.kind !== "event" &&
|
|
1853
|
-
channel.kind !== "job" &&
|
|
1854
|
-
channel.kind !== "message"
|
|
1855
|
-
) {
|
|
1856
|
-
fail(
|
|
1857
|
-
`channel "${channel.address}" of service "${service.id}" has kind "${channel.kind}", which is neither event, job, nor message`,
|
|
1858
|
-
`service ${service.id} / channel ${channel.address}`,
|
|
1859
|
-
);
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
const seen = new Set<string>();
|
|
1863
|
-
for (const message of channel.messages) {
|
|
1864
|
-
if (typeof message.name !== "string" || message.name === "") {
|
|
1865
|
-
fail(
|
|
1866
|
-
`channel "${channel.address}" of service "${service.id}" carries a message with no name; the name is what a subscriber dispatches on`,
|
|
1867
|
-
`service ${service.id} / channel ${channel.address}`,
|
|
1868
|
-
);
|
|
1869
|
-
}
|
|
1870
|
-
if (message.direction !== "send" && message.direction !== "receive") {
|
|
1871
|
-
fail(
|
|
1872
|
-
`message "${message.name}" on channel "${channel.address}" travels "${message.direction}", which is neither send nor receive`,
|
|
1873
|
-
`service ${service.id} / channel ${channel.address}`,
|
|
1874
|
-
);
|
|
1875
|
-
}
|
|
1876
|
-
const key = `${message.direction} ${message.name}`;
|
|
1877
|
-
if (seen.has(key)) {
|
|
1878
|
-
fail(
|
|
1879
|
-
`channel "${channel.address}" of service "${service.id}" declares "${message.name}" twice in the same direction`,
|
|
1880
|
-
`service ${service.id} / channel ${channel.address}`,
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1883
|
-
seen.add(key);
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
export function validateCatalog(catalog: Catalog): Catalog {
|
|
1889
|
-
if (!catalog.generatedAt) fail("catalog.generatedAt is missing", "catalog");
|
|
1890
|
-
if (!catalog.commit) fail("catalog.commit is missing", "catalog");
|
|
1891
|
-
|
|
1892
|
-
const eventIds = new Set<string>();
|
|
1893
|
-
const rpcIds = new Set<string>();
|
|
1894
|
-
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
1895
|
-
|
|
1896
|
-
assertUniqueSlugs(
|
|
1897
|
-
catalog.contexts.map((c) => c.id),
|
|
1898
|
-
"catalog",
|
|
1899
|
-
"context",
|
|
1900
|
-
);
|
|
1901
|
-
|
|
1902
|
-
for (const context of catalog.contexts) {
|
|
1903
|
-
// A context is a root, so it has nothing to be a slug relative to: id and
|
|
1904
|
-
// slug are the same string, and holding them equal here keeps every route
|
|
1905
|
-
// built from `context.id` addressing the same thing the slug names.
|
|
1906
|
-
if (context.slug !== context.id) {
|
|
1907
|
-
fail(
|
|
1908
|
-
`context "${context.id}" has slug "${context.slug}"; a context sits at the root, so its slug must equal its id`,
|
|
1909
|
-
`context ${context.id}`,
|
|
1910
|
-
);
|
|
1911
|
-
}
|
|
1912
|
-
if (context.kind !== undefined && !GROUP_KINDS.includes(context.kind)) {
|
|
1913
|
-
fail(
|
|
1914
|
-
`context "${context.id}" has kind "${context.kind}"; expected one of ${GROUP_KINDS.join(", ")}`,
|
|
1915
|
-
`context ${context.id}`,
|
|
1916
|
-
);
|
|
1917
|
-
}
|
|
1918
|
-
if (
|
|
1919
|
-
context.classification !== undefined &&
|
|
1920
|
-
!CLASSIFICATIONS.includes(context.classification)
|
|
1921
|
-
) {
|
|
1922
|
-
fail(
|
|
1923
|
-
`context "${context.id}" has classification "${context.classification}"; expected one of ${CLASSIFICATIONS.join(", ")}`,
|
|
1924
|
-
`context ${context.id}`,
|
|
1925
|
-
);
|
|
1926
|
-
}
|
|
1927
|
-
assertUniqueSlugs(
|
|
1928
|
-
context.services.map((s) => s.slug),
|
|
1929
|
-
`context "${context.id}"`,
|
|
1930
|
-
"service",
|
|
1931
|
-
);
|
|
1932
|
-
for (const service of context.services) {
|
|
1933
|
-
if (service.id !== `${context.id}.${service.slug}`) {
|
|
1934
|
-
fail(
|
|
1935
|
-
`service "${service.id}" in context "${context.id}" must have id "${context.id}.${service.slug}"`,
|
|
1936
|
-
`service ${service.id}`,
|
|
1937
|
-
);
|
|
1938
|
-
}
|
|
1939
|
-
if (
|
|
1940
|
-
service.kind !== undefined &&
|
|
1941
|
-
!COMPONENT_KINDS.includes(service.kind)
|
|
1942
|
-
) {
|
|
1943
|
-
fail(
|
|
1944
|
-
`service "${service.id}" has kind "${service.kind}"; expected one of ${COMPONENT_KINDS.join(", ")}`,
|
|
1945
|
-
`service ${service.id}`,
|
|
1946
|
-
);
|
|
1947
|
-
}
|
|
1948
|
-
// Owners are opaque - the estate's business is who to ask, not what a
|
|
1949
|
-
// handle resolves to - so only the two things that would render as a
|
|
1950
|
-
// hole are checked: a blank chip, and one name shown twice.
|
|
1951
|
-
const handles = new Set<string>();
|
|
1952
|
-
for (const handle of service.owners ?? []) {
|
|
1953
|
-
if (!handle.trim()) {
|
|
1954
|
-
fail(
|
|
1955
|
-
`service "${service.id}" has an owner with no name`,
|
|
1956
|
-
`service ${service.id}`,
|
|
1957
|
-
);
|
|
1958
|
-
}
|
|
1959
|
-
if (handles.has(handle)) {
|
|
1960
|
-
fail(
|
|
1961
|
-
`service "${service.id}" names owner "${handle}" twice`,
|
|
1962
|
-
`service ${service.id}`,
|
|
1963
|
-
);
|
|
1964
|
-
}
|
|
1965
|
-
handles.add(handle);
|
|
1966
|
-
}
|
|
1967
|
-
const technologies = new Set<string>();
|
|
1968
|
-
for (const technology of service.technologies ?? []) {
|
|
1969
|
-
if (!technology.trim()) {
|
|
1970
|
-
fail(
|
|
1971
|
-
`service "${service.id}" has a technology with no name`,
|
|
1972
|
-
`service ${service.id}`,
|
|
1973
|
-
);
|
|
1974
|
-
}
|
|
1975
|
-
if (technologies.has(technology)) {
|
|
1976
|
-
fail(
|
|
1977
|
-
`service "${service.id}" names technology "${technology}" twice`,
|
|
1978
|
-
`service ${service.id}`,
|
|
1979
|
-
);
|
|
1980
|
-
}
|
|
1981
|
-
technologies.add(technology);
|
|
1982
|
-
}
|
|
1983
|
-
for (const call of service.consumes) rpcIds.add(call.id);
|
|
1984
|
-
for (const provided of service.provides) {
|
|
1985
|
-
// A duplicate method name is not a cosmetic problem: `exposedBy` names
|
|
1986
|
-
// a method by name alone, and `rpcProviderByMethod` is keyed by it, so
|
|
1987
|
-
// two methods called the same thing make one of them unreachable.
|
|
1988
|
-
assertUniqueSlugs(
|
|
1989
|
-
provided.methods.map((method) => method.name),
|
|
1990
|
-
`interface "${provided.id}"`,
|
|
1991
|
-
"method",
|
|
1992
|
-
);
|
|
1993
|
-
for (const method of provided.methods) {
|
|
1994
|
-
if (
|
|
1995
|
-
method.streaming !== undefined &&
|
|
1996
|
-
!STREAMING.includes(method.streaming)
|
|
1997
|
-
) {
|
|
1998
|
-
fail(
|
|
1999
|
-
`method "${provided.id}/${method.name}" streams "${method.streaming}"; expected one of ${STREAMING.join(", ")}`,
|
|
2000
|
-
`service ${service.id}`,
|
|
2001
|
-
);
|
|
2002
|
-
}
|
|
2003
|
-
if (
|
|
2004
|
-
method.soap?.version !== undefined &&
|
|
2005
|
-
method.soap.version !== "1.1" &&
|
|
2006
|
-
method.soap.version !== "1.2"
|
|
2007
|
-
) {
|
|
2008
|
-
fail(
|
|
2009
|
-
`method "${provided.id}/${method.name}" uses SOAP ${method.soap.version}; expected 1.1 or 1.2`,
|
|
2010
|
-
`service ${service.id}`,
|
|
2011
|
-
);
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
const messageNames = new Set(
|
|
2015
|
-
(provided.messages ?? []).map((message) => message.name),
|
|
2016
|
-
);
|
|
2017
|
-
for (const message of provided.messages ?? []) {
|
|
2018
|
-
if (message.discriminator !== undefined) {
|
|
2019
|
-
const discriminator = message.discriminator;
|
|
2020
|
-
if (discriminator.property === "") {
|
|
2021
|
-
fail(
|
|
2022
|
-
`rpc message "${provided.id}.${message.name}" has an empty discriminator property`,
|
|
2023
|
-
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
2024
|
-
);
|
|
2025
|
-
}
|
|
2026
|
-
const values = new Set<string>();
|
|
2027
|
-
for (const variant of discriminator.variants) {
|
|
2028
|
-
if (variant.value === "" || variant.message === "") {
|
|
2029
|
-
fail(
|
|
2030
|
-
`rpc message "${provided.id}.${message.name}" has an incomplete discriminator variant`,
|
|
2031
|
-
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
2032
|
-
);
|
|
2033
|
-
}
|
|
2034
|
-
if (values.has(variant.value)) {
|
|
2035
|
-
fail(
|
|
2036
|
-
`rpc message "${provided.id}.${message.name}" maps discriminator value "${variant.value}" more than once`,
|
|
2037
|
-
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
2038
|
-
);
|
|
2039
|
-
}
|
|
2040
|
-
values.add(variant.value);
|
|
2041
|
-
if (!messageNames.has(variant.message)) {
|
|
2042
|
-
fail(
|
|
2043
|
-
`rpc message "${provided.id}.${message.name}" discriminator references unknown message "${variant.message}"`,
|
|
2044
|
-
`service ${service.id} / rpc ${provided.id}.${message.name}`,
|
|
2045
|
-
);
|
|
2046
|
-
}
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
for (const field of message.fields) {
|
|
2050
|
-
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
2051
|
-
fail(
|
|
2052
|
-
`field "${field.name}" of rpc message "${provided.id}.${message.name}" references unknown def "${field.ref}"`,
|
|
2053
|
-
`service ${service.id} / rpc ${provided.id}.${message.name} / field ${field.name}`,
|
|
2054
|
-
);
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// Every method this service answers on, whichever interface declares it.
|
|
2061
|
-
// An operation says which of them expose it, and a name that matches none
|
|
2062
|
-
// of them is a link into nothing.
|
|
2063
|
-
const methods = new Set(
|
|
2064
|
-
service.provides.flatMap((provided) =>
|
|
2065
|
-
provided.methods.map((method) => method.name),
|
|
2066
|
-
),
|
|
2067
|
-
);
|
|
2068
|
-
|
|
2069
|
-
validateChannels(service);
|
|
2070
|
-
|
|
2071
|
-
assertUniqueSlugs(
|
|
2072
|
-
service.aggregates.map((a) => a.slug),
|
|
2073
|
-
`service "${service.id}"`,
|
|
2074
|
-
"aggregate",
|
|
2075
|
-
);
|
|
2076
|
-
for (const aggregate of service.aggregates) {
|
|
2077
|
-
for (const operation of aggregate.operations) {
|
|
2078
|
-
for (const method of operation.exposedBy ?? []) {
|
|
2079
|
-
if (!methods.has(method)) {
|
|
2080
|
-
fail(
|
|
2081
|
-
`operation "${operation.id}" of aggregate "${aggregate.id}" says it is exposed by "${method}", which no interface of service "${service.id}" declares`,
|
|
2082
|
-
`aggregate ${aggregate.id} / operation ${operation.id}`,
|
|
2083
|
-
);
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
validateBlocks(catalog, aggregate);
|
|
2088
|
-
assertUniqueSlugs(
|
|
2089
|
-
aggregate.events.map((e) => e.slug),
|
|
2090
|
-
`aggregate "${aggregate.id}"`,
|
|
2091
|
-
"event",
|
|
2092
|
-
);
|
|
2093
|
-
for (const event of aggregate.events) {
|
|
2094
|
-
if (event.versions.length === 0) {
|
|
2095
|
-
fail(
|
|
2096
|
-
`event "${event.id}" has no versions; at least one is required`,
|
|
2097
|
-
`event ${event.id}`,
|
|
2098
|
-
);
|
|
2099
|
-
}
|
|
2100
|
-
eventIds.add(event.id);
|
|
2101
|
-
if (event.wire !== undefined) {
|
|
2102
|
-
if (typeof event.wire.name !== "string" || event.wire.name === "") {
|
|
2103
|
-
fail(
|
|
2104
|
-
`event "${event.id}" has a wire with no name; the name on the message is what a wire is`,
|
|
2105
|
-
`event ${event.id}`,
|
|
2106
|
-
);
|
|
2107
|
-
}
|
|
2108
|
-
if (event.wire.channel !== undefined && event.wire.channel === "") {
|
|
2109
|
-
fail(
|
|
2110
|
-
`event "${event.id}" names an empty channel; leave it out when the source does not say`,
|
|
2111
|
-
`event ${event.id}`,
|
|
2112
|
-
);
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
for (const version of event.versions) {
|
|
2116
|
-
for (const field of version.fields) {
|
|
2117
|
-
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
2118
|
-
fail(
|
|
2119
|
-
`field "${field.name}" of ${event.id}@${version.version} references unknown def "${field.ref}"`,
|
|
2120
|
-
`event ${event.id}@${version.version} / field ${field.name}`,
|
|
2121
|
-
);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
for (const [defId, def] of Object.entries(catalog.defs)) {
|
|
2131
|
-
for (const field of def.fields) {
|
|
2132
|
-
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
2133
|
-
fail(
|
|
2134
|
-
`field "${field.name}" of def "${defId}" references unknown def "${field.ref}"`,
|
|
2135
|
-
`def ${defId} / field ${field.name}`,
|
|
2136
|
-
);
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
assertUniqueSlugs(
|
|
2142
|
-
catalog.flows.map((f) => f.slug),
|
|
2143
|
-
"catalog",
|
|
2144
|
-
"flow",
|
|
2145
|
-
);
|
|
2146
|
-
|
|
2147
|
-
const flowGroupIds = new Set(catalog.contexts.map((c) => c.id));
|
|
2148
|
-
|
|
2149
|
-
const triggerKinds = new Set<FlowTrigger["kind"]>([
|
|
2150
|
-
"http",
|
|
2151
|
-
"callback",
|
|
2152
|
-
"event",
|
|
2153
|
-
"message",
|
|
2154
|
-
"job",
|
|
2155
|
-
"startup",
|
|
2156
|
-
"scheduled",
|
|
2157
|
-
"manual",
|
|
2158
|
-
"unproven",
|
|
2159
|
-
]);
|
|
2160
|
-
const triggerConfidence = new Set<FlowTrigger["confidence"]>([
|
|
2161
|
-
"high",
|
|
2162
|
-
"medium",
|
|
2163
|
-
"low",
|
|
2164
|
-
]);
|
|
2165
|
-
|
|
2166
|
-
for (const flow of catalog.flows) {
|
|
2167
|
-
const lanes = new Set(flow.participants.map((p) => p.id));
|
|
2168
|
-
if (lanes.size !== flow.participants.length) {
|
|
2169
|
-
fail(
|
|
2170
|
-
`flow "${flow.slug}" has duplicate participant ids`,
|
|
2171
|
-
`flow ${flow.id}`,
|
|
2172
|
-
);
|
|
2173
|
-
}
|
|
2174
|
-
// Whatever derived the flow knew which service's tree it was reading, so
|
|
2175
|
-
// there is no case where the owner is unknowable. Without it the flow has
|
|
2176
|
-
// no group to sit under and the tree files it as a defect.
|
|
2177
|
-
if (flow.owner === undefined) {
|
|
2178
|
-
fail(
|
|
2179
|
-
`flow "${flow.slug}" names no owner; a flow must state the group it belongs to`,
|
|
2180
|
-
`flow ${flow.id}`,
|
|
2181
|
-
);
|
|
2182
|
-
}
|
|
2183
|
-
if (flow.owner !== undefined && !flowGroupIds.has(flow.owner)) {
|
|
2184
|
-
fail(
|
|
2185
|
-
`flow "${flow.slug}" names owner "${flow.owner}", which is not a top-level group`,
|
|
2186
|
-
`flow ${flow.id}`,
|
|
2187
|
-
);
|
|
2188
|
-
}
|
|
2189
|
-
if (flow.trigger && !triggerKinds.has(flow.trigger.kind)) {
|
|
2190
|
-
fail(
|
|
2191
|
-
`flow "${flow.slug}" has unknown trigger kind "${flow.trigger.kind}"`,
|
|
2192
|
-
`flow ${flow.id}`,
|
|
2193
|
-
);
|
|
2194
|
-
}
|
|
2195
|
-
if (flow.trigger && !triggerConfidence.has(flow.trigger.confidence)) {
|
|
2196
|
-
fail(
|
|
2197
|
-
`flow "${flow.slug}" has unknown trigger confidence "${flow.trigger.confidence}"`,
|
|
2198
|
-
`flow ${flow.id}`,
|
|
2199
|
-
);
|
|
2200
|
-
}
|
|
2201
|
-
if (flow.includes) {
|
|
2202
|
-
const included = new Set<string>();
|
|
2203
|
-
for (const slug of flow.includes) {
|
|
2204
|
-
if (!slug || slug === flow.slug || included.has(slug)) {
|
|
2205
|
-
fail(
|
|
2206
|
-
`flow "${flow.slug}" has an invalid or duplicate included flow "${slug}"`,
|
|
2207
|
-
`flow ${flow.id}`,
|
|
2208
|
-
);
|
|
2209
|
-
}
|
|
2210
|
-
included.add(slug);
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
validateFlowFrames(flow, flow.steps);
|
|
2214
|
-
|
|
2215
|
-
const steps = walkSteps(flow.steps);
|
|
2216
|
-
const stepIds = new Set<string>();
|
|
2217
|
-
const stepById = new Map<string, Step>();
|
|
2218
|
-
for (const step of steps) {
|
|
2219
|
-
if (stepIds.has(step.id)) {
|
|
2220
|
-
fail(
|
|
2221
|
-
`flow "${flow.slug}" has duplicate step id "${step.id}"`,
|
|
2222
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2223
|
-
);
|
|
2224
|
-
}
|
|
2225
|
-
stepIds.add(step.id);
|
|
2226
|
-
stepById.set(step.id, step);
|
|
2227
|
-
|
|
2228
|
-
if (
|
|
2229
|
-
!(["rpc", "event", "call", "response"] as const).includes(step.kind)
|
|
2230
|
-
) {
|
|
2231
|
-
fail(
|
|
2232
|
-
`flow "${flow.slug}" step "${step.id}" has unknown kind "${step.kind}"`,
|
|
2233
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2234
|
-
);
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
if (step.reaches?.some((entrypoint) => entrypoint.length === 0)) {
|
|
2238
|
-
fail(
|
|
2239
|
-
`flow "${flow.slug}" step "${step.id}" has an empty reached source function`,
|
|
2240
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2241
|
-
);
|
|
2242
|
-
}
|
|
2243
|
-
if (step.handoff) {
|
|
2244
|
-
if (!(["message", "job"] as const).includes(step.handoff.kind)) {
|
|
2245
|
-
fail(
|
|
2246
|
-
`flow "${flow.slug}" step "${step.id}" has unknown handoff kind "${step.handoff.kind}"`,
|
|
2247
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2248
|
-
);
|
|
2249
|
-
}
|
|
2250
|
-
if (!step.handoff.transport) {
|
|
2251
|
-
fail(
|
|
2252
|
-
`flow "${flow.slug}" step "${step.id}" has a handoff with no transport`,
|
|
2253
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2254
|
-
);
|
|
2255
|
-
}
|
|
2256
|
-
if (!step.handoff.channel) {
|
|
2257
|
-
fail(
|
|
2258
|
-
`flow "${flow.slug}" step "${step.id}" has a handoff with no channel`,
|
|
2259
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2260
|
-
);
|
|
2261
|
-
}
|
|
2262
|
-
if (step.handoff.kind === "job" && !step.handoff.message) {
|
|
2263
|
-
fail(
|
|
2264
|
-
`flow "${flow.slug}" step "${step.id}" has a job handoff with no message`,
|
|
2265
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2266
|
-
);
|
|
2267
|
-
}
|
|
2268
|
-
if (!(["send", "receive"] as const).includes(step.handoff.direction)) {
|
|
2269
|
-
fail(
|
|
2270
|
-
`flow "${flow.slug}" step "${step.id}" has unknown handoff direction "${step.handoff.direction}"`,
|
|
2271
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2272
|
-
);
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
if (step.storeAccess) {
|
|
2276
|
-
if (step.kind !== "call") {
|
|
2277
|
-
fail(
|
|
2278
|
-
`flow "${flow.slug}" step "${step.id}" has store access metadata but is not a call`,
|
|
2279
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2280
|
-
);
|
|
2281
|
-
}
|
|
2282
|
-
if (!storeIds.has(step.storeAccess.store)) {
|
|
2283
|
-
fail(
|
|
2284
|
-
`flow "${flow.slug}" step "${step.id}" names unknown store "${step.storeAccess.store}"`,
|
|
2285
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2286
|
-
);
|
|
2287
|
-
}
|
|
2288
|
-
if (
|
|
2289
|
-
step.storeAccess.operation !== undefined &&
|
|
2290
|
-
!REDIS_OPERATIONS.includes(step.storeAccess.operation)
|
|
2291
|
-
) {
|
|
2292
|
-
fail(
|
|
2293
|
-
`flow "${flow.slug}" step "${step.id}" has unknown store operation "${step.storeAccess.operation}"`,
|
|
2294
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2295
|
-
);
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
if (step.http) {
|
|
2299
|
-
if (step.kind !== "response") {
|
|
2300
|
-
fail(
|
|
2301
|
-
`flow "${flow.slug}" step "${step.id}" has HTTP response metadata but is not a response`,
|
|
2302
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2303
|
-
);
|
|
2304
|
-
}
|
|
2305
|
-
if (
|
|
2306
|
-
step.http.status !== undefined &&
|
|
2307
|
-
(step.http.status < 100 || step.http.status > 599)
|
|
2308
|
-
) {
|
|
2309
|
-
fail(
|
|
2310
|
-
`flow "${flow.slug}" response "${step.id}" has invalid HTTP status ${step.http.status}`,
|
|
2311
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2312
|
-
);
|
|
2313
|
-
}
|
|
2314
|
-
if (
|
|
2315
|
-
step.http.outcome !== undefined &&
|
|
2316
|
-
!(["success", "error"] as const).includes(step.http.outcome)
|
|
2317
|
-
) {
|
|
2318
|
-
fail(
|
|
2319
|
-
`flow "${flow.slug}" response "${step.id}" has unknown HTTP outcome "${step.http.outcome}"`,
|
|
2320
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2321
|
-
);
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
if (!lanes.has(step.from)) {
|
|
2326
|
-
fail(
|
|
2327
|
-
`flow "${flow.slug}" step "${step.id}": from "${step.from}" is not a declared participant`,
|
|
2328
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2331
|
-
if (!lanes.has(step.to)) {
|
|
2332
|
-
fail(
|
|
2333
|
-
`flow "${flow.slug}" step "${step.id}": to "${step.to}" is not a declared participant`,
|
|
2334
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2335
|
-
);
|
|
2336
|
-
}
|
|
2337
|
-
if (step.ref !== undefined && step.status !== "unresolved") {
|
|
2338
|
-
const resolves = eventIds.has(step.ref) || rpcIds.has(step.ref);
|
|
2339
|
-
if (!resolves) {
|
|
2340
|
-
fail(
|
|
2341
|
-
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event nor an RpcCall, and status is "${step.status}" rather than "unresolved"`,
|
|
2342
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2343
|
-
);
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
for (const step of steps) {
|
|
2349
|
-
if (step.kind !== "response") {
|
|
2350
|
-
if (step.replyTo !== undefined) {
|
|
2351
|
-
fail(
|
|
2352
|
-
`flow "${flow.slug}" step "${step.id}" is not a response but names replyTo "${step.replyTo}"`,
|
|
2353
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2354
|
-
);
|
|
2355
|
-
}
|
|
2356
|
-
continue;
|
|
2357
|
-
}
|
|
2358
|
-
if (!step.replyTo) {
|
|
2359
|
-
fail(
|
|
2360
|
-
`flow "${flow.slug}" response "${step.id}" names no request in replyTo`,
|
|
2361
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2362
|
-
);
|
|
2363
|
-
}
|
|
2364
|
-
const request = stepById.get(step.replyTo);
|
|
2365
|
-
if (!request || request.kind !== "rpc") {
|
|
2366
|
-
fail(
|
|
2367
|
-
`flow "${flow.slug}" response "${step.id}" replies to "${step.replyTo}", which is not an rpc request`,
|
|
2368
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2369
|
-
);
|
|
2370
|
-
}
|
|
2371
|
-
if (step.from !== request.to || step.to !== request.from) {
|
|
2372
|
-
fail(
|
|
2373
|
-
`flow "${flow.slug}" response "${step.id}" does not reverse request "${request.id}"`,
|
|
2374
|
-
`flow ${flow.id} / step ${step.id}`,
|
|
2375
|
-
);
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
validateExternals(catalog);
|
|
2381
|
-
validateStores(catalog);
|
|
2382
|
-
validateModules(catalog);
|
|
2383
|
-
validateAdrs(catalog, eventIds);
|
|
2384
|
-
validateTerms(catalog);
|
|
2385
|
-
validateRepos(catalog);
|
|
2386
|
-
|
|
2387
|
-
return catalog;
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
/**
|
|
2391
|
-
* A module reference may only point at a module that exists.
|
|
2392
|
-
*
|
|
2393
|
-
* `deps` are the exception, and deliberately NOT checked. A module's
|
|
2394
|
-
* dependencies come from its own lock file and routinely name modules this
|
|
2395
|
-
* estate never vendored - the same kind of fact as an `RpcCall` to a peer
|
|
2396
|
-
* outside the catalog. Requiring them to resolve would mean a module could only
|
|
2397
|
-
* be recorded once everything it transitively depends on had been vendored too,
|
|
2398
|
-
* which is a rule about the estate's homework rather than about the catalog
|
|
2399
|
-
* being coherent. A dangling dep is shown as a name the catalog does not hold.
|
|
2400
|
-
*
|
|
2401
|
-
* Also NOT checked: whether a method's `request` names a message the interface
|
|
2402
|
-
* actually lists, and whether a module is pinned to a commit. Both are
|
|
2403
|
-
* legitimate mid-migration states - a copy vendored before the producer
|
|
2404
|
-
* published, a module tracked by label - and refusing to render the catalog
|
|
2405
|
-
* over either would be refusing to describe the estate as it is. They belong on
|
|
2406
|
-
* the Problems page, which is where the rest of that judgement already lives.
|
|
2407
|
-
*/
|
|
2408
|
-
function validateModules(catalog: Catalog): void {
|
|
2409
|
-
const modules = allModules(catalog);
|
|
2410
|
-
const ids = new Set(modules.map((m) => m.id));
|
|
2411
|
-
const serviceIds = new Set(allServices(catalog).map((s) => s.id));
|
|
2412
|
-
|
|
2413
|
-
assertUniqueSlugs(
|
|
2414
|
-
modules.map((m) => m.id),
|
|
2415
|
-
"catalog",
|
|
2416
|
-
"module",
|
|
2417
|
-
);
|
|
2418
|
-
// Slugs are what the URL uses, so two modules sharing one would put two
|
|
2419
|
-
// entities at the same address.
|
|
2420
|
-
assertUniqueSlugs(
|
|
2421
|
-
modules.map((m) => m.slug),
|
|
2422
|
-
"catalog",
|
|
2423
|
-
"module slug",
|
|
2424
|
-
);
|
|
2425
|
-
|
|
2426
|
-
for (const module of modules) {
|
|
2427
|
-
if (module.owner !== undefined && !serviceIds.has(module.owner)) {
|
|
2428
|
-
fail(
|
|
2429
|
-
`module "${module.id}" is owned by "${module.owner}", which is not a service in this catalog`,
|
|
2430
|
-
`module ${module.id}`,
|
|
2431
|
-
);
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
const refers = (module: string, where: string, path: string) => {
|
|
2436
|
-
if (!ids.has(module)) {
|
|
2437
|
-
fail(
|
|
2438
|
-
`${where} names module "${module}", which is not in this catalog`,
|
|
2439
|
-
path,
|
|
2440
|
-
);
|
|
2441
|
-
}
|
|
2442
|
-
};
|
|
2443
|
-
|
|
2444
|
-
for (const service of allServices(catalog)) {
|
|
2445
|
-
for (const module of service.modules ?? []) {
|
|
2446
|
-
refers(module, `service "${service.id}"`, `service ${service.id}`);
|
|
2447
|
-
}
|
|
2448
|
-
for (const provided of service.provides) {
|
|
2449
|
-
if (provided.module !== undefined) {
|
|
2450
|
-
refers(
|
|
2451
|
-
provided.module,
|
|
2452
|
-
`interface "${provided.id}"`,
|
|
2453
|
-
`service ${service.id}`,
|
|
2454
|
-
);
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
for (const call of service.consumes) {
|
|
2458
|
-
if (call.module !== undefined) {
|
|
2459
|
-
refers(call.module, `call "${call.id}"`, `service ${service.id}`);
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
/**
|
|
2466
|
-
* An external sits at the root beside the contexts, so it is held to the same
|
|
2467
|
-
* shape: id equal to slug, no dot, and a name nothing else at the root uses.
|
|
2468
|
-
* The last rule is the one that matters - a flow lane, a call's `peer` and a
|
|
2469
|
-
* LikeC4 node all address the root by a bare id, and an external called
|
|
2470
|
-
* `shop` beside a context called `shop` would land every arrow on the wrong one.
|
|
2471
|
-
*
|
|
2472
|
-
* What is NOT checked: whether any service calls it. An external nobody calls
|
|
2473
|
-
* is a copy vendored ahead of the adapter, which is a legitimate mid-migration
|
|
2474
|
-
* state and shows on its page as "called by nobody" rather than failing the
|
|
2475
|
-
* build.
|
|
2476
|
-
*/
|
|
2477
|
-
function validateExternals(catalog: Catalog): void {
|
|
2478
|
-
const externals = allExternals(catalog);
|
|
2479
|
-
if (externals.length === 0) return;
|
|
2480
|
-
|
|
2481
|
-
assertUniqueSlugs(
|
|
2482
|
-
externals.map((e) => e.id),
|
|
2483
|
-
"catalog",
|
|
2484
|
-
"external",
|
|
2485
|
-
);
|
|
2486
|
-
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
2487
|
-
|
|
2488
|
-
for (const external of externals) {
|
|
2489
|
-
if (external.slug !== external.id) {
|
|
2490
|
-
fail(
|
|
2491
|
-
`external "${external.id}" has slug "${external.slug}"; an external sits at the root, so its slug must equal its id`,
|
|
2492
|
-
`external ${external.id}`,
|
|
2493
|
-
);
|
|
2494
|
-
}
|
|
2495
|
-
if (external.id.includes(".")) {
|
|
2496
|
-
fail(
|
|
2497
|
-
`external "${external.id}" has a dot in its id; an external sits at the root and is addressed by a bare name`,
|
|
2498
|
-
`external ${external.id}`,
|
|
2499
|
-
);
|
|
2500
|
-
}
|
|
2501
|
-
if (contextIds.has(external.id)) {
|
|
2502
|
-
fail(
|
|
2503
|
-
`external "${external.id}" has the id of a bounded context; the root cannot hold both`,
|
|
2504
|
-
`external ${external.id}`,
|
|
2505
|
-
);
|
|
2506
|
-
}
|
|
2507
|
-
assertUniqueSlugs(
|
|
2508
|
-
external.provides.map((p) => p.id),
|
|
2509
|
-
`external "${external.id}"`,
|
|
2510
|
-
"interface",
|
|
2511
|
-
);
|
|
2512
|
-
for (const provided of external.provides) {
|
|
2513
|
-
assertUniqueSlugs(
|
|
2514
|
-
provided.methods.map((method) => method.name),
|
|
2515
|
-
`interface "${provided.id}"`,
|
|
2516
|
-
"method",
|
|
2517
|
-
);
|
|
2518
|
-
for (const method of provided.methods) {
|
|
2519
|
-
if (
|
|
2520
|
-
method.soap?.version !== undefined &&
|
|
2521
|
-
method.soap.version !== "1.1" &&
|
|
2522
|
-
method.soap.version !== "1.2"
|
|
2523
|
-
) {
|
|
2524
|
-
fail(
|
|
2525
|
-
`method "${provided.id}/${method.name}" uses SOAP ${method.soap.version}; expected 1.1 or 1.2`,
|
|
2526
|
-
`external ${external.id}`,
|
|
2527
|
-
);
|
|
2528
|
-
}
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
|
-
|
|
2534
|
-
/**
|
|
2535
|
-
* A schema may only point at things that exist. A foreign key into a table
|
|
2536
|
-
* nobody declared, or a `persists` naming an aggregate that is not in the
|
|
2537
|
-
* catalog, would draw an edge into open water on a canvas whose whole job is
|
|
2538
|
-
* to show where the edges land — so both fail the build.
|
|
2539
|
-
*
|
|
2540
|
-
* What is NOT checked here: whether an outbox actually carries a payload, and
|
|
2541
|
-
* whether a table's columns still match the aggregate it claims to persist.
|
|
2542
|
-
* Those are judgements about a model that is allowed to be mid-migration, and
|
|
2543
|
-
* they are reported on the Problems page as warnings rather than refusing to
|
|
2544
|
-
* render the catalog at all.
|
|
2545
|
-
*/
|
|
2546
|
-
function validateStores(catalog: Catalog): void {
|
|
2547
|
-
const stores = allStores(catalog);
|
|
2548
|
-
if (stores.length === 0) return;
|
|
2549
|
-
|
|
2550
|
-
const services = new Map(allServices(catalog).map((s) => [s.id, s]));
|
|
2551
|
-
const serviceIds = new Set(services.keys());
|
|
2552
|
-
const aggregates = new Map(allAggregates(catalog).map((a) => [a.id, a]));
|
|
2553
|
-
|
|
2554
|
-
assertUniqueSlugs(
|
|
2555
|
-
stores.map((s) => s.id),
|
|
2556
|
-
"catalog",
|
|
2557
|
-
"store",
|
|
2558
|
-
);
|
|
2559
|
-
|
|
2560
|
-
// Every table id first: a foreign key may point forwards, at a table in a
|
|
2561
|
-
// store declared later in the file.
|
|
2562
|
-
const columnsOfTable = new Map<string, Set<string>>();
|
|
2563
|
-
for (const store of stores) {
|
|
2564
|
-
for (const table of store.tables) {
|
|
2565
|
-
if (columnsOfTable.has(table.id)) {
|
|
2566
|
-
fail(`table id "${table.id}" is not unique`, `store ${store.id}`);
|
|
2567
|
-
}
|
|
2568
|
-
columnsOfTable.set(table.id, new Set(table.columns.map((c) => c.name)));
|
|
2569
|
-
}
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
// Views join the same namespace: a database will not let a view and a table
|
|
2573
|
-
// share a name, and lineage points at both, so one map answers "does this id
|
|
2574
|
-
// exist, and does it have that column" for either.
|
|
2575
|
-
const columnsOfRelation = new Map(columnsOfTable);
|
|
2576
|
-
for (const store of stores) {
|
|
2577
|
-
for (const view of storeViews(store)) {
|
|
2578
|
-
if (columnsOfRelation.has(view.id)) {
|
|
2579
|
-
fail(
|
|
2580
|
-
`view id "${view.id}" collides with another table or view`,
|
|
2581
|
-
`store ${store.id}`,
|
|
2582
|
-
);
|
|
2583
|
-
}
|
|
2584
|
-
columnsOfRelation.set(view.id, new Set(view.columns.map((c) => c.name)));
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
|
-
/** A column reference — "<relation id>.<column>" — that has to resolve. */
|
|
2589
|
-
const checkColumnRef = (ref: string, where: string, what: string): void => {
|
|
2590
|
-
const relation = relationOfColumnId(ref);
|
|
2591
|
-
const columns = columnsOfRelation.get(relation);
|
|
2592
|
-
if (!columns) {
|
|
2593
|
-
fail(
|
|
2594
|
-
`${what} names "${ref}", and "${relation}" is not a table or view in the catalog`,
|
|
2595
|
-
where,
|
|
2596
|
-
);
|
|
2597
|
-
} else if (!columns.has(columnNameOfId(ref))) {
|
|
2598
|
-
fail(
|
|
2599
|
-
`${what} names "${ref}", and "${relation}" has no column "${columnNameOfId(ref)}"`,
|
|
2600
|
-
where,
|
|
2601
|
-
);
|
|
2602
|
-
}
|
|
2603
|
-
};
|
|
2604
|
-
|
|
2605
|
-
for (const store of stores) {
|
|
2606
|
-
if (store.id !== `${store.owner}.${store.slug}`) {
|
|
2607
|
-
fail(
|
|
2608
|
-
`store "${store.id}" is owned by "${store.owner}", so its id must be "${store.owner}.${store.slug}"`,
|
|
2609
|
-
`store ${store.id}`,
|
|
2610
|
-
);
|
|
2611
|
-
}
|
|
2612
|
-
if (!serviceIds.has(store.owner)) {
|
|
2613
|
-
fail(
|
|
2614
|
-
`store "${store.id}" is owned by "${store.owner}", which is not a service in the catalog`,
|
|
2615
|
-
`store ${store.id}`,
|
|
2616
|
-
);
|
|
2617
|
-
}
|
|
2618
|
-
// A store is drawn inside the service that owns it, beside that service's
|
|
2619
|
-
// aggregates, so the two share one namespace in the architecture model. A
|
|
2620
|
-
// store whose slug is also an aggregate's would be one box standing for
|
|
2621
|
-
// two things, and the id it is clicked by would answer with whichever was
|
|
2622
|
-
// registered first.
|
|
2623
|
-
if (
|
|
2624
|
-
services.get(store.owner)?.aggregates.some((a) => a.slug === store.slug)
|
|
2625
|
-
) {
|
|
2626
|
-
fail(
|
|
2627
|
-
`store "${store.id}" has the slug of an aggregate of "${store.owner}"`,
|
|
2628
|
-
`store ${store.id}`,
|
|
2629
|
-
);
|
|
2630
|
-
}
|
|
2631
|
-
if (!STORE_KINDS.includes(store.kind)) {
|
|
2632
|
-
fail(
|
|
2633
|
-
`store "${store.id}" has kind "${store.kind}"; expected one of ${STORE_KINDS.join(", ")}`,
|
|
2634
|
-
`store ${store.id}`,
|
|
2635
|
-
);
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
const keyPatterns = new Set<string>();
|
|
2639
|
-
for (const keyspace of store.keyspaces ?? []) {
|
|
2640
|
-
const where = `store ${store.id} / Redis key ${keyspace.pattern}`;
|
|
2641
|
-
if (store.kind !== "redis") {
|
|
2642
|
-
fail(
|
|
2643
|
-
`store "${store.id}" declares Redis key patterns but has kind "${store.kind}"`,
|
|
2644
|
-
where,
|
|
2645
|
-
);
|
|
2646
|
-
}
|
|
2647
|
-
if (!keyspace.pattern) {
|
|
2648
|
-
fail(`store "${store.id}" has an empty Redis key pattern`, where);
|
|
2649
|
-
}
|
|
2650
|
-
if (keyPatterns.has(keyspace.pattern)) {
|
|
2651
|
-
fail(
|
|
2652
|
-
`store "${store.id}" repeats Redis key pattern "${keyspace.pattern}"`,
|
|
2653
|
-
where,
|
|
2654
|
-
);
|
|
2655
|
-
}
|
|
2656
|
-
keyPatterns.add(keyspace.pattern);
|
|
2657
|
-
if (keyspace.operations.length === 0) {
|
|
2658
|
-
fail(
|
|
2659
|
-
`Redis key pattern "${keyspace.pattern}" has no operations`,
|
|
2660
|
-
where,
|
|
2661
|
-
);
|
|
2662
|
-
}
|
|
2663
|
-
const operations = new Set<string>();
|
|
2664
|
-
for (const operation of keyspace.operations) {
|
|
2665
|
-
if (!REDIS_OPERATIONS.includes(operation)) {
|
|
2666
|
-
fail(
|
|
2667
|
-
`Redis key pattern "${keyspace.pattern}" has operation "${operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
|
|
2668
|
-
where,
|
|
2669
|
-
);
|
|
2670
|
-
}
|
|
2671
|
-
if (operations.has(operation)) {
|
|
2672
|
-
fail(
|
|
2673
|
-
`Redis key pattern "${keyspace.pattern}" repeats operation "${operation}"`,
|
|
2674
|
-
where,
|
|
2675
|
-
);
|
|
2676
|
-
}
|
|
2677
|
-
operations.add(operation);
|
|
2678
|
-
}
|
|
2679
|
-
const aggregateId = keyspace.persists?.aggregate;
|
|
2680
|
-
if (aggregateId && !aggregates.has(aggregateId)) {
|
|
2681
|
-
fail(
|
|
2682
|
-
`Redis key pattern "${keyspace.pattern}" persists unknown aggregate "${aggregateId}"`,
|
|
2683
|
-
where,
|
|
2684
|
-
);
|
|
2685
|
-
}
|
|
2686
|
-
const blockId = keyspace.persists?.block;
|
|
2687
|
-
if (blockId) {
|
|
2688
|
-
const blockAggregate =
|
|
2689
|
-
aggregates.get(blockId.split(".").slice(0, -1).join("."));
|
|
2690
|
-
const belongs = blockAggregate
|
|
2691
|
-
? aggregateBlocks(blockAggregate).some(({ block }) => block.id === blockId)
|
|
2692
|
-
: false;
|
|
2693
|
-
if (!belongs) {
|
|
2694
|
-
fail(
|
|
2695
|
-
`Redis key pattern "${keyspace.pattern}" persists unknown block "${blockId}"`,
|
|
2696
|
-
where,
|
|
2697
|
-
);
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
2700
|
-
for (const access of keyspace.accesses ?? []) {
|
|
2701
|
-
if (!REDIS_OPERATIONS.includes(access.operation)) {
|
|
2702
|
-
fail(
|
|
2703
|
-
`Redis key pattern "${keyspace.pattern}" has access operation "${access.operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
|
|
2704
|
-
where,
|
|
2705
|
-
);
|
|
2706
|
-
}
|
|
2707
|
-
if (!operations.has(access.operation)) {
|
|
2708
|
-
fail(
|
|
2709
|
-
`Redis key pattern "${keyspace.pattern}" has ${access.operation} access absent from its operations summary`,
|
|
2710
|
-
where,
|
|
2711
|
-
);
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
for (const table of store.tables) {
|
|
2717
|
-
const where = `store ${store.id} / table ${table.name}`;
|
|
2718
|
-
if (table.id !== `${store.id}.${table.name}`) {
|
|
2719
|
-
fail(
|
|
2720
|
-
`table "${table.id}" in store "${store.id}" must have id "${store.id}.${table.name}"`,
|
|
2721
|
-
where,
|
|
2722
|
-
);
|
|
2723
|
-
}
|
|
2724
|
-
if (table.role !== undefined && !TABLE_ROLES.includes(table.role)) {
|
|
2725
|
-
fail(
|
|
2726
|
-
`table "${table.id}" has role "${table.role}"; expected one of ${TABLE_ROLES.join(", ")}`,
|
|
2727
|
-
where,
|
|
2728
|
-
);
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
const own = columnsOfTable.get(table.id) ?? new Set<string>();
|
|
2732
|
-
if (own.size !== table.columns.length) {
|
|
2733
|
-
fail(`table "${table.id}" has duplicate column names`, where);
|
|
2734
|
-
}
|
|
2735
|
-
|
|
2736
|
-
const aggregateId = table.persists?.aggregate;
|
|
2737
|
-
const aggregate = aggregateId ? aggregates.get(aggregateId) : undefined;
|
|
2738
|
-
if (aggregateId && !aggregate) {
|
|
2739
|
-
fail(
|
|
2740
|
-
`table "${table.id}" persists unknown aggregate "${aggregateId}"`,
|
|
2741
|
-
where,
|
|
2742
|
-
);
|
|
2743
|
-
}
|
|
2744
|
-
const blockId = table.persists?.block;
|
|
2745
|
-
if (blockId) {
|
|
2746
|
-
// A block is named "<aggregate id>.<slug>", so a block belonging to
|
|
2747
|
-
// another aggregate than the one the table persists is a contradiction
|
|
2748
|
-
// the id itself spells out.
|
|
2749
|
-
const owner =
|
|
2750
|
-
aggregate ??
|
|
2751
|
-
aggregates.get(blockId.split(".").slice(0, -1).join("."));
|
|
2752
|
-
const found = owner
|
|
2753
|
-
? aggregateBlocks(owner).some((b) => b.block.id === blockId)
|
|
2754
|
-
: false;
|
|
2755
|
-
if (!found) {
|
|
2756
|
-
fail(
|
|
2757
|
-
`table "${table.id}" persists block "${blockId}", which is not a block of ${aggregateId ? `aggregate "${aggregateId}"` : "any aggregate in the catalog"}`,
|
|
2758
|
-
where,
|
|
2759
|
-
);
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
for (const index of table.indexes ?? []) {
|
|
2764
|
-
for (const column of index.columns) {
|
|
2765
|
-
if (!own.has(column)) {
|
|
2766
|
-
fail(
|
|
2767
|
-
`index "${index.name}" on table "${table.id}" names column "${column}", which the table does not have`,
|
|
2768
|
-
where,
|
|
2769
|
-
);
|
|
2770
|
-
}
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
for (const column of table.columns) {
|
|
2775
|
-
for (const ref of column.from ?? []) {
|
|
2776
|
-
const self = `${table.id}.${column.name}`;
|
|
2777
|
-
if (ref === self) {
|
|
2778
|
-
fail(
|
|
2779
|
-
`column "${self}" is declared as derived from itself`,
|
|
2780
|
-
`${where} / column ${column.name}`,
|
|
2781
|
-
);
|
|
2782
|
-
}
|
|
2783
|
-
checkColumnRef(
|
|
2784
|
-
ref,
|
|
2785
|
-
`${where} / column ${column.name}`,
|
|
2786
|
-
`column "${column.name}" of table "${table.id}" is derived from a column that`,
|
|
2787
|
-
);
|
|
2788
|
-
}
|
|
2789
|
-
if (!column.fk) continue;
|
|
2790
|
-
const target = columnsOfTable.get(column.fk.table);
|
|
2791
|
-
if (!target) {
|
|
2792
|
-
fail(
|
|
2793
|
-
`column "${column.name}" of table "${table.id}" has a foreign key into "${column.fk.table}", which is not a table in the catalog`,
|
|
2794
|
-
`${where} / column ${column.name}`,
|
|
2795
|
-
);
|
|
2796
|
-
} else if (!target.has(column.fk.column)) {
|
|
2797
|
-
fail(
|
|
2798
|
-
`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`,
|
|
2799
|
-
`${where} / column ${column.name}`,
|
|
2800
|
-
);
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
for (const view of storeViews(store)) {
|
|
2806
|
-
const where = `store ${store.id} / view ${view.name}`;
|
|
2807
|
-
if (view.id !== `${store.id}.${view.name}`) {
|
|
2808
|
-
fail(
|
|
2809
|
-
`view "${view.id}" in store "${store.id}" must have id "${store.id}.${view.name}"`,
|
|
2810
|
-
where,
|
|
2811
|
-
);
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2814
|
-
const own = columnsOfRelation.get(view.id) ?? new Set<string>();
|
|
2815
|
-
if (own.size !== view.columns.length) {
|
|
2816
|
-
fail(`view "${view.id}" has duplicate column names`, where);
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
|
-
const aggregateId = view.persists?.aggregate;
|
|
2820
|
-
if (aggregateId && !aggregates.has(aggregateId)) {
|
|
2821
|
-
fail(
|
|
2822
|
-
`view "${view.id}" presents unknown aggregate "${aggregateId}"`,
|
|
2823
|
-
where,
|
|
2824
|
-
);
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
for (const readId of view.reads ?? []) {
|
|
2828
|
-
if (readId === view.id) {
|
|
2829
|
-
fail(`view "${view.id}" is declared as reading itself`, where);
|
|
2830
|
-
}
|
|
2831
|
-
if (!columnsOfRelation.has(readId)) {
|
|
2832
|
-
fail(
|
|
2833
|
-
`view "${view.id}" reads "${readId}", which is not a table or view in the catalog`,
|
|
2834
|
-
where,
|
|
2835
|
-
);
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
|
|
2839
|
-
for (const column of view.columns) {
|
|
2840
|
-
const at = `${where} / column ${column.name}`;
|
|
2841
|
-
// A view has no rows of its own, so it has no key of its own either.
|
|
2842
|
-
// Saying otherwise would put a key glyph on a card that cannot enforce
|
|
2843
|
-
// one, which is the sort of small lie a schema browser exists to stop.
|
|
2844
|
-
if (column.pk) {
|
|
2845
|
-
fail(
|
|
2846
|
-
`column "${column.name}" of view "${view.id}" is marked as a primary key; a view has no key of its own`,
|
|
2847
|
-
at,
|
|
2848
|
-
);
|
|
2849
|
-
}
|
|
2850
|
-
if (column.fk) {
|
|
2851
|
-
fail(
|
|
2852
|
-
`column "${column.name}" of view "${view.id}" declares a foreign key; a view states what it reads through lineage, not through constraints`,
|
|
2853
|
-
at,
|
|
2854
|
-
);
|
|
2855
|
-
}
|
|
2856
|
-
for (const ref of column.from ?? []) {
|
|
2857
|
-
if (ref === `${view.id}.${column.name}`) {
|
|
2858
|
-
fail(
|
|
2859
|
-
`column "${view.id}.${column.name}" is declared as derived from itself`,
|
|
2860
|
-
at,
|
|
2861
|
-
);
|
|
2862
|
-
}
|
|
2863
|
-
checkColumnRef(
|
|
2864
|
-
ref,
|
|
2865
|
-
at,
|
|
2866
|
-
`column "${column.name}" of view "${view.id}" is derived from a column that`,
|
|
2867
|
-
);
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
|
-
}
|
|
2872
|
-
|
|
2873
|
-
const storeIds = new Set(stores.map((s) => s.id));
|
|
2874
|
-
for (const service of allServices(catalog)) {
|
|
2875
|
-
for (const storeId of service.stores ?? []) {
|
|
2876
|
-
if (!storeIds.has(storeId)) {
|
|
2877
|
-
fail(
|
|
2878
|
-
`service "${service.id}" lists unknown store "${storeId}"`,
|
|
2879
|
-
`service ${service.id}`,
|
|
2880
|
-
);
|
|
2881
|
-
}
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
}
|
|
2885
|
-
|
|
2886
|
-
/**
|
|
2887
|
-
* Frames have to mean what they say. An alt with one branch is not a choice, an
|
|
2888
|
-
* untitled branch states no condition, and steps written after an alt whose
|
|
2889
|
-
* every branch is terminal can never run — each of those would be drawn as a
|
|
2890
|
-
* perfectly ordinary sequence, which is exactly the reading we are trying to
|
|
2891
|
-
* stop, so they fail the build instead.
|
|
2892
|
-
*/
|
|
2893
|
-
function validateFlowFrames(flow: Flow, nodes: FlowNode[]): void {
|
|
2894
|
-
nodes.forEach((node, i) => {
|
|
2895
|
-
switch (node.type) {
|
|
2896
|
-
case "step":
|
|
2897
|
-
break;
|
|
2898
|
-
case "parallel":
|
|
2899
|
-
for (const branch of node.branches) validateFlowFrames(flow, branch);
|
|
2900
|
-
break;
|
|
2901
|
-
case "loop":
|
|
2902
|
-
if (!node.title) {
|
|
2903
|
-
fail(
|
|
2904
|
-
`flow "${flow.slug}" loop "${node.id}" has no title, so the diagram cannot say what it repeats until`,
|
|
2905
|
-
`flow ${flow.id} / loop ${node.id}`,
|
|
2906
|
-
);
|
|
2907
|
-
}
|
|
2908
|
-
validateFlowFrames(flow, node.steps);
|
|
2909
|
-
break;
|
|
2910
|
-
case "alt": {
|
|
2911
|
-
if (node.branches.length < 2) {
|
|
2912
|
-
fail(
|
|
2913
|
-
`flow "${flow.slug}" alt "${node.id}" has ${node.branches.length} branch(es); an alt states a choice and needs at least two`,
|
|
2914
|
-
`flow ${flow.id} / alt ${node.id}`,
|
|
2915
|
-
);
|
|
2916
|
-
}
|
|
2917
|
-
const titles = new Set<string>();
|
|
2918
|
-
for (const branch of node.branches) {
|
|
2919
|
-
if (!branch.title) {
|
|
2920
|
-
fail(
|
|
2921
|
-
`flow "${flow.slug}" alt "${node.id}" has a branch with no title, so nothing says when it runs`,
|
|
2922
|
-
`flow ${flow.id} / alt ${node.id}`,
|
|
2923
|
-
);
|
|
2924
|
-
}
|
|
2925
|
-
if (titles.has(branch.title)) {
|
|
2926
|
-
fail(
|
|
2927
|
-
`flow "${flow.slug}" alt "${node.id}" has two branches titled "${branch.title}"`,
|
|
2928
|
-
`flow ${flow.id} / alt ${node.id}`,
|
|
2929
|
-
);
|
|
2930
|
-
}
|
|
2931
|
-
titles.add(branch.title);
|
|
2932
|
-
validateFlowFrames(flow, branch.steps);
|
|
2933
|
-
}
|
|
2934
|
-
if (node.branches.every((b) => b.terminal) && i < nodes.length - 1) {
|
|
2935
|
-
fail(
|
|
2936
|
-
`flow "${flow.slug}" alt "${node.id}": every branch is terminal, so the ${nodes.length - 1 - i} node(s) after it can never run`,
|
|
2937
|
-
`flow ${flow.id} / alt ${node.id}`,
|
|
2938
|
-
);
|
|
2939
|
-
}
|
|
2940
|
-
break;
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
});
|
|
2944
|
-
}
|
|
2945
|
-
|
|
2946
|
-
/**
|
|
2947
|
-
* An aggregate is a root entity plus the entities and value objects it owns.
|
|
2948
|
-
* The root has to be one of those entities: an aggregate that names a root it
|
|
2949
|
-
* does not list is a modelling mistake, not a rendering one, and the tree would
|
|
2950
|
-
* quietly print a line pointing at nothing.
|
|
2951
|
-
*/
|
|
2952
|
-
/**
|
|
2953
|
-
* An enum is a set: its slug is unique among the aggregate's enums, its id
|
|
2954
|
-
* is spelled from the aggregate's, and its values are named once each and
|
|
2955
|
-
* are at least one. An empty enum is not a fact about a closed set, it is a
|
|
2956
|
-
* reader that found the type and none of its members.
|
|
2957
|
-
*/
|
|
2958
|
-
function validateEnums(aggregate: Aggregate): void {
|
|
2959
|
-
if (aggregate.enums !== undefined && !Array.isArray(aggregate.enums)) {
|
|
2960
|
-
fail(
|
|
2961
|
-
`aggregate "${aggregate.id}" has an enums list that is not a list`,
|
|
2962
|
-
`aggregate ${aggregate.id}`,
|
|
2963
|
-
);
|
|
2964
|
-
}
|
|
2965
|
-
const enums = enumsOf(aggregate);
|
|
2966
|
-
assertUniqueSlugs(
|
|
2967
|
-
enums.map((e) => e.slug),
|
|
2968
|
-
`aggregate "${aggregate.id}"`,
|
|
2969
|
-
"enum",
|
|
2970
|
-
);
|
|
2971
|
-
for (const item of enums) {
|
|
2972
|
-
const where = `aggregate ${aggregate.id} / enum ${item.slug}`;
|
|
2973
|
-
if (item.id !== `${aggregate.id}.${item.slug}`) {
|
|
2974
|
-
fail(
|
|
2975
|
-
`enum "${item.id}" in aggregate "${aggregate.id}" must have id "${aggregate.id}.${item.slug}"`,
|
|
2976
|
-
where,
|
|
2977
|
-
);
|
|
2978
|
-
}
|
|
2979
|
-
if (!Array.isArray(item.values) || item.values.length === 0) {
|
|
2980
|
-
fail(`enum "${item.id}" has no values`, where);
|
|
2981
|
-
}
|
|
2982
|
-
const seen = new Set<string>();
|
|
2983
|
-
for (const value of item.values) {
|
|
2984
|
-
if (!value.name) {
|
|
2985
|
-
fail(`enum "${item.id}" has a value with no name`, where);
|
|
2986
|
-
}
|
|
2987
|
-
if (seen.has(value.name)) {
|
|
2988
|
-
fail(`enum "${item.id}" lists value "${value.name}" twice`, where);
|
|
2989
|
-
}
|
|
2990
|
-
seen.add(value.name);
|
|
2991
|
-
}
|
|
2992
|
-
}
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
|
|
2996
|
-
for (const [what, list] of [
|
|
2997
|
-
["entities", aggregate.entities],
|
|
2998
|
-
["valueObjects", aggregate.valueObjects],
|
|
2999
|
-
] as const) {
|
|
3000
|
-
if (!Array.isArray(list)) {
|
|
3001
|
-
fail(
|
|
3002
|
-
`aggregate "${aggregate.id}" is missing its ${what} list`,
|
|
3003
|
-
`aggregate ${aggregate.id}`,
|
|
3004
|
-
);
|
|
3005
|
-
}
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
assertUniqueSlugs(
|
|
3009
|
-
aggregate.entities.map((e) => e.slug),
|
|
3010
|
-
`aggregate "${aggregate.id}"`,
|
|
3011
|
-
"entity",
|
|
3012
|
-
);
|
|
3013
|
-
assertUniqueSlugs(
|
|
3014
|
-
aggregate.valueObjects.map((v) => v.slug),
|
|
3015
|
-
`aggregate "${aggregate.id}"`,
|
|
3016
|
-
"value object",
|
|
3017
|
-
);
|
|
3018
|
-
|
|
3019
|
-
for (const { kind, block } of aggregateBlocks(aggregate)) {
|
|
3020
|
-
const what = kind === "vo" ? "value object" : "entity";
|
|
3021
|
-
if (block.id !== `${aggregate.id}.${block.slug}`) {
|
|
3022
|
-
fail(
|
|
3023
|
-
`${what} "${block.id}" in aggregate "${aggregate.id}" must have id "${aggregate.id}.${block.slug}"`,
|
|
3024
|
-
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
3025
|
-
);
|
|
3026
|
-
}
|
|
3027
|
-
if (block.ref !== undefined && !(block.ref in catalog.defs)) {
|
|
3028
|
-
fail(
|
|
3029
|
-
`${what} "${block.id}" references unknown def "${block.ref}"`,
|
|
3030
|
-
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
3031
|
-
);
|
|
3032
|
-
}
|
|
3033
|
-
if (block.ref === undefined && (block.fields ?? []).length === 0) {
|
|
3034
|
-
fail(
|
|
3035
|
-
`${what} "${block.id}" has neither a def ref nor any fields of its own`,
|
|
3036
|
-
`aggregate ${aggregate.id} / ${what} ${block.slug}`,
|
|
3037
|
-
);
|
|
3038
|
-
}
|
|
3039
|
-
for (const field of block.fields ?? []) {
|
|
3040
|
-
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
3041
|
-
fail(
|
|
3042
|
-
`field "${field.name}" of ${what} "${block.id}" references unknown def "${field.ref}"`,
|
|
3043
|
-
`aggregate ${aggregate.id} / ${what} ${block.slug} / field ${field.name}`,
|
|
3044
|
-
);
|
|
3045
|
-
}
|
|
3046
|
-
}
|
|
3047
|
-
}
|
|
3048
|
-
|
|
3049
|
-
validateEnums(aggregate);
|
|
3050
|
-
|
|
3051
|
-
if (!aggregate.root) {
|
|
3052
|
-
fail(
|
|
3053
|
-
`aggregate "${aggregate.id}" names no root entity`,
|
|
3054
|
-
`aggregate ${aggregate.id}`,
|
|
3055
|
-
);
|
|
3056
|
-
}
|
|
3057
|
-
if (!rootEntity(aggregate)) {
|
|
3058
|
-
fail(
|
|
3059
|
-
`aggregate "${aggregate.id}" names root "${aggregate.root}", which is not one of its entities`,
|
|
3060
|
-
`aggregate ${aggregate.id}`,
|
|
3061
|
-
);
|
|
3062
|
-
}
|
|
3063
|
-
if (aggregate.lifecycle) validateLifecycle(aggregate, aggregate.lifecycle);
|
|
3064
|
-
}
|
|
3065
|
-
|
|
3066
|
-
/**
|
|
3067
|
-
* A lifecycle names only states it lists and events the aggregate owns. A
|
|
3068
|
-
* transition into a state nobody listed is a typo that would draw a box the
|
|
3069
|
-
* code never reaches; a transition emitting an event of another aggregate is
|
|
3070
|
-
* a claim the aggregate's own page could not follow.
|
|
3071
|
-
*/
|
|
3072
|
-
function validateLifecycle(aggregate: Aggregate, lifecycle: Lifecycle): void {
|
|
3073
|
-
const where = `aggregate ${aggregate.id} / lifecycle`;
|
|
3074
|
-
if (lifecycle.states.length === 0) {
|
|
3075
|
-
fail(`aggregate "${aggregate.id}" has a lifecycle with no states`, where);
|
|
3076
|
-
}
|
|
3077
|
-
const states = new Set<string>();
|
|
3078
|
-
for (const state of lifecycle.states) {
|
|
3079
|
-
if (states.has(state)) {
|
|
3080
|
-
fail(`aggregate "${aggregate.id}" lists state "${state}" twice`, where);
|
|
3081
|
-
}
|
|
3082
|
-
states.add(state);
|
|
3083
|
-
}
|
|
3084
|
-
const events = new Set(aggregate.events.map((e) => e.id));
|
|
3085
|
-
for (const t of lifecycle.transitions) {
|
|
3086
|
-
for (const end of [t.from, t.to]) {
|
|
3087
|
-
if (!states.has(end)) {
|
|
3088
|
-
fail(
|
|
3089
|
-
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} on ${t.on}, and "${end}" is not one of its states`,
|
|
3090
|
-
where,
|
|
3091
|
-
);
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
if (!t.on) {
|
|
3095
|
-
fail(
|
|
3096
|
-
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} on nothing`,
|
|
3097
|
-
where,
|
|
3098
|
-
);
|
|
3099
|
-
}
|
|
3100
|
-
if (t.emits !== undefined && !events.has(t.emits)) {
|
|
3101
|
-
fail(
|
|
3102
|
-
`aggregate "${aggregate.id}" moves ${t.from} → ${t.to} emitting "${t.emits}", which is not one of its events`,
|
|
3103
|
-
where,
|
|
3104
|
-
);
|
|
3105
|
-
}
|
|
3106
|
-
}
|
|
3107
|
-
}
|
|
3108
|
-
|
|
3109
|
-
/**
|
|
3110
|
-
* A term belongs to a context that exists, and its id says which one.
|
|
3111
|
-
*
|
|
3112
|
-
* The composition check is the one that earns its place. A glossary sits
|
|
3113
|
-
* beside a SERVICE and the words in it belong to a context, so the extractor
|
|
3114
|
-
* has to be told which - and told nothing, it falls back to the directory's
|
|
3115
|
-
* name. Left that way, `examples/shop/oms/GLOSSARY.md` produces `oms.order`
|
|
3116
|
-
* in a context called `oms` that nothing else in the estate has heard of, and
|
|
3117
|
-
* every one of its terms is a word the reader can never find from the page
|
|
3118
|
-
* that uses it. Failing here names the step; the alternative is a vocabulary
|
|
3119
|
-
* that loads and answers nothing.
|
|
3120
|
-
*/
|
|
3121
|
-
function validateTerms(catalog: Catalog): void {
|
|
3122
|
-
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
3123
|
-
const ids = new Set<string>();
|
|
3124
|
-
|
|
3125
|
-
for (const term of allTerms(catalog)) {
|
|
3126
|
-
const where = `term ${term.id}`;
|
|
3127
|
-
if (term.id !== `${term.context}.${term.slug}`) {
|
|
3128
|
-
fail(
|
|
3129
|
-
`term "${term.id}" must have id "${term.context}.${term.slug}"`,
|
|
3130
|
-
where,
|
|
3131
|
-
);
|
|
3132
|
-
}
|
|
3133
|
-
if (ids.has(term.id)) fail(`term id "${term.id}" is not unique`, where);
|
|
3134
|
-
ids.add(term.id);
|
|
3135
|
-
if (!term.name) fail(`term "${term.id}" has no name`, where);
|
|
3136
|
-
if (!term.definition) {
|
|
3137
|
-
fail(`term "${term.id}" says nothing about what it is`, where);
|
|
3138
|
-
}
|
|
3139
|
-
if (!contextIds.has(term.context)) {
|
|
3140
|
-
fail(
|
|
3141
|
-
`term "${term.id}" belongs to context "${term.context}", which the catalog does not declare`,
|
|
3142
|
-
where,
|
|
3143
|
-
);
|
|
3144
|
-
}
|
|
3145
|
-
}
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
/**
|
|
3149
|
-
* A decision record may only point at things that exist. A dangling relates
|
|
3150
|
-
* entry or a half-written supersession would let the UI draw a link to
|
|
3151
|
-
* nowhere, so both fail the build instead.
|
|
3152
|
-
*/
|
|
3153
|
-
function validateAdrs(catalog: Catalog, eventIds: Set<string>): void {
|
|
3154
|
-
if (!Array.isArray(catalog.adrs)) fail("catalog.adrs is missing", "catalog");
|
|
3155
|
-
|
|
3156
|
-
const serviceIds = new Set(allServices(catalog).map((s) => s.id));
|
|
3157
|
-
const contextIds = new Set(catalog.contexts.map((c) => c.id));
|
|
3158
|
-
const flowSlugs = new Set(catalog.flows.map((f) => f.slug));
|
|
3159
|
-
|
|
3160
|
-
assertUniqueSlugs(
|
|
3161
|
-
catalog.adrs.map((a) => a.slug),
|
|
3162
|
-
"catalog",
|
|
3163
|
-
"adr",
|
|
3164
|
-
);
|
|
3165
|
-
|
|
3166
|
-
const byId = new Map<string, Adr>();
|
|
3167
|
-
for (const adr of catalog.adrs) {
|
|
3168
|
-
if (byId.has(adr.id))
|
|
3169
|
-
fail(`adr id "${adr.id}" is not unique`, `decision ${adr.id}`);
|
|
3170
|
-
byId.set(adr.id, adr);
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
for (const adr of catalog.adrs) {
|
|
3174
|
-
const padded = String(adr.number).padStart(4, "0");
|
|
3175
|
-
if (!adr.id.endsWith(`.${padded}`)) {
|
|
3176
|
-
fail(
|
|
3177
|
-
`adr "${adr.id}" must end with its number, "${padded}"`,
|
|
3178
|
-
`decision ${adr.id}`,
|
|
3179
|
-
);
|
|
3180
|
-
}
|
|
3181
|
-
if (Number.isNaN(new Date(adr.date).getTime())) {
|
|
3182
|
-
fail(
|
|
3183
|
-
`adr "${adr.id}" has an unparseable date "${adr.date}"`,
|
|
3184
|
-
`decision ${adr.id}`,
|
|
3185
|
-
);
|
|
3186
|
-
}
|
|
3187
|
-
|
|
3188
|
-
switch (adr.scope.kind) {
|
|
3189
|
-
case "context":
|
|
3190
|
-
if (!contextIds.has(adr.scope.context)) {
|
|
3191
|
-
fail(
|
|
3192
|
-
`adr "${adr.id}" is scoped to unknown context "${adr.scope.context}"`,
|
|
3193
|
-
`decision ${adr.id}`,
|
|
3194
|
-
);
|
|
3195
|
-
}
|
|
3196
|
-
break;
|
|
3197
|
-
case "service":
|
|
3198
|
-
if (!serviceIds.has(adr.scope.service)) {
|
|
3199
|
-
fail(
|
|
3200
|
-
`adr "${adr.id}" is scoped to unknown service "${adr.scope.service}"`,
|
|
3201
|
-
`decision ${adr.id}`,
|
|
3202
|
-
);
|
|
3203
|
-
}
|
|
3204
|
-
break;
|
|
3205
|
-
case "org":
|
|
3206
|
-
break;
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
for (const serviceId of adr.relates.services ?? []) {
|
|
3210
|
-
if (!serviceIds.has(serviceId)) {
|
|
3211
|
-
fail(
|
|
3212
|
-
`adr "${adr.id}" relates to unknown service "${serviceId}"`,
|
|
3213
|
-
`decision ${adr.id}`,
|
|
3214
|
-
);
|
|
3215
|
-
}
|
|
3216
|
-
}
|
|
3217
|
-
for (const eventId of adr.relates.events ?? []) {
|
|
3218
|
-
if (!eventIds.has(eventId)) {
|
|
3219
|
-
fail(
|
|
3220
|
-
`adr "${adr.id}" relates to unknown event "${eventId}"`,
|
|
3221
|
-
`decision ${adr.id}`,
|
|
3222
|
-
);
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
for (const flowSlug of adr.relates.flows ?? []) {
|
|
3226
|
-
if (!flowSlugs.has(flowSlug)) {
|
|
3227
|
-
fail(
|
|
3228
|
-
`adr "${adr.id}" relates to unknown flow "${flowSlug}"`,
|
|
3229
|
-
`decision ${adr.id}`,
|
|
3230
|
-
);
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
// Supersession is a two-way fact. Recording one half of it is a bug in
|
|
3235
|
-
// whatever wrote the catalog, not a display problem to paper over.
|
|
3236
|
-
if (adr.status === "superseded" && !adr.supersededBy) {
|
|
3237
|
-
fail(
|
|
3238
|
-
`adr "${adr.id}" is superseded but names no supersededBy`,
|
|
3239
|
-
`decision ${adr.id}`,
|
|
3240
|
-
);
|
|
3241
|
-
}
|
|
3242
|
-
if (adr.supersededBy !== undefined) {
|
|
3243
|
-
if (adr.status !== "superseded") {
|
|
3244
|
-
fail(
|
|
3245
|
-
`adr "${adr.id}" names supersededBy "${adr.supersededBy}" but its status is "${adr.status}", not "superseded"`,
|
|
3246
|
-
`decision ${adr.id}`,
|
|
3247
|
-
);
|
|
3248
|
-
}
|
|
3249
|
-
const successor = byId.get(adr.supersededBy);
|
|
3250
|
-
if (!successor) {
|
|
3251
|
-
fail(
|
|
3252
|
-
`adr "${adr.id}" is superseded by unknown adr "${adr.supersededBy}"`,
|
|
3253
|
-
`decision ${adr.id}`,
|
|
3254
|
-
);
|
|
3255
|
-
} else if (!(successor.supersedes ?? []).includes(adr.id)) {
|
|
3256
|
-
fail(
|
|
3257
|
-
`adr "${adr.id}" is superseded by "${successor.id}", but "${successor.id}" does not list it in supersedes`,
|
|
3258
|
-
`decision ${adr.id}`,
|
|
3259
|
-
);
|
|
3260
|
-
}
|
|
3261
|
-
}
|
|
3262
|
-
for (const supersededId of adr.supersedes ?? []) {
|
|
3263
|
-
const predecessor = byId.get(supersededId);
|
|
3264
|
-
if (!predecessor) {
|
|
3265
|
-
fail(
|
|
3266
|
-
`adr "${adr.id}" supersedes unknown adr "${supersededId}"`,
|
|
3267
|
-
`decision ${adr.id}`,
|
|
3268
|
-
);
|
|
3269
|
-
} else if (predecessor.supersededBy !== adr.id) {
|
|
3270
|
-
fail(
|
|
3271
|
-
`adr "${adr.id}" supersedes "${supersededId}", but "${supersededId}" is not marked superseded by it`,
|
|
3272
|
-
`decision ${adr.id}`,
|
|
3273
|
-
);
|
|
3274
|
-
}
|
|
3275
|
-
}
|
|
3276
|
-
}
|
|
3277
|
-
}
|
|
3278
|
-
|
|
3279
|
-
/**
|
|
3280
|
-
* A pin names a repository and a commit, and names each repository once.
|
|
3281
|
-
*
|
|
3282
|
-
* Nothing else is checked here, and one omission is deliberate: a pin for a
|
|
3283
|
-
* repository no service claims to live in is NOT an error. The merge unions
|
|
3284
|
-
* sources that do not know each other, and a repository fetched for its protos
|
|
3285
|
-
* before anything reads its code is a normal intermediate state - the pin is
|
|
3286
|
-
* simply never looked up. What would be a real problem is one repository
|
|
3287
|
-
* pinned to two commits, and that is caught in the merge, where both sources
|
|
3288
|
-
* are still known and the reader can be told which file lost.
|
|
3289
|
-
*/
|
|
3290
|
-
function validateRepos(catalog: Catalog): void {
|
|
3291
|
-
const seen = new Set<string>();
|
|
3292
|
-
|
|
3293
|
-
for (const pin of allRepos(catalog)) {
|
|
3294
|
-
const where = `repo ${pin.repo || "?"}`;
|
|
3295
|
-
if (!pin.repo) fail("a repo pin names no repository", where);
|
|
3296
|
-
if (!pin.commit) {
|
|
3297
|
-
fail(
|
|
3298
|
-
`repo "${pin.repo}" is pinned to nothing; a pin without a commit is not a place a link can point at`,
|
|
3299
|
-
where,
|
|
3300
|
-
);
|
|
3301
|
-
}
|
|
3302
|
-
if (seen.has(pin.repo)) {
|
|
3303
|
-
fail(`repo "${pin.repo}" is pinned twice in one catalog`, where);
|
|
3304
|
-
}
|
|
3305
|
-
seen.add(pin.repo);
|
|
3306
|
-
}
|
|
3307
|
-
}
|
|
5
|
+
export * from "./catalog-model.ts";
|
|
6
|
+
export * from "./catalog-index.ts";
|
|
7
|
+
export * from "./catalog-validation.ts";
|