@shortlink-org/portolan 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/catalog-model.ts +22 -1
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +43 -2
- package/src/catalog.test.ts +13 -2
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +1 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +17 -1
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +11 -3
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@shortlink-org/portolan",
|
|
3
3
|
"description": "Generate a navigable architecture catalog from code and specifications.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.4",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/shortlink-org/portolan#readme",
|
package/plugins/README.md
CHANGED
|
@@ -1142,6 +1142,11 @@ live in another estate — would be the wrong end of the trade.
|
|
|
1142
1142
|
service declares — the address the broker knows, and each message on it with the
|
|
1143
1143
|
direction it travels. What it does **not** answer with is events.
|
|
1144
1144
|
|
|
1145
|
+
A message's `contentType`, or the document's `defaultContentType` when the
|
|
1146
|
+
message leaves it out, is kept as the exact declared media type. MessagePack
|
|
1147
|
+
media types are additionally normalized to `encoding: msgpack`, so source and
|
|
1148
|
+
contract extractors can be compared without depending on one MIME spelling.
|
|
1149
|
+
|
|
1145
1150
|
That looks like a gap and is a boundary. `Event.id` is
|
|
1146
1151
|
`<service>.<aggregate>.<Name>`, and an AsyncAPI document knows the message on the
|
|
1147
1152
|
wire, not the aggregate that raised it. An extractor that guessed would either
|
|
@@ -22,6 +22,12 @@ id, consumer group, key, headers and serializer facts. Authentication values
|
|
|
22
22
|
are never emitted. Partitions, replication and retention are broker-side facts
|
|
23
23
|
and remain explicitly unknown unless another catalog source declares them.
|
|
24
24
|
|
|
25
|
+
MessagePack serializers and deserializers declared through `msgpack` or
|
|
26
|
+
`messagepack` calls are normalized to `encoding: "msgpack"` on the affected
|
|
27
|
+
channel message. This includes constructor callbacks and payloads packed
|
|
28
|
+
directly before a publish; Portolan records the wire format without importing
|
|
29
|
+
or executing the codec.
|
|
30
|
+
|
|
25
31
|
```json
|
|
26
32
|
{
|
|
27
33
|
"plugin": "python-kafka",
|
|
@@ -99,7 +99,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
|
|
|
99
99
|
for item in sends:
|
|
100
100
|
key = (item.message, "send")
|
|
101
101
|
if key not in seen:
|
|
102
|
-
messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send"))
|
|
102
|
+
messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send", item.encoding))
|
|
103
103
|
seen.add(key)
|
|
104
104
|
for item in receives:
|
|
105
105
|
# Kafka subscriptions dispatch records, and source often does not prove
|
|
@@ -108,7 +108,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
|
|
|
108
108
|
name = sends[0].message if len({sent.message for sent in sends}) == 1 else "message"
|
|
109
109
|
key = (name, "receive")
|
|
110
110
|
if key not in seen:
|
|
111
|
-
messages.append(catalog.message(name, title(name), consumer_doc(item), "receive"))
|
|
111
|
+
messages.append(catalog.message(name, title(name), consumer_doc(item), "receive", item.client.encoding))
|
|
112
112
|
seen.add(key)
|
|
113
113
|
clients = []
|
|
114
114
|
for item in list(sends) + list(receives):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Framework-neutral Kafka extraction contracts."""
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import sys
|
|
@@ -9,6 +10,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
9
10
|
sys.path.insert(1, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "pyplugin"))
|
|
10
11
|
|
|
11
12
|
from extract import extract # noqa: E402
|
|
13
|
+
import kafka # noqa: E402
|
|
12
14
|
from options import Options # noqa: E402
|
|
13
15
|
from protocol import Builder, Input # noqa: E402
|
|
14
16
|
|
|
@@ -33,7 +35,7 @@ class PythonKafka(unittest.TestCase):
|
|
|
33
35
|
|
|
34
36
|
def test_three_standard_clients_form_message_channels(self):
|
|
35
37
|
channels = {item["address"]: item for item in self.service["channels"]}
|
|
36
|
-
self.assertEqual(sorted(channels), ["audit.records", "orders.created", "payments.accepted"])
|
|
38
|
+
self.assertEqual(sorted(channels), ["audit.records", "inventory.snapshots", "orders.created", "payments.accepted"])
|
|
37
39
|
self.assertTrue(all(item["kind"] == "message" for item in channels.values()))
|
|
38
40
|
self.assertEqual(
|
|
39
41
|
[message["direction"] for message in channels["orders.created"]["messages"]],
|
|
@@ -50,6 +52,20 @@ class PythonKafka(unittest.TestCase):
|
|
|
50
52
|
self.assertNotIn("sasl.password", self.contents)
|
|
51
53
|
self.assertNotIn("sasl.username", self.contents)
|
|
52
54
|
|
|
55
|
+
def test_messagepack_serializer_and_deserializer_are_machine_readable(self):
|
|
56
|
+
snapshots = next(item for item in self.service["channels"] if item["address"] == "inventory.snapshots")
|
|
57
|
+
self.assertEqual([message["encoding"] for message in snapshots["messages"]], ["msgpack", "msgpack"])
|
|
58
|
+
self.assertIn("value serializer: lambda value: msgpack.packb(value)", snapshots["doc"])
|
|
59
|
+
self.assertIn("value deserializer: msgpack.unpackb", snapshots["doc"])
|
|
60
|
+
|
|
61
|
+
def test_direct_messagepack_call_keeps_the_payload_name(self):
|
|
62
|
+
call = ast.parse("msgpack.packb(snapshot)", mode="eval").body
|
|
63
|
+
self.assertEqual(kafka.payload_name(call), "snapshot")
|
|
64
|
+
self.assertEqual(kafka.payload_encoding(call), "msgpack")
|
|
65
|
+
packer = ast.parse("msgpack.Packer()", mode="eval").body
|
|
66
|
+
method = ast.parse("packer.pack(snapshot)", mode="eval").body
|
|
67
|
+
self.assertEqual(kafka.payload_encoding(method, {"packer": ("expr", packer)}), "msgpack")
|
|
68
|
+
|
|
53
69
|
def test_publish_and_receive_flows_have_kafka_handoffs_and_source(self):
|
|
54
70
|
steps = [step for flow in self.fragment["flows"] for step in flow["steps"]]
|
|
55
71
|
handoffs = [step["handoff"] for step in steps]
|
package/plugins/portolan-go.wasm
CHANGED
|
Binary file
|
|
@@ -81,13 +81,24 @@ def channel(address: str, kind: str, title: str, doc: str, messages: List[Dict[s
|
|
|
81
81
|
return out
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def message(
|
|
84
|
+
def message(
|
|
85
|
+
name: str,
|
|
86
|
+
title: str,
|
|
87
|
+
doc: str,
|
|
88
|
+
direction: str,
|
|
89
|
+
encoding: str = "",
|
|
90
|
+
content_type: str = "",
|
|
91
|
+
) -> Dict[str, Any]:
|
|
85
92
|
out: Dict[str, Any] = {"name": name}
|
|
86
93
|
if title:
|
|
87
94
|
out["title"] = title
|
|
88
95
|
if doc:
|
|
89
96
|
out["doc"] = doc
|
|
90
97
|
out["direction"] = direction
|
|
98
|
+
if encoding:
|
|
99
|
+
out["encoding"] = encoding
|
|
100
|
+
if content_type:
|
|
101
|
+
out["contentType"] = content_type
|
|
91
102
|
return out
|
|
92
103
|
|
|
93
104
|
|
|
@@ -23,6 +23,8 @@ class Spec:
|
|
|
23
23
|
CONSTRUCTORS = {
|
|
24
24
|
"confluent_kafka.Producer": Spec("confluent-kafka", "producer"),
|
|
25
25
|
"confluent_kafka.Consumer": Spec("confluent-kafka", "consumer"),
|
|
26
|
+
"confluent_kafka.SerializingProducer": Spec("confluent-kafka", "producer"),
|
|
27
|
+
"confluent_kafka.DeserializingConsumer": Spec("confluent-kafka", "consumer"),
|
|
26
28
|
"kafka.KafkaProducer": Spec("kafka-python", "producer"),
|
|
27
29
|
"kafka.KafkaConsumer": Spec("kafka-python", "consumer"),
|
|
28
30
|
"aiokafka.AIOKafkaProducer": Spec("aiokafka", "producer"),
|
|
@@ -62,6 +64,23 @@ CONFIG_KEYS = {
|
|
|
62
64
|
"enable_auto_commit": "auto commit",
|
|
63
65
|
"key_serializer": "key serializer",
|
|
64
66
|
"value_serializer": "value serializer",
|
|
67
|
+
"key_deserializer": "key deserializer",
|
|
68
|
+
"value_deserializer": "value deserializer",
|
|
69
|
+
"key.serializer": "key serializer",
|
|
70
|
+
"value.serializer": "value serializer",
|
|
71
|
+
"key.deserializer": "key deserializer",
|
|
72
|
+
"value.deserializer": "value deserializer",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
SERDE_KEYS = {
|
|
76
|
+
"key_serializer",
|
|
77
|
+
"value_serializer",
|
|
78
|
+
"key_deserializer",
|
|
79
|
+
"value_deserializer",
|
|
80
|
+
"key.serializer",
|
|
81
|
+
"value.serializer",
|
|
82
|
+
"key.deserializer",
|
|
83
|
+
"value.deserializer",
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
|
|
@@ -73,6 +92,10 @@ class Client:
|
|
|
73
92
|
source: str = ""
|
|
74
93
|
constructor_topics: List[str] = field(default_factory=list)
|
|
75
94
|
|
|
95
|
+
@property
|
|
96
|
+
def encoding(self) -> str:
|
|
97
|
+
return encoding_of_text(" ".join(str(self.config.get(key, "")) for key in ("value serializer", "value deserializer")))
|
|
98
|
+
|
|
76
99
|
|
|
77
100
|
@dataclass
|
|
78
101
|
class Publish:
|
|
@@ -86,6 +109,7 @@ class Publish:
|
|
|
86
109
|
message_expression: str
|
|
87
110
|
key: str = ""
|
|
88
111
|
headers: str = ""
|
|
112
|
+
encoding: str = ""
|
|
89
113
|
|
|
90
114
|
@property
|
|
91
115
|
def line(self) -> str:
|
|
@@ -181,6 +205,8 @@ def value(
|
|
|
181
205
|
for key_node, val_node in zip(node.keys, node.values):
|
|
182
206
|
key = value(project, module, key_node, settings, variables, depth + 1)
|
|
183
207
|
val = value(project, module, val_node, settings, variables, depth + 1)
|
|
208
|
+
if val is None and isinstance(key, str) and key in SERDE_KEYS:
|
|
209
|
+
val = serialization_expression(module, val_node, variables)
|
|
184
210
|
if not isinstance(key, str) or val is None:
|
|
185
211
|
continue
|
|
186
212
|
out[key] = val
|
|
@@ -239,14 +265,21 @@ def _config_values(project: Project, module: Module, call: ast.Call, settings: s
|
|
|
239
265
|
first = value(project, module, call.args[0], settings, variables)
|
|
240
266
|
if isinstance(first, dict):
|
|
241
267
|
raw.update(first)
|
|
268
|
+
# Callable serializer values are not safe scalar values, but their
|
|
269
|
+
# syntax is still the evidence needed to identify MessagePack.
|
|
270
|
+
if isinstance(call.args[0], ast.Dict):
|
|
271
|
+
for key_node, val_node in zip(call.args[0].keys, call.args[0].values):
|
|
272
|
+
key = value(project, module, key_node, settings, variables)
|
|
273
|
+
if isinstance(key, str) and key in SERDE_KEYS and key not in raw:
|
|
274
|
+
raw[key] = serialization_expression(module, val_node, variables)
|
|
242
275
|
for kw in call.keywords:
|
|
243
276
|
if not kw.arg:
|
|
244
277
|
continue
|
|
245
278
|
resolved = value(project, module, kw.value, settings, variables)
|
|
246
279
|
if resolved is not None:
|
|
247
280
|
raw[kw.arg] = resolved
|
|
248
|
-
elif kw.arg in
|
|
249
|
-
raw[kw.arg] =
|
|
281
|
+
elif kw.arg in SERDE_KEYS:
|
|
282
|
+
raw[kw.arg] = serialization_expression(module, kw.value, variables)
|
|
250
283
|
out: Dict[str, Any] = {}
|
|
251
284
|
for key, val in raw.items():
|
|
252
285
|
normalized = CONFIG_KEYS.get(key)
|
|
@@ -377,7 +410,7 @@ def payload_name(node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[st
|
|
|
377
410
|
return node.id
|
|
378
411
|
if isinstance(node, ast.Call):
|
|
379
412
|
last = dotted(node.func).split(".")[-1]
|
|
380
|
-
if last in ("dumps", "dump", "encode", "serialize", "SerializeToString", "asdict", "dict", "model_dump") and node.args:
|
|
413
|
+
if last in ("dumps", "dump", "encode", "serialize", "SerializeToString", "asdict", "dict", "model_dump", "pack", "packb") and node.args:
|
|
381
414
|
return payload_name(node.args[0], variables, depth + 1)
|
|
382
415
|
return last or "message"
|
|
383
416
|
if isinstance(node, ast.Attribute):
|
|
@@ -387,6 +420,43 @@ def payload_name(node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[st
|
|
|
387
420
|
return "message"
|
|
388
421
|
|
|
389
422
|
|
|
423
|
+
def encoding_of_text(text: str) -> str:
|
|
424
|
+
lowered = text.lower()
|
|
425
|
+
if "msgpack" in lowered or "messagepack" in lowered:
|
|
426
|
+
return "msgpack"
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def serialization_expression(module: Optional[Module], node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[str, object]]] = None) -> str:
|
|
431
|
+
if node is None:
|
|
432
|
+
return ""
|
|
433
|
+
parts = [expression(node)]
|
|
434
|
+
if module is not None:
|
|
435
|
+
parts.append(external_name(module, dotted(node.func) if isinstance(node, ast.Call) else dotted(node)))
|
|
436
|
+
owner = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else (node.value if isinstance(node, ast.Attribute) else None)
|
|
437
|
+
if isinstance(owner, ast.Name):
|
|
438
|
+
bound = (variables or {}).get(owner.id)
|
|
439
|
+
if bound is not None and bound[0] == "expr" and isinstance(bound[1], ast.AST):
|
|
440
|
+
parts.append(expression(bound[1]))
|
|
441
|
+
return " ".join(filter(None, parts))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def payload_encoding(
|
|
445
|
+
node: Optional[ast.AST],
|
|
446
|
+
variables: Optional[Dict[str, Tuple[str, object]]] = None,
|
|
447
|
+
depth: int = 0,
|
|
448
|
+
module: Optional[Module] = None,
|
|
449
|
+
) -> str:
|
|
450
|
+
if node is None or depth > 6:
|
|
451
|
+
return ""
|
|
452
|
+
variables = variables or {}
|
|
453
|
+
if isinstance(node, ast.Name):
|
|
454
|
+
bound = variables.get(node.id)
|
|
455
|
+
if bound is not None and bound[0] == "expr" and isinstance(bound[1], ast.AST):
|
|
456
|
+
return payload_encoding(bound[1], variables, depth + 1, module)
|
|
457
|
+
return encoding_of_text(serialization_expression(module, node, variables))
|
|
458
|
+
|
|
459
|
+
|
|
390
460
|
def _text(node: Optional[ast.AST]) -> str:
|
|
391
461
|
text = expression(node)
|
|
392
462
|
return text if len(text) <= 100 else text[:97] + "..."
|
|
@@ -541,6 +611,7 @@ class Scanner:
|
|
|
541
611
|
_text(payload),
|
|
542
612
|
_text(keyword(call, "key")),
|
|
543
613
|
_text(keyword(call, "headers")),
|
|
614
|
+
payload_encoding(payload, env, module=module) or client.encoding,
|
|
544
615
|
)
|
|
545
616
|
)
|
|
546
617
|
|
package/scripts/gen-likec4.mjs
CHANGED
|
@@ -590,10 +590,11 @@ function containerPredicates(pairs, carried, indent) {
|
|
|
590
590
|
* in a `break` frame, which is what a sequence diagram calls a branch that
|
|
591
591
|
* leaves the flow rather than rejoining it.
|
|
592
592
|
*/
|
|
593
|
-
// ---
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
593
|
+
// --- request and response edges (mirrors src/flow/answers.ts) --------------
|
|
594
|
+
// A unary RPC is two messages, not one long edge label. When composition has
|
|
595
|
+
// already materialised a response step, that step is the return. Otherwise we
|
|
596
|
+
// draw the contract response: immediately for a nested call, and at the end of
|
|
597
|
+
// the flow for the actor request that opened it.
|
|
597
598
|
const methodOf = new Map();
|
|
598
599
|
const serviceById = new Map();
|
|
599
600
|
for (const context of catalog.contexts) {
|
|
@@ -615,29 +616,43 @@ for (const external of catalog.externals ?? []) {
|
|
|
615
616
|
}
|
|
616
617
|
}
|
|
617
618
|
|
|
618
|
-
function
|
|
619
|
-
if (step.kind !== "rpc") return
|
|
620
|
-
if (step.ref) return methodOf.get(step.ref)
|
|
619
|
+
function contractOf(step) {
|
|
620
|
+
if (step.kind !== "rpc") return null;
|
|
621
|
+
if (step.ref) return methodOf.get(step.ref) ?? null;
|
|
621
622
|
const service = serviceById.get(step.to);
|
|
622
|
-
if (!service || !step.label) return
|
|
623
|
+
if (!service || !step.label) return null;
|
|
623
624
|
for (const provided of service.provides) {
|
|
624
625
|
const found = provided.methods.find((m) => m.name === step.label);
|
|
625
|
-
if (found) return found
|
|
626
|
+
if (found) return found;
|
|
626
627
|
}
|
|
627
|
-
return
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function emitSyntheticResponse(out, indent, node, response) {
|
|
632
|
+
out.push(
|
|
633
|
+
`${indent}${participantRef(node.to)} -> ${participantRef(node.from)} ${q(response)} {`,
|
|
634
|
+
);
|
|
635
|
+
out.push(
|
|
636
|
+
`${indent} color ${node.status} line dashed head ${KIND_HEAD.response}`,
|
|
637
|
+
);
|
|
638
|
+
out.push(`${indent}}`);
|
|
628
639
|
}
|
|
629
640
|
|
|
630
|
-
function emitSteps(nodes, out, indent, replied) {
|
|
641
|
+
function emitSteps(nodes, out, indent, replied, deferredResponseId) {
|
|
631
642
|
for (const node of nodes) {
|
|
632
643
|
if (node.type === "step") {
|
|
633
|
-
const
|
|
644
|
+
const contract = contractOf(node);
|
|
645
|
+
const request = contract?.request ?? "";
|
|
646
|
+
const response = replied.has(node.id) ? "" : (contract?.response ?? "");
|
|
634
647
|
const storeLabel =
|
|
635
648
|
node.storeAccess?.operation && node.storeAccess?.keyspace
|
|
636
649
|
? `${node.storeAccess.operation.toUpperCase()} ${node.storeAccess.keyspace}`
|
|
637
650
|
: "";
|
|
638
651
|
const label =
|
|
639
|
-
|
|
640
|
-
(
|
|
652
|
+
storeLabel ||
|
|
653
|
+
(node.kind === "rpc" && request
|
|
654
|
+
? request
|
|
655
|
+
: node.label || node.ref || node.kind);
|
|
641
656
|
const attrs = [
|
|
642
657
|
`color ${node.http?.outcome === "error" ? "response_error" : node.status}`,
|
|
643
658
|
`line ${node.kind === "response" ? "dashed" : "solid"}`,
|
|
@@ -656,18 +671,21 @@ function emitSteps(nodes, out, indent, replied) {
|
|
|
656
671
|
if (notes.length > 0)
|
|
657
672
|
out.push(`${indent} notes ${q(notes.join(" — "))}`);
|
|
658
673
|
out.push(`${indent}}`);
|
|
674
|
+
if (response && node.id !== deferredResponseId) {
|
|
675
|
+
emitSyntheticResponse(out, indent, node, response);
|
|
676
|
+
}
|
|
659
677
|
continue;
|
|
660
678
|
}
|
|
661
679
|
if (node.type === "parallel") {
|
|
662
680
|
out.push(`${indent}par ${node.title ? `${q(node.title)} ` : ""}{`);
|
|
663
681
|
for (const branch of node.branches)
|
|
664
|
-
emitSteps(branch, out, `${indent} `, replied);
|
|
682
|
+
emitSteps(branch, out, `${indent} `, replied, deferredResponseId);
|
|
665
683
|
out.push(`${indent}}`);
|
|
666
684
|
continue;
|
|
667
685
|
}
|
|
668
686
|
if (node.type === "loop") {
|
|
669
687
|
out.push(`${indent}loop ${q(node.title)} {`);
|
|
670
|
-
emitSteps(node.steps, out, `${indent} `, replied);
|
|
688
|
+
emitSteps(node.steps, out, `${indent} `, replied, deferredResponseId);
|
|
671
689
|
out.push(`${indent}}`);
|
|
672
690
|
continue;
|
|
673
691
|
}
|
|
@@ -678,10 +696,22 @@ function emitSteps(nodes, out, indent, replied) {
|
|
|
678
696
|
out.push(`${indent} ${keyword} ${q(branch.title)} {`);
|
|
679
697
|
if (branch.terminal) {
|
|
680
698
|
out.push(`${indent} break 'ends the flow' {`);
|
|
681
|
-
emitSteps(
|
|
699
|
+
emitSteps(
|
|
700
|
+
branch.steps,
|
|
701
|
+
out,
|
|
702
|
+
`${indent} `,
|
|
703
|
+
replied,
|
|
704
|
+
deferredResponseId,
|
|
705
|
+
);
|
|
682
706
|
out.push(`${indent} }`);
|
|
683
707
|
} else {
|
|
684
|
-
emitSteps(
|
|
708
|
+
emitSteps(
|
|
709
|
+
branch.steps,
|
|
710
|
+
out,
|
|
711
|
+
`${indent} `,
|
|
712
|
+
replied,
|
|
713
|
+
deferredResponseId,
|
|
714
|
+
);
|
|
685
715
|
}
|
|
686
716
|
out.push(`${indent} }`);
|
|
687
717
|
});
|
|
@@ -961,16 +991,34 @@ views.push("");
|
|
|
961
991
|
for (const flow of catalog.flows) {
|
|
962
992
|
const contexts = new Map(flow.participants.map((p) => [p.id, p.context]));
|
|
963
993
|
const contextOf = (id) => contexts.get(id) ?? null;
|
|
994
|
+
const actorIds = new Set(
|
|
995
|
+
flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
|
|
996
|
+
);
|
|
964
997
|
const replied = new Set();
|
|
965
998
|
walkFlowSteps(flow.steps, (step) => {
|
|
966
999
|
if (step.kind === "response" && step.replyTo) replied.add(step.replyTo);
|
|
967
1000
|
});
|
|
1001
|
+
let deferredResponse = null;
|
|
1002
|
+
walkFlowSteps(flow.steps, (step) => {
|
|
1003
|
+
if (deferredResponse || step.kind !== "rpc" || !actorIds.has(step.from))
|
|
1004
|
+
return;
|
|
1005
|
+
const response = replied.has(step.id) ? "" : (contractOf(step)?.response ?? "");
|
|
1006
|
+
if (response) deferredResponse = { step, response };
|
|
1007
|
+
});
|
|
968
1008
|
|
|
969
1009
|
views.push(` dynamic view ${flowViewId(flow)} {`);
|
|
970
1010
|
views.push(` title ${q(flow.name)}`);
|
|
971
1011
|
views.push(` description ${q(flow.summary)}`);
|
|
972
1012
|
const body = [];
|
|
973
|
-
emitSteps(flow.steps, body, " ", replied);
|
|
1013
|
+
emitSteps(flow.steps, body, " ", replied, deferredResponse?.step.id);
|
|
1014
|
+
if (deferredResponse) {
|
|
1015
|
+
emitSyntheticResponse(
|
|
1016
|
+
body,
|
|
1017
|
+
" ",
|
|
1018
|
+
deferredResponse.step,
|
|
1019
|
+
deferredResponse.response,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
974
1022
|
views.push(...body);
|
|
975
1023
|
views.push(" }");
|
|
976
1024
|
views.push("");
|
|
@@ -979,7 +1027,17 @@ for (const flow of catalog.flows) {
|
|
|
979
1027
|
views.push(` dynamic view ${flowCrossViewId(flow)} {`);
|
|
980
1028
|
views.push(` title ${q(`${flow.name} — crossings only`)}`);
|
|
981
1029
|
const crossBody = [];
|
|
982
|
-
|
|
1030
|
+
const crossStepIds = new Set();
|
|
1031
|
+
walkFlowSteps(cross, (step) => crossStepIds.add(step.id));
|
|
1032
|
+
emitSteps(cross, crossBody, " ", replied, deferredResponse?.step.id);
|
|
1033
|
+
if (deferredResponse && crossStepIds.has(deferredResponse.step.id)) {
|
|
1034
|
+
emitSyntheticResponse(
|
|
1035
|
+
crossBody,
|
|
1036
|
+
" ",
|
|
1037
|
+
deferredResponse.step,
|
|
1038
|
+
deferredResponse.response,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
983
1041
|
if (crossBody.length === 0) {
|
|
984
1042
|
// A flow with no crossing at all still needs a renderable view.
|
|
985
1043
|
const first = flow.participants[0];
|
|
@@ -164,11 +164,34 @@ describe("the LikeC4 generator", () => {
|
|
|
164
164
|
},
|
|
165
165
|
],
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
id: "flow.get-book-contract",
|
|
169
|
+
slug: "get-book-contract",
|
|
170
|
+
name: "Get book contract",
|
|
171
|
+
summary: "",
|
|
172
|
+
owner: "demo",
|
|
173
|
+
participants: [
|
|
174
|
+
{ id: "demo.api", kind: "service", context: "demo" },
|
|
175
|
+
{ id: "demo.book", kind: "service", context: "demo" },
|
|
176
|
+
],
|
|
177
|
+
steps: [
|
|
178
|
+
{
|
|
179
|
+
type: "step",
|
|
180
|
+
id: "request",
|
|
181
|
+
from: "demo.api",
|
|
182
|
+
to: "demo.book",
|
|
183
|
+
kind: "rpc",
|
|
184
|
+
ref: "book.v1.Book/Get",
|
|
185
|
+
label: "Get",
|
|
186
|
+
status: "declared",
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
167
190
|
],
|
|
168
191
|
});
|
|
169
192
|
|
|
170
193
|
expect(views).toContain(
|
|
171
|
-
"demo.api -> demo.book '
|
|
194
|
+
"demo.api -> demo.book 'GetRequest' {\n color declared line solid head normal",
|
|
172
195
|
);
|
|
173
196
|
expect(views).toContain(
|
|
174
197
|
"demo.book -> demo.api 'GetResponse' {\n color declared line dashed head normal",
|
|
@@ -177,7 +200,7 @@ describe("the LikeC4 generator", () => {
|
|
|
177
200
|
"demo.book -> demo.api '500 · Error' {\n color response_error line dashed head normal",
|
|
178
201
|
);
|
|
179
202
|
expect(spec).toContain("color response_error #b7646b");
|
|
180
|
-
expect(views).not.toContain("
|
|
203
|
+
expect(views).not.toContain("GetRequest → GetResponse");
|
|
181
204
|
});
|
|
182
205
|
|
|
183
206
|
it("treats dots in a root participant id as data, not containment", () => {
|
|
@@ -31,6 +31,10 @@ describe("crumbsFor", () => {
|
|
|
31
31
|
{ label: "settings", to: "/settings" },
|
|
32
32
|
{ label: "delivery", to: "/settings/delivery" },
|
|
33
33
|
]);
|
|
34
|
+
expect(crumbsFor("/settings/integrations")).toEqual([
|
|
35
|
+
{ label: "settings", to: "/settings" },
|
|
36
|
+
{ label: "integrations", to: "/settings/integrations" },
|
|
37
|
+
]);
|
|
34
38
|
});
|
|
35
39
|
|
|
36
40
|
it("reads 'data' as a literal, not as an aggregate", () => {
|
package/src/app/Breadcrumbs.tsx
CHANGED
package/src/catalog-model.ts
CHANGED
|
@@ -627,6 +627,10 @@ export interface ChannelMessage {
|
|
|
627
627
|
title?: string;
|
|
628
628
|
doc?: string;
|
|
629
629
|
direction: ChannelDirection;
|
|
630
|
+
/** Normalized payload serialization, such as `msgpack`. */
|
|
631
|
+
encoding?: string;
|
|
632
|
+
/** Exact media type declared by the source contract. */
|
|
633
|
+
contentType?: string;
|
|
630
634
|
}
|
|
631
635
|
export interface EventConsumer {
|
|
632
636
|
service: string;
|
|
@@ -771,6 +775,23 @@ export interface Table {
|
|
|
771
775
|
/** The domain object this table holds: an aggregate id, and optionally a block id. */
|
|
772
776
|
persists?: { aggregate?: string; block?: string };
|
|
773
777
|
role?: TableRole;
|
|
778
|
+
/** Source-backed repository methods that read or write this table. */
|
|
779
|
+
accesses?: TableAccess[];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export type TableOperation = "read" | "write" | "delete";
|
|
783
|
+
|
|
784
|
+
export const TABLE_OPERATIONS: readonly TableOperation[] = [
|
|
785
|
+
"read",
|
|
786
|
+
"write",
|
|
787
|
+
"delete",
|
|
788
|
+
] as const;
|
|
789
|
+
|
|
790
|
+
export interface TableAccess {
|
|
791
|
+
operation: TableOperation;
|
|
792
|
+
/** Enclosing adapter method, for example `Postgres.Save`. */
|
|
793
|
+
method?: string;
|
|
794
|
+
source?: string;
|
|
774
795
|
}
|
|
775
796
|
|
|
776
797
|
export interface TableIndex {
|
|
@@ -893,7 +914,7 @@ export interface Step {
|
|
|
893
914
|
from: string;
|
|
894
915
|
to: string; // participant ids; from === to is a self-message
|
|
895
916
|
kind: "rpc" | "event" | "call" | "response";
|
|
896
|
-
ref?: string; // Event.id
|
|
917
|
+
ref?: string; // Event.id, RpcCall.id or provided RPC method id; otherwise unresolved
|
|
897
918
|
label?: string;
|
|
898
919
|
status: Status;
|
|
899
920
|
note?: string;
|
|
@@ -45,6 +45,23 @@ describe("store validation", () => {
|
|
|
45
45
|
expect(() => validateCatalog(clone())).not.toThrow();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("accepts source-backed table readers and writers", () => {
|
|
49
|
+
const good = clone();
|
|
50
|
+
omsStore(good).tables[0]!.accesses = [
|
|
51
|
+
{ operation: "read", method: "Postgres.ByID", source: "postgres.go:40" },
|
|
52
|
+
{ operation: "write", method: "Postgres.Save", source: "postgres.go:20" },
|
|
53
|
+
];
|
|
54
|
+
expect(() => validateCatalog(good)).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects an unknown table access operation", () => {
|
|
58
|
+
const bad = clone();
|
|
59
|
+
omsStore(bad).tables[0]!.accesses = [
|
|
60
|
+
{ operation: "merge" as "read", method: "Postgres.Save" },
|
|
61
|
+
];
|
|
62
|
+
expect(failureOf(bad).message).toContain("access operation");
|
|
63
|
+
});
|
|
64
|
+
|
|
48
65
|
it("rejects a foreign key into a table nobody declared", () => {
|
|
49
66
|
const bad = clone();
|
|
50
67
|
const table = omsStore(bad).tables.find((t) => t.name === "order_items");
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
REDIS_OPERATIONS,
|
|
17
17
|
STORE_KINDS,
|
|
18
18
|
STREAMING,
|
|
19
|
+
TABLE_OPERATIONS,
|
|
19
20
|
TABLE_ROLES,
|
|
20
21
|
aggregateBlocks,
|
|
21
22
|
allAggregates,
|
|
@@ -124,6 +125,18 @@ function validateChannels(service: Service): void {
|
|
|
124
125
|
`service ${service.id} / channel ${channel.address}`,
|
|
125
126
|
);
|
|
126
127
|
}
|
|
128
|
+
if (message.encoding !== undefined && (typeof message.encoding !== "string" || message.encoding === "")) {
|
|
129
|
+
fail(
|
|
130
|
+
`message "${message.name}" on channel "${channel.address}" has an empty encoding`,
|
|
131
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (message.contentType !== undefined && (typeof message.contentType !== "string" || message.contentType === "")) {
|
|
135
|
+
fail(
|
|
136
|
+
`message "${message.name}" on channel "${channel.address}" has an empty content type`,
|
|
137
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
127
140
|
const key = `${message.direction} ${message.name}`;
|
|
128
141
|
if (seen.has(key)) {
|
|
129
142
|
fail(
|
|
@@ -142,6 +155,15 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
142
155
|
|
|
143
156
|
const eventIds = new Set<string>();
|
|
144
157
|
const rpcIds = new Set<string>();
|
|
158
|
+
const providedRpcRefs = new Set(
|
|
159
|
+
allExternals(catalog).flatMap((external) =>
|
|
160
|
+
external.provides.flatMap((provided) =>
|
|
161
|
+
provided.methods.map(
|
|
162
|
+
(method) => `${external.id}|${provided.id}/${method.name}`,
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
145
167
|
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
146
168
|
|
|
147
169
|
assertUniqueSlugs(
|
|
@@ -232,6 +254,13 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
232
254
|
technologies.add(technology);
|
|
233
255
|
}
|
|
234
256
|
for (const call of service.consumes) rpcIds.add(call.id);
|
|
257
|
+
for (const provided of service.provides) {
|
|
258
|
+
for (const method of provided.methods) {
|
|
259
|
+
providedRpcRefs.add(
|
|
260
|
+
`${service.id}|${provided.id}/${method.name}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
235
264
|
for (const provided of [
|
|
236
265
|
...service.provides,
|
|
237
266
|
...(service.copies ?? []),
|
|
@@ -589,10 +618,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
589
618
|
);
|
|
590
619
|
}
|
|
591
620
|
if (step.ref !== undefined && step.status !== "unresolved") {
|
|
592
|
-
const resolves =
|
|
621
|
+
const resolves =
|
|
622
|
+
eventIds.has(step.ref) ||
|
|
623
|
+
rpcIds.has(step.ref) ||
|
|
624
|
+
(step.kind === "rpc" &&
|
|
625
|
+
providedRpcRefs.has(`${step.to}|${step.ref}`));
|
|
593
626
|
if (!resolves) {
|
|
594
627
|
fail(
|
|
595
|
-
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event nor
|
|
628
|
+
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
|
|
596
629
|
`flow ${flow.id} / step ${step.id}`,
|
|
597
630
|
);
|
|
598
631
|
}
|
|
@@ -990,6 +1023,14 @@ function validateStores(catalog: Catalog): void {
|
|
|
990
1023
|
where,
|
|
991
1024
|
);
|
|
992
1025
|
}
|
|
1026
|
+
for (const access of table.accesses ?? []) {
|
|
1027
|
+
if (!TABLE_OPERATIONS.includes(access.operation)) {
|
|
1028
|
+
fail(
|
|
1029
|
+
`table "${table.id}" has access operation "${access.operation}"; expected one of ${TABLE_OPERATIONS.join(", ")}`,
|
|
1030
|
+
where,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
993
1034
|
|
|
994
1035
|
const own = columnsOfTable.get(table.id) ?? new Set<string>();
|
|
995
1036
|
if (own.size !== table.columns.length) {
|