@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.
- package/README.md +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/portolan-go.wasm +0 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +16 -0
- package/src/catalog.ts +6 -3306
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/derive.ts +1 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/merge.test.ts +46 -0
- package/src/merge.ts +35 -1
- package/src/pages/Settings.tsx +1 -126
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,1339 @@
|
|
|
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
|
+
}
|
|
631
|
+
export interface EventConsumer {
|
|
632
|
+
service: string;
|
|
633
|
+
status: Status;
|
|
634
|
+
note?: string;
|
|
635
|
+
/** Set when the consumer was derived from a flow step rather than declared. */
|
|
636
|
+
via?: EdgeVia;
|
|
637
|
+
}
|
|
638
|
+
export interface EventVersion {
|
|
639
|
+
version: string;
|
|
640
|
+
doc: string;
|
|
641
|
+
/** This version is superseded, per a `@deprecated` on the class that carries it. */
|
|
642
|
+
deprecated?: boolean;
|
|
643
|
+
source: string;
|
|
644
|
+
fields: Field[];
|
|
645
|
+
}
|
|
646
|
+
export interface Field {
|
|
647
|
+
name: string;
|
|
648
|
+
type: string;
|
|
649
|
+
doc: string;
|
|
650
|
+
/** Still on the wire, but not to be written or read anew: a `@deprecated` on the field. */
|
|
651
|
+
deprecated?: boolean;
|
|
652
|
+
ref?: string;
|
|
653
|
+
/** Protobuf field number; absent for sources whose wire has no field numbers. */
|
|
654
|
+
number?: number;
|
|
655
|
+
} // ref -> defs key
|
|
656
|
+
export interface TypeDef {
|
|
657
|
+
fields: Field[];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
// Persistence. Where an aggregate actually lives when nothing is running.
|
|
662
|
+
//
|
|
663
|
+
// This axis is deliberately shallow: a store, its tables, their columns, and
|
|
664
|
+
// the foreign keys between them. It says nothing about how the rows got there.
|
|
665
|
+
// What it does say — through `persists` and `maps` — is which domain object a
|
|
666
|
+
// table holds and which domain field a column carries, which is the only
|
|
667
|
+
// question that makes a schema readable next to a model rather than beside it.
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
export type StoreKind =
|
|
671
|
+
| "postgres"
|
|
672
|
+
| "mysql"
|
|
673
|
+
| "sqlite"
|
|
674
|
+
| "redis"
|
|
675
|
+
| "mongodb"
|
|
676
|
+
| "clickhouse"
|
|
677
|
+
| "s3"
|
|
678
|
+
| "other";
|
|
679
|
+
|
|
680
|
+
export const STORE_KINDS: readonly StoreKind[] = [
|
|
681
|
+
"postgres",
|
|
682
|
+
"mysql",
|
|
683
|
+
"sqlite",
|
|
684
|
+
"redis",
|
|
685
|
+
"mongodb",
|
|
686
|
+
"clickhouse",
|
|
687
|
+
"s3",
|
|
688
|
+
"other",
|
|
689
|
+
] as const;
|
|
690
|
+
|
|
691
|
+
export interface Store {
|
|
692
|
+
id: string; // "shop.oms.pg"
|
|
693
|
+
slug: string;
|
|
694
|
+
name: string;
|
|
695
|
+
kind: StoreKind;
|
|
696
|
+
/** Service id. Exactly one service owns a store; everyone else reads it. */
|
|
697
|
+
owner: string;
|
|
698
|
+
tables: Table[];
|
|
699
|
+
/**
|
|
700
|
+
* Views declared over those tables. Optional in the file for the same reason
|
|
701
|
+
* `stores` is: a catalog written before the extractor learned to read
|
|
702
|
+
* `CREATE VIEW` still loads, and every reader sees an empty list.
|
|
703
|
+
*/
|
|
704
|
+
views?: View[];
|
|
705
|
+
/** Redis key families proved by client calls. Dynamic parts use `{name}`. */
|
|
706
|
+
keyspaces?: RedisKeyspace[];
|
|
707
|
+
/** Migrations directory or config path, as a reader would open it. */
|
|
708
|
+
source?: string;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export type RedisOperation =
|
|
712
|
+
"read" | "write" | "delete" | "exists" | "expire" | "count";
|
|
713
|
+
|
|
714
|
+
export const REDIS_OPERATIONS: readonly RedisOperation[] = [
|
|
715
|
+
"read",
|
|
716
|
+
"write",
|
|
717
|
+
"delete",
|
|
718
|
+
"exists",
|
|
719
|
+
"expire",
|
|
720
|
+
"count",
|
|
721
|
+
] as const;
|
|
722
|
+
|
|
723
|
+
/** A source-backed family of Redis keys, not a relational table. */
|
|
724
|
+
export interface RedisKeyspace {
|
|
725
|
+
pattern: string;
|
|
726
|
+
operations: RedisOperation[];
|
|
727
|
+
/** Source spelling of a fixed, configured or caller-provided expiry. */
|
|
728
|
+
ttl?: string;
|
|
729
|
+
/** Value type where a write or marshal call proves it. */
|
|
730
|
+
value?: string;
|
|
731
|
+
source?: string;
|
|
732
|
+
/** Aggregate or block whose value this key family holds, when provable. */
|
|
733
|
+
persists?: { aggregate?: string; block?: string };
|
|
734
|
+
/** Individual client calls, before they are folded into `operations`. */
|
|
735
|
+
accesses?: RedisAccess[];
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export interface RedisAccess {
|
|
739
|
+
operation: RedisOperation;
|
|
740
|
+
/** Enclosing adapter method, for example `Store.Get`. */
|
|
741
|
+
method?: string;
|
|
742
|
+
ttl?: string;
|
|
743
|
+
value?: string;
|
|
744
|
+
source?: string;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* What a table is FOR. The role is not decoration: an outbox and a projection
|
|
749
|
+
* are read completely differently from the table that holds the aggregate, and
|
|
750
|
+
* a canvas that draws all three the same way hides the only structural fact a
|
|
751
|
+
* reader came for.
|
|
752
|
+
*/
|
|
753
|
+
export type TableRole =
|
|
754
|
+
"aggregate-root" | "child" | "outbox" | "projection" | "lookup" | "other";
|
|
755
|
+
|
|
756
|
+
export const TABLE_ROLES: readonly TableRole[] = [
|
|
757
|
+
"aggregate-root",
|
|
758
|
+
"child",
|
|
759
|
+
"outbox",
|
|
760
|
+
"projection",
|
|
761
|
+
"lookup",
|
|
762
|
+
"other",
|
|
763
|
+
] as const;
|
|
764
|
+
|
|
765
|
+
export interface Table {
|
|
766
|
+
id: string; // "<store id>.<table>"
|
|
767
|
+
name: string;
|
|
768
|
+
doc?: string;
|
|
769
|
+
columns: Column[];
|
|
770
|
+
indexes?: TableIndex[];
|
|
771
|
+
/** The domain object this table holds: an aggregate id, and optionally a block id. */
|
|
772
|
+
persists?: { aggregate?: string; block?: string };
|
|
773
|
+
role?: TableRole;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export interface TableIndex {
|
|
777
|
+
name: string;
|
|
778
|
+
columns: string[];
|
|
779
|
+
unique: boolean;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export interface Column {
|
|
783
|
+
name: string;
|
|
784
|
+
/** The db type as declared — uuid, timestamptz, jsonb — not a normalised one. */
|
|
785
|
+
type: string;
|
|
786
|
+
nullable: boolean;
|
|
787
|
+
pk?: boolean;
|
|
788
|
+
/** `table` is a Table.id, so a foreign key names its target unambiguously. */
|
|
789
|
+
fk?: { table: string; column: string; onDelete?: string };
|
|
790
|
+
/**
|
|
791
|
+
* The columns this one is computed from, as "<table or view id>.<column>".
|
|
792
|
+
*
|
|
793
|
+
* A foreign key says which row this value points AT; lineage says where the
|
|
794
|
+
* value CAME FROM, which is a different question and the only one that can
|
|
795
|
+
* be asked of a view column or of a projection rebuilt from an event. It is
|
|
796
|
+
* declared on the derived end because that is the end that knows: a source
|
|
797
|
+
* table has no idea who reads it.
|
|
798
|
+
*/
|
|
799
|
+
from?: string[];
|
|
800
|
+
/** Domain field path, e.g. "Order.CustomerID". */
|
|
801
|
+
maps?: string;
|
|
802
|
+
doc?: string;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* A view: a query the database has a name for.
|
|
807
|
+
*
|
|
808
|
+
* It is kept apart from Table rather than folded in behind a flag because the
|
|
809
|
+
* two answer different questions. A table is where rows live; a view is a
|
|
810
|
+
* reading of rows that live somewhere else, so it has no primary key, no
|
|
811
|
+
* foreign keys, and no migrations of its own — what it has instead is the list
|
|
812
|
+
* of things it reads, which is the only reason it is on the canvas at all.
|
|
813
|
+
*/
|
|
814
|
+
export interface View {
|
|
815
|
+
id: string; // "<store id>.<view name>"
|
|
816
|
+
name: string;
|
|
817
|
+
doc?: string;
|
|
818
|
+
/**
|
|
819
|
+
* True when the database keeps the rows rather than recomputing them. A
|
|
820
|
+
* matview can be stale, which is the one fact a reader has to have before
|
|
821
|
+
* believing a row, so it is drawn differently rather than noted in prose.
|
|
822
|
+
*/
|
|
823
|
+
materialized?: boolean;
|
|
824
|
+
columns: Column[];
|
|
825
|
+
/**
|
|
826
|
+
* Tables and views this one is defined over, by id. Column lineage already
|
|
827
|
+
* implies most of them; this is what a view whose columns nobody has mapped
|
|
828
|
+
* still says out loud, and it is what the canvas draws when a column-level
|
|
829
|
+
* edge would be a guess.
|
|
830
|
+
*/
|
|
831
|
+
reads?: string[];
|
|
832
|
+
/** The SELECT, as the migration declares it. Shown, never parsed. */
|
|
833
|
+
definition?: string;
|
|
834
|
+
/** The domain object this view presents, when it presents exactly one. */
|
|
835
|
+
persists?: { aggregate?: string; block?: string };
|
|
836
|
+
/** Migration or model file, as a reader would open it. */
|
|
837
|
+
source?: string;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* A sequence read out of source.
|
|
842
|
+
*
|
|
843
|
+
* Extractors may attach the execution trigger they proved. Authored flows omit
|
|
844
|
+
* it when that evidence is not part of the document.
|
|
845
|
+
*/
|
|
846
|
+
export interface Flow {
|
|
847
|
+
id: string;
|
|
848
|
+
slug: string;
|
|
849
|
+
name: string;
|
|
850
|
+
summary: string;
|
|
851
|
+
source?: string; // the file the flow was read out of
|
|
852
|
+
/** Source-backed execution root and the strength of that evidence. */
|
|
853
|
+
trigger?: FlowTrigger;
|
|
854
|
+
/** Source function this flow expands, used for evidence-backed composition. */
|
|
855
|
+
entrypoint?: string;
|
|
856
|
+
/** Source-backed flow fragments composed into this root flow. */
|
|
857
|
+
includes?: string[];
|
|
858
|
+
/**
|
|
859
|
+
* The top-level group this flow belongs to. Whatever derived the flow read
|
|
860
|
+
* one component's tree to find it and therefore knows the answer, so the flow
|
|
861
|
+
* states it instead of leaving a reader to recover it from `source` - and the
|
|
862
|
+
* validator holds every flow to it, because a flow with no owner has nowhere
|
|
863
|
+
* to sit in the tree.
|
|
864
|
+
*/
|
|
865
|
+
owner: string;
|
|
866
|
+
participants: Participant[]; // order is significant - it is the lane order
|
|
867
|
+
steps: FlowNode[];
|
|
868
|
+
}
|
|
869
|
+
export interface FlowTrigger {
|
|
870
|
+
kind:
|
|
871
|
+
| "http"
|
|
872
|
+
| "callback"
|
|
873
|
+
| "event"
|
|
874
|
+
| "message"
|
|
875
|
+
| "job"
|
|
876
|
+
| "startup"
|
|
877
|
+
| "scheduled"
|
|
878
|
+
| "manual"
|
|
879
|
+
| "unproven";
|
|
880
|
+
label?: string;
|
|
881
|
+
confidence: "high" | "medium" | "low";
|
|
882
|
+
}
|
|
883
|
+
export interface Participant {
|
|
884
|
+
id: string;
|
|
885
|
+
kind: "actor" | "service" | "broker" | "store" | "external" | "unknown";
|
|
886
|
+
context: string | null; // null for actors and brokers
|
|
887
|
+
label?: string;
|
|
888
|
+
}
|
|
889
|
+
export type FlowNode = Step | Parallel | Alt | Loop;
|
|
890
|
+
export interface Step {
|
|
891
|
+
type: "step";
|
|
892
|
+
id: string;
|
|
893
|
+
from: string;
|
|
894
|
+
to: string; // participant ids; from === to is a self-message
|
|
895
|
+
kind: "rpc" | "event" | "call" | "response";
|
|
896
|
+
ref?: string; // Event.id or RpcCall.id - resolvable, or status must be unresolved
|
|
897
|
+
label?: string;
|
|
898
|
+
status: Status;
|
|
899
|
+
note?: string;
|
|
900
|
+
line?: string;
|
|
901
|
+
/** Synchronous request step this synthesized response returns from. */
|
|
902
|
+
replyTo?: string;
|
|
903
|
+
/** Proven HTTP wire contract for a response step. */
|
|
904
|
+
http?: HTTPResponse;
|
|
905
|
+
/** Source function execution enters here, when an extractor can prove it. */
|
|
906
|
+
continuesAt?: string;
|
|
907
|
+
/** Source functions proven to execute on the path represented by this step. */
|
|
908
|
+
reaches?: string[];
|
|
909
|
+
/** Exact asynchronous send/receive evidence used for flow composition. */
|
|
910
|
+
handoff?: FlowHandoff;
|
|
911
|
+
/** Repository call resolved to a concrete store operation after merge. */
|
|
912
|
+
storeAccess?: FlowStoreAccess;
|
|
913
|
+
}
|
|
914
|
+
export interface HTTPResponse {
|
|
915
|
+
status?: number;
|
|
916
|
+
contentType?: string;
|
|
917
|
+
body?: string;
|
|
918
|
+
/** RPC method whose response value is serialized into this body. */
|
|
919
|
+
bodyRef?: string;
|
|
920
|
+
encoding?: string;
|
|
921
|
+
outcome?: "success" | "error";
|
|
922
|
+
warning?: string;
|
|
923
|
+
source?: string;
|
|
924
|
+
/** Shape recovered directly from a literal response body. */
|
|
925
|
+
fields?: Field[];
|
|
926
|
+
}
|
|
927
|
+
export interface FlowHandoff {
|
|
928
|
+
kind: "message" | "job";
|
|
929
|
+
transport: string;
|
|
930
|
+
channel: string;
|
|
931
|
+
message?: string;
|
|
932
|
+
direction: "send" | "receive";
|
|
933
|
+
}
|
|
934
|
+
export interface FlowStoreAccess {
|
|
935
|
+
store: string;
|
|
936
|
+
method?: string;
|
|
937
|
+
operation?: RedisOperation;
|
|
938
|
+
keyspace?: string;
|
|
939
|
+
/** Concrete adapter call rather than the use-case-side repository call. */
|
|
940
|
+
source?: string;
|
|
941
|
+
}
|
|
942
|
+
export interface Parallel {
|
|
943
|
+
type: "parallel";
|
|
944
|
+
id: string;
|
|
945
|
+
title?: string;
|
|
946
|
+
branches: FlowNode[][];
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* A choice. Exactly one branch runs, so the branches are not a sequence and
|
|
950
|
+
* nothing that reads a flow may treat them as one.
|
|
951
|
+
*
|
|
952
|
+
* `terminal` marks a branch that ENDS the flow rather than rejoining it — the
|
|
953
|
+
* cancel arm of a risk check, say. Without it a reader has no way to tell that
|
|
954
|
+
* the steps drawn after the alt do not follow that branch, and the sequence
|
|
955
|
+
* reads as "the order was cancelled and then charged".
|
|
956
|
+
*/
|
|
957
|
+
export interface Alt {
|
|
958
|
+
type: "alt";
|
|
959
|
+
id: string;
|
|
960
|
+
branches: AltBranch[];
|
|
961
|
+
}
|
|
962
|
+
export interface AltBranch {
|
|
963
|
+
/** The condition under which this branch runs, in words. */
|
|
964
|
+
title: string;
|
|
965
|
+
steps: FlowNode[];
|
|
966
|
+
/** True when the flow stops here instead of continuing past the alt. */
|
|
967
|
+
terminal?: boolean;
|
|
968
|
+
}
|
|
969
|
+
export interface Loop {
|
|
970
|
+
type: "loop";
|
|
971
|
+
id: string;
|
|
972
|
+
title: string;
|
|
973
|
+
steps: FlowNode[];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// The ubiquitous language. One meaning per word inside a context, written down
|
|
978
|
+
// where the code that uses the word lives, and the leaf everything else points
|
|
979
|
+
// to: a term links nowhere, and nothing here is derived from the model.
|
|
980
|
+
//
|
|
981
|
+
// A word and a sentence, and nothing else. What the sentence says is the
|
|
982
|
+
// author's business - the one thing in this catalog that no extractor could
|
|
983
|
+
// have worked out from the code, and the one thing a parser has no business
|
|
984
|
+
// taking apart.
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
export interface Term {
|
|
988
|
+
/** "<context>.<slug>" - auth.session. A word means one thing per context. */
|
|
989
|
+
id: string;
|
|
990
|
+
slug: string;
|
|
991
|
+
/** The context whose vocabulary this is, never the service the file sat in. */
|
|
992
|
+
context: string;
|
|
993
|
+
/** As the glossary spells it, which is how the code spells it: "Email address". */
|
|
994
|
+
name: string;
|
|
995
|
+
/** What it means, as the glossary's own paragraph: markdown, one line. */
|
|
996
|
+
definition: string;
|
|
997
|
+
/** `path:line` of the entry, as everything else in the catalog spells a source. */
|
|
998
|
+
source: string;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ---------------------------------------------------------------------------
|
|
1002
|
+
// Decision records. An ADR is frozen history: it says what was decided and
|
|
1003
|
+
// when, not what the model looks like now. Nothing here is regenerated from
|
|
1004
|
+
// the current catalog, and nothing on an ADR page redraws from it.
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
export type AdrStatus =
|
|
1008
|
+
"proposed" | "accepted" | "superseded" | "deprecated" | "rejected";
|
|
1009
|
+
|
|
1010
|
+
export type AdrScope =
|
|
1011
|
+
| { kind: "org" }
|
|
1012
|
+
| { kind: "context"; context: string }
|
|
1013
|
+
| { kind: "service"; service: string };
|
|
1014
|
+
|
|
1015
|
+
export interface Adr {
|
|
1016
|
+
id: string; // "shop.oms.0007" - scope prefix plus zero-padded number
|
|
1017
|
+
slug: string;
|
|
1018
|
+
number: number; // 7
|
|
1019
|
+
title: string;
|
|
1020
|
+
status: AdrStatus;
|
|
1021
|
+
date: string; // decision date, ISO
|
|
1022
|
+
scope: AdrScope;
|
|
1023
|
+
body: string; // markdown, MADR structure
|
|
1024
|
+
// Prose about the record that no other field holds - most often that part of
|
|
1025
|
+
// it was decided again elsewhere without the whole of it being superseded.
|
|
1026
|
+
// It sits in the header, above the frozen body, because it is the thing to
|
|
1027
|
+
// read before the decision rather than after it.
|
|
1028
|
+
note?: string;
|
|
1029
|
+
supersededBy?: string; // Adr.id
|
|
1030
|
+
supersedes?: string[];
|
|
1031
|
+
relates: { services?: string[]; events?: string[]; flows?: string[] };
|
|
1032
|
+
source: string; // path to the .md in its repo
|
|
1033
|
+
// What git says about the file: the commit that first added it, and the one
|
|
1034
|
+
// that last touched it when that is a different commit. Absent when the
|
|
1035
|
+
// tree had no history to read.
|
|
1036
|
+
created?: AdrCommit;
|
|
1037
|
+
revised?: AdrCommit;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export interface AdrCommit {
|
|
1041
|
+
commit: string; // full sha
|
|
1042
|
+
author: string; // author name as git records it
|
|
1043
|
+
date: string; // committer date, ISO
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
// Traversal helpers. Everything here is DERIVED from the steps, never stored in
|
|
1048
|
+
// the JSON.
|
|
1049
|
+
//
|
|
1050
|
+
// There is deliberately no flow-level score. How far a flow can be trusted is
|
|
1051
|
+
// said step by step, by each `Step.status`; a ratio over those averaged claims
|
|
1052
|
+
// that are not comparable, hid the only actionable one (`unresolved`), and —
|
|
1053
|
+
// once alt branches are counted — divided by a number no single execution ever
|
|
1054
|
+
// reaches.
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
|
|
1057
|
+
/** Depth-first walk over every Step in a node list, in numbering order. */
|
|
1058
|
+
export function walkSteps(nodes: FlowNode[]): Step[] {
|
|
1059
|
+
const out: Step[] = [];
|
|
1060
|
+
const visit = (list: FlowNode[]): void => {
|
|
1061
|
+
for (const node of list) {
|
|
1062
|
+
switch (node.type) {
|
|
1063
|
+
case "step":
|
|
1064
|
+
out.push(node);
|
|
1065
|
+
break;
|
|
1066
|
+
case "parallel":
|
|
1067
|
+
for (const branch of node.branches) visit(branch);
|
|
1068
|
+
break;
|
|
1069
|
+
case "alt":
|
|
1070
|
+
for (const branch of node.branches) visit(branch.steps);
|
|
1071
|
+
break;
|
|
1072
|
+
case "loop":
|
|
1073
|
+
visit(node.steps);
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
visit(nodes);
|
|
1079
|
+
return out;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* One frame enclosing a step: the alt, parallel or loop it sits inside.
|
|
1084
|
+
*
|
|
1085
|
+
* This is what the rail and the detail panel need in order to say *under what
|
|
1086
|
+
* condition* a step runs. Without it a step is just a line in a sequence, and
|
|
1087
|
+
* a reader cannot tell an alternative apart from a consequence.
|
|
1088
|
+
*/
|
|
1089
|
+
export interface StepFrame {
|
|
1090
|
+
kind: "parallel" | "alt" | "loop";
|
|
1091
|
+
/** Id of the Parallel / Alt / Loop node. */
|
|
1092
|
+
id: string;
|
|
1093
|
+
/** Loop or parallel title. An alt carries its condition on the branch. */
|
|
1094
|
+
title?: string;
|
|
1095
|
+
/** Alt: the branch condition. Parallel: the 1-based branch number. */
|
|
1096
|
+
branch?: string;
|
|
1097
|
+
/** Alt only: this branch ends the flow rather than rejoining it. */
|
|
1098
|
+
terminal?: boolean;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The frames around every step, outermost first. Steps not inside any frame
|
|
1103
|
+
* map to an empty list, so callers never have to special-case the flat case.
|
|
1104
|
+
*/
|
|
1105
|
+
export function stepFrames(nodes: FlowNode[]): Map<string, StepFrame[]> {
|
|
1106
|
+
const out = new Map<string, StepFrame[]>();
|
|
1107
|
+
const visit = (list: FlowNode[], stack: StepFrame[]): void => {
|
|
1108
|
+
for (const node of list) {
|
|
1109
|
+
switch (node.type) {
|
|
1110
|
+
case "step":
|
|
1111
|
+
out.set(node.id, stack);
|
|
1112
|
+
break;
|
|
1113
|
+
case "parallel":
|
|
1114
|
+
node.branches.forEach((branch, i) =>
|
|
1115
|
+
visit(branch, [
|
|
1116
|
+
...stack,
|
|
1117
|
+
{
|
|
1118
|
+
kind: "parallel",
|
|
1119
|
+
id: node.id,
|
|
1120
|
+
title: node.title,
|
|
1121
|
+
branch: String(i + 1),
|
|
1122
|
+
},
|
|
1123
|
+
]),
|
|
1124
|
+
);
|
|
1125
|
+
break;
|
|
1126
|
+
case "alt":
|
|
1127
|
+
for (const branch of node.branches) {
|
|
1128
|
+
visit(branch.steps, [
|
|
1129
|
+
...stack,
|
|
1130
|
+
{
|
|
1131
|
+
kind: "alt",
|
|
1132
|
+
id: node.id,
|
|
1133
|
+
branch: branch.title,
|
|
1134
|
+
terminal: branch.terminal,
|
|
1135
|
+
},
|
|
1136
|
+
]);
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
case "loop":
|
|
1140
|
+
visit(node.steps, [
|
|
1141
|
+
...stack,
|
|
1142
|
+
{ kind: "loop", id: node.id, title: node.title },
|
|
1143
|
+
]);
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
visit(nodes, []);
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The conditions a step runs under, outermost first — the alt branches around
|
|
1154
|
+
* it and nothing else. A step with none of these runs on every path.
|
|
1155
|
+
*/
|
|
1156
|
+
export function stepConditions(frames: readonly StepFrame[]): StepFrame[] {
|
|
1157
|
+
return frames.filter((f) => f.kind === "alt");
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export function allServices(catalog: Catalog): Service[] {
|
|
1161
|
+
return catalog.contexts.flatMap((c) => c.services);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Neutral alias for allServices; both names intentionally address the same wire model. */
|
|
1165
|
+
export function allComponents(catalog: Catalog): Component[] {
|
|
1166
|
+
return allServices(catalog);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
export function groupKind(group: Group): GroupKind {
|
|
1170
|
+
return group.kind ?? "bounded-context";
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export function componentKind(component: Component): ComponentKind {
|
|
1174
|
+
return component.kind ?? "service";
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Every system outside the estate with a contract, in catalog order. */
|
|
1178
|
+
export function allExternals(catalog: Catalog): External[] {
|
|
1179
|
+
return catalog.externals ?? [];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export function allEvents(catalog: Catalog): Event[] {
|
|
1183
|
+
return allServices(catalog).flatMap((s) =>
|
|
1184
|
+
s.aggregates.flatMap((a) => a.events),
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function allAggregates(catalog: Catalog): Aggregate[] {
|
|
1189
|
+
return allServices(catalog).flatMap((s) => s.aggregates);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Every store, whether or not any service lists it. Absent means none. */
|
|
1193
|
+
export function allStores(catalog: Catalog): Store[] {
|
|
1194
|
+
return catalog.stores ?? [];
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
export function allModules(catalog: Catalog): ProtoModule[] {
|
|
1198
|
+
return catalog.modules ?? [];
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Every term in every glossary. Absent means none, exactly as with modules. */
|
|
1202
|
+
export function allTerms(catalog: Catalog): Term[] {
|
|
1203
|
+
return catalog.terms ?? [];
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Every repository the estate was read at. Absent means none, exactly as with modules. */
|
|
1207
|
+
export function allRepos(catalog: Catalog): RepoPin[] {
|
|
1208
|
+
return catalog.repos ?? [];
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Who to ask about a service, without the caller having to know the field is optional. */
|
|
1212
|
+
export function ownersOf(service: Service): string[] {
|
|
1213
|
+
return service.owners ?? [];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function technologiesOf(component: Component): string[] {
|
|
1217
|
+
return component.technologies ?? [];
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export function commandsOf(component: Component): Command[] {
|
|
1221
|
+
return component.commands ?? [];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Every view in every store. Absent means none, exactly as with tables. */
|
|
1225
|
+
export function allViews(catalog: Catalog): View[] {
|
|
1226
|
+
return allStores(catalog).flatMap((s) => s.views ?? []);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** The views of one store, without the caller having to know the field is optional. */
|
|
1230
|
+
export function storeViews(store: Store): View[] {
|
|
1231
|
+
return store.views ?? [];
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* What a view reads, table by table: what it declares, then everything its
|
|
1236
|
+
* columns point at that it forgot to declare. A view is allowed to state only
|
|
1237
|
+
* one of the two — the coarse list is easier to write by hand, the column
|
|
1238
|
+
* lineage is what an extractor produces — and readers should not have to know
|
|
1239
|
+
* which of the two the catalog happened to carry.
|
|
1240
|
+
*/
|
|
1241
|
+
export function viewReads(view: View): string[] {
|
|
1242
|
+
const out: string[] = [];
|
|
1243
|
+
const add = (id: string) => {
|
|
1244
|
+
if (!out.includes(id)) out.push(id);
|
|
1245
|
+
};
|
|
1246
|
+
for (const id of view.reads ?? []) add(id);
|
|
1247
|
+
for (const column of view.columns) {
|
|
1248
|
+
for (const ref of column.from ?? []) add(relationOfColumnId(ref));
|
|
1249
|
+
}
|
|
1250
|
+
return out;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The relation half of a column id. Ids are dotted all the way down and only
|
|
1255
|
+
* the last segment is the column name, so this is a right split, not a left
|
|
1256
|
+
* one: "shop.oms.pg.orders.status" is the `status` column of `shop.oms.pg.orders`.
|
|
1257
|
+
*/
|
|
1258
|
+
export function relationOfColumnId(id: string): string {
|
|
1259
|
+
return id.split(".").slice(0, -1).join(".");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** The column half of a column id — everything after the last dot. */
|
|
1263
|
+
export function columnNameOfId(id: string): string {
|
|
1264
|
+
return id.split(".").at(-1) ?? "";
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* A column's id. Columns are not addressed in the JSON, but the selection layer
|
|
1269
|
+
* needs one identifier per selectable thing, and "<table id>.<column>" is the
|
|
1270
|
+
* spelling a reader would type.
|
|
1271
|
+
*/
|
|
1272
|
+
export function columnId(tableId: string, column: string): string {
|
|
1273
|
+
return `${tableId}.${column}`;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** The columns a collapsed table card shows: its key, then everything it points at. */
|
|
1277
|
+
export function keyColumns(table: Table): Column[] {
|
|
1278
|
+
return table.columns.filter((c) => c.pk || c.fk);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* The fields a block actually has: its own when written inline, otherwise the
|
|
1283
|
+
* shared def it names. An empty list means the catalog knows the block by name
|
|
1284
|
+
* only, which pages say out loud rather than drawing a blank table.
|
|
1285
|
+
*/
|
|
1286
|
+
/** The enums an aggregate declares; [] for a source that wrote none. */
|
|
1287
|
+
export function enumsOf(aggregate: Aggregate): Enum[] {
|
|
1288
|
+
return aggregate.enums ?? [];
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
export function blockFields(catalog: Catalog, block: Block): Field[] {
|
|
1292
|
+
if (block.fields) return block.fields;
|
|
1293
|
+
if (block.ref) return catalog.defs[block.ref]?.fields ?? [];
|
|
1294
|
+
return [];
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** Value objects and entities of one aggregate, tagged with which they are. */
|
|
1298
|
+
export function aggregateBlocks(
|
|
1299
|
+
aggregate: Aggregate,
|
|
1300
|
+
): { kind: BlockKind; block: Block }[] {
|
|
1301
|
+
return [
|
|
1302
|
+
...aggregate.valueObjects.map((block) => ({ kind: "vo" as const, block })),
|
|
1303
|
+
...aggregate.entities.map((block) => ({ kind: "entity" as const, block })),
|
|
1304
|
+
];
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** The entity an aggregate names as its root, if the catalog lists it. */
|
|
1308
|
+
export function rootEntity(aggregate: Aggregate): Entity | undefined {
|
|
1309
|
+
return aggregate.entities.find((e) => e.name === aggregate.root);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export interface BlockCounts {
|
|
1313
|
+
entities: number;
|
|
1314
|
+
valueObjects: number;
|
|
1315
|
+
enums: number;
|
|
1316
|
+
events: number;
|
|
1317
|
+
commands: number;
|
|
1318
|
+
queries: number;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export function blockCounts(aggregate: Aggregate): BlockCounts {
|
|
1322
|
+
return {
|
|
1323
|
+
entities: aggregate.entities.length,
|
|
1324
|
+
valueObjects: aggregate.valueObjects.length,
|
|
1325
|
+
enums: enumsOf(aggregate).length,
|
|
1326
|
+
events: aggregate.events.length,
|
|
1327
|
+
commands: aggregate.operations.filter((o) => o.kind === "command").length,
|
|
1328
|
+
queries: aggregate.operations.filter((o) => o.kind === "query").length,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Contexts touched by a flow, in participant order, ignoring null-context lanes. */
|
|
1333
|
+
export function flowContexts(flow: Flow): string[] {
|
|
1334
|
+
const seen: string[] = [];
|
|
1335
|
+
for (const p of flow.participants) {
|
|
1336
|
+
if (p.context && !seen.includes(p.context)) seen.push(p.context);
|
|
1337
|
+
}
|
|
1338
|
+
return seen;
|
|
1339
|
+
}
|