@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/src/flow/answers.ts
CHANGED
|
@@ -9,14 +9,33 @@
|
|
|
9
9
|
// queryset - which no interface in the catalog describes; an event is a
|
|
10
10
|
// publication, and drawing a reply to it would be a lie about the bus.
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
CatalogIndex,
|
|
14
|
+
External,
|
|
15
|
+
Flow,
|
|
16
|
+
RpcMethod,
|
|
17
|
+
RpcService,
|
|
18
|
+
Service,
|
|
19
|
+
Step,
|
|
20
|
+
} from "../catalog";
|
|
13
21
|
import { walkSteps } from "../catalog";
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
export interface StepRpcContract {
|
|
24
|
+
id: string;
|
|
25
|
+
provider: Service | External;
|
|
26
|
+
provided: RpcService;
|
|
27
|
+
method: RpcMethod;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The interface and method an RPC step reaches, when the catalog has them. */
|
|
31
|
+
export function stepRpcContract(
|
|
32
|
+
index: CatalogIndex,
|
|
33
|
+
step: Step,
|
|
34
|
+
): StepRpcContract | undefined {
|
|
17
35
|
if (step.kind !== "rpc") return undefined;
|
|
18
36
|
|
|
19
|
-
//
|
|
37
|
+
// A recorded ref is the full `<interface>/<method>` id, whether the flow
|
|
38
|
+
// enters this service or calls another one.
|
|
20
39
|
if (step.ref) {
|
|
21
40
|
const cut = step.ref.lastIndexOf("/");
|
|
22
41
|
if (cut < 0) return undefined;
|
|
@@ -26,22 +45,32 @@ function methodOf(index: CatalogIndex, step: Step): RpcMethod | undefined {
|
|
|
26
45
|
const provider =
|
|
27
46
|
index.rpcProviderByMethod.get(step.ref) ?? index.externalProviderByMethod.get(step.ref);
|
|
28
47
|
const provided = provider?.provides.find((p) => p.id === interfaceId);
|
|
29
|
-
|
|
48
|
+
const method = provided?.methods.find((m) => m.name === name);
|
|
49
|
+
if (!provider || !provided || !method) return undefined;
|
|
50
|
+
return { id: step.ref, provider, provided, method };
|
|
30
51
|
}
|
|
31
52
|
|
|
32
|
-
//
|
|
53
|
+
// Older incoming flows recorded only the operation label. Keep resolving
|
|
54
|
+
// those catalogs while new extractors write the full ref above.
|
|
33
55
|
const service = index.serviceById.get(step.to);
|
|
34
56
|
if (!service || !step.label) return undefined;
|
|
35
57
|
for (const provided of service.provides) {
|
|
36
58
|
const found = provided.methods.find((m) => m.name === step.label);
|
|
37
|
-
if (found)
|
|
59
|
+
if (found) {
|
|
60
|
+
return {
|
|
61
|
+
id: `${provided.id}/${found.name}`,
|
|
62
|
+
provider: service,
|
|
63
|
+
provided,
|
|
64
|
+
method: found,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
38
67
|
}
|
|
39
68
|
return undefined;
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
/** What the callee hands back, as the contract names it. */
|
|
43
72
|
export function stepAnswer(index: CatalogIndex, step: Step): string | undefined {
|
|
44
|
-
return
|
|
73
|
+
return stepRpcContract(index, step)?.method.response || undefined;
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
/** Every request without an explicit response step that has an answer. */
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { catalog, index } from "../testing/estate";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
backlinkCount,
|
|
5
|
+
backlinksFor,
|
|
6
|
+
distinctBacklinks,
|
|
7
|
+
stepsInto,
|
|
8
|
+
} from "./backlinks";
|
|
4
9
|
import type { BacklinkGroup, BacklinkTarget } from "./backlinks";
|
|
5
10
|
import type { Kind } from "./kinds";
|
|
6
11
|
|
|
@@ -290,6 +295,16 @@ describe("grouping", () => {
|
|
|
290
295
|
const g = groups({ kind: "service", id: "shop.oms" });
|
|
291
296
|
expect(backlinkCount(g)).toBe(g.reduce((n, x) => n + x.links.length, 0));
|
|
292
297
|
});
|
|
298
|
+
|
|
299
|
+
it("collapses repeated entities for compact backlink lines", () => {
|
|
300
|
+
const links = distinctBacklinks(
|
|
301
|
+
groups({ kind: "store", id: "shop.oms.pg" }),
|
|
302
|
+
);
|
|
303
|
+
const ids = links.map((link) => `${link.kind}:${link.id}`);
|
|
304
|
+
|
|
305
|
+
expect(ids).toEqual([...new Set(ids)]);
|
|
306
|
+
expect(ids).toContain("aggregate:shop.oms.order");
|
|
307
|
+
});
|
|
293
308
|
});
|
|
294
309
|
|
|
295
310
|
describe("the word that names it", () => {
|
package/src/lib/backlinks.ts
CHANGED
|
@@ -100,6 +100,26 @@ export function backlinkCount(groups: readonly BacklinkGroup[]): number {
|
|
|
100
100
|
return groups.reduce((n, g) => n + g.links.length, 0);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* One link per entity for compact surfaces that cannot show each link's
|
|
105
|
+
* reason. The full section deliberately keeps separate edges (for example an
|
|
106
|
+
* aggregate persisted by two tables), but two identical chips would only look
|
|
107
|
+
* like an accidental duplicate when `via` is not visible.
|
|
108
|
+
*/
|
|
109
|
+
export function distinctBacklinks(
|
|
110
|
+
groups: readonly BacklinkGroup[],
|
|
111
|
+
): Backlink[] {
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
return groups.flatMap((group) =>
|
|
114
|
+
group.links.filter((link) => {
|
|
115
|
+
const key = `${link.kind}:${link.id}`;
|
|
116
|
+
if (seen.has(key)) return false;
|
|
117
|
+
seen.add(key);
|
|
118
|
+
return true;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
// ---------------------------------------------------------------------------
|
|
104
124
|
// Row builders. One per kind of thing that can do the pointing.
|
|
105
125
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +57,24 @@ describe("diffCatalogs", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
describe("diffCatalogs: what a reviewer is looking for", () => {
|
|
60
|
+
it("calls a change between known message encodings breaking", () => {
|
|
61
|
+
const before = JSON.parse(JSON.stringify(catalog)) as Catalog;
|
|
62
|
+
const service = before.contexts[0]!.services[0]!;
|
|
63
|
+
service.channels = [{
|
|
64
|
+
address: "shop.cart.basket",
|
|
65
|
+
messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "json" }],
|
|
66
|
+
}];
|
|
67
|
+
const after = JSON.parse(JSON.stringify(before)) as Catalog;
|
|
68
|
+
after.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
|
|
69
|
+
|
|
70
|
+
expect(diffCatalogs(before, after)).toEqual([{
|
|
71
|
+
kind: "message.encoding",
|
|
72
|
+
severity: "breaking",
|
|
73
|
+
where: service.id,
|
|
74
|
+
summary: "send message cart.BasketCreated on shop.cart.basket uses msgpack, was json",
|
|
75
|
+
}]);
|
|
76
|
+
});
|
|
77
|
+
|
|
60
78
|
// The finding the whole report exists for.
|
|
61
79
|
it("names a new event nothing consumes", () => {
|
|
62
80
|
const changes = edited((c) => {
|
package/src/lib/catalog-diff.ts
CHANGED
|
@@ -331,7 +331,7 @@ function diffCalls(before: Service, after: Service, add: Add): void {
|
|
|
331
331
|
function diffChannels(before: Service, after: Service, add: Add): void {
|
|
332
332
|
const was = byId(before.channels ?? [], (c) => c.address);
|
|
333
333
|
const now = byId(after.channels ?? [], (c) => c.address);
|
|
334
|
-
const { added, removed } = partition(was, now);
|
|
334
|
+
const { added, removed, kept } = partition(was, now);
|
|
335
335
|
|
|
336
336
|
for (const address of added) {
|
|
337
337
|
add("channel.added", "addition", before.id, `"${before.id}" declares channel ${address}`);
|
|
@@ -339,6 +339,24 @@ function diffChannels(before: Service, after: Service, add: Add): void {
|
|
|
339
339
|
for (const address of removed) {
|
|
340
340
|
add("channel.removed", "breaking", before.id, `"${before.id}" no longer declares channel ${address}`);
|
|
341
341
|
}
|
|
342
|
+
for (const address of kept) {
|
|
343
|
+
const previous = byId(was.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
|
|
344
|
+
const current = byId(now.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
|
|
345
|
+
const messages = partition(previous, current).kept;
|
|
346
|
+
for (const id of messages) {
|
|
347
|
+
const a = previous.get(id)!;
|
|
348
|
+
const b = current.get(id)!;
|
|
349
|
+
const from = a.encoding || a.contentType || "";
|
|
350
|
+
const to = b.encoding || b.contentType || "";
|
|
351
|
+
if (from === to) continue;
|
|
352
|
+
add(
|
|
353
|
+
"message.encoding",
|
|
354
|
+
from && to ? "breaking" : "change",
|
|
355
|
+
before.id,
|
|
356
|
+
`${a.direction} message ${a.name} on ${address} uses ${to || "an unspecified encoding"}, was ${from || "unspecified"}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
342
360
|
}
|
|
343
361
|
|
|
344
362
|
function diffServiceStores(before: Service, after: Service, add: Add): void {
|
package/src/lib/derive.ts
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Catalog, Channel } from "../catalog";
|
|
3
|
+
import {
|
|
4
|
+
isKafkaChannel,
|
|
5
|
+
kafkaHandoffChannels,
|
|
6
|
+
kafkaUiTopicUrl,
|
|
7
|
+
normalizeKafkaUiUrl,
|
|
8
|
+
} from "./kafka-ui";
|
|
9
|
+
|
|
10
|
+
describe("Kafka UI integration", () => {
|
|
11
|
+
it("accepts web URLs and rejects values that cannot be opened safely", () => {
|
|
12
|
+
expect(normalizeKafkaUiUrl(" https://kafka.example/ ")).toBe(
|
|
13
|
+
"https://kafka.example",
|
|
14
|
+
);
|
|
15
|
+
expect(normalizeKafkaUiUrl("javascript:alert(1)")).toBeNull();
|
|
16
|
+
expect(normalizeKafkaUiUrl("not a URL")).toBeNull();
|
|
17
|
+
expect(normalizeKafkaUiUrl(" ")).toBe("");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("opens the exact topic from a Kafbat cluster URL behind a path prefix", () => {
|
|
21
|
+
expect(
|
|
22
|
+
kafkaUiTopicUrl(
|
|
23
|
+
"https://ops.example/kafka/ui/clusters/prod/all-topics/old",
|
|
24
|
+
"orders/new",
|
|
25
|
+
),
|
|
26
|
+
).toBe(
|
|
27
|
+
"https://ops.example/kafka/ui/clusters/prod/all-topics/orders%2Fnew",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps a plain installation URL as the useful fallback", () => {
|
|
32
|
+
expect(kafkaUiTopicUrl("https://kafka.example", "orders.created")).toBe(
|
|
33
|
+
"https://kafka.example",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("recognizes both Kafka-labelled cards and explicit Kafka handoffs", () => {
|
|
38
|
+
const labelled: Channel = {
|
|
39
|
+
address: "orders.created",
|
|
40
|
+
title: "Kafka · orders.created",
|
|
41
|
+
messages: [],
|
|
42
|
+
};
|
|
43
|
+
const flowOnly: Channel = {
|
|
44
|
+
address: "payments.accepted",
|
|
45
|
+
title: "message stream",
|
|
46
|
+
messages: [],
|
|
47
|
+
};
|
|
48
|
+
const nats: Channel = {
|
|
49
|
+
address: "shop.cart",
|
|
50
|
+
title: "JetStream subject",
|
|
51
|
+
messages: [],
|
|
52
|
+
};
|
|
53
|
+
const catalog = {
|
|
54
|
+
flows: [
|
|
55
|
+
{
|
|
56
|
+
id: "payment-kafka",
|
|
57
|
+
slug: "payment-kafka",
|
|
58
|
+
name: "Payment Kafka",
|
|
59
|
+
summary: "",
|
|
60
|
+
owner: "payments",
|
|
61
|
+
participants: [],
|
|
62
|
+
steps: [
|
|
63
|
+
{
|
|
64
|
+
type: "step",
|
|
65
|
+
id: "publish",
|
|
66
|
+
from: "payments",
|
|
67
|
+
to: "broker",
|
|
68
|
+
kind: "event",
|
|
69
|
+
status: "verified",
|
|
70
|
+
handoff: {
|
|
71
|
+
kind: "message",
|
|
72
|
+
transport: "kafka",
|
|
73
|
+
channel: "payments.accepted",
|
|
74
|
+
direction: "send",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
} as Pick<Catalog, "flows">;
|
|
81
|
+
const handoffs = kafkaHandoffChannels(catalog);
|
|
82
|
+
|
|
83
|
+
expect(isKafkaChannel(labelled, handoffs)).toBe(true);
|
|
84
|
+
expect(isKafkaChannel(flowOnly, handoffs)).toBe(true);
|
|
85
|
+
expect(isKafkaChannel(nats, handoffs)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The optional hand-off from a catalogued Kafka topic to the operational UI.
|
|
2
|
+
//
|
|
3
|
+
// The configured value belongs to the reader, not to the catalog: two readers
|
|
4
|
+
// can use different Kafka UI installations for the same generated estate. It
|
|
5
|
+
// therefore lives in localStorage, like the editor and display preferences.
|
|
6
|
+
// A cluster URL gives us enough information to open the exact topic; a plain
|
|
7
|
+
// installation URL remains useful and opens Kafka UI without inventing a
|
|
8
|
+
// cluster name.
|
|
9
|
+
|
|
10
|
+
import { create } from "zustand";
|
|
11
|
+
import type { Catalog, Channel } from "../catalog";
|
|
12
|
+
import { walkSteps } from "../catalog";
|
|
13
|
+
|
|
14
|
+
export const KAFKA_UI_KEY = "portolan.integrations.kafka-ui";
|
|
15
|
+
|
|
16
|
+
export function normalizeKafkaUiUrl(value: string): string | null {
|
|
17
|
+
const clean = value.trim();
|
|
18
|
+
if (!clean) return "";
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(clean);
|
|
21
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
22
|
+
url.hash = "";
|
|
23
|
+
return url.toString().replace(/\/$/, "");
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build Kafbat's topic route when the configured URL names a cluster. */
|
|
30
|
+
export function kafkaUiTopicUrl(
|
|
31
|
+
configured: string,
|
|
32
|
+
topic: string,
|
|
33
|
+
): string | null {
|
|
34
|
+
const normalized = normalizeKafkaUiUrl(configured);
|
|
35
|
+
if (!normalized) return null;
|
|
36
|
+
const url = new URL(normalized);
|
|
37
|
+
const match = url.pathname.match(
|
|
38
|
+
/^(.*\/ui\/clusters\/[^/]+)(?:\/.*)?$/,
|
|
39
|
+
);
|
|
40
|
+
if (!match?.[1]) return normalized;
|
|
41
|
+
url.pathname = `${match[1]}/all-topics/${encodeURIComponent(topic)}`;
|
|
42
|
+
url.search = "";
|
|
43
|
+
url.hash = "";
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Topic addresses whose source-backed flow explicitly says Kafka. */
|
|
48
|
+
export function kafkaHandoffChannels(catalog: Pick<Catalog, "flows">): Set<string> {
|
|
49
|
+
const channels = new Set<string>();
|
|
50
|
+
for (const flow of catalog.flows) {
|
|
51
|
+
for (const step of walkSteps(flow.steps)) {
|
|
52
|
+
if (step.handoff?.transport.toLowerCase() === "kafka") {
|
|
53
|
+
channels.add(step.handoff.channel);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return channels;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A channel is Kafka only when an extractor said so in its presentation facts
|
|
62
|
+
* or a matching flow carries explicit Kafka transport evidence. Channel itself
|
|
63
|
+
* predates the transport field, so address alone is deliberately insufficient.
|
|
64
|
+
*/
|
|
65
|
+
export function isKafkaChannel(
|
|
66
|
+
channel: Pick<Channel, "address" | "title" | "doc">,
|
|
67
|
+
handoffChannels: ReadonlySet<string>,
|
|
68
|
+
): boolean {
|
|
69
|
+
const description = `${channel.title ?? ""} ${channel.doc ?? ""}`;
|
|
70
|
+
return /(^|[^a-z0-9])kafka([^a-z0-9]|$)/i.test(description)
|
|
71
|
+
|| handoffChannels.has(channel.address);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function read(): string {
|
|
75
|
+
try {
|
|
76
|
+
const value = localStorage.getItem(KAFKA_UI_KEY) ?? "";
|
|
77
|
+
return normalizeKafkaUiUrl(value) ?? "";
|
|
78
|
+
} catch {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function write(value: string): void {
|
|
84
|
+
try {
|
|
85
|
+
if (value) localStorage.setItem(KAFKA_UI_KEY, value);
|
|
86
|
+
else localStorage.removeItem(KAFKA_UI_KEY);
|
|
87
|
+
} catch {
|
|
88
|
+
/* private mode: keep the value for this session */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface KafkaUiState {
|
|
93
|
+
url: string;
|
|
94
|
+
setUrl: (url: string) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const useKafkaUi = create<KafkaUiState>()((set) => ({
|
|
98
|
+
url: read(),
|
|
99
|
+
setUrl: (url) => {
|
|
100
|
+
const normalized = normalizeKafkaUiUrl(url);
|
|
101
|
+
if (normalized === null) return;
|
|
102
|
+
write(normalized);
|
|
103
|
+
set({ url: normalized });
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
@@ -260,4 +260,25 @@ describe("wireProblems", () => {
|
|
|
260
260
|
]);
|
|
261
261
|
expect(unresolved[0]?.severity).toBe("warning");
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
it("reports a MessagePack subscriber paired with a JSON publisher", () => {
|
|
265
|
+
const publisherChannel = channel("inventory.snapshots", "send inventory.Snapshot");
|
|
266
|
+
publisherChannel.messages[0]!.encoding = "json";
|
|
267
|
+
const subscriberChannel = channel("inventory.snapshots", "receive inventory.Snapshot");
|
|
268
|
+
subscriberChannel.messages[0]!.encoding = "msgpack";
|
|
269
|
+
const publisher = speaking(service("shop.inventory", []), publisherChannel);
|
|
270
|
+
const subscriber = speaking(service("shop.search", []), subscriberChannel);
|
|
271
|
+
|
|
272
|
+
const mismatch = found(catalogWith([publisher, subscriber])).find(
|
|
273
|
+
(problem) => problem.kind === "message-encoding",
|
|
274
|
+
);
|
|
275
|
+
expect(mismatch).toMatchObject({
|
|
276
|
+
severity: "error",
|
|
277
|
+
service: "shop.search",
|
|
278
|
+
peer: "shop.inventory",
|
|
279
|
+
});
|
|
280
|
+
expect(mismatch?.note).toContain("expects inventory.Snapshot");
|
|
281
|
+
expect(mismatch?.note).toContain("msgpack");
|
|
282
|
+
expect(mismatch?.note).toContain("json");
|
|
283
|
+
});
|
|
263
284
|
});
|
package/src/lib/wire-problems.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// merged catalog, for the same reason the store rule is: each extractor sees
|
|
20
20
|
// one repository and cannot know what another declares.
|
|
21
21
|
|
|
22
|
-
import type { Catalog, CatalogIndex, Event, Service } from "../catalog";
|
|
22
|
+
import type { Catalog, CatalogIndex, ChannelMessage, Event, Service } from "../catalog";
|
|
23
23
|
import type { Problem } from "./derive";
|
|
24
24
|
|
|
25
25
|
interface Publisher {
|
|
@@ -38,10 +38,71 @@ export function wireProblems(
|
|
|
38
38
|
return [
|
|
39
39
|
...sharedChannels(catalog),
|
|
40
40
|
...documentAgainstCode(catalog),
|
|
41
|
+
...messageEncodingMismatches(catalog),
|
|
41
42
|
...unresolvedSubscriptions(catalog, index),
|
|
42
43
|
];
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
interface MessageEndpoint {
|
|
47
|
+
context: string;
|
|
48
|
+
service: Service;
|
|
49
|
+
address: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
message: ChannelMessage;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Producer and subscriber have both named a format, and those formats differ. */
|
|
55
|
+
function messageEncodingMismatches(catalog: Catalog): Problem[] {
|
|
56
|
+
const sends = new Map<string, MessageEndpoint[]>();
|
|
57
|
+
const receives: MessageEndpoint[] = [];
|
|
58
|
+
for (const context of catalog.contexts) {
|
|
59
|
+
for (const service of context.services) {
|
|
60
|
+
for (const channel of service.channels ?? []) {
|
|
61
|
+
for (const message of channel.messages) {
|
|
62
|
+
const endpoint = { context: context.id, service, address: channel.address, source: channel.source, message };
|
|
63
|
+
const key = `${channel.address}\u0000${message.name}`;
|
|
64
|
+
if (message.direction === "send") {
|
|
65
|
+
const publishers = sends.get(key) ?? [];
|
|
66
|
+
publishers.push(endpoint);
|
|
67
|
+
sends.set(key, publishers);
|
|
68
|
+
} else {
|
|
69
|
+
receives.push(endpoint);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const out: Problem[] = [];
|
|
77
|
+
for (const receiver of receives) {
|
|
78
|
+
const expected = wireFormat(receiver.message);
|
|
79
|
+
if (!expected) continue;
|
|
80
|
+
const key = `${receiver.address}\u0000${receiver.message.name}`;
|
|
81
|
+
for (const publisher of sends.get(key) ?? []) {
|
|
82
|
+
const actual = wireFormat(publisher.message);
|
|
83
|
+
if (!actual || actual === expected) continue;
|
|
84
|
+
out.push({
|
|
85
|
+
kind: "message-encoding",
|
|
86
|
+
severity: "error",
|
|
87
|
+
context: receiver.context,
|
|
88
|
+
service: receiver.service.id,
|
|
89
|
+
id: receiver.service.id,
|
|
90
|
+
peer: publisher.service.id,
|
|
91
|
+
note: `${receiver.service.id} expects ${receiver.message.name} on ${receiver.address} as ${expected}, but ${publisher.service.id} sends it as ${actual}.`,
|
|
92
|
+
source: receiver.source,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function wireFormat(message: ChannelMessage): string {
|
|
100
|
+
if (message.encoding) return message.encoding.trim().toLowerCase();
|
|
101
|
+
const contentType = message.contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
102
|
+
if (contentType.includes("msgpack") || contentType.includes("messagepack")) return "msgpack";
|
|
103
|
+
return contentType;
|
|
104
|
+
}
|
|
105
|
+
|
|
45
106
|
/**
|
|
46
107
|
* Every service that publishes on a channel another service publishes on
|
|
47
108
|
* too: one row per service per such channel, pointing at the other side.
|
package/src/likec4/FlowView.tsx
CHANGED
|
@@ -100,18 +100,14 @@ export function FlowView({
|
|
|
100
100
|
|
|
101
101
|
const highlightEdges = useMemo(
|
|
102
102
|
() =>
|
|
103
|
-
marked
|
|
104
|
-
.map((stepId) => pairing.edgeOf.get(stepId))
|
|
105
|
-
.filter((id): id is string => id !== undefined),
|
|
103
|
+
marked.flatMap((stepId) => pairing.edgesOf.get(stepId) ?? []),
|
|
106
104
|
[marked, pairing],
|
|
107
105
|
);
|
|
108
106
|
|
|
109
107
|
const focusedPathEdges = useMemo(
|
|
110
108
|
() =>
|
|
111
109
|
pathSteps
|
|
112
|
-
? pathSteps
|
|
113
|
-
.map((stepId) => pairing.edgeOf.get(stepId))
|
|
114
|
-
.filter((id): id is string => id !== undefined)
|
|
110
|
+
? pathSteps.flatMap((stepId) => pairing.edgesOf.get(stepId) ?? [])
|
|
115
111
|
: null,
|
|
116
112
|
[pathSteps, pairing],
|
|
117
113
|
);
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { catalog } from "../data";
|
|
3
|
+
import type { Flow } from "../catalog";
|
|
3
4
|
import { walkSteps } from "../catalog";
|
|
4
5
|
import { hiddenStepIds } from "../flow/cross-context";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
drawnEdgeStepIds,
|
|
8
|
+
drawnStepIds,
|
|
9
|
+
pairEdgesToSteps,
|
|
10
|
+
} from "./flow-edges";
|
|
6
11
|
|
|
7
12
|
describe("pairEdgesToSteps", () => {
|
|
8
13
|
it("pairs by position, both ways", () => {
|
|
@@ -14,6 +19,16 @@ describe("pairEdgesToSteps", () => {
|
|
|
14
19
|
expect(pairing.edgeOf.get("s3")).toBe("step-02:par.02");
|
|
15
20
|
});
|
|
16
21
|
|
|
22
|
+
it("pairs a request and its generated response to the same catalog step", () => {
|
|
23
|
+
const pairing = pairEdgesToSteps(
|
|
24
|
+
["step-01", "step-02"],
|
|
25
|
+
["request", "request"],
|
|
26
|
+
);
|
|
27
|
+
expect(pairing.stepOf.get("step-02")).toBe("request");
|
|
28
|
+
expect(pairing.edgeOf.get("request")).toBe("step-01");
|
|
29
|
+
expect(pairing.edgesOf.get("request")).toEqual(["step-01", "step-02"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* A length mismatch means the generator and the view have drifted. Guessing
|
|
19
34
|
* would light the wrong arrow, which is worse than lighting none, so the
|
|
@@ -23,6 +38,7 @@ describe("pairEdgesToSteps", () => {
|
|
|
23
38
|
const pairing = pairEdgesToSteps(["step-01"], ["s1", "s2"]);
|
|
24
39
|
expect(pairing.stepOf.size).toBe(0);
|
|
25
40
|
expect(pairing.edgeOf.size).toBe(0);
|
|
41
|
+
expect(pairing.edgesOf.size).toBe(0);
|
|
26
42
|
});
|
|
27
43
|
});
|
|
28
44
|
|
|
@@ -49,3 +65,50 @@ describe("drawnStepIds", () => {
|
|
|
49
65
|
}
|
|
50
66
|
});
|
|
51
67
|
});
|
|
68
|
+
|
|
69
|
+
describe("drawnEdgeStepIds", () => {
|
|
70
|
+
it("returns nested RPC responses in place and the actor response at the end", () => {
|
|
71
|
+
const flow: Flow = {
|
|
72
|
+
id: "flow.checkout",
|
|
73
|
+
slug: "checkout",
|
|
74
|
+
name: "Checkout",
|
|
75
|
+
summary: "",
|
|
76
|
+
owner: "shop",
|
|
77
|
+
participants: [
|
|
78
|
+
{ id: "client", kind: "actor", context: null },
|
|
79
|
+
{ id: "shop.cart", kind: "service", context: "shop" },
|
|
80
|
+
{ id: "auth.auth", kind: "service", context: "auth" },
|
|
81
|
+
],
|
|
82
|
+
steps: [
|
|
83
|
+
{
|
|
84
|
+
type: "step",
|
|
85
|
+
id: "root",
|
|
86
|
+
from: "client",
|
|
87
|
+
to: "shop.cart",
|
|
88
|
+
kind: "rpc",
|
|
89
|
+
status: "declared",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: "step",
|
|
93
|
+
id: "nested",
|
|
94
|
+
from: "shop.cart",
|
|
95
|
+
to: "auth.auth",
|
|
96
|
+
kind: "rpc",
|
|
97
|
+
status: "declared",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "step",
|
|
101
|
+
id: "done",
|
|
102
|
+
from: "shop.cart",
|
|
103
|
+
to: "client",
|
|
104
|
+
kind: "event",
|
|
105
|
+
status: "declared",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
drawnEdgeStepIds(flow, false, new Set(["root", "nested"])),
|
|
112
|
+
).toEqual(["root", "nested", "nested", "done", "root"]);
|
|
113
|
+
});
|
|
114
|
+
});
|