@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.
- package/CHANGELOG.md +866 -0
- package/README.md +228 -0
- package/package.json +40 -0
- package/src/activation-report.ts +385 -0
- package/src/backend-support.ts +11 -0
- package/src/derive.ts +690 -0
- package/src/diff.ts +1088 -0
- package/src/forces.ts +125 -0
- package/src/hash.ts +55 -0
- package/src/index.ts +273 -0
- package/src/internal/topo-snapshots.ts +682 -0
- package/src/internal/topo-store-read.ts +1102 -0
- package/src/internal/topo-store.ts +1651 -0
- package/src/io.ts +280 -0
- package/src/library-derivation.ts +131 -0
- package/src/overlays.ts +155 -0
- package/src/permit.ts +19 -0
- package/src/source-fingerprint.ts +103 -0
- package/src/surface-bindings.ts +101 -0
- package/src/topo-store.ts +716 -0
- package/src/types.ts +749 -0
- package/src/versioning.ts +277 -0
- package/src/wayfind/error-facts.ts +363 -0
- package/src/wayfind/filters.ts +430 -0
- package/src/wayfind/loader.ts +443 -0
- package/src/wayfind/navigation.ts +265 -0
- package/src/wayfind/provenance.ts +152 -0
- package/src/wayfind/queries.ts +1656 -0
- package/src/wayfind/relations.ts +344 -0
- package/src/workspace-configured-aliases.ts +221 -0
- package/src/workspace-lock-census.ts +306 -0
- package/src/workspace-view-types.ts +104 -0
- package/src/workspace-view.ts +486 -0
|
@@ -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
|
+
};
|