@ontrails/topography 1.0.0-beta.41

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,344 @@
1
+ import { AmbiguousError, Result } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import type { DiffResult, TopoGraph, TopoGraphEntry } from '../types.js';
5
+ import { listWayfinderEntityRefs } from './filters.js';
6
+ import type { WayfinderEntityKind, WayfinderEntityRef } from './filters.js';
7
+
8
+ export const relationKindSchema = z.enum([
9
+ 'composed-by',
10
+ 'consumed-by',
11
+ 'entity-referenced-by',
12
+ 'trailhead-groups',
13
+ 'fired-by',
14
+ 'has-version',
15
+ 'surface-projects',
16
+ 'used-by',
17
+ ]);
18
+
19
+ export const relationRefSchema = z.object({
20
+ id: z.string(),
21
+ kind: z.enum([
22
+ 'entity',
23
+ 'trailhead',
24
+ 'resource',
25
+ 'signal',
26
+ 'surface',
27
+ 'trail',
28
+ 'version',
29
+ ]),
30
+ trailId: z.string().optional(),
31
+ versionKey: z.string().optional(),
32
+ });
33
+
34
+ export const relationEdgeSchema = z.object({
35
+ from: relationRefSchema,
36
+ relation: relationKindSchema,
37
+ to: relationRefSchema,
38
+ });
39
+
40
+ export const relationGroupSchema = z.object({
41
+ direction: z.enum(['incoming', 'outgoing']),
42
+ refs: z.array(relationRefSchema).readonly(),
43
+ relation: relationKindSchema,
44
+ });
45
+
46
+ export const impactNodeSchema = relationRefSchema.extend({
47
+ depth: z.number(),
48
+ from: relationRefSchema.optional(),
49
+ via: relationKindSchema.optional(),
50
+ });
51
+
52
+ export type RelationKind = z.output<typeof relationKindSchema>;
53
+ export type RelationRef = z.output<typeof relationRefSchema>;
54
+ export type RelationEdge = z.output<typeof relationEdgeSchema>;
55
+ export type ImpactDirection = 'downstream' | 'upstream' | 'both';
56
+
57
+ export interface ImpactOptions {
58
+ readonly direction: ImpactDirection;
59
+ readonly limit: number;
60
+ readonly maxDepth: number;
61
+ }
62
+
63
+ const entryRef = (entry: TopoGraphEntry): WayfinderEntityRef => ({
64
+ entry,
65
+ id: entry.id,
66
+ kind: entry.kind,
67
+ });
68
+
69
+ const refFor = (
70
+ graph: TopoGraph,
71
+ id: string,
72
+ kind: WayfinderEntityKind
73
+ ): WayfinderEntityRef =>
74
+ listWayfinderEntityRefs(graph).find(
75
+ (ref) => ref.id === id && ref.kind === kind
76
+ ) ?? { id, kind };
77
+
78
+ export const refSummary = (ref: WayfinderEntityRef): RelationRef => ({
79
+ id: ref.id,
80
+ kind: ref.kind,
81
+ ...(ref.trailId === undefined ? {} : { trailId: ref.trailId }),
82
+ ...(ref.versionKey === undefined ? {} : { versionKey: ref.versionKey }),
83
+ });
84
+
85
+ const signalUses = (
86
+ entry: TopoGraphEntry
87
+ ): readonly {
88
+ readonly relation: Extract<RelationKind, 'consumed-by' | 'fired-by'>;
89
+ readonly signalId: string;
90
+ }[] => [
91
+ ...(entry.fires ?? []).map((signalId) => ({
92
+ relation: 'fired-by' as const,
93
+ signalId,
94
+ })),
95
+ ...[
96
+ ...(entry.on ?? []),
97
+ ...(entry.from ?? []),
98
+ ...(entry.consumers ?? []),
99
+ ].map((signalId) => ({
100
+ relation: 'consumed-by' as const,
101
+ signalId,
102
+ })),
103
+ ...(entry.producers ?? []).map((signalId) => ({
104
+ relation: 'fired-by' as const,
105
+ signalId,
106
+ })),
107
+ ];
108
+
109
+ const relationEdge = (
110
+ from: WayfinderEntityRef,
111
+ relation: RelationKind,
112
+ to: WayfinderEntityRef
113
+ ): RelationEdge => ({
114
+ from: refSummary(from),
115
+ relation,
116
+ to: refSummary(to),
117
+ });
118
+
119
+ const relationKey = (edge: RelationEdge): string =>
120
+ `${edge.from.kind}:${edge.from.id}:${edge.relation}:${edge.to.kind}:${edge.to.id}`;
121
+
122
+ export const relationEdges = (graph: TopoGraph): readonly RelationEdge[] => {
123
+ const edges: RelationEdge[] = [];
124
+ const add = (
125
+ from: WayfinderEntityRef,
126
+ relation: RelationKind,
127
+ to: WayfinderEntityRef
128
+ ) => edges.push(relationEdge(from, relation, to));
129
+
130
+ for (const entry of graph.entries) {
131
+ const target = entryRef(entry);
132
+ for (const composedId of entry.composes ?? []) {
133
+ add(refFor(graph, composedId, 'trail'), 'composed-by', target);
134
+ }
135
+ for (const entityId of entry.entities ?? []) {
136
+ add(refFor(graph, entityId, 'entity'), 'entity-referenced-by', target);
137
+ }
138
+ for (const resourceId of entry.resources ?? []) {
139
+ add(refFor(graph, resourceId, 'resource'), 'used-by', target);
140
+ }
141
+ for (const { relation, signalId } of signalUses(entry)) {
142
+ add(refFor(graph, signalId, 'signal'), relation, target);
143
+ }
144
+ for (const surfaceId of entry.surfaces) {
145
+ add(refFor(graph, surfaceId, 'surface'), 'surface-projects', target);
146
+ }
147
+ if (entry.kind === 'trail' && entry.version !== undefined) {
148
+ add(target, 'has-version', {
149
+ entry,
150
+ id: `${entry.id}@${entry.version}`,
151
+ kind: 'version',
152
+ trailId: entry.id,
153
+ versionKey: String(entry.version),
154
+ });
155
+ }
156
+ for (const [versionKey, version] of Object.entries(entry.versions ?? {})) {
157
+ add(target, 'has-version', {
158
+ entry,
159
+ id: `${entry.id}@${versionKey}`,
160
+ kind: 'version',
161
+ trailId: entry.id,
162
+ version,
163
+ versionKey,
164
+ });
165
+ }
166
+ }
167
+
168
+ for (const trailhead of graph.trailheads ?? []) {
169
+ const trailheadRef = refFor(graph, trailhead.id, 'trailhead');
170
+ const surfaceRefs = trailhead.surfaces.map((surfaceId) =>
171
+ refFor(graph, surfaceId, 'surface')
172
+ );
173
+ for (const memberId of trailhead.memberIds) {
174
+ const memberRef = refFor(graph, memberId, 'trail');
175
+ add(trailheadRef, 'trailhead-groups', memberRef);
176
+ for (const surfaceRef of surfaceRefs) {
177
+ add(surfaceRef, 'surface-projects', memberRef);
178
+ }
179
+ }
180
+ }
181
+
182
+ return [
183
+ ...new Map(edges.map((edge) => [relationKey(edge), edge])).values(),
184
+ ].toSorted((left, right) =>
185
+ relationKey(left).localeCompare(relationKey(right))
186
+ );
187
+ };
188
+
189
+ const refMatches = (ref: RelationRef, target: RelationRef): boolean =>
190
+ ref.id === target.id && ref.kind === target.kind;
191
+
192
+ export const groupNearbyEdges = (
193
+ edges: readonly RelationEdge[],
194
+ target: RelationRef
195
+ ) => {
196
+ const groups = new Map<string, z.output<typeof relationGroupSchema>>();
197
+ for (const edge of edges) {
198
+ const direction = refMatches(edge.from, target) ? 'outgoing' : 'incoming';
199
+ const ref = direction === 'outgoing' ? edge.to : edge.from;
200
+ const key = `${direction}:${edge.relation}`;
201
+ const group = groups.get(key) ?? {
202
+ direction,
203
+ refs: [],
204
+ relation: edge.relation,
205
+ };
206
+ groups.set(key, {
207
+ ...group,
208
+ refs: [...group.refs, ref].toSorted((left, right) =>
209
+ `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)
210
+ ),
211
+ });
212
+ }
213
+ return [...groups.values()].toSorted((left, right) =>
214
+ `${left.direction}:${left.relation}`.localeCompare(
215
+ `${right.direction}:${right.relation}`
216
+ )
217
+ );
218
+ };
219
+
220
+ export const edgeTouches = (edge: RelationEdge, target: RelationRef): boolean =>
221
+ refMatches(edge.from, target) || refMatches(edge.to, target);
222
+
223
+ const ambiguousRefId = (
224
+ id: string,
225
+ refs: readonly WayfinderEntityRef[]
226
+ ): AmbiguousError =>
227
+ new AmbiguousError(
228
+ `Wayfinder id "${id}" matched multiple entity kinds: ${refs
229
+ .map((ref) => ref.kind)
230
+ .join(', ')}. Pass kind to disambiguate.`
231
+ );
232
+
233
+ export const resolveEntityRef = (
234
+ graph: TopoGraph,
235
+ input: {
236
+ readonly id: string;
237
+ readonly kind?: WayfinderEntityKind | undefined;
238
+ }
239
+ ): Result<WayfinderEntityRef | undefined, AmbiguousError> => {
240
+ const refs = listWayfinderEntityRefs(graph).filter(
241
+ (ref) =>
242
+ ref.id === input.id &&
243
+ (input.kind === undefined || ref.kind === input.kind)
244
+ );
245
+ if (input.kind === undefined) {
246
+ const uniqueKinds = new Set(refs.map((ref) => ref.kind));
247
+ if (uniqueKinds.size > 1) {
248
+ return Result.err(ambiguousRefId(input.id, refs));
249
+ }
250
+ }
251
+ return Result.ok(refs[0]);
252
+ };
253
+
254
+ const edgesForDirection = (
255
+ edges: readonly RelationEdge[],
256
+ current: RelationRef,
257
+ direction: ImpactDirection
258
+ ): readonly {
259
+ readonly edge: RelationEdge;
260
+ readonly node: RelationRef;
261
+ }[] => {
262
+ const outgoing =
263
+ direction === 'downstream' || direction === 'both'
264
+ ? edges
265
+ .filter((edge) => refMatches(edge.from, current))
266
+ .map((edge) => ({ edge, node: edge.to }))
267
+ : [];
268
+ const incoming =
269
+ direction === 'upstream' || direction === 'both'
270
+ ? edges
271
+ .filter((edge) => refMatches(edge.to, current))
272
+ .map((edge) => ({ edge, node: edge.from }))
273
+ : [];
274
+ return [...outgoing, ...incoming].toSorted((left, right) =>
275
+ relationKey(left.edge).localeCompare(relationKey(right.edge))
276
+ );
277
+ };
278
+
279
+ export const impactFor = (
280
+ graph: TopoGraph,
281
+ input: ImpactOptions,
282
+ target: RelationRef
283
+ ) => {
284
+ const edges = relationEdges(graph);
285
+ const seen = new Set([`${target.kind}:${target.id}`]);
286
+ const queue: {
287
+ readonly depth: number;
288
+ readonly ref: RelationRef;
289
+ }[] = [{ depth: 0, ref: target }];
290
+ const nodes: z.output<typeof impactNodeSchema>[] = [];
291
+ const includedEdges = new Map<string, RelationEdge>();
292
+
293
+ for (
294
+ let index = 0;
295
+ index < queue.length && nodes.length < input.limit;
296
+ index += 1
297
+ ) {
298
+ const current = queue[index];
299
+ if (current === undefined) {
300
+ break;
301
+ }
302
+ if (current.depth >= input.maxDepth) {
303
+ continue;
304
+ }
305
+ for (const { edge, node } of edgesForDirection(
306
+ edges,
307
+ current.ref,
308
+ input.direction
309
+ )) {
310
+ includedEdges.set(relationKey(edge), edge);
311
+ const key = `${node.kind}:${node.id}`;
312
+ if (seen.has(key)) {
313
+ continue;
314
+ }
315
+ seen.add(key);
316
+ const reached = {
317
+ ...node,
318
+ depth: current.depth + 1,
319
+ from: current.ref,
320
+ via: edge.relation,
321
+ };
322
+ nodes.push(reached);
323
+ queue.push({ depth: reached.depth, ref: node });
324
+ if (nodes.length >= input.limit) {
325
+ break;
326
+ }
327
+ }
328
+ }
329
+
330
+ return {
331
+ edges: [...includedEdges.values()].toSorted((left, right) =>
332
+ relationKey(left).localeCompare(relationKey(right))
333
+ ),
334
+ nodes,
335
+ };
336
+ };
337
+
338
+ export const diffResult = (diff: DiffResult): DiffResult => ({
339
+ breaking: diff.breaking,
340
+ entries: diff.entries,
341
+ hasBreaking: diff.hasBreaking,
342
+ info: diff.info,
343
+ warnings: diff.warnings,
344
+ });