@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,483 @@
1
+ /**
2
+ * Workspace-wide trail-id index for compose-app resolution.
3
+ *
4
+ * Builds a `{ trailId → appName }` index that lets `trails run <id>` resolve
5
+ * a trail to its owning app without scanning every app's source. The index is
6
+ * either read from a committed `trails.lock` workspace index (the cached, fast
7
+ * path) or discovered by walking the workspace's `workspaces` glob, loading each app's
8
+ * topo, and reading its trail ids.
9
+ *
10
+ * @remarks
11
+ * **Boundary.** This module lives in `@ontrails/topography` because it
12
+ * persists artifacts derived from the resolved graph (per ADR-0042). Compose-app
13
+ * resolution is CLI tooling that reads Topography artifacts before runtime
14
+ * begins — `@ontrails/core` resolves a single in-memory graph and stays
15
+ * unaware of workspace topology.
16
+ */
17
+
18
+ import { existsSync } from 'node:fs';
19
+ import { basename, isAbsolute, join, relative } from 'node:path';
20
+
21
+ import type { Topo } from '@ontrails/core';
22
+
23
+ import { readWorkspaceTopoMetadata } from './io.js';
24
+ import type {
25
+ WorkspaceTopoMetadata,
26
+ WorkspaceTrailCollision,
27
+ WorkspaceTrailEntry,
28
+ WorkspaceTrailIndex,
29
+ } from './types.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Loader contract for a single workspace app.
37
+ *
38
+ * Receives the resolved app directory and the workspace root, and returns the
39
+ * loaded `Topo`. The default loader (see {@link defaultLoadTopo}) imports the
40
+ * app's entry module and reads a `default`, `graph`, or `app` export. Tests
41
+ * can substitute their own loader to avoid the dynamic-import dance against a
42
+ * temp directory.
43
+ */
44
+ export type WorkspaceTopoLoader = (
45
+ appDir: string,
46
+ workspaceRoot: string,
47
+ entryRelative?: string | undefined
48
+ ) => Promise<Topo>;
49
+
50
+ /**
51
+ * Options accepted by {@link buildWorkspaceTrailIndex}.
52
+ */
53
+ export interface BuildWorkspaceTrailIndexOptions {
54
+ /** Workspace root (a directory containing a `package.json` with `workspaces`). */
55
+ readonly cwd: string;
56
+ /**
57
+ * Loader for individual app topos. Defaults to {@link defaultLoadTopo} which
58
+ * imports the resolved entry module via `await import()`.
59
+ */
60
+ readonly loadTopo?: WorkspaceTopoLoader;
61
+ /**
62
+ * Topo artifact directory consulted before discovery runs. Defaults to `cwd`,
63
+ * where the root `trails.lock` lives.
64
+ */
65
+ readonly artifactDir?: string | undefined;
66
+ /** @deprecated Use `artifactDir`. */
67
+ readonly lockDir?: string;
68
+ }
69
+
70
+ /**
71
+ * Structured result of building the workspace trail-id index.
72
+ *
73
+ * @remarks
74
+ * `index` is the trail-id-to-app-name map for **non-colliding** ids. When a
75
+ * trail id is exported by more than one app, it is **omitted** from `index`
76
+ * and recorded in `collisions` instead — callers must consult `collisions`
77
+ * to resolve ambiguous ids deterministically.
78
+ *
79
+ * `source` distinguishes a cache hit (the topo artifact already carried the
80
+ * workspace trail index) from discovery (apps were walked and loaded). The
81
+ * topo artifact path cannot collide because the index is itself a flat
82
+ * `{ trailId → appName }` map. `apps` lists the app names actually represented
83
+ * in the index. `warnings` reports load failures and other non-collision
84
+ * issues; collisions live in their own structured field.
85
+ */
86
+ export interface WorkspaceTrailIndexResult {
87
+ readonly index: WorkspaceTrailIndex;
88
+ readonly source: 'trails-lock' | 'discovery';
89
+ readonly apps: readonly string[];
90
+ readonly warnings: readonly string[];
91
+ readonly collisions: readonly WorkspaceTrailCollision[];
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers — package.json reading
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** A small subset of app `package.json` fields the discovery layer cares about. */
99
+ export interface AppManifest {
100
+ readonly name?: string | undefined;
101
+ readonly trails?: { readonly module?: string | undefined } | undefined;
102
+ }
103
+
104
+ export const isAppManifest = (value: unknown): value is AppManifest => {
105
+ if (typeof value !== 'object' || value === null) {
106
+ return false;
107
+ }
108
+ const candidate = value as Record<string, unknown>;
109
+ if ('name' in candidate && typeof candidate['name'] !== 'string') {
110
+ return false;
111
+ }
112
+ if ('trails' in candidate) {
113
+ const trailsField = candidate['trails'];
114
+ if (typeof trailsField !== 'object' || trailsField === null) {
115
+ return false;
116
+ }
117
+ const moduleField = (trailsField as Record<string, unknown>)['module'];
118
+ if (moduleField !== undefined && typeof moduleField !== 'string') {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
123
+ };
124
+
125
+ export const readAppManifest = async (
126
+ appDir: string
127
+ ): Promise<AppManifest | null> => {
128
+ const file = Bun.file(join(appDir, 'package.json'));
129
+ if (!(await file.exists())) {
130
+ return null;
131
+ }
132
+ try {
133
+ const parsed: unknown = await file.json();
134
+ return isAppManifest(parsed) ? parsed : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ };
139
+
140
+ export interface RootManifest {
141
+ readonly workspaces?: readonly string[] | undefined;
142
+ }
143
+
144
+ export const isRootManifest = (value: unknown): value is RootManifest => {
145
+ if (typeof value !== 'object' || value === null) {
146
+ return false;
147
+ }
148
+ const candidate = value as Record<string, unknown>;
149
+ if (!('workspaces' in candidate)) {
150
+ return true;
151
+ }
152
+ const ws = candidate['workspaces'];
153
+ return Array.isArray(ws) && ws.every((entry) => typeof entry === 'string');
154
+ };
155
+
156
+ export const readWorkspacesGlobs = async (
157
+ cwd: string
158
+ ): Promise<readonly string[]> => {
159
+ const file = Bun.file(join(cwd, 'package.json'));
160
+ if (!(await file.exists())) {
161
+ return [];
162
+ }
163
+ try {
164
+ const parsed: unknown = await file.json();
165
+ if (!isRootManifest(parsed) || parsed.workspaces === undefined) {
166
+ return [];
167
+ }
168
+ return parsed.workspaces;
169
+ } catch {
170
+ return [];
171
+ }
172
+ };
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Discovery — walk workspaces and identify Trails apps
176
+ // ---------------------------------------------------------------------------
177
+
178
+ interface CandidateApp {
179
+ readonly appDir: string;
180
+ readonly appName: string;
181
+ readonly entryRelative: string;
182
+ readonly modulePath: string;
183
+ }
184
+
185
+ /**
186
+ * Resolve a single workspace member into a candidate app, or `null` if it
187
+ * does not look like a Trails app (no `package.json`, or no Trails entry).
188
+ *
189
+ * The convention:
190
+ * 1. The package.json must exist and parse.
191
+ * 2. Either an explicit `trails.module` field must be present, OR a default
192
+ * `src/app.ts` file must exist in the member.
193
+ * 3. The package's `name` field becomes the app name; if missing, the last
194
+ * path segment of the member directory is used as a stable fallback.
195
+ */
196
+ const resolveCandidateApp = async (
197
+ memberDir: string,
198
+ workspaceRoot: string
199
+ ): Promise<CandidateApp | null> => {
200
+ const manifest = await readAppManifest(memberDir);
201
+ if (manifest === null) {
202
+ return null;
203
+ }
204
+
205
+ const explicitEntry = manifest.trails?.module;
206
+ const entryRelative = explicitEntry ?? 'src/app.ts';
207
+ if (manifest.trails?.module === undefined) {
208
+ const conventionEntry = Bun.file(join(memberDir, 'src/app.ts'));
209
+ if (!(await conventionEntry.exists())) {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ const fallbackName = basename(memberDir) || memberDir;
215
+ const appName = manifest.name ?? fallbackName;
216
+ const modulePath = relative(workspaceRoot, join(memberDir, entryRelative));
217
+ return { appDir: memberDir, appName, entryRelative, modulePath };
218
+ };
219
+
220
+ /**
221
+ * Expand the workspace's `workspaces` globs into concrete member directories.
222
+ *
223
+ * Only directory-shaped globs are supported (`apps/*`, `packages/*`). The
224
+ * scanner uses {@link Bun.Glob} so we stay Bun-native and avoid pulling in
225
+ * extra glob libraries.
226
+ */
227
+ const expandWorkspaceMembers = async (
228
+ cwd: string,
229
+ globs: readonly string[]
230
+ ): Promise<readonly string[]> => {
231
+ const members: string[] = [];
232
+ for (const pattern of globs) {
233
+ const glob = new Bun.Glob(pattern);
234
+ for await (const match of glob.scan({ cwd, onlyFiles: false })) {
235
+ members.push(join(cwd, match));
236
+ }
237
+ }
238
+ // Sort for deterministic discovery order. Collision resolution depends on
239
+ // a stable order so that "last-write-wins" is reproducible across runs.
240
+ return [...members].toSorted();
241
+ };
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Default loader
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /** Property names checked, in order, when extracting a Topo from a module. */
248
+ const TOPO_EXPORT_NAMES = ['default', 'graph', 'app'] as const;
249
+
250
+ const isTopo = (value: unknown): value is Topo => {
251
+ if (typeof value !== 'object' || value === null) {
252
+ return false;
253
+ }
254
+ const candidate = value as Record<string, unknown>;
255
+ return (
256
+ typeof candidate['ids'] === 'function' &&
257
+ typeof candidate['name'] === 'string'
258
+ );
259
+ };
260
+
261
+ /**
262
+ * Default loader: imports the app's entry module and returns the first export
263
+ * matching the `default` / `graph` / `app` convention.
264
+ */
265
+ export const defaultLoadTopo: WorkspaceTopoLoader = async (
266
+ appDir,
267
+ _workspaceRoot,
268
+ entryRelative
269
+ ) => {
270
+ const entryAbsolute = join(appDir, entryRelative ?? 'src/app.ts');
271
+ const mod = (await import(entryAbsolute)) as Record<string, unknown>;
272
+ for (const exportName of TOPO_EXPORT_NAMES) {
273
+ const candidate = mod[exportName];
274
+ if (isTopo(candidate)) {
275
+ return candidate;
276
+ }
277
+ }
278
+ throw new Error(
279
+ `App at "${appDir}" does not export a Topo via default, graph, or app.`
280
+ );
281
+ };
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Public API
285
+ // ---------------------------------------------------------------------------
286
+
287
+ interface IndexAccumulator {
288
+ /** All apps each trail id was registered by, in registration order. */
289
+ readonly owners: Map<string, WorkspaceTrailEntry[]>;
290
+ readonly apps: Set<string>;
291
+ readonly warnings: string[];
292
+ }
293
+
294
+ const recordTrails = (
295
+ accumulator: IndexAccumulator,
296
+ app: CandidateApp,
297
+ trailIds: readonly string[]
298
+ ): void => {
299
+ for (const trailId of trailIds) {
300
+ accumulator.apps.add(app.appName);
301
+ const entry: WorkspaceTrailEntry = {
302
+ appName: app.appName,
303
+ modulePath: app.modulePath,
304
+ trailId,
305
+ };
306
+ const owners = accumulator.owners.get(trailId);
307
+ if (owners === undefined) {
308
+ accumulator.owners.set(trailId, [entry]);
309
+ continue;
310
+ }
311
+ if (!owners.some((owner) => owner.appName === app.appName)) {
312
+ owners.push(entry);
313
+ }
314
+ }
315
+ };
316
+
317
+ interface ResolvedOwners {
318
+ readonly index: WorkspaceTrailIndex;
319
+ readonly collisions: readonly WorkspaceTrailCollision[];
320
+ }
321
+
322
+ const resolveOwners = (
323
+ owners: ReadonlyMap<string, readonly WorkspaceTrailEntry[]>
324
+ ): ResolvedOwners => {
325
+ const index: WorkspaceTrailIndex = {};
326
+ const collisions: WorkspaceTrailCollision[] = [];
327
+ for (const [trailId, entries] of owners) {
328
+ if (entries.length === 1) {
329
+ // Safe: length-1 array, so destructured element is defined.
330
+ const [sole] = entries;
331
+ if (sole !== undefined) {
332
+ index[trailId] = sole;
333
+ }
334
+ continue;
335
+ }
336
+ const sortedOwners = [...entries].toSorted((a, b) =>
337
+ a.appName < b.appName ? -1 : 1
338
+ );
339
+ collisions.push({
340
+ apps: sortedOwners.map((entry) => entry.appName),
341
+ owners: sortedOwners,
342
+ trailId,
343
+ });
344
+ }
345
+ collisions.sort((a, b) => (a.trailId < b.trailId ? -1 : 1));
346
+ return { collisions, index };
347
+ };
348
+
349
+ const buildFromTopoLock = (
350
+ workspace: WorkspaceTopoMetadata
351
+ ): WorkspaceTrailIndexResult => {
352
+ const apps = new Set<string>();
353
+ const collisions = Object.freeze([...(workspace.collisions ?? [])]);
354
+ const workspaceIndex = workspace.trails;
355
+ for (const entry of Object.values(workspaceIndex)) {
356
+ apps.add(entry.appName);
357
+ }
358
+ for (const collision of collisions) {
359
+ for (const appName of collision.apps) {
360
+ apps.add(appName);
361
+ }
362
+ }
363
+ return {
364
+ apps: [...apps].toSorted(),
365
+ collisions,
366
+ index: Object.freeze({ ...workspaceIndex }),
367
+ source: 'trails-lock',
368
+ warnings: [],
369
+ };
370
+ };
371
+
372
+ const buildFromDiscovery = async (
373
+ cwd: string,
374
+ loadTopo: WorkspaceTopoLoader,
375
+ initialWarnings: readonly string[] = []
376
+ ): Promise<WorkspaceTrailIndexResult> => {
377
+ const accumulator: IndexAccumulator = {
378
+ apps: new Set<string>(),
379
+ owners: new Map<string, WorkspaceTrailEntry[]>(),
380
+ warnings: [...initialWarnings],
381
+ };
382
+
383
+ const globs = await readWorkspacesGlobs(cwd);
384
+ if (globs.length === 0) {
385
+ return {
386
+ apps: [],
387
+ collisions: [],
388
+ index: Object.freeze({}),
389
+ source: 'discovery',
390
+ warnings: [...accumulator.warnings],
391
+ };
392
+ }
393
+
394
+ const members = await expandWorkspaceMembers(cwd, globs);
395
+ const resolvedCandidates = await Promise.all(
396
+ members.map((memberDir) => resolveCandidateApp(memberDir, cwd))
397
+ );
398
+ const candidates = resolvedCandidates.filter(
399
+ (candidate): candidate is CandidateApp => candidate !== null
400
+ );
401
+ const settled = await Promise.allSettled(
402
+ candidates.map(async (candidate) => {
403
+ const loaded = await loadTopo(
404
+ candidate.appDir,
405
+ cwd,
406
+ candidate.entryRelative
407
+ );
408
+ return { candidate, trailIds: loaded.ids() };
409
+ })
410
+ );
411
+ for (const [index, result] of settled.entries()) {
412
+ const candidate = candidates[index];
413
+ if (candidate === undefined) {
414
+ continue;
415
+ }
416
+ if (result.status === 'fulfilled') {
417
+ recordTrails(accumulator, result.value.candidate, result.value.trailIds);
418
+ continue;
419
+ }
420
+ const message =
421
+ result.reason instanceof Error
422
+ ? result.reason.message
423
+ : String(result.reason);
424
+ accumulator.warnings.push(
425
+ `Failed to load app "${candidate.appName}" at ${candidate.appDir}: ${message}`
426
+ );
427
+ }
428
+
429
+ const { index, collisions } = resolveOwners(accumulator.owners);
430
+ return {
431
+ apps: [...accumulator.apps].toSorted(),
432
+ collisions,
433
+ index: Object.freeze(index),
434
+ source: 'discovery',
435
+ warnings: [...accumulator.warnings],
436
+ };
437
+ };
438
+
439
+ /**
440
+ * Build a workspace-wide trail-id-to-app-name index.
441
+ *
442
+ * Prefers a committed topo artifact (`trails.lock` carrying a workspace
443
+ * trail index) when present; otherwise walks the workspace's
444
+ * `workspaces` globs, loads each app's topo, and reads its trail ids.
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * const result = await buildWorkspaceTrailIndex({ cwd: process.cwd() });
449
+ * if (result.source === 'trails-lock') {
450
+ * // Cached path — no app loading happened.
451
+ * }
452
+ * const owningApp = result.index['my-app.do-thing'];
453
+ * ```
454
+ */
455
+ export const buildWorkspaceTrailIndex = async (
456
+ options: BuildWorkspaceTrailIndexOptions
457
+ ): Promise<WorkspaceTrailIndexResult> => {
458
+ const {
459
+ artifactDir = options.lockDir,
460
+ cwd,
461
+ loadTopo = defaultLoadTopo,
462
+ } = options;
463
+
464
+ let resolvedArtifactDir: string;
465
+ if (artifactDir === undefined) {
466
+ resolvedArtifactDir = cwd;
467
+ } else if (isAbsolute(artifactDir)) {
468
+ resolvedArtifactDir = artifactDir;
469
+ } else {
470
+ resolvedArtifactDir = join(cwd, artifactDir);
471
+ }
472
+ const workspace = await readWorkspaceTopoMetadata({
473
+ dir: resolvedArtifactDir,
474
+ });
475
+ if (workspace !== null) {
476
+ return buildFromTopoLock(workspace);
477
+ }
478
+
479
+ const fallbackWarning = existsSync(join(resolvedArtifactDir, 'trails.lock'))
480
+ ? `Workspace trails.lock in "${resolvedArtifactDir}" does not include workspace metadata; falling back to discovery.`
481
+ : `No workspace trails.lock found in "${resolvedArtifactDir}"; falling back to discovery.`;
482
+ return await buildFromDiscovery(cwd, loadTopo, [fallbackWarning]);
483
+ };