@shortlink-org/portolan 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/portolan-go.wasm +0 -0
  5. package/scripts/builtin-plugins.mjs +3 -2
  6. package/scripts/catalog-sources.mjs +2 -1
  7. package/scripts/catalog-sources.test.mjs +11 -1
  8. package/scripts/delivery-presets.mjs +207 -24
  9. package/scripts/diff.mjs +5 -1
  10. package/scripts/local-api.mjs +26 -377
  11. package/scripts/local-api.test.mjs +50 -0
  12. package/scripts/local-discovery.mjs +378 -0
  13. package/scripts/manifest.mjs +42 -1
  14. package/scripts/manifest.test.mjs +24 -1
  15. package/scripts/run-builtin.mjs +4 -2
  16. package/scripts/schema.mjs +4 -0
  17. package/scripts/site-docs.mjs +2 -2
  18. package/src/app/CatalogApp.tsx +4 -4
  19. package/src/app/Sidebar.tsx +13 -730
  20. package/src/app/SidebarFlowSections.tsx +251 -0
  21. package/src/app/SidebarFooter.tsx +160 -0
  22. package/src/app/SidebarTree.tsx +322 -0
  23. package/src/catalog-index.ts +485 -0
  24. package/src/catalog-model.ts +1339 -0
  25. package/src/catalog-validation.ts +1570 -0
  26. package/src/catalog.test.ts +16 -0
  27. package/src/catalog.ts +6 -3306
  28. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  29. package/src/components/ProblemRow.tsx +3 -0
  30. package/src/components/SourcePreview.tsx +1 -1
  31. package/src/index.css +0 -17
  32. package/src/landing/LandingPage.tsx +4 -4
  33. package/src/lib/all-problems.ts +1 -1
  34. package/src/lib/derive.ts +1 -0
  35. package/src/lib/local-api.ts +19 -7
  36. package/src/lib/motion.test.ts +4 -2
  37. package/src/lib/motion.tsx +5 -4
  38. package/src/lib/proto-problems.test.ts +170 -3
  39. package/src/lib/proto-problems.ts +176 -4
  40. package/src/merge.test.ts +46 -0
  41. package/src/merge.ts +35 -1
  42. package/src/pages/Settings.tsx +1 -126
  43. package/src/pages/settings/AboutSettings.tsx +129 -0
  44. package/src/pages/settings/DeliverySettings.tsx +71 -15
  45. package/src/selection/pages.test.ts +14 -1
  46. package/src/selection/pages.ts +9 -3
  47. package/vite.config.ts +3 -1
package/src/catalog.ts CHANGED
@@ -1,3307 +1,7 @@
1
- // The contract. Every fact rendered by portolan comes from a Catalog value,
2
- // and every Catalog value is validated before the app is allowed to draw it.
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 type Status = "verified" | "declared" | "unresolved";
5
-
6
- /** Every status, best first: the order a count or a filter lists them in. */
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";