@shortlink-org/portolan 0.2.2 → 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/README.md +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- 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/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- 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/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- 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/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- 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 +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/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 +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- 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/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import type { Catalog, CatalogIndex, Service } from "../catalog";
|
|
3
3
|
import { protoProblems } from "./proto-problems";
|
|
4
4
|
|
|
5
5
|
function service(id: string, overrides: Partial<Service> = {}): Service {
|
|
@@ -43,8 +43,39 @@ const pricing = () =>
|
|
|
43
43
|
provides: [
|
|
44
44
|
{
|
|
45
45
|
id: "pricing.v1.Pricing",
|
|
46
|
-
methods: [
|
|
46
|
+
methods: [
|
|
47
|
+
{
|
|
48
|
+
name: "GetQuote",
|
|
49
|
+
request: "GetQuoteRequest",
|
|
50
|
+
response: "Quote",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
47
53
|
source: "proto/pricing/v1/pricing.proto:7",
|
|
54
|
+
messages: [
|
|
55
|
+
{
|
|
56
|
+
name: "GetQuoteRequest",
|
|
57
|
+
fields: [
|
|
58
|
+
{ name: "sku", type: "string", number: 1, doc: "" },
|
|
59
|
+
{ name: "locale", type: "string", number: 2, doc: "" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "Quote",
|
|
64
|
+
fields: [
|
|
65
|
+
{ name: "amount", type: "int64", number: 1, doc: "" },
|
|
66
|
+
{ name: "status", type: "QuoteStatus", number: 2, doc: "" },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
enums: [
|
|
71
|
+
{
|
|
72
|
+
name: "QuoteStatus",
|
|
73
|
+
values: [
|
|
74
|
+
{ name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
|
|
75
|
+
{ name: "QUOTE_STATUS_READY", number: 1 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
48
79
|
},
|
|
49
80
|
],
|
|
50
81
|
});
|
|
@@ -61,8 +92,75 @@ const calling = (id: string, status: "declared" | "unresolved" = "declared") =>
|
|
|
61
92
|
],
|
|
62
93
|
});
|
|
63
94
|
|
|
95
|
+
const copiedPricing = (overrides = {}) =>
|
|
96
|
+
service("shop.oms", {
|
|
97
|
+
consumes: [
|
|
98
|
+
{
|
|
99
|
+
id: "pricing.v1.Pricing/GetQuote",
|
|
100
|
+
peer: "shop.pricing",
|
|
101
|
+
status: "declared",
|
|
102
|
+
source: "internal/infrastructure/pricing/pricing.proto:8",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
copies: [
|
|
106
|
+
{
|
|
107
|
+
id: "pricing.v1.Pricing",
|
|
108
|
+
methods: [
|
|
109
|
+
{
|
|
110
|
+
name: "GetQuote",
|
|
111
|
+
request: "GetQuoteRequest",
|
|
112
|
+
response: "Quote",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
source: "internal/infrastructure/pricing/pricing.proto:7",
|
|
116
|
+
messages: [
|
|
117
|
+
{
|
|
118
|
+
name: "GetQuoteRequest",
|
|
119
|
+
fields: [{ name: "sku", type: "string", number: 1, doc: "" }],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "Quote",
|
|
123
|
+
fields: [
|
|
124
|
+
{ name: "amount", type: "int64", number: 1, doc: "" },
|
|
125
|
+
{ name: "status", type: "QuoteStatus", number: 2, doc: "" },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
enums: [
|
|
130
|
+
{
|
|
131
|
+
name: "QuoteStatus",
|
|
132
|
+
values: [
|
|
133
|
+
{ name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
|
|
134
|
+
{ name: "QUOTE_STATUS_READY", number: 1 },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
...overrides,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
|
|
64
143
|
function found(catalog: Catalog) {
|
|
65
|
-
|
|
144
|
+
const serviceById = new Map<string, Service>();
|
|
145
|
+
const serviceContext = new Map<string, Catalog["contexts"][number]>();
|
|
146
|
+
const rpcProviderByMethod = new Map<string, Service>();
|
|
147
|
+
for (const context of catalog.contexts) {
|
|
148
|
+
for (const item of context.services) {
|
|
149
|
+
serviceById.set(item.id, item);
|
|
150
|
+
serviceContext.set(item.id, context);
|
|
151
|
+
for (const provided of item.provides) {
|
|
152
|
+
for (const method of provided.methods) {
|
|
153
|
+
rpcProviderByMethod.set(`${provided.id}/${method.name}`, item);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const index = {
|
|
159
|
+
serviceById,
|
|
160
|
+
serviceContext,
|
|
161
|
+
rpcProviderByMethod,
|
|
162
|
+
} as CatalogIndex;
|
|
163
|
+
return protoProblems(catalog, index);
|
|
66
164
|
}
|
|
67
165
|
|
|
68
166
|
describe("protoProblems", () => {
|
|
@@ -118,4 +216,73 @@ describe("protoProblems", () => {
|
|
|
118
216
|
it("finds nothing in a catalog where nobody calls anybody", () => {
|
|
119
217
|
expect(found(catalogWith([pricing()]))).toEqual([]);
|
|
120
218
|
});
|
|
219
|
+
|
|
220
|
+
it("accepts a narrowed copy whose retained fields and enum agree", () => {
|
|
221
|
+
expect(found(catalogWith([pricing(), copiedPricing()]))).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("reports copied fields whose type, number, or existence drifted", () => {
|
|
225
|
+
const caller = copiedPricing({
|
|
226
|
+
messages: [
|
|
227
|
+
{
|
|
228
|
+
name: "GetQuoteRequest",
|
|
229
|
+
fields: [
|
|
230
|
+
{ name: "sku", type: "bytes", number: 3, doc: "" },
|
|
231
|
+
{ name: "currency", type: "string", number: 4, doc: "" },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const problems = found(catalogWith([pricing(), caller]));
|
|
238
|
+
|
|
239
|
+
expect(problems).toHaveLength(1);
|
|
240
|
+
expect(problems[0]).toMatchObject({
|
|
241
|
+
kind: "proto-drift",
|
|
242
|
+
severity: "warning",
|
|
243
|
+
service: "shop.oms",
|
|
244
|
+
id: "pricing.v1.Pricing",
|
|
245
|
+
peer: "shop.pricing",
|
|
246
|
+
});
|
|
247
|
+
expect(problems[0]?.note).toContain("GetQuoteRequest.sku is bytes");
|
|
248
|
+
expect(problems[0]?.note).toContain("GetQuoteRequest.sku is field 3");
|
|
249
|
+
expect(problems[0]?.note).toContain(
|
|
250
|
+
"GetQuoteRequest.currency is absent from the provider",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("reports enum values added, removed, or renumbered", () => {
|
|
255
|
+
const caller = copiedPricing({
|
|
256
|
+
enums: [
|
|
257
|
+
{
|
|
258
|
+
name: "QuoteStatus",
|
|
259
|
+
values: [
|
|
260
|
+
{ name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
|
|
261
|
+
{ name: "QUOTE_STATUS_READY", number: 7 },
|
|
262
|
+
{ name: "QUOTE_STATUS_OLD", number: 1 },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const [problem] = found(catalogWith([pricing(), caller]));
|
|
269
|
+
|
|
270
|
+
expect(problem?.note).toContain("QUOTE_STATUS_READY is 7, provider has 1");
|
|
271
|
+
expect(problem?.note).toContain("QUOTE_STATUS_OLD is absent from the provider");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("reports an enum value present only in the provider", () => {
|
|
275
|
+
const caller = copiedPricing({
|
|
276
|
+
enums: [
|
|
277
|
+
{
|
|
278
|
+
name: "QuoteStatus",
|
|
279
|
+
values: [{ name: "QUOTE_STATUS_UNSPECIFIED", number: 0 }],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const [problem] = found(catalogWith([pricing(), caller]));
|
|
285
|
+
|
|
286
|
+
expect(problem?.note).toContain("QUOTE_STATUS_READY is missing from the copy");
|
|
287
|
+
});
|
|
121
288
|
});
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// docs/adr/org.0001.md promises this: producers publish their schema, consumers
|
|
4
4
|
// keep a narrowed copy, and "a field, method or enum value that differs between
|
|
5
|
-
// the two is reported against the consuming service".
|
|
6
|
-
//
|
|
5
|
+
// the two is reported against the consuming service". The extractor retains
|
|
6
|
+
// those copies on the consumer; this reader compares them after every source
|
|
7
|
+
// has met in the merged catalog.
|
|
7
8
|
//
|
|
8
9
|
// It is deliberately NOT done in the merge. src/merge.ts is documented as
|
|
9
10
|
// union-by-id plus first-non-empty and nothing else, and teaching it to compare
|
|
@@ -16,11 +17,20 @@
|
|
|
16
17
|
// is about a call whose peer is known and whose METHOD is not - the copy is
|
|
17
18
|
// stale, or the producer removed something, and the two are different repairs.
|
|
18
19
|
|
|
19
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
Catalog,
|
|
22
|
+
CatalogIndex,
|
|
23
|
+
RpcEnum,
|
|
24
|
+
RpcMessage,
|
|
25
|
+
RpcMethod,
|
|
26
|
+
RpcService,
|
|
27
|
+
Service,
|
|
28
|
+
} from "../catalog";
|
|
20
29
|
import type { Problem } from "./derive";
|
|
21
30
|
|
|
22
31
|
/**
|
|
23
|
-
* Every
|
|
32
|
+
* Every missing method and every retained consumer descriptor that disagrees
|
|
33
|
+
* with the provider's method, field, or enum value.
|
|
24
34
|
*
|
|
25
35
|
* An unresolved call is skipped: it already has a problem of its own from
|
|
26
36
|
* `problems()`, and reporting the same edge twice under two headings would make
|
|
@@ -58,8 +68,170 @@ export function protoProblems(
|
|
|
58
68
|
source: call.source,
|
|
59
69
|
});
|
|
60
70
|
}
|
|
71
|
+
|
|
72
|
+
for (const copy of service.copies ?? []) {
|
|
73
|
+
const drift = driftAgainstProvider(service, copy, index);
|
|
74
|
+
if (drift) out.push(drift);
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
return out;
|
|
65
80
|
}
|
|
81
|
+
|
|
82
|
+
/** Compare one consumer copy only when one of its calls resolves to a provider. */
|
|
83
|
+
function driftAgainstProvider(
|
|
84
|
+
consumer: Service,
|
|
85
|
+
copy: RpcService,
|
|
86
|
+
index: CatalogIndex,
|
|
87
|
+
): Problem | undefined {
|
|
88
|
+
const calls = new Map(
|
|
89
|
+
consumer.consumes
|
|
90
|
+
.filter((call) => call.id.startsWith(`${copy.id}/`))
|
|
91
|
+
.map((call) => [call.id, call]),
|
|
92
|
+
);
|
|
93
|
+
const resolved = copy.methods
|
|
94
|
+
.map((method) => calls.get(`${copy.id}/${method.name}`))
|
|
95
|
+
.find(
|
|
96
|
+
(call) =>
|
|
97
|
+
call?.status !== "unresolved" && index.serviceById.has(call?.peer ?? ""),
|
|
98
|
+
);
|
|
99
|
+
if (!resolved) return undefined;
|
|
100
|
+
|
|
101
|
+
const providerService = index.serviceById.get(resolved.peer);
|
|
102
|
+
const provider = providerService?.provides.find(
|
|
103
|
+
(candidate) => candidate.id === copy.id,
|
|
104
|
+
);
|
|
105
|
+
if (!providerService || !provider) return undefined;
|
|
106
|
+
|
|
107
|
+
const differences = compareInterfaces(copy, provider);
|
|
108
|
+
if (differences.length === 0) return undefined;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
kind: "proto-drift",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
context: index.serviceContext.get(consumer.id)?.id ?? "",
|
|
114
|
+
service: consumer.id,
|
|
115
|
+
id: copy.id,
|
|
116
|
+
peer: providerService.id,
|
|
117
|
+
note: `Vendored copy differs from ${providerService.id}: ${differences.join("; ")}.`,
|
|
118
|
+
source: copy.source,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function compareInterfaces(copy: RpcService, provider: RpcService): string[] {
|
|
123
|
+
const out: string[] = [];
|
|
124
|
+
const methods = new Map(provider.methods.map((method) => [method.name, method]));
|
|
125
|
+
|
|
126
|
+
for (const method of copy.methods) {
|
|
127
|
+
const actual = methods.get(method.name);
|
|
128
|
+
// A missing method already has the stronger proto-missing error.
|
|
129
|
+
if (!actual) continue;
|
|
130
|
+
compareMethod(method, actual, out);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const messages = new Map(
|
|
134
|
+
(provider.messages ?? []).map((message) => [message.name, message]),
|
|
135
|
+
);
|
|
136
|
+
for (const message of copy.messages ?? []) {
|
|
137
|
+
compareMessage(message, messages.get(message.name), out);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const enums = new Map(
|
|
141
|
+
(provider.enums ?? []).map((item) => [item.name, item]),
|
|
142
|
+
);
|
|
143
|
+
for (const item of copy.enums ?? []) {
|
|
144
|
+
compareEnum(item, enums.get(item.name), out);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function compareMethod(
|
|
151
|
+
copy: RpcMethod,
|
|
152
|
+
provider: RpcMethod,
|
|
153
|
+
out: string[],
|
|
154
|
+
): void {
|
|
155
|
+
for (const side of ["request", "response"] as const) {
|
|
156
|
+
if (copy[side] !== provider[side]) {
|
|
157
|
+
out.push(
|
|
158
|
+
`${copy.name} ${side} is ${copy[side] || "unnamed"}, provider has ${provider[side] || "unnamed"}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (copy.streaming !== provider.streaming) {
|
|
163
|
+
out.push(
|
|
164
|
+
`${copy.name} streaming is ${copy.streaming ?? "unary"}, provider has ${provider.streaming ?? "unary"}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A narrowed copy may omit provider fields it does not read. Every field it
|
|
171
|
+
* does carry must still have the provider's type and protobuf number.
|
|
172
|
+
*/
|
|
173
|
+
function compareMessage(
|
|
174
|
+
copy: RpcMessage,
|
|
175
|
+
provider: RpcMessage | undefined,
|
|
176
|
+
out: string[],
|
|
177
|
+
): void {
|
|
178
|
+
if (!provider) {
|
|
179
|
+
out.push(`message ${copy.name} is absent from the provider`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const fields = new Map(provider.fields.map((field) => [field.name, field]));
|
|
184
|
+
for (const field of copy.fields) {
|
|
185
|
+
const actual = fields.get(field.name);
|
|
186
|
+
if (!actual) {
|
|
187
|
+
out.push(`${copy.name}.${field.name} is absent from the provider`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (field.type !== actual.type) {
|
|
191
|
+
out.push(
|
|
192
|
+
`${copy.name}.${field.name} is ${field.type}, provider has ${actual.type}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
field.number !== undefined &&
|
|
197
|
+
actual.number !== undefined &&
|
|
198
|
+
field.number !== actual.number
|
|
199
|
+
) {
|
|
200
|
+
out.push(
|
|
201
|
+
`${copy.name}.${field.name} is field ${field.number}, provider has ${actual.number}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Enum names and numbers are both wire claims, so the sets must match whole. */
|
|
208
|
+
function compareEnum(
|
|
209
|
+
copy: RpcEnum,
|
|
210
|
+
provider: RpcEnum | undefined,
|
|
211
|
+
out: string[],
|
|
212
|
+
): void {
|
|
213
|
+
if (!provider) {
|
|
214
|
+
out.push(`enum ${copy.name} is absent from the provider`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const copyByName = new Map(copy.values.map((value) => [value.name, value]));
|
|
219
|
+
const providerByName = new Map(
|
|
220
|
+
provider.values.map((value) => [value.name, value]),
|
|
221
|
+
);
|
|
222
|
+
for (const value of copy.values) {
|
|
223
|
+
const actual = providerByName.get(value.name);
|
|
224
|
+
if (!actual) {
|
|
225
|
+
out.push(`${copy.name}.${value.name} is absent from the provider`);
|
|
226
|
+
} else if (value.number !== actual.number) {
|
|
227
|
+
out.push(
|
|
228
|
+
`${copy.name}.${value.name} is ${value.number}, provider has ${actual.number}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const value of provider.values) {
|
|
233
|
+
if (!copyByName.has(value.name)) {
|
|
234
|
+
out.push(`${copy.name}.${value.name} is missing from the copy`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -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
|
+
});
|