@ontrails/core 1.0.0-beta.32 → 1.0.0-beta.39

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/src/trail.ts CHANGED
@@ -315,6 +315,84 @@ export type TrailVersions<
315
315
  >
316
316
  >;
317
317
 
318
+ /**
319
+ * Spec for {@link forkVersion}: a fork entry whose blaze signature is owned
320
+ * by the entry's own schemas instead of falling back to `unknown`.
321
+ */
322
+ export interface TrailVersionForkSpec<
323
+ TInputSchema extends z.ZodType,
324
+ TOutputSchema extends z.ZodType,
325
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
326
+ > {
327
+ /** The historical blaze, typed by this entry's schemas. */
328
+ readonly blaze: Implementation<
329
+ BlazeInput<
330
+ z.output<TInputSchema>,
331
+ ComposeSchemaOutput<TComposeInputSchema>
332
+ >,
333
+ z.output<TOutputSchema>
334
+ >;
335
+ readonly composeInput?: TComposeInputSchema | undefined;
336
+ readonly composes?: readonly (string | AnyTrail)[] | undefined;
337
+ readonly detours?:
338
+ | readonly Detour<
339
+ z.output<TInputSchema>,
340
+ z.output<TOutputSchema>,
341
+ TrailsError
342
+ >[]
343
+ | undefined;
344
+ readonly examples?:
345
+ | readonly TrailExample<z.input<TInputSchema>, z.output<TOutputSchema>>[]
346
+ | undefined;
347
+ readonly input: TInputSchema;
348
+ readonly output: TOutputSchema;
349
+ readonly resources?: readonly AnyResource[] | undefined;
350
+ readonly status?: TrailVersionStatus | undefined;
351
+ }
352
+
353
+ /**
354
+ * Author a fork version entry with a blaze typed by the entry's own schemas.
355
+ *
356
+ * `TrailVersions` fixes every entry's generics to `unknown`, so a fork blaze
357
+ * written inline receives `unknown` input and authors end up re-parsing the
358
+ * already-validated value just to narrow it. This helper threads the entry's
359
+ * `input`/`output` schemas into the blaze signature and erases the generics
360
+ * on the way out. The erasure is sound because the fork pipeline validates
361
+ * raw input against this entry's own `input` schema before dispatching to
362
+ * the entry blaze (see `createForkTrailVersion` in execute.ts).
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * const gearV1Input = z.object({ name: z.string(), weightOz: z.number() });
367
+ * const gearV1Output = z.object({ id: z.string(), weightOz: z.number() });
368
+ *
369
+ * const gearCreate = trail('gear.create', {
370
+ * // ... current v2 contract ...
371
+ * version: 2,
372
+ * versions: {
373
+ * 1: forkVersion({
374
+ * blaze: (input) =>
375
+ * // input is { name: string; weightOz: number } — no re-parse
376
+ * Result.ok({ id: input.name, weightOz: input.weightOz }),
377
+ * input: gearV1Input,
378
+ * output: gearV1Output,
379
+ * }),
380
+ * },
381
+ * });
382
+ * ```
383
+ */
384
+ export const forkVersion = <
385
+ TInputSchema extends z.ZodType,
386
+ TOutputSchema extends z.ZodType,
387
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
388
+ >(
389
+ spec: TrailVersionForkSpec<TInputSchema, TOutputSchema, TComposeInputSchema>
390
+ ): TrailVersionForkEntry =>
391
+ // Erasing the per-entry generics narrows function parameters, which TS
392
+ // cannot express without a conversion. Safe: the fork pipeline re-validates
393
+ // input against `spec.input` before the blaze runs.
394
+ spec as unknown as TrailVersionForkEntry;
395
+
318
396
  export const getTrailVersionEntryKind = (
319
397
  entry: TrailVersionEntry
320
398
  ): TrailVersionEntryKind => {
package/src/trails-db.ts CHANGED
@@ -1,10 +1,19 @@
1
- import { Database } from 'bun:sqlite';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { basename, dirname, join, resolve } from 'node:path';
1
+ import type { Database } from 'bun:sqlite';
6
2
 
7
3
  import { NotFoundError } from './errors.js';
4
+ import { loadRuntimeBuiltin } from './runtime-builtins.js';
5
+ import { sha256Hex } from './sha256.js';
6
+
7
+ // Altitude ruling (TRL-1198, ADR-0051 lens): trails-db stays core-owned
8
+ // shared framework infrastructure (ADR-0014) and stays on the barrel —
9
+ // topographer, tracing, warden, wayfinder, and the operator app all
10
+ // consume it from `@ontrails/core`. What it may NOT do is assume runtime
11
+ // capabilities eagerly: `bun:sqlite` and the node builtins load lazily at
12
+ // first use so the barrel's module graph stays execution-portable.
13
+ const sqlite = () => loadRuntimeBuiltin('bun:sqlite');
14
+ const fs = () => loadRuntimeBuiltin('node:fs');
15
+ const os = () => loadRuntimeBuiltin('node:os');
16
+ const nodePath = () => loadRuntimeBuiltin('node:path');
8
17
 
9
18
  const TRAILS_DIR = '.trails';
10
19
  const TRAILS_DB_FILE = 'trails.db';
@@ -46,7 +55,7 @@ interface SchemaVersionRow {
46
55
  }
47
56
 
48
57
  const deriveRootDir = (rootDir?: string): string =>
49
- resolve(rootDir ?? process.cwd());
58
+ nodePath().resolve(rootDir ?? process.cwd());
50
59
 
51
60
  const sanitizeProjectKeyName = (name: string): string => {
52
61
  const normalized = name.replaceAll(/[^a-zA-Z0-9._-]+/g, '-');
@@ -54,33 +63,30 @@ const sanitizeProjectKeyName = (name: string): string => {
54
63
  };
55
64
 
56
65
  const projectHash = (rootDir: string): string =>
57
- createHash('sha256')
58
- .update(rootDir)
59
- .digest('hex')
60
- .slice(0, PROJECT_KEY_HASH_LENGTH);
66
+ sha256Hex(rootDir).slice(0, PROJECT_KEY_HASH_LENGTH);
61
67
 
62
68
  export const deriveTrailsProjectKey = (
63
69
  options?: TrailsDbLocationOptions
64
70
  ): string => {
65
71
  const rootDir = deriveRootDir(options?.rootDir);
66
- return `${sanitizeProjectKeyName(basename(rootDir))}-${projectHash(rootDir)}`;
72
+ return `${sanitizeProjectKeyName(nodePath().basename(rootDir))}-${projectHash(rootDir)}`;
67
73
  };
68
74
 
69
75
  export const deriveTrailsStateHome = (
70
76
  options?: TrailsDbLocationOptions
71
77
  ): string => {
72
78
  const env = options?.env ?? process.env;
73
- return resolve(
79
+ return nodePath().resolve(
74
80
  env['TRAILS_STATE_HOME'] ??
75
81
  env['XDG_STATE_HOME'] ??
76
- join(homedir(), '.local', 'state')
82
+ nodePath().join(os().homedir(), '.local', 'state')
77
83
  );
78
84
  };
79
85
 
80
86
  export const deriveTrailsStateDir = (
81
87
  options?: TrailsDbLocationOptions
82
88
  ): string =>
83
- join(
89
+ nodePath().join(
84
90
  deriveTrailsStateHome(options),
85
91
  TRAILS_STORE_DIR,
86
92
  TRAILS_PROJECTS_DIR,
@@ -88,15 +94,15 @@ export const deriveTrailsStateDir = (
88
94
  );
89
95
 
90
96
  export const deriveTrailsDir = (options?: TrailsDbLocationOptions): string =>
91
- join(deriveRootDir(options?.rootDir), TRAILS_DIR);
97
+ nodePath().join(deriveRootDir(options?.rootDir), TRAILS_DIR);
92
98
 
93
99
  export const deriveTrailsDbPath = (options?: TrailsDbLocationOptions): string =>
94
100
  options?.path
95
- ? resolve(options.path)
96
- : join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
101
+ ? nodePath().resolve(options.path)
102
+ : nodePath().join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
97
103
 
98
104
  const ensureDbParentDir = (dbPath: string): void => {
99
- mkdirSync(dirname(dbPath), { recursive: true });
105
+ fs().mkdirSync(nodePath().dirname(dbPath), { recursive: true });
100
106
  };
101
107
 
102
108
  /**
@@ -108,7 +114,7 @@ const ensureDbParentDir = (dbPath: string): void => {
108
114
  */
109
115
  export const ensureTrailsWorkspace = (rootDir: string): void => {
110
116
  const trailsDir = deriveTrailsDir({ rootDir });
111
- mkdirSync(trailsDir, { recursive: true });
117
+ fs().mkdirSync(trailsDir, { recursive: true });
112
118
  };
113
119
 
114
120
  const initializeWritePragmas = (db: Database): void => {
@@ -168,7 +174,7 @@ export const openWriteTrailsDb = (
168
174
 
169
175
  ensureDbParentDir(dbPath);
170
176
 
171
- const db = new Database(dbPath, { create: true });
177
+ const db = new (sqlite().Database)(dbPath, { create: true });
172
178
  initializeWritePragmas(db);
173
179
  ensureSchemaVersionTable(db);
174
180
  return db;
@@ -178,12 +184,12 @@ export const openReadTrailsDb = (
178
184
  options?: TrailsDbLocationOptions
179
185
  ): Database => {
180
186
  const dbPath = deriveTrailsDbPath(options);
181
- if (!existsSync(dbPath)) {
187
+ if (!fs().existsSync(dbPath)) {
182
188
  throw new NotFoundError(
183
189
  `Trails database not found at "${dbPath}". Run a write operation first to initialize it.`
184
190
  );
185
191
  }
186
- const db = new Database(dbPath, { readonly: true });
192
+ const db = new (sqlite().Database)(dbPath, { readonly: true });
187
193
  initializeReadPragmas(db);
188
194
  return db;
189
195
  };
@@ -34,11 +34,49 @@ import { validateWebhookSource } from './webhook.js';
34
34
  // Issue shape
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
+ export type TopoDiagnosticCode = 'topo.missing-reference';
38
+
39
+ export type TopoReferenceKind =
40
+ | 'compose'
41
+ | 'contour-reference'
42
+ | 'resource'
43
+ | 'signal-fire'
44
+ | 'signal-on'
45
+ | 'signal-origin';
46
+
47
+ export type TopoReferenceOwnerKind =
48
+ | 'contour'
49
+ | 'signal'
50
+ | 'trail'
51
+ | 'trail-version';
52
+
53
+ /**
54
+ * Stable machine-readable payload for dangling topo references.
55
+ *
56
+ * Consumers such as Regrade should branch on `code` and `reference` instead
57
+ * of parsing `message`, which remains a human-readable diagnostic.
58
+ */
59
+ export interface TopoMissingReference {
60
+ readonly fromId: string;
61
+ readonly fromKind: TopoReferenceOwnerKind;
62
+ readonly fromTrailId?: string;
63
+ readonly missingId: string;
64
+ readonly referenceKind: TopoReferenceKind;
65
+ readonly version?: number;
66
+ }
67
+
37
68
  export interface TopoDiagnostic {
69
+ /**
70
+ * Stable machine-readable code. Human-facing messages may change for
71
+ * clarity; downstream automation should depend on this code and the typed
72
+ * payload fields.
73
+ */
74
+ readonly code?: TopoDiagnosticCode;
38
75
  readonly trailId: string;
39
76
  readonly rule: string;
40
77
  readonly message: string;
41
78
  readonly inputPath?: readonly (string | number)[];
79
+ readonly reference?: TopoMissingReference;
42
80
  readonly schemaIssues?: readonly TopoSchemaIssue[];
43
81
  readonly sourceId?: string;
44
82
  readonly sourceKind?: string;
@@ -54,6 +92,22 @@ export interface TopoIssue extends TopoDiagnostic {
54
92
 
55
93
  export type TopoSchemaIssue = ActivationSchemaIssue;
56
94
 
95
+ const isTopoDiagnostic = (value: unknown): value is TopoDiagnostic =>
96
+ typeof value === 'object' &&
97
+ value !== null &&
98
+ typeof (value as { message?: unknown }).message === 'string' &&
99
+ typeof (value as { rule?: unknown }).rule === 'string' &&
100
+ typeof (value as { trailId?: unknown }).trailId === 'string';
101
+
102
+ const missingReferenceDiagnostic = (
103
+ issue: Omit<TopoDiagnostic, 'code' | 'reference'> & {
104
+ readonly reference: TopoMissingReference;
105
+ }
106
+ ): TopoDiagnostic => ({
107
+ ...issue,
108
+ code: 'topo.missing-reference',
109
+ });
110
+
57
111
  // ---------------------------------------------------------------------------
58
112
  // Validators
59
113
  // ---------------------------------------------------------------------------
@@ -147,11 +201,20 @@ const checkComposes = (
147
201
  trailId: id,
148
202
  });
149
203
  } else if (!topo.has(composedId) && !isDraftId(composedId)) {
150
- issues.push({
151
- message: `Composes "${composedId}" which is not in the topo`,
152
- rule: 'compose-exists',
153
- trailId: id,
154
- });
204
+ issues.push(
205
+ missingReferenceDiagnostic({
206
+ message: `Composes "${composedId}" which is not in the topo`,
207
+ reference: {
208
+ fromId: id,
209
+ fromKind: 'trail',
210
+ fromTrailId: id,
211
+ missingId: composedId,
212
+ referenceKind: 'compose',
213
+ },
214
+ rule: 'compose-exists',
215
+ trailId: id,
216
+ })
217
+ );
155
218
  }
156
219
  }
157
220
  for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
@@ -174,11 +237,21 @@ const checkComposes = (
174
237
  trailId: id,
175
238
  });
176
239
  } else if (!topo.has(composedId) && !isDraftId(composedId)) {
177
- issues.push({
178
- message: `Version ${version} composes "${composedId}" which is not in the topo`,
179
- rule: 'compose-exists',
180
- trailId: id,
181
- });
240
+ issues.push(
241
+ missingReferenceDiagnostic({
242
+ message: `Version ${version} composes "${composedId}" which is not in the topo`,
243
+ reference: {
244
+ fromId: id,
245
+ fromKind: 'trail-version',
246
+ fromTrailId: id,
247
+ missingId: composedId,
248
+ referenceKind: 'compose',
249
+ version,
250
+ },
251
+ rule: 'compose-exists',
252
+ trailId: id,
253
+ })
254
+ );
182
255
  }
183
256
  }
184
257
  }
@@ -199,11 +272,20 @@ const checkResources = (
199
272
  !topo.hasResource(declaredResource.id) &&
200
273
  !isDraftId(declaredResource.id)
201
274
  ) {
202
- issues.push({
203
- message: `Resource "${declaredResource.id}" is not in the topo`,
204
- rule: 'resource-exists',
205
- trailId: id,
206
- });
275
+ issues.push(
276
+ missingReferenceDiagnostic({
277
+ message: `Resource "${declaredResource.id}" is not in the topo`,
278
+ reference: {
279
+ fromId: id,
280
+ fromKind: 'trail',
281
+ fromTrailId: id,
282
+ missingId: declaredResource.id,
283
+ referenceKind: 'resource',
284
+ },
285
+ rule: 'resource-exists',
286
+ trailId: id,
287
+ })
288
+ );
207
289
  }
208
290
  }
209
291
  for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
@@ -221,11 +303,21 @@ const checkResources = (
221
303
  !topo.hasResource(declaredResource.id) &&
222
304
  !isDraftId(declaredResource.id)
223
305
  ) {
224
- issues.push({
225
- message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
226
- rule: 'resource-exists',
227
- trailId: id,
228
- });
306
+ issues.push(
307
+ missingReferenceDiagnostic({
308
+ message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
309
+ reference: {
310
+ fromId: id,
311
+ fromKind: 'trail-version',
312
+ fromTrailId: id,
313
+ missingId: declaredResource.id,
314
+ referenceKind: 'resource',
315
+ version,
316
+ },
317
+ rule: 'resource-exists',
318
+ trailId: id,
319
+ })
320
+ );
229
321
  }
230
322
  }
231
323
  }
