@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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/plugins/README.md +5 -0
  3. package/plugins/extract-python-kafka/README.md +6 -0
  4. package/plugins/extract-python-kafka/extract.py +2 -2
  5. package/plugins/extract-python-kafka/extract_test.py +17 -1
  6. package/plugins/portolan-go.wasm +0 -0
  7. package/plugins/pyplugin/catalog.py +12 -1
  8. package/plugins/pyplugin/kafka.py +74 -3
  9. package/scripts/gen-likec4.mjs +78 -20
  10. package/scripts/gen-likec4.test.mjs +25 -2
  11. package/src/app/Breadcrumbs.test.ts +4 -0
  12. package/src/app/Breadcrumbs.tsx +1 -0
  13. package/src/catalog-model.ts +22 -1
  14. package/src/catalog-stores.test.ts +17 -0
  15. package/src/catalog-validation.ts +43 -2
  16. package/src/catalog.test.ts +13 -2
  17. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  18. package/src/components/ChannelRows.test.tsx +54 -0
  19. package/src/components/ChannelRows.tsx +57 -10
  20. package/src/components/LifecycleDiagram.tsx +28 -12
  21. package/src/components/ProblemRow.tsx +4 -0
  22. package/src/components/WhatLinksHere.tsx +6 -4
  23. package/src/enrich.test.ts +4 -5
  24. package/src/flow/StepDetail.tsx +98 -54
  25. package/src/flow/answers.test.ts +18 -1
  26. package/src/flow/answers.ts +37 -8
  27. package/src/lib/backlinks.test.ts +16 -1
  28. package/src/lib/backlinks.ts +20 -0
  29. package/src/lib/catalog-diff.test.ts +18 -0
  30. package/src/lib/catalog-diff.ts +19 -1
  31. package/src/lib/derive.ts +1 -0
  32. package/src/lib/kafka-ui.test.ts +87 -0
  33. package/src/lib/kafka-ui.ts +105 -0
  34. package/src/lib/wire-problems.test.ts +21 -0
  35. package/src/lib/wire-problems.ts +62 -1
  36. package/src/likec4/FlowView.tsx +2 -6
  37. package/src/likec4/flow-edges.test.ts +64 -1
  38. package/src/likec4/flow-edges.ts +43 -7
  39. package/src/likec4/view-index.ts +8 -2
  40. package/src/merge.test.ts +23 -0
  41. package/src/merge.ts +17 -1
  42. package/src/pages/CatalogFailure.tsx +2 -2
  43. package/src/pages/Settings.tsx +11 -3
  44. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  45. package/src/routes.test.ts +2 -0
  46. package/src/routes.ts +2 -1
  47. 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.3",
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]
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(name: str, title: str, doc: str, direction: str) -> Dict[str, Any]:
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 ("key_serializer", "value_serializer"):
249
- raw[kw.arg] = expression(kw.value)
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
 
@@ -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
- // --- what comes back from a call (mirrors src/flow/answers.ts) -------------
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.
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 answerOf(step) {
619
- if (step.kind !== "rpc") return "";
620
- if (step.ref) return methodOf.get(step.ref)?.response ?? "";
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.response ?? "";
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 answer = replied.has(node.id) ? "" : answerOf(node);
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
- (storeLabel || node.label || node.ref || node.kind) +
640
- (answer ? ` ${answer}` : "");
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(branch.steps, out, `${indent} `, replied);
699
+ emitSteps(
700
+ branch.steps,
701
+ out,
702
+ `${indent} `,
703
+ replied,
704
+ deferredResponseId,
705
+ );
682
706
  out.push(`${indent} }`);
683
707
  } else {
684
- emitSteps(branch.steps, out, `${indent} `, replied);
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
- emitSteps(cross, crossBody, " ", replied);
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 'Get' {\n color declared line solid head normal",
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("Get → GetResponse");
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", () => {
@@ -51,6 +51,7 @@ export function crumbsFor(pathname: string): Crumb[] {
51
51
  projects: "projects",
52
52
  pipeline: "pipeline",
53
53
  delivery: "delivery",
54
+ integrations: "integrations",
54
55
  preferences: "preferences",
55
56
  };
56
57
  return [
@@ -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 or RpcCall.id - resolvable, or status must be unresolved
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 = eventIds.has(step.ref) || rpcIds.has(step.ref);
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 an RpcCall, and status is "${step.status}" rather than "unresolved"`,
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) {