@ontrails/topography 0.2.0

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,443 @@
1
+ import { existsSync } from 'node:fs';
2
+ import {
3
+ NotFoundError,
4
+ ValidationError,
5
+ deriveTrailsDbPath,
6
+ openReadTrailsDb,
7
+ } from '@ontrails/core';
8
+ import type { TrailsDbLocationOptions } from '@ontrails/core';
9
+ import { createTopoStore, TOPO_STORE_SCHEMA_VERSION } from '../topo-store.js';
10
+ import { deriveSourceFingerprint } from '../source-fingerprint.js';
11
+ import { deriveTopoGraphHash } from '../hash.js';
12
+ import {
13
+ isTopoArtifactRegenerationError,
14
+ readLockManifest,
15
+ readTopoGraph,
16
+ } from '../io.js';
17
+ import { stripTopoGraphForces } from '../forces.js';
18
+ import type {
19
+ TopoStoreEntityRecord,
20
+ TopoStoreExportRecord,
21
+ TopoStoreResourceRecord,
22
+ TopoStoreSignalDetailRecord,
23
+ TopoStoreTopoGraphEntryRecord,
24
+ TopoStoreTopoGraphRecord,
25
+ TopoStoreTrailDetailRecord,
26
+ TopoSnapshot,
27
+ } from '../topo-store.js';
28
+ import { TOPO_GRAPH_SCHEMA_VERSION } from '../types.js';
29
+ import type {
30
+ LockManifest,
31
+ LockManifestSummary,
32
+ ReadOptions,
33
+ TopoGraph,
34
+ } from '../types.js';
35
+
36
+ import type {
37
+ WayfinderArtifactStatus,
38
+ WayfinderArtifactKind,
39
+ WayfinderStaleReason,
40
+ } from './provenance.js';
41
+
42
+ export type WayfinderArtifactLoaderOptions = ReadOptions &
43
+ TrailsDbLocationOptions;
44
+
45
+ export interface WayfinderTopoStoreLoad {
46
+ readonly entities: readonly TopoStoreEntityRecord[];
47
+ readonly entries: readonly TopoStoreTopoGraphEntryRecord[];
48
+ readonly export: TopoStoreExportRecord | null;
49
+ readonly path: string;
50
+ readonly resources: readonly TopoStoreResourceRecord[];
51
+ readonly schemaVersion: number;
52
+ readonly signals: readonly TopoStoreSignalDetailRecord[];
53
+ readonly snapshot: TopoSnapshot;
54
+ readonly topoGraph: TopoStoreTopoGraphRecord | null;
55
+ readonly trails: readonly TopoStoreTrailDetailRecord[];
56
+ }
57
+
58
+ export interface WayfinderArtifactLoad {
59
+ readonly artifactStatus: WayfinderArtifactStatus;
60
+ /** @deprecated Use artifactStatus. */
61
+ readonly freshness: WayfinderArtifactStatus;
62
+ readonly lockManifest: LockManifest | null;
63
+ readonly topoGraph: TopoGraph | null;
64
+ readonly topoStore: WayfinderTopoStoreLoad | null;
65
+ }
66
+
67
+ const artifactLoad = (
68
+ load: Omit<WayfinderArtifactLoad, 'freshness'>
69
+ ): WayfinderArtifactLoad => ({
70
+ ...load,
71
+ freshness: load.artifactStatus,
72
+ });
73
+
74
+ type ArtifactRead<TValue> =
75
+ | {
76
+ readonly kind: 'ok';
77
+ readonly value: TValue | null;
78
+ }
79
+ | {
80
+ readonly artifact: WayfinderArtifactKind;
81
+ readonly kind: 'schema-version-drift';
82
+ readonly message: string;
83
+ };
84
+
85
+ const resolveArtifactReadOptions = (
86
+ options?: WayfinderArtifactLoaderOptions
87
+ ): ReadOptions | undefined => {
88
+ if (options?.dir !== undefined) {
89
+ return { dir: options.dir };
90
+ }
91
+ if (options?.rootDir !== undefined) {
92
+ return { dir: options.rootDir };
93
+ }
94
+ return undefined;
95
+ };
96
+
97
+ const resolveTopoStoreLocation = (
98
+ options?: WayfinderArtifactLoaderOptions
99
+ ): TrailsDbLocationOptions | undefined => {
100
+ if (options?.path !== undefined) {
101
+ return options.rootDir === undefined
102
+ ? { path: options.path }
103
+ : { path: options.path, rootDir: options.rootDir };
104
+ }
105
+ if (options?.rootDir !== undefined) {
106
+ return { rootDir: options.rootDir };
107
+ }
108
+ if (options?.dir !== undefined) {
109
+ return undefined;
110
+ }
111
+ return {};
112
+ };
113
+
114
+ const readTopoArtifact = async (
115
+ options?: WayfinderArtifactLoaderOptions
116
+ ): Promise<ArtifactRead<TopoGraph>> => {
117
+ try {
118
+ return {
119
+ kind: 'ok',
120
+ value: await readTopoGraph(resolveArtifactReadOptions(options)),
121
+ };
122
+ } catch (error) {
123
+ if (isTopoArtifactRegenerationError(error)) {
124
+ return {
125
+ artifact: 'topoGraph',
126
+ kind: 'schema-version-drift',
127
+ message: error.message,
128
+ };
129
+ }
130
+ throw error;
131
+ }
132
+ };
133
+
134
+ const readLockArtifact = async (
135
+ options?: WayfinderArtifactLoaderOptions
136
+ ): Promise<ArtifactRead<LockManifest>> => {
137
+ try {
138
+ return {
139
+ kind: 'ok',
140
+ value: await readLockManifest(resolveArtifactReadOptions(options)),
141
+ };
142
+ } catch (error) {
143
+ if (isTopoArtifactRegenerationError(error)) {
144
+ return {
145
+ artifact: 'lockManifest',
146
+ kind: 'schema-version-drift',
147
+ message: error.message,
148
+ };
149
+ }
150
+ throw error;
151
+ }
152
+ };
153
+
154
+ const readTopoStoreArtifact = (
155
+ options?: WayfinderArtifactLoaderOptions
156
+ ): ArtifactRead<WayfinderTopoStoreLoad> => {
157
+ const location = resolveTopoStoreLocation(options);
158
+ if (location === undefined) {
159
+ return { kind: 'ok', value: null };
160
+ }
161
+ const path = deriveTrailsDbPath(location);
162
+ if (!existsSync(path)) {
163
+ return { kind: 'ok', value: null };
164
+ }
165
+
166
+ let actualVersion: number | undefined;
167
+ let db: ReturnType<typeof openReadTrailsDb> | undefined;
168
+ try {
169
+ db = openReadTrailsDb(location);
170
+ const row = db
171
+ .query<{ version: number }, [string]>(
172
+ 'SELECT version FROM meta_schema_versions WHERE subsystem = ?'
173
+ )
174
+ .get('topo');
175
+ actualVersion = row?.version;
176
+ } catch {
177
+ return {
178
+ artifact: 'topoStore',
179
+ kind: 'schema-version-drift',
180
+ message: `Unsupported trails.db topo store schema; regenerate with \`trails compile\`. Expected ${TOPO_STORE_SCHEMA_VERSION}.`,
181
+ };
182
+ } finally {
183
+ db?.close();
184
+ }
185
+
186
+ if (actualVersion === undefined) {
187
+ return { kind: 'ok', value: null };
188
+ }
189
+
190
+ if (actualVersion !== TOPO_STORE_SCHEMA_VERSION) {
191
+ return {
192
+ artifact: 'topoStore',
193
+ kind: 'schema-version-drift',
194
+ message: `Unsupported trails.db topo store schema; regenerate with \`trails compile\`. Expected ${TOPO_STORE_SCHEMA_VERSION}, found ${actualVersion}.`,
195
+ };
196
+ }
197
+
198
+ const store = createTopoStore(location);
199
+ let snapshot: TopoSnapshot | undefined;
200
+ try {
201
+ snapshot = store.snapshots.latest();
202
+ } catch (error) {
203
+ if (error instanceof NotFoundError) {
204
+ return { kind: 'ok', value: null };
205
+ }
206
+ throw error;
207
+ }
208
+
209
+ if (snapshot === undefined) {
210
+ return { kind: 'ok', value: null };
211
+ }
212
+
213
+ const ref = { snapshotId: snapshot.id };
214
+ try {
215
+ return {
216
+ kind: 'ok',
217
+ value: {
218
+ entities: store.entities.list({ snapshot: ref }),
219
+ entries: store.entries.list({ snapshot: ref }),
220
+ export: store.exports.get(ref) ?? null,
221
+ path,
222
+ resources: store.resources.list({ snapshot: ref }),
223
+ schemaVersion: TOPO_STORE_SCHEMA_VERSION,
224
+ signals: store.signals
225
+ .list({ snapshot: ref })
226
+ .map((signal) => store.signals.get(signal.id, { snapshot: ref }))
227
+ .filter(
228
+ (signal): signal is TopoStoreSignalDetailRecord =>
229
+ signal !== undefined
230
+ ),
231
+ snapshot,
232
+ topoGraph: store.topoGraph.get(ref) ?? null,
233
+ trails: store.trails
234
+ .list({ snapshot: ref })
235
+ .map((trail) => store.trails.get(trail.id, { snapshot: ref }))
236
+ .filter(
237
+ (trail): trail is TopoStoreTrailDetailRecord => trail !== undefined
238
+ ),
239
+ },
240
+ };
241
+ } catch (error) {
242
+ if (error instanceof ValidationError) {
243
+ return {
244
+ artifact: 'topoStore',
245
+ kind: 'schema-version-drift',
246
+ message: `${error.message} Regenerate the Topography store with \`trails compile\`.`,
247
+ };
248
+ }
249
+ throw error;
250
+ }
251
+ };
252
+
253
+ const countEntries = (
254
+ topoGraph: TopoGraph,
255
+ kind: TopoGraph['entries'][number]['kind']
256
+ ): number => topoGraph.entries.filter((entry) => entry.kind === kind).length;
257
+
258
+ const summarizeTopoGraph = (topoGraph: TopoGraph): LockManifestSummary => ({
259
+ entities: countEntries(topoGraph, 'entity'),
260
+ resources: countEntries(topoGraph, 'resource'),
261
+ signals: countEntries(topoGraph, 'signal'),
262
+ trails: countEntries(topoGraph, 'trail'),
263
+ });
264
+
265
+ const summariesEqual = (
266
+ left: LockManifestSummary,
267
+ right: LockManifestSummary
268
+ ): boolean =>
269
+ left.entities === right.entities &&
270
+ left.resources === right.resources &&
271
+ left.signals === right.signals &&
272
+ left.trails === right.trails;
273
+
274
+ const sourceFingerprintReason = (
275
+ topoStore: WayfinderTopoStoreLoad,
276
+ options?: WayfinderArtifactLoaderOptions
277
+ ): WayfinderStaleReason | undefined => {
278
+ const recorded = topoStore.snapshot.sourceFingerprint;
279
+ const rootDir = options?.rootDir;
280
+ if (recorded === undefined || rootDir === undefined) {
281
+ return undefined;
282
+ }
283
+ const current = deriveSourceFingerprint(rootDir);
284
+ if (current === recorded) {
285
+ return undefined;
286
+ }
287
+ return {
288
+ actual: current,
289
+ expected: recorded,
290
+ reason: 'topo-store-source-fingerprint-mismatch',
291
+ snapshotId: topoStore.snapshot.id,
292
+ };
293
+ };
294
+
295
+ const staleReasons = (
296
+ topoGraph: TopoGraph,
297
+ lockManifest: LockManifest,
298
+ topoStore: WayfinderTopoStoreLoad
299
+ ): readonly WayfinderStaleReason[] => {
300
+ const reasons: WayfinderStaleReason[] = [];
301
+ const actualHash = deriveTopoGraphHash(topoGraph);
302
+ const contractHash = deriveTopoGraphHash(stripTopoGraphForces(topoGraph));
303
+ const topoArtifact = lockManifest.artifacts.find(
304
+ (artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
305
+ );
306
+
307
+ if (topoArtifact === undefined) {
308
+ reasons.push({ reason: 'lock-manifest-topo-artifact-missing' });
309
+ } else if (topoArtifact.sha256 !== actualHash) {
310
+ reasons.push({
311
+ actual: actualHash,
312
+ expected: topoArtifact.sha256,
313
+ reason: 'lock-manifest-hash-mismatch',
314
+ });
315
+ }
316
+
317
+ const storeExport = topoStore.export;
318
+ if (storeExport === null) {
319
+ reasons.push({ reason: 'topo-store-export-missing' });
320
+ } else if (storeExport.topoGraphHash !== contractHash) {
321
+ reasons.push({
322
+ actual: storeExport.topoGraphHash,
323
+ expected: contractHash,
324
+ reason: 'topo-store-hash-mismatch',
325
+ snapshotId: storeExport.snapshot.id,
326
+ });
327
+ }
328
+
329
+ const actualSummary = summarizeTopoGraph(topoGraph);
330
+ if (!summariesEqual(lockManifest.summary, actualSummary)) {
331
+ reasons.push({
332
+ actual: actualSummary,
333
+ expected: lockManifest.summary,
334
+ reason: 'lock-manifest-summary-mismatch',
335
+ });
336
+ }
337
+
338
+ return reasons;
339
+ };
340
+
341
+ export const loadWayfinderArtifacts = async (
342
+ options?: WayfinderArtifactLoaderOptions
343
+ ): Promise<WayfinderArtifactLoad> => {
344
+ const [topoGraphRead, lockManifestRead, topoStoreRead] = await Promise.all([
345
+ readTopoArtifact(options),
346
+ readLockArtifact(options),
347
+ readTopoStoreArtifact(options),
348
+ ]);
349
+
350
+ if (topoGraphRead.kind === 'schema-version-drift') {
351
+ return artifactLoad({
352
+ artifactStatus: {
353
+ artifact: topoGraphRead.artifact,
354
+ message: topoGraphRead.message,
355
+ status: 'schema-version-drift',
356
+ },
357
+ lockManifest:
358
+ lockManifestRead.kind === 'ok' ? lockManifestRead.value : null,
359
+ topoGraph: null,
360
+ topoStore: topoStoreRead.kind === 'ok' ? topoStoreRead.value : null,
361
+ });
362
+ }
363
+
364
+ if (lockManifestRead.kind === 'schema-version-drift') {
365
+ return artifactLoad({
366
+ artifactStatus: {
367
+ artifact: lockManifestRead.artifact,
368
+ message: lockManifestRead.message,
369
+ status: 'schema-version-drift',
370
+ },
371
+ lockManifest: null,
372
+ topoGraph: topoGraphRead.value,
373
+ topoStore: topoStoreRead.kind === 'ok' ? topoStoreRead.value : null,
374
+ });
375
+ }
376
+
377
+ if (topoStoreRead.kind === 'schema-version-drift') {
378
+ return artifactLoad({
379
+ artifactStatus: {
380
+ artifact: topoStoreRead.artifact,
381
+ message: topoStoreRead.message,
382
+ status: 'schema-version-drift',
383
+ },
384
+ lockManifest: lockManifestRead.value,
385
+ topoGraph: topoGraphRead.value,
386
+ topoStore: null,
387
+ });
388
+ }
389
+
390
+ const topoGraph = topoGraphRead.value;
391
+ const lockManifest = lockManifestRead.value;
392
+ const topoStore = topoStoreRead.value;
393
+ if (topoGraph === null || lockManifest === null || topoStore === null) {
394
+ const missing: WayfinderArtifactKind[] = [];
395
+ if (topoGraph === null) {
396
+ missing.push('topoGraph');
397
+ }
398
+ if (lockManifest === null) {
399
+ missing.push('lockManifest');
400
+ }
401
+ if (topoStore === null) {
402
+ missing.push('topoStore');
403
+ }
404
+ return artifactLoad({
405
+ artifactStatus: { artifacts: missing, status: 'missing' },
406
+ lockManifest,
407
+ topoGraph,
408
+ topoStore,
409
+ });
410
+ }
411
+
412
+ const fingerprintReason = sourceFingerprintReason(topoStore, options);
413
+ const reasons = [
414
+ ...staleReasons(topoGraph, lockManifest, topoStore),
415
+ ...(fingerprintReason === undefined ? [] : [fingerprintReason]),
416
+ ];
417
+ return artifactLoad({
418
+ artifactStatus:
419
+ reasons.length === 0 ? { status: 'fresh' } : { reasons, status: 'stale' },
420
+ lockManifest,
421
+ topoGraph,
422
+ topoStore,
423
+ });
424
+ };
425
+
426
+ export const wayfinderTopoGraphSource = (
427
+ options?: WayfinderArtifactLoaderOptions
428
+ ) => ({
429
+ kind: 'topoGraph' as const,
430
+ path: `${resolveArtifactReadOptions(options)?.dir ?? '.'}/trails.lock`,
431
+ schemaVersion: TOPO_GRAPH_SCHEMA_VERSION,
432
+ });
433
+
434
+ export const wayfinderTopoStoreSource = (
435
+ options?: WayfinderArtifactLoaderOptions
436
+ ) => {
437
+ const location = resolveTopoStoreLocation(options);
438
+ return {
439
+ kind: 'topoStore' as const,
440
+ ...(location === undefined ? {} : { path: deriveTrailsDbPath(location) }),
441
+ schemaVersion: TOPO_STORE_SCHEMA_VERSION,
442
+ };
443
+ };
@@ -0,0 +1,265 @@
1
+ import { Result } from '@ontrails/core';
2
+ import type { AmbiguousError } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import type { TopoGraph } from '../types.js';
6
+ import {
7
+ filterWayfinderEntityRefs,
8
+ wayfinderEntityFilterSchema,
9
+ } from './filters.js';
10
+ import type {
11
+ WayfinderEntityFilterInput,
12
+ WayfinderEntityKind,
13
+ WayfinderEntityRef,
14
+ } from './filters.js';
15
+ import {
16
+ edgeTouches,
17
+ groupNearbyEdges,
18
+ impactFor,
19
+ refSummary,
20
+ relationEdges,
21
+ resolveEntityRef,
22
+ } from './relations.js';
23
+ import type {
24
+ ImpactDirection,
25
+ ImpactOptions,
26
+ RelationEdge,
27
+ RelationRef,
28
+ } from './relations.js';
29
+
30
+ export const wayfinderSourceModeSchema = z.enum(['locked', 'live']);
31
+
32
+ export type WayfinderSourceMode = z.output<typeof wayfinderSourceModeSchema>;
33
+
34
+ export const wayfinderResolverSchema = z.enum([
35
+ 'id',
36
+ 'pattern',
37
+ 'query',
38
+ 'file',
39
+ ]);
40
+
41
+ export type WayfinderResolver = z.output<typeof wayfinderResolverSchema>;
42
+
43
+ export const wayfinderViewSchema = z.enum([
44
+ 'overview',
45
+ 'list',
46
+ 'summary',
47
+ 'describe',
48
+ 'contract',
49
+ 'outline',
50
+ 'map',
51
+ ]);
52
+
53
+ export type WayfinderView = z.output<typeof wayfinderViewSchema>;
54
+
55
+ export const wayfinderIncludeSchema = z.enum([
56
+ 'adapters',
57
+ 'errors',
58
+ 'examples',
59
+ 'surfaces',
60
+ 'versions',
61
+ ]);
62
+
63
+ export type WayfinderInclude = z.output<typeof wayfinderIncludeSchema>;
64
+
65
+ export const wayfinderDriftStatusSchema = z.enum([
66
+ 'absent',
67
+ 'aligned',
68
+ 'drifted',
69
+ ]);
70
+
71
+ export type WayfinderDriftStatus = z.output<typeof wayfinderDriftStatusSchema>;
72
+
73
+ export const wayfinderNavigationPlanSchema = z.object({
74
+ filters: wayfinderEntityFilterSchema.optional(),
75
+ include: z.array(wayfinderIncludeSchema).readonly().default([]),
76
+ limit: z.number().int().positive().max(500).default(100),
77
+ resolver: wayfinderResolverSchema,
78
+ source: wayfinderSourceModeSchema.default('locked'),
79
+ view: wayfinderViewSchema.default('list'),
80
+ });
81
+
82
+ export type WayfinderNavigationPlan = z.output<
83
+ typeof wayfinderNavigationPlanSchema
84
+ >;
85
+
86
+ export interface WayfinderPopulationInput {
87
+ readonly filters?: WayfinderEntityFilterInput | undefined;
88
+ readonly kind?: WayfinderEntityKind | undefined;
89
+ readonly limit: number;
90
+ }
91
+
92
+ export const resolveWayfinderPopulation = (
93
+ graph: TopoGraph,
94
+ input: WayfinderPopulationInput
95
+ ): readonly WayfinderEntityRef[] =>
96
+ filterWayfinderEntityRefs(graph, {
97
+ ...input.filters,
98
+ ...(input.kind === undefined ? {} : { kind: input.kind }),
99
+ }).slice(0, input.limit);
100
+
101
+ export const wayfinderRelationModeSchema = z.enum([
102
+ 'related',
103
+ 'deps',
104
+ 'impact',
105
+ ]);
106
+
107
+ export type WayfinderRelationMode = z.output<
108
+ typeof wayfinderRelationModeSchema
109
+ >;
110
+
111
+ /** @deprecated Use WayfinderRelationMode. */
112
+ export type WayfinderRelationResolver = WayfinderRelationMode;
113
+
114
+ export interface WayfinderResolvedRelationInput {
115
+ readonly id: string;
116
+ readonly filters?: WayfinderEntityFilterInput | undefined;
117
+ readonly kind?: WayfinderEntityKind | undefined;
118
+ readonly limit: number;
119
+ readonly maxDepth: number;
120
+ readonly mode: WayfinderRelationMode;
121
+ readonly view?: 'groups' | 'impact' | undefined;
122
+ }
123
+
124
+ export interface WayfinderResolvedRelations {
125
+ readonly direction: ImpactDirection;
126
+ readonly edges: readonly RelationEdge[];
127
+ readonly groups: ReturnType<typeof groupNearbyEdges>;
128
+ readonly nodes:
129
+ | ReturnType<typeof impactFor>['nodes']
130
+ | readonly RelationRef[];
131
+ readonly target: RelationRef;
132
+ }
133
+
134
+ const relationDirection = (mode: WayfinderRelationMode): ImpactDirection => {
135
+ switch (mode) {
136
+ case 'related': {
137
+ return 'both';
138
+ }
139
+ case 'deps': {
140
+ return 'upstream';
141
+ }
142
+ case 'impact': {
143
+ return 'downstream';
144
+ }
145
+ default: {
146
+ mode satisfies never;
147
+ return 'both';
148
+ }
149
+ }
150
+ };
151
+
152
+ const relationOptions = (
153
+ input: WayfinderResolvedRelationInput
154
+ ): ImpactOptions => ({
155
+ direction: relationDirection(input.mode),
156
+ limit: input.limit,
157
+ maxDepth: input.maxDepth,
158
+ });
159
+
160
+ const refKey = (ref: RelationRef): string => `${ref.kind}:${ref.id}`;
161
+
162
+ const filterRelationRefs = (
163
+ graph: TopoGraph,
164
+ refs: readonly RelationRef[],
165
+ filters: WayfinderEntityFilterInput | undefined
166
+ ): readonly RelationRef[] => {
167
+ if (filters === undefined || Object.keys(filters).length === 0) {
168
+ return refs;
169
+ }
170
+ const allowed = new Set(
171
+ filterWayfinderEntityRefs(graph, filters).map(refKey)
172
+ );
173
+ return refs.filter((ref) => allowed.has(refKey(ref)));
174
+ };
175
+
176
+ const edgeOtherRef = (
177
+ edge: RelationEdge,
178
+ target: RelationRef
179
+ ): RelationRef | undefined => {
180
+ if (edge.from.id === target.id && edge.from.kind === target.kind) {
181
+ return edge.to;
182
+ }
183
+ if (edge.to.id === target.id && edge.to.kind === target.kind) {
184
+ return edge.from;
185
+ }
186
+ return undefined;
187
+ };
188
+
189
+ const filterRelationEdges = (
190
+ graph: TopoGraph,
191
+ edges: readonly RelationEdge[],
192
+ target: RelationRef,
193
+ filters: WayfinderEntityFilterInput | undefined
194
+ ): readonly RelationEdge[] => {
195
+ if (filters === undefined || Object.keys(filters).length === 0) {
196
+ return edges;
197
+ }
198
+ const allowed = new Set(
199
+ filterWayfinderEntityRefs(graph, filters).map(refKey)
200
+ );
201
+ return edges.filter((edge) => {
202
+ const other = edgeOtherRef(edge, target);
203
+ return other !== undefined && allowed.has(refKey(other));
204
+ });
205
+ };
206
+
207
+ const filterImpactEdges = (
208
+ edges: readonly RelationEdge[],
209
+ target: RelationRef,
210
+ nodes: readonly RelationRef[],
211
+ filters: WayfinderEntityFilterInput | undefined
212
+ ): readonly RelationEdge[] => {
213
+ if (filters === undefined || Object.keys(filters).length === 0) {
214
+ return edges;
215
+ }
216
+ const allowed = new Set([refKey(target), ...nodes.map(refKey)]);
217
+ return edges.filter(
218
+ (edge) => allowed.has(refKey(edge.from)) && allowed.has(refKey(edge.to))
219
+ );
220
+ };
221
+
222
+ export const resolveWayfinderRelations = (
223
+ graph: TopoGraph,
224
+ input: WayfinderResolvedRelationInput
225
+ ): Result<WayfinderResolvedRelations | undefined, AmbiguousError> => {
226
+ const target = resolveEntityRef(graph, input);
227
+ if (target.isErr()) {
228
+ return target;
229
+ }
230
+ if (target.value === undefined) {
231
+ return Result.ok();
232
+ }
233
+ const summary = refSummary(target.value);
234
+ if (input.mode === 'related' && input.view === 'groups') {
235
+ const edges = filterRelationEdges(
236
+ graph,
237
+ relationEdges(graph).filter((edge) => edgeTouches(edge, summary)),
238
+ summary,
239
+ input.filters
240
+ );
241
+ const groups = groupNearbyEdges(edges, summary);
242
+ return Result.ok({
243
+ direction: 'both',
244
+ edges,
245
+ groups,
246
+ nodes: groups.flatMap((group) => group.refs),
247
+ target: summary,
248
+ });
249
+ }
250
+ const resolved = impactFor(graph, relationOptions(input), summary);
251
+ const nodes = filterRelationRefs(graph, resolved.nodes, input.filters);
252
+ const edges = filterImpactEdges(
253
+ resolved.edges,
254
+ summary,
255
+ nodes,
256
+ input.filters
257
+ );
258
+ return Result.ok({
259
+ direction: relationDirection(input.mode),
260
+ edges,
261
+ groups: [],
262
+ nodes,
263
+ target: summary,
264
+ });
265
+ };