@@ -310,11 +402,19 @@ const checkSignalOrigins = (
310
402
  }
311
403
  for (const originId of evt.from) {
312
404
  if (!topo.has(originId) && !isDraftId(originId)) {
313
- issues.push({
314
- message: `Signal origin "${originId}" is not in the topo`,
315
- rule: 'signal-origin-exists',
316
- trailId: id,
317
- });
405
+ issues.push(
406
+ missingReferenceDiagnostic({
407
+ message: `Signal origin "${originId}" is not in the topo`,
408
+ reference: {
409
+ fromId: id,
410
+ fromKind: 'signal',
411
+ missingId: originId,
412
+ referenceKind: 'signal-origin',
413
+ },
414
+ rule: 'signal-origin-exists',
415
+ trailId: id,
416
+ })
417
+ );
318
418
  }
319
419
  }
320
420
  }
@@ -330,21 +430,39 @@ const checkSignalReferences = (
330
430
  for (const [id, trail] of trails) {
331
431
  for (const signalId of trail.fires ?? []) {
332
432
  if (!signals.has(signalId) && !isDraftId(signalId)) {
333
- issues.push({
334
- message: `Trail fires signal "${signalId}" which is not in the topo`,
335
- rule: 'signal-fire-exists',
336
- trailId: id,
337
- });
433
+ issues.push(
434
+ missingReferenceDiagnostic({
435
+ message: `Trail fires signal "${signalId}" which is not in the topo`,
436
+ reference: {
437
+ fromId: id,
438
+ fromKind: 'trail',
439
+ fromTrailId: id,
440
+ missingId: signalId,
441
+ referenceKind: 'signal-fire',
442
+ },
443
+ rule: 'signal-fire-exists',
444
+ trailId: id,
445
+ })
446
+ );
338
447
  }
339
448
  }
340
449
 
341
450
  for (const signalId of trail.on ?? []) {
342
451
  if (!signals.has(signalId) && !isDraftId(signalId)) {
343
- issues.push({
344
- message: `Trail declares on signal "${signalId}" which is not in the topo`,
345
- rule: 'signal-on-exists',
346
- trailId: id,
347
- });
452
+ issues.push(
453
+ missingReferenceDiagnostic({
454
+ message: `Trail declares on signal "${signalId}" which is not in the topo`,
455
+ reference: {
456
+ fromId: id,
457
+ fromKind: 'trail',
458
+ fromTrailId: id,
459
+ missingId: signalId,
460
+ referenceKind: 'signal-on',
461
+ },
462
+ rule: 'signal-on-exists',
463
+ trailId: id,
464
+ })
465
+ );
348
466
  }
349
467
  }
350
468
  }
@@ -517,11 +635,19 @@ const checkContourReferences = (
517
635
  for (const [name, contourDef] of contours) {
518
636
  for (const ref of getContourReferences(contourDef)) {
519
637
  if (!topo.hasContour(ref.contour) && !isDraftId(ref.contour)) {
520
- issues.push({
521
- message: `Contour "${name}" references "${ref.contour}" which is not in the topo`,
522
- rule: 'contour-reference-exists',
523
- trailId: name,
524
- });
638
+ issues.push(
639
+ missingReferenceDiagnostic({
640
+ message: `Contour "${name}" references "${ref.contour}" which is not in the topo`,
641
+ reference: {
642
+ fromId: name,
643
+ fromKind: 'contour',
644
+ missingId: ref.contour,
645
+ referenceKind: 'contour-reference',
646
+ },
647
+ rule: 'contour-reference-exists',
648
+ trailId: name,
649
+ })
650
+ );
525
651
  }
526
652
  }
527
653
  }
@@ -533,6 +659,21 @@ const checkContourReferences = (
533
659
  // Public API
534
660
  // ---------------------------------------------------------------------------
535
661
 
662
+ /**
663
+ * Extract structured topo diagnostics from a validation error.
664
+ *
665
+ * `validateTopo` keeps source compatibility by returning `Result<void,
666
+ * ValidationError>`. Consumers that need machine-readable diagnostics should
667
+ * use this helper instead of parsing the human `message` text.
668
+ */
669
+ export const getTopoDiagnostics = (
670
+ error: ValidationError
671
+ ): readonly TopoDiagnostic[] => {
672
+ const context = error.context as { issues?: unknown } | undefined;
673
+ const issues = context?.issues;
674
+ return Array.isArray(issues) ? issues.filter(isTopoDiagnostic) : [];
675
+ };
676
+
536
677
  /**
537
678
  * Validate the structural integrity of a Topo graph.
538
679
  *