@shortlink-org/portolan 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -0,0 +1,1360 @@
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.
3
+
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
+ /** Interfaces read from vendored proto copies, retained for drift checks. */
132
+ copies?: RpcService[];
133
+ aggregates: Aggregate[];
134
+ /**
135
+ * Stores this service touches, by id — the ones it owns and the ones it only
136
+ * reads. Ownership is not stated here: a store names its own owner, so a
137
+ * service listing a store it does not own is reading it, and the pages say
138
+ * so rather than guessing.
139
+ */
140
+ stores?: string[];
141
+ /**
142
+ * Schema modules this service publishes or vendors, by id. Which of the two
143
+ * is not stated here: a module names its own owner, so a module in this list
144
+ * that does not call this service its owner is one the service reads.
145
+ */
146
+ modules?: string[];
147
+ /**
148
+ * Channels this service declares it publishes on or listens to, read out of
149
+ * an AsyncAPI document. Absent for a service with no such document, which is
150
+ * not the same as a service that speaks to nobody.
151
+ */
152
+ channels?: Channel[];
153
+ /**
154
+ * Who to ask about it, as CODEOWNERS spells them: `@acme/oms-team`,
155
+ * `@someone`, `dev@acme.io`.
156
+ *
157
+ * Handles, and deliberately nothing more. Resolving one to the people in it
158
+ * is a call to a forge's API, which needs a credential, answers differently
159
+ * tomorrow, and would put the estate's documentation behind an outage. A
160
+ * handle is what the reviewer types and what the file says, so a handle is
161
+ * what the page shows.
162
+ *
163
+ * Absent means nobody was named, which is not the same as nobody owning it -
164
+ * an estate that keeps no CODEOWNERS has an owner for everything and has
165
+ * written it down nowhere.
166
+ */
167
+ owners?: string[];
168
+ /**
169
+ * What a developer types against the checkout: the make targets, npm
170
+ * scripts, just recipes and task-runner tasks the repository declares. Read
171
+ * from the runner files, never from the README, so the list is the one the
172
+ * runner would accept. Absent when nothing declares any, which is not the
173
+ * same as a service that cannot be built.
174
+ */
175
+ commands?: Command[];
176
+ }
177
+
178
+ /**
179
+ * One entry of a task runner's file: a make target, an npm script, a just
180
+ * recipe, a Taskfile task, a poe or pdm task.
181
+ *
182
+ * `run` is the whole point - the line a reader copies - and it is spelled
183
+ * here rather than rebuilt from the runner and the name, because `npm test`
184
+ * and `npm run typecheck` are two spellings of one runner and the page should
185
+ * not have to know which scripts npm treats specially.
186
+ */
187
+ export interface Command {
188
+ /** The tool the line is typed at: make, npm, pnpm, yarn, bun, just, task, poe, pdm. */
189
+ runner: string;
190
+ /** The target, script, recipe or task as its file spells it. */
191
+ name: string;
192
+ /** The line to type at a shell in the service's directory. */
193
+ run: string;
194
+ /**
195
+ * What the file says the command is for, when it says anything: a `##`
196
+ * comment on a make target, a `#` line over a just recipe, a task's `desc`,
197
+ * a poe task's `help`. Most files say nothing.
198
+ */
199
+ doc?: string;
200
+ /**
201
+ * What the runner executes for it: the recipe, the script line, the cmds.
202
+ * Carried so a name that says nothing can still be read, and shown folded,
203
+ * because a build script is not a sentence.
204
+ */
205
+ body?: string;
206
+ /** The file and line the entry was read at. */
207
+ source?: string;
208
+ }
209
+
210
+ export type ComponentKind =
211
+ | "service"
212
+ | "application"
213
+ | "webapp"
214
+ | "worker"
215
+ | "job"
216
+ | "function"
217
+ | "cli"
218
+ | "library"
219
+ | "data-pipeline";
220
+
221
+ export const COMPONENT_KINDS: readonly ComponentKind[] = [
222
+ "service",
223
+ "application",
224
+ "webapp",
225
+ "worker",
226
+ "job",
227
+ "function",
228
+ "cli",
229
+ "library",
230
+ "data-pipeline",
231
+ ] as const;
232
+
233
+ /** Neutral vocabulary for consumers that do not assume DDD. */
234
+ export type Group = BoundedContext;
235
+ export type Component = Service;
236
+ export interface RpcService {
237
+ id: string;
238
+ methods: RpcMethod[];
239
+ source: string;
240
+ /**
241
+ * Request and response shapes, when the generator could read them. Optional:
242
+ * a service whose protos were not parsed still lists its methods.
243
+ */
244
+ messages?: RpcMessage[];
245
+ /**
246
+ * The enums the messages' fields name, reached the way `messages` are:
247
+ * from the methods, through the fields, as far as the document declares.
248
+ */
249
+ enums?: RpcEnum[];
250
+ /** The schema module declaring this interface, by `ProtoModule.id`. */
251
+ module?: string;
252
+ }
253
+
254
+ /**
255
+ * One method of one interface.
256
+ *
257
+ * A string would have done for the name, and did until protos were read. What
258
+ * a string could not carry is the shapes on either side: an endpoint whose
259
+ * request and response are named is one a reader can follow without opening
260
+ * the source, and a streaming method drawn as a unary call is a lie about how
261
+ * the two ends are coupled.
262
+ *
263
+ * Only `name` is required. An interface read from an OpenAPI document supplies
264
+ * nothing else, and must keep reading the way it always did.
265
+ *
266
+ * There is no id here. `<rpcServiceId>/<name>` is already how the app spells
267
+ * one, everywhere it needs one, and a stored copy would be a second place for
268
+ * it to be wrong.
269
+ */
270
+ export interface RpcMethod {
271
+ /**
272
+ * The name as the interface declares it - a proto method, an OpenAPI
273
+ * `operationId`. This is what `Operation.exposedBy` names.
274
+ */
275
+ name: string;
276
+ doc?: string;
277
+ /**
278
+ * The request and response messages, by the name they carry in
279
+ * `RpcService.messages`. `ref` keys `catalog.defs` when the shape is shared -
280
+ * the same pairing, for the same reason, as `Field.type` and `Field.ref`.
281
+ */
282
+ request?: string;
283
+ requestRef?: string;
284
+ response?: string;
285
+ responseRef?: string;
286
+ /** How the method streams. Absent is unary, which is most of them. */
287
+ streaming?: Streaming;
288
+ deprecated?: boolean;
289
+ /**
290
+ * The route, for a method read from an OpenAPI document: the verb and the
291
+ * path template as the document writes them. This is what lets a request
292
+ * seen on the wire be read back to the operation it ran.
293
+ */
294
+ http?: HttpRoute;
295
+ /** Concrete SOAP binding read from a WSDL operation. */
296
+ soap?: SoapRoute;
297
+ }
298
+
299
+ export interface HttpRoute {
300
+ /** Upper case: `POST`. */
301
+ method: string;
302
+ /** As templated in the document: `/v1/users/{id}`. */
303
+ path: string;
304
+ }
305
+
306
+ export interface SoapRoute {
307
+ action?: string;
308
+ version?: "1.1" | "1.2";
309
+ style?: string;
310
+ endpoint?: string;
311
+ binding?: string;
312
+ faults?: string[];
313
+ headers?: string[];
314
+ }
315
+
316
+ export type Streaming = "client" | "server" | "bidi";
317
+
318
+ export const STREAMING: readonly Streaming[] = [
319
+ "client",
320
+ "server",
321
+ "bidi",
322
+ ] as const;
323
+ /** An enum a proto declares. The number is what a binary message carries. */
324
+ export interface RpcEnum {
325
+ name: string;
326
+ doc?: string;
327
+ values: RpcEnumValue[];
328
+ }
329
+ export interface RpcEnumValue {
330
+ name: string;
331
+ number: number;
332
+ doc?: string;
333
+ }
334
+
335
+ export interface RpcMessage {
336
+ name: string; // "PlaceOrderRequest"
337
+ fields: Field[];
338
+ /** How this OpenAPI message selects one concrete variant on the wire. */
339
+ discriminator?: RpcDiscriminator;
340
+ }
341
+
342
+ export interface RpcDiscriminator {
343
+ /** JSON property carrying the discriminator value. */
344
+ property: string;
345
+ variants: RpcVariant[];
346
+ }
347
+
348
+ export interface RpcVariant {
349
+ /** Value carried in `property`. */
350
+ value: string;
351
+ /** Concrete message selected by that value. */
352
+ message: string;
353
+ }
354
+ /**
355
+ * Where a derived edge was read from: the flow step that implies it. A
356
+ * consumer or a call carrying `via` was not declared by any source; it is
357
+ * what a flow already said, written where the graph can read it. It is kept
358
+ * as a field rather than a note because the UI links back to the step.
359
+ */
360
+ export interface EdgeVia {
361
+ flow: string; // Flow.slug
362
+ step: string; // Step.id
363
+ }
364
+ export interface RpcCall {
365
+ id: string; // "<proto.package.Service>/<Method>"
366
+ peer: string; // service id if resolved, else raw name
367
+ status: Status;
368
+ source: string;
369
+ note?: string;
370
+ /** The module the vendored copy this call was read from belongs to. */
371
+ module?: string;
372
+ /** Set when the call was derived from a flow step rather than declared. */
373
+ via?: EdgeVia;
374
+ }
375
+
376
+ /**
377
+ * A schema module: a set of .proto files with a name, a version and a
378
+ * publisher - `buf.build/acme/shop`.
379
+ *
380
+ * It sits at the top level rather than inside the service that publishes it,
381
+ * because the interesting fact about a module is usually who ELSE reads it.
382
+ *
383
+ * Its id is the module's own registry-global name and NOT `<owner>.<slug>` the
384
+ * way a store's is. A store is declared by exactly one source - the service
385
+ * that owns it - so deriving its id from its owner is safe. A module is
386
+ * declared by several sources that do not know each other: the producer's
387
+ * extractor knows which service publishes it, and the consumer's extractor,
388
+ * reading a vendored copy in another repository, knows only the module name.
389
+ * Since the merge unions top-level entities BY ID, an owner-derived id would
390
+ * grow one module per consumer.
391
+ *
392
+ * What it carries is identity and inventory, not schema. The interfaces are
393
+ * found through `RpcService.module` and the shapes live in `RpcService.messages`
394
+ * and `catalog.defs`, in one place rather than two that can disagree.
395
+ */
396
+ export interface ProtoModule {
397
+ /** "buf.build/acme/shop", or "local:proto/shop" for a set never published. */
398
+ id: string;
399
+ /** Unique across the catalog, and what the URL uses: "acme-shop". */
400
+ slug: string;
401
+ name: string; // "acme/shop"
402
+ /** "buf.build". Absent when the module was never published to one. */
403
+ registry?: string;
404
+ /**
405
+ * The service that publishes it, by id, when the estate knows.
406
+ *
407
+ * Optional on purpose, and the first entity where "nobody here owns this" is
408
+ * an honest answer rather than a defect: a module published by a team, or by
409
+ * a repository outside the estate, is the ordinary case.
410
+ */
411
+ owner?: string;
412
+ /** The commit this catalog was built from. */
413
+ commit?: string;
414
+ /** The registry's content digest of that commit - what makes a copy checkable. */
415
+ digest?: string;
416
+ /** Proto packages declared inside it, sorted. */
417
+ packages: string[];
418
+ /** Files, module-relative and sorted. */
419
+ files: string[];
420
+ /** Modules it depends on, by id. */
421
+ deps?: string[];
422
+ /** Where the copy in this repository lives, as a reader would type it. */
423
+ source: string;
424
+ }
425
+
426
+ /**
427
+ * A repository the estate was read at, and the commit it was read at.
428
+ *
429
+ * It exists so a source path can be a link when the code is not in this
430
+ * repository. A service says which repository it lives in; only whoever
431
+ * fetched that repository knows which commit the copy is of, and by the time
432
+ * an extractor runs, that fact is in a lock file no page ever reads.
433
+ *
434
+ * It is a list on the catalog rather than a field on `Service` because the pin
435
+ * is a fact about the estate and not about one service: a repository holding
436
+ * three services is fetched once, at one commit, and writing that commit three
437
+ * times would be three places for it to disagree with itself.
438
+ */
439
+ export interface RepoPin {
440
+ /** The repository, spelled the way `Service.repo` spells it: "github.com/acme/shop". */
441
+ repo: string;
442
+ /** The commit the copy was made of. Full sha: it is not resolved locally, so there is nothing to expand it against. */
443
+ commit: string;
444
+ }
445
+ export interface Aggregate {
446
+ id: string;
447
+ slug: string;
448
+ name: string;
449
+ readme: string;
450
+ /** Name of the entity that is the aggregate root; must be one of `entities`. */
451
+ root: string;
452
+ entities: Entity[];
453
+ valueObjects: ValueObject[];
454
+ operations: Operation[];
455
+ events: Event[];
456
+ /**
457
+ * The closed sets the aggregate's fields take values from: a reason, a
458
+ * status, a code. Read through `enumsOf`, which answers [] for a source
459
+ * that declared none - the same shape every other optional list here has.
460
+ */
461
+ enums?: Enum[];
462
+ /**
463
+ * Where the root can go from where it is, when the aggregate has a status
464
+ * and the code writes its transitions down as one table. Absent means the
465
+ * aggregate has no lifecycle worth the name, or the extractor found none.
466
+ */
467
+ lifecycle?: Lifecycle;
468
+ }
469
+ /**
470
+ * A state machine read off the aggregate: the states in the order the code
471
+ * lists them, the first being the one a new root starts in, and every move
472
+ * between them. A state nothing leads out of is terminal; that is derived,
473
+ * never declared.
474
+ */
475
+ export interface Lifecycle {
476
+ states: string[];
477
+ transitions: Transition[];
478
+ }
479
+ export interface Transition {
480
+ from: string;
481
+ to: string;
482
+ /** The method on the root that makes the move, as written: `checkout`. */
483
+ on: string;
484
+ /** The event the method hands back for it, by id, when it hands one back. */
485
+ emits?: string;
486
+ /** Where the move is made, `file:line`. */
487
+ source?: string;
488
+ }
489
+ export interface Operation {
490
+ id: string;
491
+ kind: "command" | "query";
492
+ doc?: string;
493
+ /** Still callable, but the source says not to: a JSDoc `@deprecated`. */
494
+ deprecated?: boolean;
495
+ /**
496
+ * The interface methods that expose this operation, by the name they carry
497
+ * in `RpcService.methods` - an OpenAPI `operationId`, a proto method.
498
+ *
499
+ * A method rather than a full `<service>/<method>` id, because the two ends
500
+ * are read by different generators out of different files: one reads the
501
+ * handlers and knows which use case an endpoint runs, the other reads the
502
+ * document and knows what the interface is called. Neither can state the
503
+ * other's half, and the pairing resolves once they are merged.
504
+ *
505
+ * Empty is a fact, not an omission: an operation nothing exposes is one the
506
+ * estate can only reach from inside, which is sometimes exactly the point.
507
+ */
508
+ exposedBy?: string[];
509
+ }
510
+
511
+ /**
512
+ * A DDD building block held inside an aggregate. Entities have identity and
513
+ * value objects do not, but both are named shapes, so they share a structure
514
+ * and are told apart by the list they sit in.
515
+ *
516
+ * The shape is either NAMED - `ref` points at a shared `catalog.defs` entry, and
517
+ * every other block, event field or RPC message naming that same def is
518
+ * knowably the same type - or INLINE, when the type is local to the aggregate.
519
+ */
520
+ export interface Block {
521
+ id: string; // "<aggregate id>.<slug>"
522
+ slug: string;
523
+ name: string;
524
+ doc: string;
525
+ /** The shape is on its way out, per a `@deprecated` on its class. */
526
+ deprecated?: boolean;
527
+ ref?: string; // key into catalog.defs
528
+ fields?: Field[]; // inline shape, used when there is no ref
529
+ }
530
+ export type ValueObject = Block;
531
+ export type Entity = Block;
532
+ export type BlockKind = "vo" | "entity";
533
+
534
+ /**
535
+ * A closed set of values a field can hold. What a consumer switches on: an
536
+ * order hearing `PaymentDeclined` reads `reason` and does one thing for
537
+ * CARD_REFUSED and another for ORDER_CANCELLED, and this is the list it has
538
+ * to handle. Not a Block - it has no fields - and told apart from a status
539
+ * the lifecycle already knows by nothing: the lifecycle keeps the moves, the
540
+ * enum keeps the doc on each value, and a page may draw both.
541
+ */
542
+ export interface Enum {
543
+ id: string; // "<aggregate id>.<slug>"
544
+ slug: string;
545
+ name: string;
546
+ doc: string;
547
+ /** The whole set is on its way out. */
548
+ deprecated?: boolean;
549
+ values: EnumValue[];
550
+ }
551
+ export interface EnumValue {
552
+ /**
553
+ * What a consumer sees on the wire when the source says so - a Go
554
+ * constant's literal, a Rust `as_str` arm - and the variant's own name
555
+ * otherwise.
556
+ */
557
+ name: string;
558
+ doc: string;
559
+ deprecated?: boolean;
560
+ }
561
+ export interface Event {
562
+ id: string; // "<service id>.<aggregate>.<Name>"
563
+ slug: string;
564
+ name: string;
565
+ versions: EventVersion[]; // >=1, oldest first
566
+ consumers: EventConsumer[];
567
+ /**
568
+ * How the event leaves the service. Optional because a hand-written catalog
569
+ * may not know, and an extractor only says what the source declares.
570
+ */
571
+ wire?: EventWire;
572
+ }
573
+ /**
574
+ * The event as the bus sees it: its name on the message and the channel it
575
+ * is published on. The two are different facts - one topic carries every
576
+ * event of an aggregate, and a subscriber dispatches on the name - and a
577
+ * trace carries both, as `event.name` and `messaging.destination.name`.
578
+ * This is the one place the catalog and a running system meet by string.
579
+ */
580
+ export interface EventWire {
581
+ /** "cart.BasketCreated" - the name on the message, as a trace's event.name. */
582
+ name: string;
583
+ /**
584
+ * "cart_basket" - the topic, subject or stream it is published on. Absent
585
+ * when the source names the event but does not say where it goes.
586
+ */
587
+ channel?: string;
588
+ }
589
+ /**
590
+ * A topic, subject or stream a service says it uses, and the messages that
591
+ * travel on it. This is what an AsyncAPI document declares - the async half of
592
+ * what an OpenAPI document says about routes.
593
+ *
594
+ * The catalog knew about channels before this, but only by inference: an event
595
+ * carries a wire, and a channel was whatever the events happened to name. A
596
+ * declaration is a different fact, and it says two things inference could not.
597
+ * What the service means to put on the bus, whether or not an extractor found
598
+ * an event saying so - and what it listens for, which nothing in a publisher's
599
+ * source could ever say.
600
+ */
601
+ export interface Channel {
602
+ /**
603
+ * "shop.cart.basket" - the channel as the broker knows it. The same string an
604
+ * event's `wire.channel` carries, and comparing the two is how a document and
605
+ * the code beside it are held against each other.
606
+ */
607
+ address: string;
608
+ /** Domain event by default; jobs are work queues and messages are generic streams. */
609
+ kind?: "event" | "job" | "message";
610
+ title?: string;
611
+ doc?: string;
612
+ messages: ChannelMessage[];
613
+ /** The document this was read out of. */
614
+ source?: string;
615
+ }
616
+ /**
617
+ * Which way a message travels, from this service's side.
618
+ *
619
+ * It decides ownership: a service that sends on a channel publishes on it, and
620
+ * a channel has one publisher. A service that only receives is a subscriber,
621
+ * and any number of those is the point of a bus.
622
+ */
623
+ export type ChannelDirection = "send" | "receive";
624
+ export interface ChannelMessage {
625
+ /** "cart.BasketCreated" - the name on the message, as an event's wire.name. */
626
+ name: string;
627
+ title?: string;
628
+ doc?: string;
629
+ direction: ChannelDirection;
630
+ /** Normalized payload serialization, such as `msgpack`. */
631
+ encoding?: string;
632
+ /** Exact media type declared by the source contract. */
633
+ contentType?: string;
634
+ }
635
+ export interface EventConsumer {
636
+ service: string;
637
+ status: Status;
638
+ note?: string;
639
+ /** Set when the consumer was derived from a flow step rather than declared. */
640
+ via?: EdgeVia;
641
+ }
642
+ export interface EventVersion {
643
+ version: string;
644
+ doc: string;
645
+ /** This version is superseded, per a `@deprecated` on the class that carries it. */
646
+ deprecated?: boolean;
647
+ source: string;
648
+ fields: Field[];
649
+ }
650
+ export interface Field {
651
+ name: string;
652
+ type: string;
653
+ doc: string;
654
+ /** Still on the wire, but not to be written or read anew: a `@deprecated` on the field. */
655
+ deprecated?: boolean;
656
+ ref?: string;
657
+ /** Protobuf field number; absent for sources whose wire has no field numbers. */
658
+ number?: number;
659
+ } // ref -> defs key
660
+ export interface TypeDef {
661
+ fields: Field[];
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Persistence. Where an aggregate actually lives when nothing is running.
666
+ //
667
+ // This axis is deliberately shallow: a store, its tables, their columns, and
668
+ // the foreign keys between them. It says nothing about how the rows got there.
669
+ // What it does say — through `persists` and `maps` — is which domain object a
670
+ // table holds and which domain field a column carries, which is the only
671
+ // question that makes a schema readable next to a model rather than beside it.
672
+ // ---------------------------------------------------------------------------
673
+
674
+ export type StoreKind =
675
+ | "postgres"
676
+ | "mysql"
677
+ | "sqlite"
678
+ | "redis"
679
+ | "mongodb"
680
+ | "clickhouse"
681
+ | "s3"
682
+ | "other";
683
+
684
+ export const STORE_KINDS: readonly StoreKind[] = [
685
+ "postgres",
686
+ "mysql",
687
+ "sqlite",
688
+ "redis",
689
+ "mongodb",
690
+ "clickhouse",
691
+ "s3",
692
+ "other",
693
+ ] as const;
694
+
695
+ export interface Store {
696
+ id: string; // "shop.oms.pg"
697
+ slug: string;
698
+ name: string;
699
+ kind: StoreKind;
700
+ /** Service id. Exactly one service owns a store; everyone else reads it. */
701
+ owner: string;
702
+ tables: Table[];
703
+ /**
704
+ * Views declared over those tables. Optional in the file for the same reason
705
+ * `stores` is: a catalog written before the extractor learned to read
706
+ * `CREATE VIEW` still loads, and every reader sees an empty list.
707
+ */
708
+ views?: View[];
709
+ /** Redis key families proved by client calls. Dynamic parts use `{name}`. */
710
+ keyspaces?: RedisKeyspace[];
711
+ /** Migrations directory or config path, as a reader would open it. */
712
+ source?: string;
713
+ }
714
+
715
+ export type RedisOperation =
716
+ "read" | "write" | "delete" | "exists" | "expire" | "count";
717
+
718
+ export const REDIS_OPERATIONS: readonly RedisOperation[] = [
719
+ "read",
720
+ "write",
721
+ "delete",
722
+ "exists",
723
+ "expire",
724
+ "count",
725
+ ] as const;
726
+
727
+ /** A source-backed family of Redis keys, not a relational table. */
728
+ export interface RedisKeyspace {
729
+ pattern: string;
730
+ operations: RedisOperation[];
731
+ /** Source spelling of a fixed, configured or caller-provided expiry. */
732
+ ttl?: string;
733
+ /** Value type where a write or marshal call proves it. */
734
+ value?: string;
735
+ source?: string;
736
+ /** Aggregate or block whose value this key family holds, when provable. */
737
+ persists?: { aggregate?: string; block?: string };
738
+ /** Individual client calls, before they are folded into `operations`. */
739
+ accesses?: RedisAccess[];
740
+ }
741
+
742
+ export interface RedisAccess {
743
+ operation: RedisOperation;
744
+ /** Enclosing adapter method, for example `Store.Get`. */
745
+ method?: string;
746
+ ttl?: string;
747
+ value?: string;
748
+ source?: string;
749
+ }
750
+
751
+ /**
752
+ * What a table is FOR. The role is not decoration: an outbox and a projection
753
+ * are read completely differently from the table that holds the aggregate, and
754
+ * a canvas that draws all three the same way hides the only structural fact a
755
+ * reader came for.
756
+ */
757
+ export type TableRole =
758
+ "aggregate-root" | "child" | "outbox" | "projection" | "lookup" | "other";
759
+
760
+ export const TABLE_ROLES: readonly TableRole[] = [
761
+ "aggregate-root",
762
+ "child",
763
+ "outbox",
764
+ "projection",
765
+ "lookup",
766
+ "other",
767
+ ] as const;
768
+
769
+ export interface Table {
770
+ id: string; // "<store id>.<table>"
771
+ name: string;
772
+ doc?: string;
773
+ columns: Column[];
774
+ indexes?: TableIndex[];
775
+ /** The domain object this table holds: an aggregate id, and optionally a block id. */
776
+ persists?: { aggregate?: string; block?: string };
777
+ role?: TableRole;
778
+ /** Source-backed repository methods that read or write this table. */
779
+ accesses?: TableAccess[];
780
+ }
781
+
782
+ export type TableOperation = "read" | "write" | "delete";
783
+
784
+ export const TABLE_OPERATIONS: readonly TableOperation[] = [
785
+ "read",
786
+ "write",
787
+ "delete",
788
+ ] as const;
789
+
790
+ export interface TableAccess {
791
+ operation: TableOperation;
792
+ /** Enclosing adapter method, for example `Postgres.Save`. */
793
+ method?: string;
794
+ source?: string;
795
+ }
796
+
797
+ export interface TableIndex {
798
+ name: string;
799
+ columns: string[];
800
+ unique: boolean;
801
+ }
802
+
803
+ export interface Column {
804
+ name: string;
805
+ /** The db type as declared — uuid, timestamptz, jsonb — not a normalised one. */
806
+ type: string;
807
+ nullable: boolean;
808
+ pk?: boolean;
809
+ /** `table` is a Table.id, so a foreign key names its target unambiguously. */
810
+ fk?: { table: string; column: string; onDelete?: string };
811
+ /**
812
+ * The columns this one is computed from, as "<table or view id>.<column>".
813
+ *
814
+ * A foreign key says which row this value points AT; lineage says where the
815
+ * value CAME FROM, which is a different question and the only one that can
816
+ * be asked of a view column or of a projection rebuilt from an event. It is
817
+ * declared on the derived end because that is the end that knows: a source
818
+ * table has no idea who reads it.
819
+ */
820
+ from?: string[];
821
+ /** Domain field path, e.g. "Order.CustomerID". */
822
+ maps?: string;
823
+ doc?: string;
824
+ }
825
+
826
+ /**
827
+ * A view: a query the database has a name for.
828
+ *
829
+ * It is kept apart from Table rather than folded in behind a flag because the
830
+ * two answer different questions. A table is where rows live; a view is a
831
+ * reading of rows that live somewhere else, so it has no primary key, no
832
+ * foreign keys, and no migrations of its own — what it has instead is the list
833
+ * of things it reads, which is the only reason it is on the canvas at all.
834
+ */
835
+ export interface View {
836
+ id: string; // "<store id>.<view name>"
837
+ name: string;
838
+ doc?: string;
839
+ /**
840
+ * True when the database keeps the rows rather than recomputing them. A
841
+ * matview can be stale, which is the one fact a reader has to have before
842
+ * believing a row, so it is drawn differently rather than noted in prose.
843
+ */
844
+ materialized?: boolean;
845
+ columns: Column[];
846
+ /**
847
+ * Tables and views this one is defined over, by id. Column lineage already
848
+ * implies most of them; this is what a view whose columns nobody has mapped
849
+ * still says out loud, and it is what the canvas draws when a column-level
850
+ * edge would be a guess.
851
+ */
852
+ reads?: string[];
853
+ /** The SELECT, as the migration declares it. Shown, never parsed. */
854
+ definition?: string;
855
+ /** The domain object this view presents, when it presents exactly one. */
856
+ persists?: { aggregate?: string; block?: string };
857
+ /** Migration or model file, as a reader would open it. */
858
+ source?: string;
859
+ }
860
+
861
+ /**
862
+ * A sequence read out of source.
863
+ *
864
+ * Extractors may attach the execution trigger they proved. Authored flows omit
865
+ * it when that evidence is not part of the document.
866
+ */
867
+ export interface Flow {
868
+ id: string;
869
+ slug: string;
870
+ name: string;
871
+ summary: string;
872
+ source?: string; // the file the flow was read out of
873
+ /** Source-backed execution root and the strength of that evidence. */
874
+ trigger?: FlowTrigger;
875
+ /** Source function this flow expands, used for evidence-backed composition. */
876
+ entrypoint?: string;
877
+ /** Source-backed flow fragments composed into this root flow. */
878
+ includes?: string[];
879
+ /**
880
+ * The top-level group this flow belongs to. Whatever derived the flow read
881
+ * one component's tree to find it and therefore knows the answer, so the flow
882
+ * states it instead of leaving a reader to recover it from `source` - and the
883
+ * validator holds every flow to it, because a flow with no owner has nowhere
884
+ * to sit in the tree.
885
+ */
886
+ owner: string;
887
+ participants: Participant[]; // order is significant - it is the lane order
888
+ steps: FlowNode[];
889
+ }
890
+ export interface FlowTrigger {
891
+ kind:
892
+ | "http"
893
+ | "callback"
894
+ | "event"
895
+ | "message"
896
+ | "job"
897
+ | "startup"
898
+ | "scheduled"
899
+ | "manual"
900
+ | "unproven";
901
+ label?: string;
902
+ confidence: "high" | "medium" | "low";
903
+ }
904
+ export interface Participant {
905
+ id: string;
906
+ kind: "actor" | "service" | "broker" | "store" | "external" | "unknown";
907
+ context: string | null; // null for actors and brokers
908
+ label?: string;
909
+ }
910
+ export type FlowNode = Step | Parallel | Alt | Loop;
911
+ export interface Step {
912
+ type: "step";
913
+ id: string;
914
+ from: string;
915
+ to: string; // participant ids; from === to is a self-message
916
+ kind: "rpc" | "event" | "call" | "response";
917
+ ref?: string; // Event.id, RpcCall.id or provided RPC method id; otherwise unresolved
918
+ label?: string;
919
+ status: Status;
920
+ note?: string;
921
+ line?: string;
922
+ /** Synchronous request step this synthesized response returns from. */
923
+ replyTo?: string;
924
+ /** Proven HTTP wire contract for a response step. */
925
+ http?: HTTPResponse;
926
+ /** Source function execution enters here, when an extractor can prove it. */
927
+ continuesAt?: string;
928
+ /** Source functions proven to execute on the path represented by this step. */
929
+ reaches?: string[];
930
+ /** Exact asynchronous send/receive evidence used for flow composition. */
931
+ handoff?: FlowHandoff;
932
+ /** Repository call resolved to a concrete store operation after merge. */
933
+ storeAccess?: FlowStoreAccess;
934
+ }
935
+ export interface HTTPResponse {
936
+ status?: number;
937
+ contentType?: string;
938
+ body?: string;
939
+ /** RPC method whose response value is serialized into this body. */
940
+ bodyRef?: string;
941
+ encoding?: string;
942
+ outcome?: "success" | "error";
943
+ warning?: string;
944
+ source?: string;
945
+ /** Shape recovered directly from a literal response body. */
946
+ fields?: Field[];
947
+ }
948
+ export interface FlowHandoff {
949
+ kind: "message" | "job";
950
+ transport: string;
951
+ channel: string;
952
+ message?: string;
953
+ direction: "send" | "receive";
954
+ }
955
+ export interface FlowStoreAccess {
956
+ store: string;
957
+ method?: string;
958
+ operation?: RedisOperation;
959
+ keyspace?: string;
960
+ /** Concrete adapter call rather than the use-case-side repository call. */
961
+ source?: string;
962
+ }
963
+ export interface Parallel {
964
+ type: "parallel";
965
+ id: string;
966
+ title?: string;
967
+ branches: FlowNode[][];
968
+ }
969
+ /**
970
+ * A choice. Exactly one branch runs, so the branches are not a sequence and
971
+ * nothing that reads a flow may treat them as one.
972
+ *
973
+ * `terminal` marks a branch that ENDS the flow rather than rejoining it — the
974
+ * cancel arm of a risk check, say. Without it a reader has no way to tell that
975
+ * the steps drawn after the alt do not follow that branch, and the sequence
976
+ * reads as "the order was cancelled and then charged".
977
+ */
978
+ export interface Alt {
979
+ type: "alt";
980
+ id: string;
981
+ branches: AltBranch[];
982
+ }
983
+ export interface AltBranch {
984
+ /** The condition under which this branch runs, in words. */
985
+ title: string;
986
+ steps: FlowNode[];
987
+ /** True when the flow stops here instead of continuing past the alt. */
988
+ terminal?: boolean;
989
+ }
990
+ export interface Loop {
991
+ type: "loop";
992
+ id: string;
993
+ title: string;
994
+ steps: FlowNode[];
995
+ }
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // The ubiquitous language. One meaning per word inside a context, written down
999
+ // where the code that uses the word lives, and the leaf everything else points
1000
+ // to: a term links nowhere, and nothing here is derived from the model.
1001
+ //
1002
+ // A word and a sentence, and nothing else. What the sentence says is the
1003
+ // author's business - the one thing in this catalog that no extractor could
1004
+ // have worked out from the code, and the one thing a parser has no business
1005
+ // taking apart.
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ export interface Term {
1009
+ /** "<context>.<slug>" - auth.session. A word means one thing per context. */
1010
+ id: string;
1011
+ slug: string;
1012
+ /** The context whose vocabulary this is, never the service the file sat in. */
1013
+ context: string;
1014
+ /** As the glossary spells it, which is how the code spells it: "Email address". */
1015
+ name: string;
1016
+ /** What it means, as the glossary's own paragraph: markdown, one line. */
1017
+ definition: string;
1018
+ /** `path:line` of the entry, as everything else in the catalog spells a source. */
1019
+ source: string;
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------------
1023
+ // Decision records. An ADR is frozen history: it says what was decided and
1024
+ // when, not what the model looks like now. Nothing here is regenerated from
1025
+ // the current catalog, and nothing on an ADR page redraws from it.
1026
+ // ---------------------------------------------------------------------------
1027
+
1028
+ export type AdrStatus =
1029
+ "proposed" | "accepted" | "superseded" | "deprecated" | "rejected";
1030
+
1031
+ export type AdrScope =
1032
+ | { kind: "org" }
1033
+ | { kind: "context"; context: string }
1034
+ | { kind: "service"; service: string };
1035
+
1036
+ export interface Adr {
1037
+ id: string; // "shop.oms.0007" - scope prefix plus zero-padded number
1038
+ slug: string;
1039
+ number: number; // 7
1040
+ title: string;
1041
+ status: AdrStatus;
1042
+ date: string; // decision date, ISO
1043
+ scope: AdrScope;
1044
+ body: string; // markdown, MADR structure
1045
+ // Prose about the record that no other field holds - most often that part of
1046
+ // it was decided again elsewhere without the whole of it being superseded.
1047
+ // It sits in the header, above the frozen body, because it is the thing to
1048
+ // read before the decision rather than after it.
1049
+ note?: string;
1050
+ supersededBy?: string; // Adr.id
1051
+ supersedes?: string[];
1052
+ relates: { services?: string[]; events?: string[]; flows?: string[] };
1053
+ source: string; // path to the .md in its repo
1054
+ // What git says about the file: the commit that first added it, and the one
1055
+ // that last touched it when that is a different commit. Absent when the
1056
+ // tree had no history to read.
1057
+ created?: AdrCommit;
1058
+ revised?: AdrCommit;
1059
+ }
1060
+
1061
+ export interface AdrCommit {
1062
+ commit: string; // full sha
1063
+ author: string; // author name as git records it
1064
+ date: string; // committer date, ISO
1065
+ }
1066
+
1067
+ // ---------------------------------------------------------------------------
1068
+ // Traversal helpers. Everything here is DERIVED from the steps, never stored in
1069
+ // the JSON.
1070
+ //
1071
+ // There is deliberately no flow-level score. How far a flow can be trusted is
1072
+ // said step by step, by each `Step.status`; a ratio over those averaged claims
1073
+ // that are not comparable, hid the only actionable one (`unresolved`), and —
1074
+ // once alt branches are counted — divided by a number no single execution ever
1075
+ // reaches.
1076
+ // ---------------------------------------------------------------------------
1077
+
1078
+ /** Depth-first walk over every Step in a node list, in numbering order. */
1079
+ export function walkSteps(nodes: FlowNode[]): Step[] {
1080
+ const out: Step[] = [];
1081
+ const visit = (list: FlowNode[]): void => {
1082
+ for (const node of list) {
1083
+ switch (node.type) {
1084
+ case "step":
1085
+ out.push(node);
1086
+ break;
1087
+ case "parallel":
1088
+ for (const branch of node.branches) visit(branch);
1089
+ break;
1090
+ case "alt":
1091
+ for (const branch of node.branches) visit(branch.steps);
1092
+ break;
1093
+ case "loop":
1094
+ visit(node.steps);
1095
+ break;
1096
+ }
1097
+ }
1098
+ };
1099
+ visit(nodes);
1100
+ return out;
1101
+ }
1102
+
1103
+ /**
1104
+ * One frame enclosing a step: the alt, parallel or loop it sits inside.
1105
+ *
1106
+ * This is what the rail and the detail panel need in order to say *under what
1107
+ * condition* a step runs. Without it a step is just a line in a sequence, and
1108
+ * a reader cannot tell an alternative apart from a consequence.
1109
+ */
1110
+ export interface StepFrame {
1111
+ kind: "parallel" | "alt" | "loop";
1112
+ /** Id of the Parallel / Alt / Loop node. */
1113
+ id: string;
1114
+ /** Loop or parallel title. An alt carries its condition on the branch. */
1115
+ title?: string;
1116
+ /** Alt: the branch condition. Parallel: the 1-based branch number. */
1117
+ branch?: string;
1118
+ /** Alt only: this branch ends the flow rather than rejoining it. */
1119
+ terminal?: boolean;
1120
+ }
1121
+
1122
+ /**
1123
+ * The frames around every step, outermost first. Steps not inside any frame
1124
+ * map to an empty list, so callers never have to special-case the flat case.
1125
+ */
1126
+ export function stepFrames(nodes: FlowNode[]): Map<string, StepFrame[]> {
1127
+ const out = new Map<string, StepFrame[]>();
1128
+ const visit = (list: FlowNode[], stack: StepFrame[]): void => {
1129
+ for (const node of list) {
1130
+ switch (node.type) {
1131
+ case "step":
1132
+ out.set(node.id, stack);
1133
+ break;
1134
+ case "parallel":
1135
+ node.branches.forEach((branch, i) =>
1136
+ visit(branch, [
1137
+ ...stack,
1138
+ {
1139
+ kind: "parallel",
1140
+ id: node.id,
1141
+ title: node.title,
1142
+ branch: String(i + 1),
1143
+ },
1144
+ ]),
1145
+ );
1146
+ break;
1147
+ case "alt":
1148
+ for (const branch of node.branches) {
1149
+ visit(branch.steps, [
1150
+ ...stack,
1151
+ {
1152
+ kind: "alt",
1153
+ id: node.id,
1154
+ branch: branch.title,
1155
+ terminal: branch.terminal,
1156
+ },
1157
+ ]);
1158
+ }
1159
+ break;
1160
+ case "loop":
1161
+ visit(node.steps, [
1162
+ ...stack,
1163
+ { kind: "loop", id: node.id, title: node.title },
1164
+ ]);
1165
+ break;
1166
+ }
1167
+ }
1168
+ };
1169
+ visit(nodes, []);
1170
+ return out;
1171
+ }
1172
+
1173
+ /**
1174
+ * The conditions a step runs under, outermost first — the alt branches around
1175
+ * it and nothing else. A step with none of these runs on every path.
1176
+ */
1177
+ export function stepConditions(frames: readonly StepFrame[]): StepFrame[] {
1178
+ return frames.filter((f) => f.kind === "alt");
1179
+ }
1180
+
1181
+ export function allServices(catalog: Catalog): Service[] {
1182
+ return catalog.contexts.flatMap((c) => c.services);
1183
+ }
1184
+
1185
+ /** Neutral alias for allServices; both names intentionally address the same wire model. */
1186
+ export function allComponents(catalog: Catalog): Component[] {
1187
+ return allServices(catalog);
1188
+ }
1189
+
1190
+ export function groupKind(group: Group): GroupKind {
1191
+ return group.kind ?? "bounded-context";
1192
+ }
1193
+
1194
+ export function componentKind(component: Component): ComponentKind {
1195
+ return component.kind ?? "service";
1196
+ }
1197
+
1198
+ /** Every system outside the estate with a contract, in catalog order. */
1199
+ export function allExternals(catalog: Catalog): External[] {
1200
+ return catalog.externals ?? [];
1201
+ }
1202
+
1203
+ export function allEvents(catalog: Catalog): Event[] {
1204
+ return allServices(catalog).flatMap((s) =>
1205
+ s.aggregates.flatMap((a) => a.events),
1206
+ );
1207
+ }
1208
+
1209
+ export function allAggregates(catalog: Catalog): Aggregate[] {
1210
+ return allServices(catalog).flatMap((s) => s.aggregates);
1211
+ }
1212
+
1213
+ /** Every store, whether or not any service lists it. Absent means none. */
1214
+ export function allStores(catalog: Catalog): Store[] {
1215
+ return catalog.stores ?? [];
1216
+ }
1217
+
1218
+ export function allModules(catalog: Catalog): ProtoModule[] {
1219
+ return catalog.modules ?? [];
1220
+ }
1221
+
1222
+ /** Every term in every glossary. Absent means none, exactly as with modules. */
1223
+ export function allTerms(catalog: Catalog): Term[] {
1224
+ return catalog.terms ?? [];
1225
+ }
1226
+
1227
+ /** Every repository the estate was read at. Absent means none, exactly as with modules. */
1228
+ export function allRepos(catalog: Catalog): RepoPin[] {
1229
+ return catalog.repos ?? [];
1230
+ }
1231
+
1232
+ /** Who to ask about a service, without the caller having to know the field is optional. */
1233
+ export function ownersOf(service: Service): string[] {
1234
+ return service.owners ?? [];
1235
+ }
1236
+
1237
+ export function technologiesOf(component: Component): string[] {
1238
+ return component.technologies ?? [];
1239
+ }
1240
+
1241
+ export function commandsOf(component: Component): Command[] {
1242
+ return component.commands ?? [];
1243
+ }
1244
+
1245
+ /** Every view in every store. Absent means none, exactly as with tables. */
1246
+ export function allViews(catalog: Catalog): View[] {
1247
+ return allStores(catalog).flatMap((s) => s.views ?? []);
1248
+ }
1249
+
1250
+ /** The views of one store, without the caller having to know the field is optional. */
1251
+ export function storeViews(store: Store): View[] {
1252
+ return store.views ?? [];
1253
+ }
1254
+
1255
+ /**
1256
+ * What a view reads, table by table: what it declares, then everything its
1257
+ * columns point at that it forgot to declare. A view is allowed to state only
1258
+ * one of the two — the coarse list is easier to write by hand, the column
1259
+ * lineage is what an extractor produces — and readers should not have to know
1260
+ * which of the two the catalog happened to carry.
1261
+ */
1262
+ export function viewReads(view: View): string[] {
1263
+ const out: string[] = [];
1264
+ const add = (id: string) => {
1265
+ if (!out.includes(id)) out.push(id);
1266
+ };
1267
+ for (const id of view.reads ?? []) add(id);
1268
+ for (const column of view.columns) {
1269
+ for (const ref of column.from ?? []) add(relationOfColumnId(ref));
1270
+ }
1271
+ return out;
1272
+ }
1273
+
1274
+ /**
1275
+ * The relation half of a column id. Ids are dotted all the way down and only
1276
+ * the last segment is the column name, so this is a right split, not a left
1277
+ * one: "shop.oms.pg.orders.status" is the `status` column of `shop.oms.pg.orders`.
1278
+ */
1279
+ export function relationOfColumnId(id: string): string {
1280
+ return id.split(".").slice(0, -1).join(".");
1281
+ }
1282
+
1283
+ /** The column half of a column id — everything after the last dot. */
1284
+ export function columnNameOfId(id: string): string {
1285
+ return id.split(".").at(-1) ?? "";
1286
+ }
1287
+
1288
+ /**
1289
+ * A column's id. Columns are not addressed in the JSON, but the selection layer
1290
+ * needs one identifier per selectable thing, and "<table id>.<column>" is the
1291
+ * spelling a reader would type.
1292
+ */
1293
+ export function columnId(tableId: string, column: string): string {
1294
+ return `${tableId}.${column}`;
1295
+ }
1296
+
1297
+ /** The columns a collapsed table card shows: its key, then everything it points at. */
1298
+ export function keyColumns(table: Table): Column[] {
1299
+ return table.columns.filter((c) => c.pk || c.fk);
1300
+ }
1301
+
1302
+ /**
1303
+ * The fields a block actually has: its own when written inline, otherwise the
1304
+ * shared def it names. An empty list means the catalog knows the block by name
1305
+ * only, which pages say out loud rather than drawing a blank table.
1306
+ */
1307
+ /** The enums an aggregate declares; [] for a source that wrote none. */
1308
+ export function enumsOf(aggregate: Aggregate): Enum[] {
1309
+ return aggregate.enums ?? [];
1310
+ }
1311
+
1312
+ export function blockFields(catalog: Catalog, block: Block): Field[] {
1313
+ if (block.fields) return block.fields;
1314
+ if (block.ref) return catalog.defs[block.ref]?.fields ?? [];
1315
+ return [];
1316
+ }
1317
+
1318
+ /** Value objects and entities of one aggregate, tagged with which they are. */
1319
+ export function aggregateBlocks(
1320
+ aggregate: Aggregate,
1321
+ ): { kind: BlockKind; block: Block }[] {
1322
+ return [
1323
+ ...aggregate.valueObjects.map((block) => ({ kind: "vo" as const, block })),
1324
+ ...aggregate.entities.map((block) => ({ kind: "entity" as const, block })),
1325
+ ];
1326
+ }
1327
+
1328
+ /** The entity an aggregate names as its root, if the catalog lists it. */
1329
+ export function rootEntity(aggregate: Aggregate): Entity | undefined {
1330
+ return aggregate.entities.find((e) => e.name === aggregate.root);
1331
+ }
1332
+
1333
+ export interface BlockCounts {
1334
+ entities: number;
1335
+ valueObjects: number;
1336
+ enums: number;
1337
+ events: number;
1338
+ commands: number;
1339
+ queries: number;
1340
+ }
1341
+
1342
+ export function blockCounts(aggregate: Aggregate): BlockCounts {
1343
+ return {
1344
+ entities: aggregate.entities.length,
1345
+ valueObjects: aggregate.valueObjects.length,
1346
+ enums: enumsOf(aggregate).length,
1347
+ events: aggregate.events.length,
1348
+ commands: aggregate.operations.filter((o) => o.kind === "command").length,
1349
+ queries: aggregate.operations.filter((o) => o.kind === "query").length,
1350
+ };
1351
+ }
1352
+
1353
+ /** Contexts touched by a flow, in participant order, ignoring null-context lanes. */
1354
+ export function flowContexts(flow: Flow): string[] {
1355
+ const seen: string[] = [];
1356
+ for (const p of flow.participants) {
1357
+ if (p.context && !seen.includes(p.context)) seen.push(p.context);
1358
+ }
1359
+ return seen;
1360
+ }