@shortlink-org/portolan 0.2.1 → 0.2.2
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/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +363 -28
- package/scripts/local-api.test.mjs +195 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -0
- package/src/landing/LandingPage.tsx +12 -8
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +17 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +55 -0
- package/src/merge.ts +30 -0
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
package/scripts/gen-likec4.mjs
CHANGED
|
@@ -11,12 +11,13 @@ import { writeFileSync, mkdirSync } from "node:fs";
|
|
|
11
11
|
|
|
12
12
|
import { loadCatalog } from "./catalog-sources.mjs";
|
|
13
13
|
import reserved from "../src/likec4/reserved.json" with { type: "json" };
|
|
14
|
+
import { catalogProfiles } from "../src/catalog-profile.ts";
|
|
14
15
|
|
|
15
16
|
// Every source, not one file: a service that publishes its own facts gets a
|
|
16
17
|
// C4 view like any other, and generating from a single file would leave it out
|
|
17
18
|
// of the pictures while the rest of the app knows about it.
|
|
18
19
|
const { catalog, manifest } = await loadCatalog();
|
|
19
|
-
const profiles = manifest
|
|
20
|
+
const profiles = catalogProfiles(manifest);
|
|
20
21
|
|
|
21
22
|
// --- ids (mirrors src/likec4/ids.ts; kept in step by src/likec4/ids.test.ts) ---
|
|
22
23
|
// The reserved words are not mirrored, they are the same file: a word the
|
|
@@ -25,7 +26,9 @@ const profiles = manifest.catalogs ?? [];
|
|
|
25
26
|
const RESERVED = new Set(reserved);
|
|
26
27
|
const safeId = (raw) => {
|
|
27
28
|
const cleaned = raw.replace(/[^A-Za-z0-9_]/g, "_");
|
|
28
|
-
return /^[0-9]/.test(cleaned) || RESERVED.has(cleaned)
|
|
29
|
+
return /^[0-9]/.test(cleaned) || RESERVED.has(cleaned)
|
|
30
|
+
? `_${cleaned}`
|
|
31
|
+
: cleaned;
|
|
29
32
|
};
|
|
30
33
|
const fqn = (id) => id.split(".").map(safeId).join(".");
|
|
31
34
|
const flowViewId = (flow) => `flow_${safeId(flow.slug)}`;
|
|
@@ -35,17 +38,24 @@ const serviceViewId = (s) => `svc_${safeId(s.id)}`;
|
|
|
35
38
|
const serviceInsideViewId = (s) => `${serviceViewId(s)}_inside`;
|
|
36
39
|
const LANDSCAPE_VIEW = "landscape";
|
|
37
40
|
const CONTAINERS_VIEW = "containers";
|
|
38
|
-
const profileLandscapeViewId = (profile) =>
|
|
39
|
-
|
|
41
|
+
const profileLandscapeViewId = (profile) =>
|
|
42
|
+
`${LANDSCAPE_VIEW}_${safeId(profile.id)}`;
|
|
43
|
+
const profileContainersViewId = (profile) =>
|
|
44
|
+
`${CONTAINERS_VIEW}_${safeId(profile.id)}`;
|
|
45
|
+
const includeTargets = (targets) =>
|
|
46
|
+
targets.length > 0 ? targets.join(", ") : "*";
|
|
40
47
|
|
|
41
|
-
const q = (text) =>
|
|
48
|
+
const q = (text) =>
|
|
49
|
+
`'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
42
50
|
|
|
43
51
|
// The protocol a call travels on, read off the document that declares it.
|
|
44
52
|
// Mirrors sourceDocKind in src/lib/source-doc.ts, plus the generated stub a
|
|
45
53
|
// Go client is read from when no proto is vendored. Empty when the source
|
|
46
54
|
// says nothing a reader could name.
|
|
47
55
|
const protocolOf = (source) => {
|
|
48
|
-
const path = String(source ?? "")
|
|
56
|
+
const path = String(source ?? "")
|
|
57
|
+
.replace(/:\d+$/, "")
|
|
58
|
+
.toLowerCase();
|
|
49
59
|
if (path.endsWith(".proto") || path.endsWith(".pb.go")) return "gRPC";
|
|
50
60
|
if (path.endsWith(".wsdl")) return "SOAP";
|
|
51
61
|
if (path.endsWith(".graphql") || path.endsWith(".graphqls")) return "GraphQL";
|
|
@@ -58,29 +68,57 @@ const protocolOf = (source) => {
|
|
|
58
68
|
// contracts it provides. Both are facts the catalog holds; neither is guessed.
|
|
59
69
|
const technologyOf = (service) => {
|
|
60
70
|
const protocols = new Set(
|
|
61
|
-
service.provides
|
|
71
|
+
service.provides
|
|
72
|
+
.map((provided) => protocolOf(provided.source))
|
|
73
|
+
.filter(Boolean),
|
|
74
|
+
);
|
|
75
|
+
return [...(service.technologies ?? []), ...[...protocols].sort()].join(
|
|
76
|
+
" · ",
|
|
62
77
|
);
|
|
63
|
-
return [...(service.technologies ?? []), ...[...protocols].sort()].join(" · ");
|
|
64
78
|
};
|
|
65
79
|
|
|
66
80
|
// --- shared visual language ------------------------------------------------
|
|
67
81
|
// These hex values are the light/dark-neutral midpoints of the --ctx-N and
|
|
68
82
|
// --status-* tokens in src/index.css. LikeC4 bakes colours into its own theme,
|
|
69
83
|
// so the seam between renderers is kept invisible by hand here.
|
|
70
|
-
const CTX_COLORS = [
|
|
84
|
+
const CTX_COLORS = [
|
|
85
|
+
"#4a86c8",
|
|
86
|
+
"#9a66cc",
|
|
87
|
+
"#2aa196",
|
|
88
|
+
"#c4703f",
|
|
89
|
+
"#6b7cb4",
|
|
90
|
+
"#b46b8b",
|
|
91
|
+
];
|
|
71
92
|
const STATUS_COLORS = {
|
|
72
93
|
verified: "#2f9e63",
|
|
73
94
|
declared: "#b8912f",
|
|
74
95
|
unresolved: "#e0453f",
|
|
75
96
|
};
|
|
76
|
-
const
|
|
97
|
+
const RESPONSE_ERROR_COLOR = "#b7646b";
|
|
98
|
+
const STATUS_LINE = {
|
|
99
|
+
verified: "solid",
|
|
100
|
+
declared: "dashed",
|
|
101
|
+
unresolved: "dotted",
|
|
102
|
+
};
|
|
77
103
|
const STATUS_RANK = { verified: 0, declared: 1, unresolved: 2 };
|
|
78
|
-
const KIND_HEAD = {
|
|
104
|
+
const KIND_HEAD = {
|
|
105
|
+
event: "onormal",
|
|
106
|
+
rpc: "normal",
|
|
107
|
+
call: "none",
|
|
108
|
+
response: "normal",
|
|
109
|
+
};
|
|
79
110
|
|
|
80
111
|
// Every relation carries the kind of fact it is, so a view can say which
|
|
81
112
|
// facts it draws: a level-2 picture with the bus on it leaves out the arrows
|
|
82
113
|
// that skip the bus, and nothing else has to change for it to do so.
|
|
83
|
-
const RELATION_KINDS = [
|
|
114
|
+
const RELATION_KINDS = [
|
|
115
|
+
"consumes",
|
|
116
|
+
"calls",
|
|
117
|
+
"bus",
|
|
118
|
+
"reads",
|
|
119
|
+
"persists",
|
|
120
|
+
"uses",
|
|
121
|
+
];
|
|
84
122
|
|
|
85
123
|
const contextColorName = (contextId) => {
|
|
86
124
|
const i = catalog.contexts.findIndex((c) => c.id === contextId);
|
|
@@ -102,7 +140,10 @@ for (const flow of catalog.flows) {
|
|
|
102
140
|
// not a flow has walked to it yet: a call recorded on `consumes` lands on it,
|
|
103
141
|
// and the catalog knows what it is called, which a lane's bare id does not.
|
|
104
142
|
for (const external of catalog.externals ?? []) {
|
|
105
|
-
rootParticipants.set(external.id, {
|
|
143
|
+
rootParticipants.set(external.id, {
|
|
144
|
+
kind: "external",
|
|
145
|
+
label: external.name || external.id,
|
|
146
|
+
});
|
|
106
147
|
}
|
|
107
148
|
// Event consumers that no service accounts for are real dependencies too.
|
|
108
149
|
for (const context of catalog.contexts) {
|
|
@@ -128,7 +169,8 @@ for (const context of catalog.contexts) {
|
|
|
128
169
|
// one participant, joined by the label the lane kept, so the call resolves to
|
|
129
170
|
// the lane and not to a `v1` nested inside a `risk` that nobody declared.
|
|
130
171
|
const participantByLabel = new Map();
|
|
131
|
-
for (const [id, meta] of rootParticipants)
|
|
172
|
+
for (const [id, meta] of rootParticipants)
|
|
173
|
+
participantByLabel.set(meta.label, id);
|
|
132
174
|
// A dot means containment only for catalog services. Root participants are
|
|
133
175
|
// declared as one safe identifier, so a broker named `river.orders` must be
|
|
134
176
|
// referenced as `river_orders`, not as an undeclared `orders` inside `river`.
|
|
@@ -156,9 +198,11 @@ for (const store of catalog.stores ?? []) {
|
|
|
156
198
|
function walkFlowSteps(nodes, visit) {
|
|
157
199
|
for (const node of nodes) {
|
|
158
200
|
if (node.type === "step") visit(node);
|
|
159
|
-
else if (node.type === "parallel")
|
|
201
|
+
else if (node.type === "parallel")
|
|
202
|
+
node.branches.forEach((b) => walkFlowSteps(b, visit));
|
|
160
203
|
else if (node.type === "loop") walkFlowSteps(node.steps, visit);
|
|
161
|
-
else if (node.type === "alt")
|
|
204
|
+
else if (node.type === "alt")
|
|
205
|
+
node.branches.forEach((b) => walkFlowSteps(b.steps, visit));
|
|
162
206
|
}
|
|
163
207
|
}
|
|
164
208
|
|
|
@@ -166,12 +210,15 @@ function walkFlowSteps(nodes, visit) {
|
|
|
166
210
|
// specification
|
|
167
211
|
// ---------------------------------------------------------------------------
|
|
168
212
|
const spec = [];
|
|
169
|
-
spec.push(
|
|
213
|
+
spec.push(
|
|
214
|
+
"// GENERATED by scripts/gen-likec4.mjs from the merged catalog — do not edit.",
|
|
215
|
+
);
|
|
170
216
|
spec.push("specification {");
|
|
171
217
|
CTX_COLORS.forEach((hex, i) => spec.push(` color ctx${i} ${hex}`));
|
|
172
218
|
for (const [name, hex] of Object.entries(STATUS_COLORS)) {
|
|
173
219
|
spec.push(` color ${name} ${hex}`);
|
|
174
220
|
}
|
|
221
|
+
spec.push(` color response_error ${RESPONSE_ERROR_COLOR}`);
|
|
175
222
|
spec.push("");
|
|
176
223
|
spec.push(" element context {");
|
|
177
224
|
spec.push(" style { shape rectangle opacity 5% border dashed }");
|
|
@@ -217,16 +264,22 @@ model.push("");
|
|
|
217
264
|
|
|
218
265
|
for (const context of catalog.contexts) {
|
|
219
266
|
model.push(` ${safeId(context.id)} = context ${q(context.name)} {`);
|
|
220
|
-
model.push(
|
|
267
|
+
model.push(
|
|
268
|
+
` description ${q([context.kind, context.summary].filter(Boolean).join(" · "))}`,
|
|
269
|
+
);
|
|
221
270
|
model.push(` style { color ${contextColorName(context.id)} }`);
|
|
222
271
|
for (const service of context.services) {
|
|
223
272
|
model.push(` ${safeId(service.slug)} = service ${q(service.name)} {`);
|
|
224
|
-
model.push(
|
|
273
|
+
model.push(
|
|
274
|
+
` description ${q([service.kind, ...(service.technologies ?? []), `${service.repo}/${service.path}`].filter(Boolean).join(" · "))}`,
|
|
275
|
+
);
|
|
225
276
|
const technology = technologyOf(service);
|
|
226
277
|
if (technology) model.push(` technology ${q(technology)}`);
|
|
227
278
|
model.push(` style { color ${contextColorName(context.id)} }`);
|
|
228
279
|
for (const aggregate of service.aggregates) {
|
|
229
|
-
model.push(
|
|
280
|
+
model.push(
|
|
281
|
+
` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.name)} {`,
|
|
282
|
+
);
|
|
230
283
|
for (const event of aggregate.events) {
|
|
231
284
|
const latest = event.versions[event.versions.length - 1];
|
|
232
285
|
model.push(` ${safeId(event.name)} = event ${q(event.name)} {`);
|
|
@@ -296,20 +349,38 @@ for (const context of catalog.contexts) {
|
|
|
296
349
|
// and the tables that carry it are the label. A table persisting an aggregate
|
|
297
350
|
// another service owns draws the same arrow across the boundary, which is the
|
|
298
351
|
// crossing the Problems page reports.
|
|
299
|
-
const persists = new Map(); // "aggregate|store" -> { aggregate, store,
|
|
352
|
+
const persists = new Map(); // "aggregate|store" -> { aggregate, store, labels:[] }
|
|
300
353
|
for (const store of catalog.stores ?? []) {
|
|
301
354
|
for (const table of store.tables) {
|
|
302
355
|
const aggregate = table.persists?.aggregate;
|
|
303
356
|
if (!aggregate) continue;
|
|
304
357
|
const key = `${aggregate}|${store.id}`;
|
|
305
|
-
const edge = persists.get(key) ?? {
|
|
306
|
-
|
|
358
|
+
const edge = persists.get(key) ?? {
|
|
359
|
+
aggregate,
|
|
360
|
+
store: store.id,
|
|
361
|
+
labels: [],
|
|
362
|
+
};
|
|
363
|
+
edge.labels.push(table.name);
|
|
364
|
+
persists.set(key, edge);
|
|
365
|
+
}
|
|
366
|
+
for (const keyspace of store.keyspaces ?? []) {
|
|
367
|
+
const aggregate = keyspace.persists?.aggregate;
|
|
368
|
+
if (!aggregate) continue;
|
|
369
|
+
const key = `${aggregate}|${store.id}`;
|
|
370
|
+
const edge = persists.get(key) ?? {
|
|
371
|
+
aggregate,
|
|
372
|
+
store: store.id,
|
|
373
|
+
labels: [],
|
|
374
|
+
};
|
|
375
|
+
edge.labels.push(keyspace.pattern);
|
|
307
376
|
persists.set(key, edge);
|
|
308
377
|
}
|
|
309
378
|
}
|
|
310
379
|
for (const edge of persists.values()) {
|
|
311
380
|
const label =
|
|
312
|
-
edge.
|
|
381
|
+
edge.labels.length > 3
|
|
382
|
+
? `${edge.labels.length} persisted shapes`
|
|
383
|
+
: edge.labels.join(", ");
|
|
313
384
|
relations.push(
|
|
314
385
|
` ${fqn(edge.aggregate)} -[persists]-> ${fqn(edge.store)} ${q(label)} {\n` +
|
|
315
386
|
` style { color declared line ${STATUS_LINE.declared} head normal }\n` +
|
|
@@ -324,11 +395,14 @@ for (const edge of persists.values()) {
|
|
|
324
395
|
// however many events or jobs travel that way — so a level-2 picture can put
|
|
325
396
|
// the bus between publisher and subscriber instead of an arrow that skips it.
|
|
326
397
|
const brokerIds = new Set(
|
|
327
|
-
[...rootParticipants]
|
|
398
|
+
[...rootParticipants]
|
|
399
|
+
.filter(([, meta]) => meta.kind === "broker")
|
|
400
|
+
.map(([id]) => id),
|
|
328
401
|
);
|
|
329
402
|
const busEdges = new Map(); // "from|to" -> { from, to, kind, labels:Set, status }
|
|
330
403
|
for (const flow of catalog.flows) {
|
|
331
404
|
walkFlowSteps(flow.steps, (step) => {
|
|
405
|
+
if (step.kind === "response") return;
|
|
332
406
|
if (brokerIds.has(step.from) === brokerIds.has(step.to)) return;
|
|
333
407
|
const key = `${step.from}|${step.to}`;
|
|
334
408
|
const edge = busEdges.get(key) ?? {
|
|
@@ -339,7 +413,8 @@ for (const flow of catalog.flows) {
|
|
|
339
413
|
status: "unresolved",
|
|
340
414
|
};
|
|
341
415
|
if (step.label) edge.labels.add(step.label);
|
|
342
|
-
if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
|
|
416
|
+
if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
|
|
417
|
+
edge.status = step.status;
|
|
343
418
|
busEdges.set(key, edge);
|
|
344
419
|
});
|
|
345
420
|
}
|
|
@@ -359,7 +434,9 @@ for (const edge of busEdges.values()) {
|
|
|
359
434
|
// the only evidence that they touch the estate at all, so it is read as a
|
|
360
435
|
// relation — once per pair, however many flows walk it.
|
|
361
436
|
const actorIds = new Set(
|
|
362
|
-
[...rootParticipants]
|
|
437
|
+
[...rootParticipants]
|
|
438
|
+
.filter(([, meta]) => meta.kind === "actor")
|
|
439
|
+
.map(([id]) => id),
|
|
363
440
|
);
|
|
364
441
|
//
|
|
365
442
|
// The lane is a fallback, though: an extractor opens every inbound endpoint
|
|
@@ -381,6 +458,7 @@ for (const context of catalog.contexts) {
|
|
|
381
458
|
const actorEdges = new Map(); // "from|to" -> { from, to, flows:Set, status }
|
|
382
459
|
for (const flow of catalog.flows) {
|
|
383
460
|
walkFlowSteps(flow.steps, (step) => {
|
|
461
|
+
if (step.kind === "response") return;
|
|
384
462
|
if (!actorIds.has(step.from) && !actorIds.has(step.to)) return;
|
|
385
463
|
if (step.from === step.to) return;
|
|
386
464
|
const key = `${step.from}|${step.to}`;
|
|
@@ -393,7 +471,8 @@ for (const flow of catalog.flows) {
|
|
|
393
471
|
edge.flows.add(flow.name);
|
|
394
472
|
// The best evidence any step offers: one observed crossing is enough to
|
|
395
473
|
// say the actor really does touch the estate there.
|
|
396
|
-
if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
|
|
474
|
+
if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
|
|
475
|
+
edge.status = step.status;
|
|
397
476
|
actorEdges.set(key, edge);
|
|
398
477
|
});
|
|
399
478
|
}
|
|
@@ -435,7 +514,8 @@ for (const context of catalog.contexts) {
|
|
|
435
514
|
pair.methods.push(call.id.split("/").pop() ?? call.id);
|
|
436
515
|
const protocol = protocolOf(call.source);
|
|
437
516
|
if (protocol) pair.protocols.add(protocol);
|
|
438
|
-
if (STATUS_RANK[call.status] < STATUS_RANK[pair.status])
|
|
517
|
+
if (STATUS_RANK[call.status] < STATUS_RANK[pair.status])
|
|
518
|
+
pair.status = call.status;
|
|
439
519
|
callPairs.set(key, pair);
|
|
440
520
|
}
|
|
441
521
|
}
|
|
@@ -444,7 +524,9 @@ for (const context of catalog.contexts) {
|
|
|
444
524
|
/** The `include a -> b with { … }` line that labels one pair's folded edge. */
|
|
445
525
|
function pairEdge(pair) {
|
|
446
526
|
const title =
|
|
447
|
-
pair.methods.length === 1
|
|
527
|
+
pair.methods.length === 1
|
|
528
|
+
? pair.methods[0]
|
|
529
|
+
: `${pair.methods.length} calls`;
|
|
448
530
|
const technology = [...pair.protocols].sort().join(" · ");
|
|
449
531
|
const props = [
|
|
450
532
|
`title ${q(title)}`,
|
|
@@ -471,7 +553,9 @@ for (const context of catalog.contexts) {
|
|
|
471
553
|
consumers.delete(service.id);
|
|
472
554
|
for (const consumer of consumers) {
|
|
473
555
|
const via = [...brokerIds].some(
|
|
474
|
-
(broker) =>
|
|
556
|
+
(broker) =>
|
|
557
|
+
busEdges.has(`${service.id}|${broker}`) &&
|
|
558
|
+
busEdges.has(`${broker}|${consumer}`),
|
|
475
559
|
);
|
|
476
560
|
if (via) carriedByBus.push([service.id, consumer]);
|
|
477
561
|
}
|
|
@@ -507,10 +591,9 @@ function containerPredicates(pairs, carried, indent) {
|
|
|
507
591
|
* leaves the flow rather than rejoining it.
|
|
508
592
|
*/
|
|
509
593
|
// --- what comes back from a call (mirrors src/flow/answers.ts) -------------
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// numbers are the ones the rail beside the canvas counts.
|
|
594
|
+
// Standalone calls keep the contract answer on the request label. Composition
|
|
595
|
+
// turns a proven synchronous return into an explicit response step, so the
|
|
596
|
+
// request then keeps only its own label and the return gets a dashed arrow.
|
|
514
597
|
const methodOf = new Map();
|
|
515
598
|
const serviceById = new Map();
|
|
516
599
|
for (const context of catalog.contexts) {
|
|
@@ -544,15 +627,20 @@ function answerOf(step) {
|
|
|
544
627
|
return "";
|
|
545
628
|
}
|
|
546
629
|
|
|
547
|
-
function emitSteps(nodes, out, indent) {
|
|
630
|
+
function emitSteps(nodes, out, indent, replied) {
|
|
548
631
|
for (const node of nodes) {
|
|
549
632
|
if (node.type === "step") {
|
|
550
|
-
const answer = answerOf(node);
|
|
633
|
+
const answer = replied.has(node.id) ? "" : answerOf(node);
|
|
634
|
+
const storeLabel =
|
|
635
|
+
node.storeAccess?.operation && node.storeAccess?.keyspace
|
|
636
|
+
? `${node.storeAccess.operation.toUpperCase()} ${node.storeAccess.keyspace}`
|
|
637
|
+
: "";
|
|
551
638
|
const label =
|
|
552
|
-
(node.label
|
|
639
|
+
(storeLabel || node.label || node.ref || node.kind) +
|
|
640
|
+
(answer ? ` → ${answer}` : "");
|
|
553
641
|
const attrs = [
|
|
554
|
-
`color ${node.status}`,
|
|
555
|
-
`line ${
|
|
642
|
+
`color ${node.http?.outcome === "error" ? "response_error" : node.status}`,
|
|
643
|
+
`line ${node.kind === "response" ? "dashed" : "solid"}`,
|
|
556
644
|
`head ${KIND_HEAD[node.kind]}`,
|
|
557
645
|
];
|
|
558
646
|
// The condition used to be pasted onto every label because there was no
|
|
@@ -560,21 +648,26 @@ function emitSteps(nodes, out, indent) {
|
|
|
560
648
|
const notes = [];
|
|
561
649
|
if (node.note) notes.push(node.note);
|
|
562
650
|
if (node.line) notes.push(node.line);
|
|
563
|
-
|
|
651
|
+
if (node.storeAccess?.source) notes.push(node.storeAccess.source);
|
|
652
|
+
out.push(
|
|
653
|
+
`${indent}${participantRef(node.from)} -> ${participantRef(node.to)} ${q(label)} {`,
|
|
654
|
+
);
|
|
564
655
|
out.push(`${indent} ${attrs.join(" ")}`);
|
|
565
|
-
if (notes.length > 0)
|
|
656
|
+
if (notes.length > 0)
|
|
657
|
+
out.push(`${indent} notes ${q(notes.join(" — "))}`);
|
|
566
658
|
out.push(`${indent}}`);
|
|
567
659
|
continue;
|
|
568
660
|
}
|
|
569
661
|
if (node.type === "parallel") {
|
|
570
662
|
out.push(`${indent}par ${node.title ? `${q(node.title)} ` : ""}{`);
|
|
571
|
-
for (const branch of node.branches)
|
|
663
|
+
for (const branch of node.branches)
|
|
664
|
+
emitSteps(branch, out, `${indent} `, replied);
|
|
572
665
|
out.push(`${indent}}`);
|
|
573
666
|
continue;
|
|
574
667
|
}
|
|
575
668
|
if (node.type === "loop") {
|
|
576
669
|
out.push(`${indent}loop ${q(node.title)} {`);
|
|
577
|
-
emitSteps(node.steps, out, `${indent}
|
|
670
|
+
emitSteps(node.steps, out, `${indent} `, replied);
|
|
578
671
|
out.push(`${indent}}`);
|
|
579
672
|
continue;
|
|
580
673
|
}
|
|
@@ -585,10 +678,10 @@ function emitSteps(nodes, out, indent) {
|
|
|
585
678
|
out.push(`${indent} ${keyword} ${q(branch.title)} {`);
|
|
586
679
|
if (branch.terminal) {
|
|
587
680
|
out.push(`${indent} break 'ends the flow' {`);
|
|
588
|
-
emitSteps(branch.steps, out, `${indent}
|
|
681
|
+
emitSteps(branch.steps, out, `${indent} `, replied);
|
|
589
682
|
out.push(`${indent} }`);
|
|
590
683
|
} else {
|
|
591
|
-
emitSteps(branch.steps, out, `${indent}
|
|
684
|
+
emitSteps(branch.steps, out, `${indent} `, replied);
|
|
592
685
|
}
|
|
593
686
|
out.push(`${indent} }`);
|
|
594
687
|
});
|
|
@@ -647,12 +740,14 @@ const outside = [...rootParticipants]
|
|
|
647
740
|
views.push(` view ${LANDSCAPE_VIEW} {`);
|
|
648
741
|
views.push(" title 'Estate'");
|
|
649
742
|
views.push(
|
|
650
|
-
catalog.contexts.some(
|
|
743
|
+
catalog.contexts.some(
|
|
744
|
+
(context) => context.kind && context.kind !== "bounded-context",
|
|
745
|
+
)
|
|
651
746
|
? " description 'Every architecture group, and everything outside the estate that touches one.'"
|
|
652
747
|
: " description 'Every bounded context, and everything outside the estate that touches one.'",
|
|
653
748
|
);
|
|
654
749
|
views.push(
|
|
655
|
-
` include ${[...catalog.contexts.map((c) => safeId(c.id)), ...outside]
|
|
750
|
+
` include ${includeTargets([...catalog.contexts.map((c) => safeId(c.id)), ...outside])}`,
|
|
656
751
|
);
|
|
657
752
|
views.push(" }");
|
|
658
753
|
views.push("");
|
|
@@ -665,28 +760,36 @@ views.push("");
|
|
|
665
760
|
// mean when they ask for "the architecture".
|
|
666
761
|
const brokersOfService = new Map(); // service id -> Set of broker ids
|
|
667
762
|
for (const edge of busEdges.values()) {
|
|
668
|
-
const [service, broker] = brokerIds.has(edge.to)
|
|
763
|
+
const [service, broker] = brokerIds.has(edge.to)
|
|
764
|
+
? [edge.from, edge.to]
|
|
765
|
+
: [edge.to, edge.from];
|
|
669
766
|
const set = brokersOfService.get(service) ?? new Set();
|
|
670
767
|
set.add(broker);
|
|
671
768
|
brokersOfService.set(service, set);
|
|
672
769
|
}
|
|
673
770
|
const allServices = catalog.contexts.flatMap((c) => c.services);
|
|
674
|
-
const drawnBrokers = [
|
|
771
|
+
const drawnBrokers = [
|
|
772
|
+
...new Set(
|
|
773
|
+
allServices.flatMap((s) => [...(brokersOfService.get(s.id) ?? [])]),
|
|
774
|
+
),
|
|
775
|
+
];
|
|
675
776
|
views.push(` view ${CONTAINERS_VIEW} {`);
|
|
676
777
|
views.push(" title 'Containers'");
|
|
677
778
|
views.push(
|
|
678
779
|
" description 'Every service, the store it keeps its state in, the brokers between them, and everything outside the estate that touches one.'",
|
|
679
780
|
);
|
|
680
781
|
views.push(
|
|
681
|
-
` include ${[
|
|
782
|
+
` include ${includeTargets([
|
|
682
783
|
...catalog.contexts.map((c) => safeId(c.id)),
|
|
683
784
|
...allServices.map((s) => fqn(s.id)),
|
|
684
785
|
...(catalog.stores ?? []).map((store) => fqn(store.id)),
|
|
685
786
|
...drawnBrokers.map(safeId),
|
|
686
787
|
...outside,
|
|
687
|
-
]
|
|
788
|
+
])}`,
|
|
789
|
+
);
|
|
790
|
+
views.push(
|
|
791
|
+
...containerPredicates([...callPairs.values()], carriedByBus, " "),
|
|
688
792
|
);
|
|
689
|
-
views.push(...containerPredicates([...callPairs.values()], carriedByBus, " "));
|
|
690
793
|
views.push(" }");
|
|
691
794
|
views.push("");
|
|
692
795
|
|
|
@@ -695,15 +798,30 @@ views.push("");
|
|
|
695
798
|
// UI uses: only the configured groups and the outsiders their own flows name
|
|
696
799
|
// are included.
|
|
697
800
|
for (const profile of profiles) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
801
|
+
// An empty context selection is the historical single-catalog manifest's
|
|
802
|
+
// implicit `default` profile. The browser treats it as the whole catalog;
|
|
803
|
+
// the generated views must do the same or it asks for landscape_default
|
|
804
|
+
// while the bundle only contains the unscoped landscape view.
|
|
805
|
+
const profileContexts = profile.contexts.length
|
|
806
|
+
? catalog.contexts.filter((context) =>
|
|
807
|
+
profile.contexts.includes(context.id),
|
|
808
|
+
)
|
|
809
|
+
: catalog.contexts;
|
|
810
|
+
const profileContextIds = new Set(
|
|
811
|
+
profileContexts.map((context) => context.id),
|
|
812
|
+
);
|
|
813
|
+
const profileServices = profileContexts.flatMap(
|
|
814
|
+
(context) => context.services,
|
|
815
|
+
);
|
|
816
|
+
const profileServiceIds = new Set(
|
|
817
|
+
profileServices.map((service) => service.id),
|
|
818
|
+
);
|
|
702
819
|
const profileRoots = new Set();
|
|
703
820
|
for (const flow of catalog.flows) {
|
|
704
821
|
if (!profileContextIds.has(flow.owner)) continue;
|
|
705
822
|
for (const participant of flow.participants) {
|
|
706
|
-
if (!profileServiceIds.has(participant.id))
|
|
823
|
+
if (!profileServiceIds.has(participant.id))
|
|
824
|
+
profileRoots.add(participant.id);
|
|
707
825
|
}
|
|
708
826
|
}
|
|
709
827
|
for (const service of profileServices) {
|
|
@@ -714,7 +832,8 @@ for (const profile of profiles) {
|
|
|
714
832
|
for (const aggregate of service.aggregates) {
|
|
715
833
|
for (const event of aggregate.events) {
|
|
716
834
|
for (const consumer of event.consumers) {
|
|
717
|
-
if (!profileServiceIds.has(consumer.service))
|
|
835
|
+
if (!profileServiceIds.has(consumer.service))
|
|
836
|
+
profileRoots.add(consumer.service);
|
|
718
837
|
}
|
|
719
838
|
}
|
|
720
839
|
}
|
|
@@ -725,30 +844,48 @@ for (const profile of profiles) {
|
|
|
725
844
|
const profileBrokers = [...profileRoots]
|
|
726
845
|
.filter((id) => rootParticipants.get(id)?.kind === "broker")
|
|
727
846
|
.map(safeId);
|
|
728
|
-
const profileStores = (catalog.stores ?? []).filter((store) =>
|
|
847
|
+
const profileStores = (catalog.stores ?? []).filter((store) =>
|
|
848
|
+
profileServiceIds.has(store.owner),
|
|
849
|
+
);
|
|
729
850
|
|
|
730
851
|
views.push(` view ${profileLandscapeViewId(profile)} {`);
|
|
731
852
|
views.push(` title ${q(profile.title)}`);
|
|
732
|
-
views.push(
|
|
733
|
-
|
|
853
|
+
views.push(
|
|
854
|
+
` description 'The groups and outside systems selected by this catalog profile.'`,
|
|
855
|
+
);
|
|
856
|
+
views.push(
|
|
857
|
+
` include ${includeTargets([...profileContexts.map((context) => safeId(context.id)), ...profileOutside])}`,
|
|
858
|
+
);
|
|
734
859
|
views.push(" }");
|
|
735
860
|
views.push("");
|
|
736
861
|
|
|
737
862
|
views.push(` view ${profileContainersViewId(profile)} {`);
|
|
738
863
|
views.push(` title ${q(`${profile.title} containers`)}`);
|
|
739
|
-
views.push(
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
864
|
+
views.push(
|
|
865
|
+
` description 'The services, stores and transports selected by this catalog profile.'`,
|
|
866
|
+
);
|
|
867
|
+
views.push(
|
|
868
|
+
` include ${includeTargets([
|
|
869
|
+
...profileContexts.map((context) => safeId(context.id)),
|
|
870
|
+
...profileServices.map((service) => fqn(service.id)),
|
|
871
|
+
...profileStores.map((store) => fqn(store.id)),
|
|
872
|
+
...profileBrokers,
|
|
873
|
+
...profileOutside,
|
|
874
|
+
])}`,
|
|
875
|
+
);
|
|
876
|
+
views.push(
|
|
877
|
+
...containerPredicates(
|
|
878
|
+
[...callPairs.values()].filter(
|
|
879
|
+
(pair) =>
|
|
880
|
+
profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to),
|
|
881
|
+
),
|
|
882
|
+
carriedByBus.filter(
|
|
883
|
+
(pair) =>
|
|
884
|
+
profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to),
|
|
885
|
+
),
|
|
886
|
+
" ",
|
|
887
|
+
),
|
|
888
|
+
);
|
|
752
889
|
views.push(" }");
|
|
753
890
|
views.push("");
|
|
754
891
|
}
|
|
@@ -766,7 +903,8 @@ for (const context of catalog.contexts) {
|
|
|
766
903
|
const contextStores = new Set();
|
|
767
904
|
const contextServiceIds = new Set(context.services.map((s) => s.id));
|
|
768
905
|
for (const service of context.services) {
|
|
769
|
-
for (const store of storesByOwner.get(service.id) ?? [])
|
|
906
|
+
for (const store of storesByOwner.get(service.id) ?? [])
|
|
907
|
+
contextStores.add(store.id);
|
|
770
908
|
for (const storeId of service.stores ?? []) {
|
|
771
909
|
if (storeById.has(storeId)) contextStores.add(storeId);
|
|
772
910
|
}
|
|
@@ -775,12 +913,17 @@ for (const context of catalog.contexts) {
|
|
|
775
913
|
// Only the pairs the picture holds both ends of: a neighbour in another
|
|
776
914
|
// context is drawn folded into its context, and naming one of its services
|
|
777
915
|
// would unfold it into a second box.
|
|
778
|
-
const inside = ([from, to]) =>
|
|
779
|
-
|
|
916
|
+
const inside = ([from, to]) =>
|
|
917
|
+
contextServiceIds.has(from) && contextServiceIds.has(to);
|
|
918
|
+
const pairs = [...callPairs.values()].filter((pair) =>
|
|
919
|
+
inside([pair.from, pair.to]),
|
|
920
|
+
);
|
|
780
921
|
views.push(` view ${contextViewId(context)} of ${safeId(context.id)} {`);
|
|
781
922
|
views.push(` title ${q(context.name)}`);
|
|
782
923
|
views.push(` include ${include}`);
|
|
783
|
-
views.push(
|
|
924
|
+
views.push(
|
|
925
|
+
...containerPredicates(pairs, carriedByBus.filter(inside), " "),
|
|
926
|
+
);
|
|
784
927
|
views.push(" }");
|
|
785
928
|
|
|
786
929
|
for (const service of context.services) {
|
|
@@ -791,7 +934,9 @@ for (const context of catalog.contexts) {
|
|
|
791
934
|
// read from inside `auth.auth` resolves to nothing at all.
|
|
792
935
|
const parts = [
|
|
793
936
|
...service.aggregates.map((a) => safeId(a.slug)),
|
|
794
|
-
...(storesByOwner.get(service.id) ?? []).map((store) =>
|
|
937
|
+
...(storesByOwner.get(service.id) ?? []).map((store) =>
|
|
938
|
+
safeId(store.slug),
|
|
939
|
+
),
|
|
795
940
|
];
|
|
796
941
|
views.push(` view ${serviceViewId(service)} of ${fqn(service.id)} {`);
|
|
797
942
|
views.push(` title ${q(`${service.name} — neighbours`)}`);
|
|
@@ -803,7 +948,9 @@ for (const context of catalog.contexts) {
|
|
|
803
948
|
// Its aggregates and its stores. Events are in the model but not in this
|
|
804
949
|
// picture: a service with eleven of them would draw a wall of boxes where
|
|
805
950
|
// the page already lists them, one line each.
|
|
806
|
-
views.push(
|
|
951
|
+
views.push(
|
|
952
|
+
` view ${serviceInsideViewId(service)} of ${fqn(service.id)} {`,
|
|
953
|
+
);
|
|
807
954
|
views.push(` title ${q(`${service.name} — inside`)}`);
|
|
808
955
|
views.push(" include *");
|
|
809
956
|
views.push(" }");
|
|
@@ -814,12 +961,16 @@ views.push("");
|
|
|
814
961
|
for (const flow of catalog.flows) {
|
|
815
962
|
const contexts = new Map(flow.participants.map((p) => [p.id, p.context]));
|
|
816
963
|
const contextOf = (id) => contexts.get(id) ?? null;
|
|
964
|
+
const replied = new Set();
|
|
965
|
+
walkFlowSteps(flow.steps, (step) => {
|
|
966
|
+
if (step.kind === "response" && step.replyTo) replied.add(step.replyTo);
|
|
967
|
+
});
|
|
817
968
|
|
|
818
969
|
views.push(` dynamic view ${flowViewId(flow)} {`);
|
|
819
970
|
views.push(` title ${q(flow.name)}`);
|
|
820
971
|
views.push(` description ${q(flow.summary)}`);
|
|
821
972
|
const body = [];
|
|
822
|
-
emitSteps(flow.steps, body, " ");
|
|
973
|
+
emitSteps(flow.steps, body, " ", replied);
|
|
823
974
|
views.push(...body);
|
|
824
975
|
views.push(" }");
|
|
825
976
|
views.push("");
|
|
@@ -828,7 +979,7 @@ for (const flow of catalog.flows) {
|
|
|
828
979
|
views.push(` dynamic view ${flowCrossViewId(flow)} {`);
|
|
829
980
|
views.push(` title ${q(`${flow.name} — crossings only`)}`);
|
|
830
981
|
const crossBody = [];
|
|
831
|
-
emitSteps(cross, crossBody, " ");
|
|
982
|
+
emitSteps(cross, crossBody, " ", replied);
|
|
832
983
|
if (crossBody.length === 0) {
|
|
833
984
|
// A flow with no crossing at all still needs a renderable view.
|
|
834
985
|
const first = flow.participants[0];
|
|
@@ -845,8 +996,14 @@ views.push("}");
|
|
|
845
996
|
|
|
846
997
|
mkdirSync("likec4", { recursive: true });
|
|
847
998
|
writeFileSync("likec4/spec.c4", `${spec.join("\n")}\n`);
|
|
848
|
-
writeFileSync(
|
|
849
|
-
|
|
999
|
+
writeFileSync(
|
|
1000
|
+
"likec4/model.c4",
|
|
1001
|
+
`// GENERATED — do not edit.\n${model.join("\n")}\n`,
|
|
1002
|
+
);
|
|
1003
|
+
writeFileSync(
|
|
1004
|
+
"likec4/views.c4",
|
|
1005
|
+
`// GENERATED — do not edit.\n${views.join("\n")}\n`,
|
|
1006
|
+
);
|
|
850
1007
|
|
|
851
1008
|
console.log(
|
|
852
1009
|
`wrote likec4/spec.c4, likec4/model.c4, likec4/views.c4 ` +
|