@benzenejs/mesh-aggregator 0.0.0-stage → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,766 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MeshAggregator = void 0;
4
+ /** Port of Benzene.Mesh.Aggregator.MeshAggregator. */
5
+ const abstractions_1 = require("@benzenejs/abstractions");
6
+ const mesh_contracts_1 = require("@benzenejs/mesh-contracts");
7
+ const results_1 = require("@benzenejs/results");
8
+ const AsyncApiCompositor_1 = require("./AsyncApiCompositor");
9
+ const MeshSnapshotBuilder_1 = require("./MeshSnapshotBuilder");
10
+ const UnknownMeshServiceSource_1 = require("./UnknownMeshServiceSource");
11
+ // Matches Benzene.HealthChecks.TimeOutHealthCheck's 10-second convention: an explicit, documented bound on
12
+ // each fetch rather than relying solely on a source's own (potentially much longer) defaults - one
13
+ // slow/hung service shouldn't be able to stall a run. `CancellationTokenSource(timeout)` -> `AbortSignal.timeout`.
14
+ const PerServiceFetchTimeoutMs = 10_000;
15
+ // The synthesized `result`-tag tokens the metric standard writes onto benzene.messages.processed, which
16
+ // become MeshUsageEntry.status for the metrics-backend adapters. Successes collapse to "success"; failures
17
+ // are itemized by their real status, plus "exception" for an escaped throw and a legacy "failure" fallback.
18
+ const SuccessResult = 'success';
19
+ const FailureResult = 'failure';
20
+ const ExceptionResult = 'exception';
21
+ // A cycle-guard alone bounds recursion by ref name, but an inline (ref-less) self-referential shape has no
22
+ // name to guard on - this hard depth cap is the backstop that keeps a pathological spec from stalling a run.
23
+ const MaxSchemaDepth = 32;
24
+ /**
25
+ * Polls every service in a `MeshServiceRegistry` for its spec and health documents, computes contract-drift,
26
+ * and publishes the resulting catalog to an `IMeshArtifactStore`. `System.Text.Json` (nodes + document) ->
27
+ * native `JSON.parse`/`JSON.stringify`; `DateTimeOffset` clock -> `() => number` (epoch ms); `Task.WhenAll`
28
+ * -> `Promise.all`; LINQ `GroupBy`/`ToDictionary` -> `Map`s.
29
+ */
30
+ class MeshAggregator {
31
+ sources;
32
+ store;
33
+ clock;
34
+ usageSources;
35
+ /**
36
+ * @param sources Every registered `IMeshServiceSource`, keyed by `key` (case-insensitive) to resolve each
37
+ * entry's `source` against. An entry whose `source` has no matching source here is recorded as that
38
+ * service's own fetch error, not a run-wide failure.
39
+ * @param store Where generated catalog artifacts are published (and, for drift comparison, read back from).
40
+ * @param clock Supplies the current time (epoch ms); defaults to `Date.now`. Overridable for deterministic tests.
41
+ * @param usageSources Every registered `IMeshUsageSource`. Optional and empty by default: with none
42
+ * registered no `usage.json` is ever published, so the artifact's absence keeps meaning "no usage feed wired".
43
+ */
44
+ constructor(sources, store, clock, usageSources) {
45
+ this.sources = new Map();
46
+ for (const source of sources) {
47
+ this.sources.set(source.key.toLowerCase(), source);
48
+ }
49
+ this.store = store;
50
+ this.clock = clock ?? (() => Date.now());
51
+ this.usageSources = usageSources ? [...usageSources] : [];
52
+ }
53
+ /**
54
+ * Polls every registered service once, publishing a `services/{name}.json` snapshot per service and a
55
+ * top-level `manifest.json` summarizing all of them. A single service's spec/health fetch failing (or
56
+ * timing out) does not prevent the rest from being processed and published. Services are polled
57
+ * concurrently, so one slow service adds to the run's total time only up to its own timeout.
58
+ */
59
+ async runOnceAsync(registry) {
60
+ const entries = registry.services;
61
+ // Usage adapters are polled concurrently with the services themselves - independent I/O.
62
+ const usagePromise = this.fetchUsageAsync();
63
+ const results = await Promise.all(entries.map((entry) => this.buildServiceAsync(entry)));
64
+ // Build every artifact's content first (cheap, in-memory), then publish them all concurrently.
65
+ const manifestEntries = [];
66
+ const writes = [];
67
+ for (let i = 0; i < entries.length; i++) {
68
+ const entry = entries[i];
69
+ const snapshot = results[i].snapshot;
70
+ writes.push(this.store.publishAsync(`services/${entry.name}.json`, JSON.stringify(snapshot, null, 2)));
71
+ manifestEntries.push(new mesh_contracts_1.MeshManifestEntry(entry.name, determineStatus(snapshot), snapshot.contractDrift, entry.specUrl, entry.healthUrl, entry.owningTeam, [...results[i].transports], snapshot.fetchedAtUtc));
72
+ }
73
+ const manifest = new mesh_contracts_1.MeshManifest(this.clock(), manifestEntries);
74
+ writes.push(this.store.publishAsync('manifest.json', JSON.stringify(manifest, null, 2)));
75
+ // Cross-service topic catalog, diffed against the previous run's own catalog.
76
+ const catalog = await this.applyCatalogDiffAsync(this.buildTopicCatalog(entries, results));
77
+ writes.push(this.store.publishAsync('topics.json', JSON.stringify(catalog, null, 2)));
78
+ // Observed usage is awaited here - before the topology below - so structural edges can carry a
79
+ // usage-derived req/min + error rate where the feed can attribute one honestly.
80
+ const usage = await usagePromise;
81
+ // Structural ("designed to call") topology derived from the specs.
82
+ const topology = this.buildTopology(entries, results, usage);
83
+ writes.push(this.store.publishAsync('topology.json', JSON.stringify(topology, null, 2)));
84
+ // Composite AsyncAPI: merge every service's own AsyncAPI doc into one fleet-wide document.
85
+ writes.push(this.store.publishAsync('asyncapi.json', this.buildCompositeAsyncApi(entries, results)));
86
+ if (usage !== undefined) {
87
+ writes.push(this.store.publishAsync('usage.json', JSON.stringify(usage, null, 2)));
88
+ }
89
+ await Promise.all(writes);
90
+ return manifest;
91
+ }
92
+ /**
93
+ * Polls every registered usage adapter (bounded by the per-service timeout each; a throwing/timing-out
94
+ * source contributes nothing rather than failing the run) and merges the reports into one `MeshUsage`.
95
+ * Returns `undefined` when no source reported.
96
+ */
97
+ async fetchUsageAsync() {
98
+ if (this.usageSources.length === 0) {
99
+ return undefined;
100
+ }
101
+ const reports = await Promise.all(this.usageSources.map((source) => this.fetchOneUsageAsync(source)));
102
+ const available = reports.filter((report) => report !== undefined);
103
+ if (available.length === 0) {
104
+ return undefined;
105
+ }
106
+ return new mesh_contracts_1.MeshUsage(this.clock(), minDefined(available.map((report) => report.windowStartUtc)), maxDefined(available.map((report) => report.windowEndUtc)), available.flatMap((report) => report.entries));
107
+ }
108
+ async fetchOneUsageAsync(source) {
109
+ try {
110
+ return await source.fetchUsageAsync(AbortSignal.timeout(PerServiceFetchTimeoutMs));
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Builds the composite AsyncAPI document from every service that returned an AsyncAPI doc, passing each
118
+ * service's reserved-topic ids (from its benzene spec) so utility channels are dropped.
119
+ */
120
+ buildCompositeAsyncApi(entries, results) {
121
+ const documents = [];
122
+ for (let i = 0; i < entries.length; i++) {
123
+ const asyncApiJson = results[i].asyncApiJson;
124
+ if (asyncApiJson === undefined || asyncApiJson.trim().length === 0) {
125
+ continue;
126
+ }
127
+ const reserved = new Set(results[i].topics.filter((topic) => topic.reserved).map((topic) => topic.topic));
128
+ documents.push({ serviceName: entries[i].name, asyncApiJson, reservedTopics: reserved });
129
+ }
130
+ return AsyncApiCompositor_1.AsyncApiCompositor.merge(documents, this.clock());
131
+ }
132
+ /**
133
+ * Derives the structural topology: for every domain topic a service declares it *sends* (the spec's
134
+ * `events` and `sends`), an edge to every service that *handles* it (the spec's `requests`). Where the merged `usage`
135
+ * feed can attribute a topic's observed traffic to a specific edge *unambiguously*, that edge also carries
136
+ * a usage-derived req/min and (when the outcome is classifiable) error rate.
137
+ */
138
+ buildTopology(entries, results, usage) {
139
+ // Consumers of a topic (spec `requests`, non-reserved) and producers of it (spec `events` ∪ `sends`).
140
+ const consumersByTopic = new Map();
141
+ const producersByTopic = new Map();
142
+ for (let i = 0; i < entries.length; i++) {
143
+ for (const topic of results[i].topics.filter((t) => !t.reserved)) {
144
+ pushInto(consumersByTopic, topic.topic, entries[i].name);
145
+ }
146
+ for (const topic of results[i].outboundTopics) {
147
+ pushInto(producersByTopic, topic.topic, entries[i].name);
148
+ }
149
+ }
150
+ // Dedup on the (client, server) pair itself (pairKey is collision-free), remembering the topics each
151
+ // edge carries so usage can be attributed per edge.
152
+ const order = [];
153
+ const carriedByEdge = new Map();
154
+ for (let i = 0; i < entries.length; i++) {
155
+ const client = entries[i].name;
156
+ // Topology edges are topic-id-level, not version-level.
157
+ for (const topic of results[i].outboundTopics) {
158
+ const servers = consumersByTopic.get(topic.topic);
159
+ if (servers === undefined) {
160
+ continue;
161
+ }
162
+ for (const server of servers) {
163
+ if (server.toLowerCase() === client.toLowerCase()) {
164
+ continue; // a service calling itself isn't a mesh edge
165
+ }
166
+ const key = pairKey(client, server);
167
+ let carried = carriedByEdge.get(key);
168
+ if (carried === undefined) {
169
+ carried = [];
170
+ carriedByEdge.set(key, carried);
171
+ order.push({ client, server });
172
+ }
173
+ if (!carried.includes(topic.topic)) {
174
+ carried.push(topic.topic);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ const windowMinutes = usage?.windowStartUtc !== undefined && usage?.windowEndUtc !== undefined
180
+ ? (usage.windowEndUtc - usage.windowStartUtc) / 60_000
181
+ : 0;
182
+ const entriesByTopic = new Map();
183
+ if (usage !== undefined) {
184
+ for (const entry of usage.entries) {
185
+ pushInto(entriesByTopic, entry.topic, entry);
186
+ }
187
+ }
188
+ const edges = [];
189
+ for (const key of order) {
190
+ const carried = carriedByEdge.get(pairKey(key.client, key.server));
191
+ const [requestsPerMinute, errorRate] = attributeEdge(carried, key.server, producersByTopic, consumersByTopic, entriesByTopic, windowMinutes);
192
+ edges.push(new mesh_contracts_1.TopologyEdge(key.client, key.server, mesh_contracts_1.TopologyEdgeSource.structural, requestsPerMinute, errorRate, undefined, undefined, undefined));
193
+ }
194
+ return new mesh_contracts_1.MeshTopology(this.clock(), edges);
195
+ }
196
+ /**
197
+ * Builds the cross-service topic catalog: every (topic, version) pair seen anywhere in the fleet, who
198
+ * consumes it (spec `requests`) and who produces it (spec `events`), plus an informational status.
199
+ */
200
+ buildTopicCatalog(entries, results) {
201
+ const byTopic = new Map();
202
+ for (let i = 0; i < entries.length; i++) {
203
+ for (const topic of results[i].topics) {
204
+ const aggregate = getOrAddAggregate(byTopic, topic.topic, topic.version);
205
+ aggregate.reserved = aggregate.reserved || topic.reserved;
206
+ aggregate.consumers.push(new mesh_contracts_1.MeshTopicService(entries[i].name, topic.httpMappings));
207
+ aggregate.consumerSchemas.push({ request: topic.requestSchema, response: topic.responseSchema });
208
+ }
209
+ for (const outbound of results[i].outboundTopics) {
210
+ const aggregate = getOrAddAggregate(byTopic, outbound.topic, outbound.version);
211
+ aggregate.producers.push(new mesh_contracts_1.MeshTopicProducer(entries[i].name));
212
+ aggregate.messageSchema = aggregate.messageSchema ?? outbound.messageSchema;
213
+ }
214
+ }
215
+ const topics = [...byTopic.values()]
216
+ .map((aggregate) => buildTopicEntry(aggregate.topic, aggregate.version, aggregate))
217
+ .sort((a, b) => {
218
+ const reserved = (a.reserved ? 1 : 0) - (b.reserved ? 1 : 0); // domain topics first, utilities last
219
+ if (reserved !== 0)
220
+ return reserved;
221
+ const topic = ordinalCompare(a.topic, b.topic);
222
+ if (topic !== 0)
223
+ return topic;
224
+ return ordinalCompare(a.version, b.version);
225
+ });
226
+ const versionCompatibility = buildVersionCompatibility(byTopic);
227
+ return new mesh_contracts_1.MeshTopicCatalog(this.clock(), topics, [], versionCompatibility);
228
+ }
229
+ /**
230
+ * Annotates the freshly-built catalog with what changed since the previous run, by reading back the
231
+ * store's own last `topics.json`. A first run, or an unreadable/unparseable previous catalog, claims no
232
+ * changes at all - never a wall of "added" noise, and never a failed run.
233
+ */
234
+ async applyCatalogDiffAsync(catalog) {
235
+ let previous;
236
+ try {
237
+ const previousJson = await this.store.tryReadAsync('topics.json');
238
+ if (previousJson !== undefined) {
239
+ previous = JSON.parse(previousJson);
240
+ }
241
+ }
242
+ catch {
243
+ // No previous catalog to diff against this run; the fresh catalog still publishes.
244
+ }
245
+ if (previous === undefined || previous === null || !Array.isArray(previous.topics)) {
246
+ return catalog;
247
+ }
248
+ const previousByKey = new Map();
249
+ for (const entry of previous.topics) {
250
+ const key = pairKey(entry.topic, entry.version);
251
+ if (!previousByKey.has(key)) {
252
+ previousByKey.set(key, entry);
253
+ }
254
+ }
255
+ const topics = catalog.topics.map((entry) => diffTopicEntry(entry, previousByKey));
256
+ const currentKeys = new Set(catalog.topics.map((entry) => pairKey(entry.topic, entry.version)));
257
+ const removed = previous.topics
258
+ .filter((entry) => !entry.reserved && !currentKeys.has(pairKey(entry.topic, entry.version)))
259
+ .map((entry) => new mesh_contracts_1.MeshRemovedTopic(entry.topic, entry.version))
260
+ .sort((a, b) => ordinalCompare(a.topic, b.topic) || ordinalCompare(a.version, b.version));
261
+ // Version compatibility is derived from the current fleet, not the diff - carry it through unchanged.
262
+ return new mesh_contracts_1.MeshTopicCatalog(catalog.generatedAtUtc, topics, removed, catalog.versionCompatibility);
263
+ }
264
+ async buildServiceAsync(entry) {
265
+ const source = this.resolveSource(entry.source);
266
+ // The spec, health and (best-effort) AsyncAPI fetches are independent, so run them concurrently.
267
+ const [specResult, healthResult, asyncApiJson] = await Promise.all([
268
+ this.fetchSpecAsync(source, entry),
269
+ this.fetchHealthAsync(source, entry),
270
+ this.fetchAsyncApiAsync(source, entry),
271
+ ]);
272
+ // Preserve the previous precedence: a spec-fetch error is recorded first, otherwise the health one.
273
+ const error = specResult.error ?? healthResult.error;
274
+ const snapshot = await MeshSnapshotBuilder_1.MeshSnapshotBuilder.buildAsync(this.store, entry.name, this.clock(), specResult.specJson, healthResult.health, error);
275
+ return {
276
+ snapshot,
277
+ topics: parseTopics(specResult.specJson),
278
+ outboundTopics: parseOutboundTopics(specResult.specJson),
279
+ transports: parseTransports(specResult.specJson),
280
+ asyncApiJson,
281
+ };
282
+ }
283
+ async fetchSpecAsync(source, entry) {
284
+ try {
285
+ return { specJson: await source.fetchSpecAsync(entry, AbortSignal.timeout(PerServiceFetchTimeoutMs)), error: undefined };
286
+ }
287
+ catch (ex) {
288
+ // Type name only, never the message - this artifact aggregates across services into something with
289
+ // broader visibility than one service's own health endpoint.
290
+ return { specJson: undefined, error: errorName(ex) };
291
+ }
292
+ }
293
+ async fetchHealthAsync(source, entry) {
294
+ try {
295
+ const healthJson = await source.fetchHealthAsync(entry, AbortSignal.timeout(PerServiceFetchTimeoutMs));
296
+ return { health: JSON.parse(healthJson), error: undefined };
297
+ }
298
+ catch (ex) {
299
+ return { health: undefined, error: errorName(ex) };
300
+ }
301
+ }
302
+ async fetchAsyncApiAsync(source, entry) {
303
+ // AsyncAPI is fetched best-effort and additively: a failure here (or a source that can't serve
304
+ // type=asyncapi) never affects this service's own status/snapshot.
305
+ try {
306
+ if (source.tryFetchSpecAsync === undefined) {
307
+ return undefined;
308
+ }
309
+ return await source.tryFetchSpecAsync(entry, 'asyncapi', AbortSignal.timeout(PerServiceFetchTimeoutMs));
310
+ }
311
+ catch {
312
+ return undefined;
313
+ }
314
+ }
315
+ resolveSource(sourceKey) {
316
+ return this.sources.get(sourceKey.toLowerCase()) ?? new UnknownMeshServiceSource_1.UnknownMeshServiceSource(sourceKey);
317
+ }
318
+ }
319
+ exports.MeshAggregator = MeshAggregator;
320
+ class TopicAggregate {
321
+ topic;
322
+ version;
323
+ reserved = false;
324
+ consumers = [];
325
+ producers = [];
326
+ consumerSchemas = [];
327
+ messageSchema;
328
+ constructor(topic, version) {
329
+ this.topic = topic;
330
+ this.version = version;
331
+ }
332
+ }
333
+ function getOrAddAggregate(byTopic, topic, version) {
334
+ const key = pairKey(topic, version);
335
+ let aggregate = byTopic.get(key);
336
+ if (aggregate === undefined) {
337
+ aggregate = new TopicAggregate(topic, version);
338
+ byTopic.set(key, aggregate);
339
+ }
340
+ return aggregate;
341
+ }
342
+ /**
343
+ * Reconciles, per non-reserved topic id, the set of versions the fleet produces against the set it
344
+ * consumes. Only topics with more than one version in play, or an outright skew, get an entry.
345
+ */
346
+ function buildVersionCompatibility(byTopic) {
347
+ const byTopicId = new Map();
348
+ for (const aggregate of byTopic.values()) {
349
+ if (aggregate.reserved) {
350
+ continue;
351
+ }
352
+ pushInto(byTopicId, aggregate.topic, aggregate);
353
+ }
354
+ const result = [];
355
+ for (const [topic, group] of byTopicId) {
356
+ const produced = distinctOrdinal(group.filter((a) => a.producers.length > 0).map((a) => a.version));
357
+ const consumed = distinctOrdinal(group.filter((a) => a.consumers.length > 0).map((a) => a.version));
358
+ const distinctVersions = new Set([...produced, ...consumed]).size;
359
+ // Only a topic that actually exists at more than one version has a version-compatibility question.
360
+ if (distinctVersions <= 1) {
361
+ continue;
362
+ }
363
+ const consumedSet = new Set(consumed);
364
+ const producedSet = new Set(produced);
365
+ const producedNotConsumed = produced.filter((v) => !consumedSet.has(v)).sort(ordinalCompare);
366
+ const consumedNotProduced = consumed.filter((v) => !producedSet.has(v)).sort(ordinalCompare);
367
+ result.push(new mesh_contracts_1.MeshTopicVersionCompatibility(topic, produced, consumed, producedNotConsumed, consumedNotProduced));
368
+ }
369
+ return result.sort((a, b) => ordinalCompare(a.topic, b.topic));
370
+ }
371
+ function diffTopicEntry(entry, previousByKey) {
372
+ if (entry.reserved) {
373
+ return entry; // utility topic churn is noise
374
+ }
375
+ const previous = previousByKey.get(pairKey(entry.topic, entry.version));
376
+ if (previous === undefined) {
377
+ return withChanges(entry, [new mesh_contracts_1.MeshTopicChange(mesh_contracts_1.MeshTopicChangeKind.added, 'Not declared anywhere in the previous run')]);
378
+ }
379
+ const changes = [];
380
+ const changedSides = [];
381
+ if (canonical(entry.requestSchema) !== canonical(previous.requestSchema))
382
+ changedSides.push('request');
383
+ if (canonical(entry.responseSchema) !== canonical(previous.responseSchema))
384
+ changedSides.push('response');
385
+ if (canonical(entry.messageSchema) !== canonical(previous.messageSchema))
386
+ changedSides.push('message');
387
+ if (changedSides.length > 0) {
388
+ changes.push(new mesh_contracts_1.MeshTopicChange(mesh_contracts_1.MeshTopicChangeKind.schemaChanged, 'Payload schema changed (' + changedSides.join(', ') + ')'));
389
+ }
390
+ addParticipantSetChange(changes, mesh_contracts_1.MeshTopicChangeKind.producersChanged, 'Producers', previous.producers.map((producer) => producer.service), entry.producers.map((producer) => producer.service));
391
+ addParticipantSetChange(changes, mesh_contracts_1.MeshTopicChangeKind.consumersChanged, 'Consumers', previous.consumers.map((consumer) => consumer.service), entry.consumers.map((consumer) => consumer.service));
392
+ return changes.length > 0 ? withChanges(entry, changes) : entry;
393
+ }
394
+ function addParticipantSetChange(changes, kind, label, before, after) {
395
+ const beforeSet = new Set(before);
396
+ const afterSet = new Set(after);
397
+ const added = [...afterSet].filter((name) => !beforeSet.has(name)).sort(ordinalCompare).map((name) => '+' + name);
398
+ const removed = [...beforeSet].filter((name) => !afterSet.has(name)).sort(ordinalCompare).map((name) => '-' + name);
399
+ const deltas = [...added, ...removed];
400
+ if (deltas.length > 0) {
401
+ changes.push(new mesh_contracts_1.MeshTopicChange(kind, label + ' changed: ' + deltas.join(', ')));
402
+ }
403
+ }
404
+ function withChanges(entry, changes) {
405
+ return new mesh_contracts_1.MeshTopicEntry(entry.topic, entry.version, entry.reserved, entry.consumers, entry.producers, entry.status, entry.requestSchema, entry.responseSchema, entry.messageSchema, entry.schemaMismatch, changes);
406
+ }
407
+ /**
408
+ * A reserved topic never gets a status. For a domain topic: producers declared but nobody consumes it is a
409
+ * deprecation candidate; consumers exist but nobody produces it AND none of those consumers are
410
+ * HTTP-reachable is a gap. Anything else is left unflagged.
411
+ */
412
+ function determineTopicStatus(aggregate) {
413
+ if (aggregate.reserved) {
414
+ return undefined;
415
+ }
416
+ const hasProducers = aggregate.producers.length > 0;
417
+ const hasConsumers = aggregate.consumers.length > 0;
418
+ if (hasProducers && !hasConsumers) {
419
+ return mesh_contracts_1.MeshTopicStatus.deprecationCandidate;
420
+ }
421
+ if (!hasProducers && hasConsumers && aggregate.consumers.every((c) => c.httpMappings.length === 0)) {
422
+ return mesh_contracts_1.MeshTopicStatus.gap;
423
+ }
424
+ return undefined;
425
+ }
426
+ /**
427
+ * Assembles one `MeshTopicEntry` from a topic's aggregate: the representative payload schemas plus the
428
+ * cross-consumer `schemaMismatch` flag (two consumers of the same (topic, version) declaring different
429
+ * inbound payloads is a likely contract error).
430
+ */
431
+ function buildTopicEntry(topic, version, aggregate) {
432
+ const requestSchema = aggregate.consumerSchemas.map((pair) => pair.request).find((schema) => schema !== undefined);
433
+ const responseSchema = aggregate.consumerSchemas.map((pair) => pair.response).find((schema) => schema !== undefined);
434
+ // Only consumers that actually declared a schema are compared; Request and Response guarded independently.
435
+ const distinctRequests = new Set(aggregate.consumerSchemas.filter((pair) => pair.request !== undefined).map((pair) => canonical(pair.request))).size;
436
+ const distinctResponses = new Set(aggregate.consumerSchemas.filter((pair) => pair.response !== undefined).map((pair) => canonical(pair.response))).size;
437
+ const mismatch = !aggregate.reserved && (distinctRequests > 1 || distinctResponses > 1);
438
+ return new mesh_contracts_1.MeshTopicEntry(topic, version, aggregate.reserved, aggregate.consumers, aggregate.producers, determineTopicStatus(aggregate), requestSchema, responseSchema, aggregate.messageSchema, mismatch);
439
+ }
440
+ // A usage entry's status counts as success when it's the synthesized "success" token or a framework
441
+ // success-class wire status; as failure when it's "failure"/"exception" or a framework failure-class wire
442
+ // status. Anything else is unclassifiable and never guessed as a failure.
443
+ function isSuccessStatus(status) {
444
+ return status === SuccessResult || results_1.BenzeneResultStatus.isSuccess(status);
445
+ }
446
+ function isFailureStatus(status) {
447
+ return status === FailureResult || status === ExceptionResult || results_1.BenzeneResultStatus.isFailure(status);
448
+ }
449
+ /**
450
+ * Computes an edge's usage-derived req/min and error rate by summing every topic it carries. All-or-nothing:
451
+ * if any carried topic can't be attributed unambiguously, both metrics are undefined.
452
+ */
453
+ function attributeEdge(carriedTopics, server, producersByTopic, consumersByTopic, entriesByTopic, windowMinutes) {
454
+ if (windowMinutes <= 0) {
455
+ return [undefined, undefined]; // no bounded window -> no rate can be computed
456
+ }
457
+ let total = 0;
458
+ let totalFailures = 0;
459
+ let allClassifiable = true;
460
+ for (const topic of carriedTopics) {
461
+ const attribution = attributeTopicToEdge(topic, server, producersByTopic, consumersByTopic, entriesByTopic);
462
+ if (attribution === undefined) {
463
+ return [undefined, undefined]; // one ambiguous topic blanks the whole edge
464
+ }
465
+ total += attribution.count;
466
+ totalFailures += attribution.failureCount;
467
+ allClassifiable = allClassifiable && attribution.classifiable;
468
+ }
469
+ const requestsPerMinute = total / windowMinutes;
470
+ const errorRate = allClassifiable && total > 0 ? totalFailures / total : undefined;
471
+ return [requestsPerMinute, errorRate];
472
+ }
473
+ /**
474
+ * Attributes one topic's observed traffic to the edge into `server`, or returns undefined when it can't be
475
+ * done unambiguously. Two independent ambiguity axes: a topic with more than one producer can't be pinned to
476
+ * a specific producer edge; and without the per-consumer `service` dimension a topic-total can only be
477
+ * pinned to an edge when the topic has exactly one consumer.
478
+ */
479
+ function attributeTopicToEdge(topic, server, producersByTopic, consumersByTopic, entriesByTopic) {
480
+ // (A) Producer ambiguity: a message the server handled could have come from any producer.
481
+ const producers = producersByTopic.get(topic);
482
+ if (producers === undefined || producers.length !== 1) {
483
+ return undefined;
484
+ }
485
+ const topicEntries = entriesByTopic.get(topic) ?? [];
486
+ // Cross-source double-count guard: one topic reported by two feeds can't be summed safely.
487
+ if (new Set(topicEntries.map((entry) => entry.source)).size > 1) {
488
+ return undefined;
489
+ }
490
+ let relevant;
491
+ if (topicEntries.some((entry) => entry.service !== undefined)) {
492
+ // Mixed granularity within one topic (some entries consumer-scoped, some not) -> don't trust.
493
+ if (topicEntries.some((entry) => entry.service === undefined)) {
494
+ return undefined;
495
+ }
496
+ relevant = topicEntries.filter((entry) => entry.service === server);
497
+ }
498
+ else {
499
+ // (C) Topic totals only: attributable to a specific edge only when the topic has one consumer.
500
+ const consumers = consumersByTopic.get(topic);
501
+ if (consumers === undefined || consumers.length !== 1) {
502
+ return undefined;
503
+ }
504
+ relevant = topicEntries;
505
+ }
506
+ const count = relevant.reduce((sum, entry) => sum + entry.count, 0);
507
+ // Classifiable only if every entry's outcome is known (success or a real failure).
508
+ const classifiable = relevant.every((entry) => isSuccessStatus(entry.status) || isFailureStatus(entry.status));
509
+ const failureCount = classifiable
510
+ ? relevant.filter((entry) => isFailureStatus(entry.status)).reduce((sum, entry) => sum + entry.count, 0)
511
+ : 0;
512
+ return { count, classifiable, failureCount };
513
+ }
514
+ /** A service's health being unreachable/undeserializable is treated as `unreachable` regardless of spec. */
515
+ function determineStatus(snapshot) {
516
+ if (snapshot.health === undefined) {
517
+ return mesh_contracts_1.MeshServiceStatus.unreachable;
518
+ }
519
+ return snapshot.health.isHealthy ? mesh_contracts_1.MeshServiceStatus.healthy : mesh_contracts_1.MeshServiceStatus.unhealthy;
520
+ }
521
+ /**
522
+ * Extracts the topics from a service's `benzene` spec (its `requests` array). Best-effort: a
523
+ * missing/unparseable spec contributes no topics, never failing the run.
524
+ */
525
+ function parseTopics(specJson) {
526
+ const doc = tryParseSpec(specJson);
527
+ if (doc === undefined || !isArray(doc.requests)) {
528
+ return [];
529
+ }
530
+ const components = readComponents(doc);
531
+ const topics = [];
532
+ for (const request of doc.requests) {
533
+ if (!isObject(request) || typeof request.topic !== 'string') {
534
+ continue;
535
+ }
536
+ const version = typeof request.version === 'string' ? request.version : '';
537
+ // A Contract Document never lists reserved topics; an older document may, so the prefix decides.
538
+ const reserved = request.reserved === true || abstractions_1.BenzeneTopic.isReserved(request.topic);
539
+ const mappings = [];
540
+ if (isArray(request.httpMappings)) {
541
+ for (const mapping of request.httpMappings) {
542
+ if (isObject(mapping)) {
543
+ const method = typeof mapping.method === 'string' ? mapping.method : '';
544
+ const path = typeof mapping.path === 'string' ? mapping.path : '';
545
+ mappings.push(new mesh_contracts_1.MeshTopicHttpMapping(method, path));
546
+ }
547
+ }
548
+ }
549
+ topics.push({
550
+ topic: request.topic,
551
+ version,
552
+ reserved,
553
+ httpMappings: mappings,
554
+ requestSchema: extractSchema(request, 'request', components),
555
+ responseSchema: extractSchema(request, 'response', components),
556
+ });
557
+ }
558
+ return topics;
559
+ }
560
+ /**
561
+ * Extracts the topics a service declares it puts on the wire from its Contract Document: its `events` (topics
562
+ * it publishes) and its `sends` (messages to topics others define) - both make it a provider of that topic
563
+ * (contract-document.md §3). Best-effort, same posture as {@link parseTopics}.
564
+ */
565
+ function parseOutboundTopics(specJson) {
566
+ const doc = tryParseSpec(specJson);
567
+ if (doc === undefined) {
568
+ return [];
569
+ }
570
+ const components = readComponents(doc);
571
+ const topics = [];
572
+ const read = (entries, schemaMember) => {
573
+ if (!isArray(entries)) {
574
+ return;
575
+ }
576
+ for (const entry of entries) {
577
+ if (isObject(entry) && typeof entry.topic === 'string' && !abstractions_1.BenzeneTopic.isReserved(entry.topic)) {
578
+ const version = typeof entry.version === 'string' ? entry.version : '';
579
+ topics.push({ topic: entry.topic, version, messageSchema: extractSchema(entry, schemaMember, components) });
580
+ }
581
+ }
582
+ };
583
+ read(doc.events, 'message');
584
+ read(doc.sends, 'request');
585
+ return topics;
586
+ }
587
+ /**
588
+ * Extracts the document-level `transports` field from a service's `benzene` spec. Best-effort, same posture
589
+ * as {@link parseTopics}.
590
+ */
591
+ function parseTransports(specJson) {
592
+ const doc = tryParseSpec(specJson);
593
+ if (doc === undefined || !isArray(doc.transports)) {
594
+ return [];
595
+ }
596
+ return doc.transports.filter((t) => typeof t === 'string');
597
+ }
598
+ function tryParseSpec(specJson) {
599
+ if (specJson === undefined || specJson.trim().length === 0) {
600
+ return undefined;
601
+ }
602
+ try {
603
+ const parsed = JSON.parse(specJson);
604
+ return isObject(parsed) ? parsed : undefined;
605
+ }
606
+ catch {
607
+ return undefined;
608
+ }
609
+ }
610
+ /** Reads a service spec's `components.schemas` map (name -> schema) for `$ref` resolution. */
611
+ function readComponents(root) {
612
+ const map = new Map();
613
+ if (isObject(root.components) && isObject(root.components.schemas)) {
614
+ for (const [name, value] of Object.entries(root.components.schemas)) {
615
+ map.set(name, value);
616
+ }
617
+ }
618
+ return map;
619
+ }
620
+ /**
621
+ * Pulls the named child schema off a spec `requests`/`events` entry and returns it fully self-contained
622
+ * (all `$ref`s inlined), or `undefined` when the entry carries no such schema.
623
+ */
624
+ function extractSchema(entry, propertyName, components) {
625
+ const node = entry[propertyName];
626
+ if (!isObject(node)) {
627
+ return undefined;
628
+ }
629
+ const result = inlineSchema(node, components, new Set(), 0);
630
+ return isObject(result) ? result : undefined;
631
+ }
632
+ /**
633
+ * Recursively inlines a schema element into a detached value: replaces each `$ref` with the referenced
634
+ * component (tagging it with a `title` of the ref name), recurses through
635
+ * `properties`/`items`/`additionalProperties`/`oneOf`/`allOf`/`anyOf`, and copies every other key verbatim.
636
+ * Recursive types are cut with a `title`-only marker.
637
+ */
638
+ function inlineSchema(node, components, visiting, depth) {
639
+ if (depth > MaxSchemaDepth) {
640
+ return { type: 'object' };
641
+ }
642
+ if (!isObject(node)) {
643
+ return structuredClone(node);
644
+ }
645
+ if (typeof node.$ref === 'string') {
646
+ const name = refName(node.$ref);
647
+ if (name === undefined || !components.has(name)) {
648
+ return {};
649
+ }
650
+ if (visiting.has(name)) {
651
+ return { type: 'object', title: name + ' (recursive)' };
652
+ }
653
+ visiting.add(name);
654
+ const resolved = inlineSchema(components.get(name), components, visiting, depth + 1);
655
+ visiting.delete(name);
656
+ if (isObject(resolved) && resolved.title === undefined) {
657
+ resolved.title = name;
658
+ }
659
+ return resolved;
660
+ }
661
+ const result = {};
662
+ for (const [key, value] of Object.entries(node)) {
663
+ if (key === 'properties' && isObject(value)) {
664
+ const properties = {};
665
+ for (const [memberKey, memberValue] of Object.entries(value)) {
666
+ properties[memberKey] = inlineSchema(memberValue, components, visiting, depth + 1);
667
+ }
668
+ result.properties = properties;
669
+ }
670
+ else if (key === 'items') {
671
+ result.items = inlineSchema(value, components, visiting, depth + 1);
672
+ }
673
+ else if (key === 'additionalProperties' && isObject(value)) {
674
+ result.additionalProperties = inlineSchema(value, components, visiting, depth + 1);
675
+ }
676
+ else if ((key === 'oneOf' || key === 'allOf' || key === 'anyOf') && isArray(value)) {
677
+ // Composition keywords hold arrays of schemas - inline each branch so the schema stays self-contained.
678
+ result[key] = value.map((branch) => inlineSchema(branch, components, visiting, depth + 1));
679
+ }
680
+ else {
681
+ result[key] = structuredClone(value);
682
+ }
683
+ }
684
+ return result;
685
+ }
686
+ function refName(reference) {
687
+ if (reference === undefined || reference.length === 0) {
688
+ return undefined;
689
+ }
690
+ return reference.substring(reference.lastIndexOf('/') + 1);
691
+ }
692
+ /**
693
+ * A stable, key-order-independent serialization of a schema node, used only to compare two consumers'
694
+ * payloads for equality (object keys sorted; arrays kept in order). `undefined`/`null` render as the literal
695
+ * `"null"`, distinct from an empty object.
696
+ */
697
+ function canonical(node) {
698
+ if (node === undefined || node === null) {
699
+ return 'null';
700
+ }
701
+ if (isArray(node)) {
702
+ return '[' + node.map(canonical).join(',') + ']';
703
+ }
704
+ if (isObject(node)) {
705
+ const keys = Object.keys(node).sort(ordinalCompare);
706
+ let builder = '{';
707
+ let first = true;
708
+ for (const key of keys) {
709
+ if (!first) {
710
+ builder += ',';
711
+ }
712
+ first = false;
713
+ builder += JSON.stringify(key) + ':' + canonical(node[key]);
714
+ }
715
+ return builder + '}';
716
+ }
717
+ return JSON.stringify(node);
718
+ }
719
+ /** Mirrors C#'s `ex.GetType().Name` - the exception's type name, never its message. */
720
+ function errorName(ex) {
721
+ return ex instanceof Error ? ex.name : typeof ex;
722
+ }
723
+ function isObject(value) {
724
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
725
+ }
726
+ function isArray(value) {
727
+ return Array.isArray(value);
728
+ }
729
+ function ordinalCompare(a, b) {
730
+ return a < b ? -1 : a > b ? 1 : 0;
731
+ }
732
+ /** Collision-free key for a (topic, version) or (client, server) pair - a value can contain the separator. */
733
+ function pairKey(a, b) {
734
+ return JSON.stringify([a, b]);
735
+ }
736
+ function pushInto(map, key, value) {
737
+ const existing = map.get(key);
738
+ if (existing === undefined) {
739
+ map.set(key, [value]);
740
+ }
741
+ else {
742
+ existing.push(value);
743
+ }
744
+ }
745
+ function distinctOrdinal(values) {
746
+ return [...new Set(values)].sort(ordinalCompare);
747
+ }
748
+ /** LINQ `.Min()` over a nullable value sequence: ignores `undefined`, returns `undefined` if all are. */
749
+ function minDefined(values) {
750
+ let min;
751
+ for (const value of values) {
752
+ if (value !== undefined && (min === undefined || value < min)) {
753
+ min = value;
754
+ }
755
+ }
756
+ return min;
757
+ }
758
+ function maxDefined(values) {
759
+ let max;
760
+ for (const value of values) {
761
+ if (value !== undefined && (max === undefined || value > max)) {
762
+ max = value;
763
+ }
764
+ }
765
+ return max;
766
+ }