@ontrails/core 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
@@ -0,0 +1,716 @@
1
+ import { DETOUR_MAX_ATTEMPTS_CAP } from './detours.js';
2
+ import { AmbiguousError, ValidationError } from './errors.js';
3
+ import { isPlainObject } from './guards.js';
4
+ import {
5
+ getTrailVersionEntryKind,
6
+ isArchivedTrailVersionEntry,
7
+ } from './trail.js';
8
+ import type { AnyTrail, TrailVersionEntry } from './trail.js';
9
+ import { schemaHasJsonSchemaOverride, zodToJsonSchema } from './validation.js';
10
+
11
+ export const TRAIL_VERSION_MARKER_LENGTH = 16;
12
+ export const TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH = 4;
13
+
14
+ export interface TrailVersionMarkerBinding {
15
+ readonly marker: string;
16
+ readonly version: number;
17
+ }
18
+
19
+ export interface TrailVersionMarkerResolution extends TrailVersionMarkerBinding {
20
+ readonly prefix: string;
21
+ }
22
+
23
+ export interface TrailVersionMarkerRecord extends TrailVersionMarkerBinding {
24
+ readonly current: boolean;
25
+ readonly kind: 'current' | 'fork' | 'revision';
26
+ readonly supported: boolean;
27
+ }
28
+
29
+ const markerPattern = /^[0-9a-f]{16}$/;
30
+ const markerPrefixPattern = /^[0-9a-f]+$/;
31
+
32
+ const markerValuePath = (path: readonly string[]): string =>
33
+ path.length === 0 ? '<root>' : path.join('.');
34
+
35
+ const markerSchemaPath = (path: readonly string[]): string => {
36
+ if (path.length === 0) {
37
+ return '<schema>';
38
+ }
39
+ let formatted = '';
40
+ for (const segment of path) {
41
+ if (segment === '[]') {
42
+ formatted = `${formatted}[]`;
43
+ continue;
44
+ }
45
+ formatted = formatted.length === 0 ? segment : `${formatted}.${segment}`;
46
+ }
47
+ return formatted;
48
+ };
49
+
50
+ const supportedMarkerSchemaTypes = new Set([
51
+ 'array',
52
+ 'boolean',
53
+ 'enum',
54
+ 'literal',
55
+ 'nullable',
56
+ 'number',
57
+ 'object',
58
+ 'optional',
59
+ 'readonly',
60
+ 'string',
61
+ 'union',
62
+ ]);
63
+
64
+ const zodDef = (
65
+ schema: unknown,
66
+ path: readonly string[]
67
+ ): Readonly<Record<string, unknown>> => {
68
+ if (typeof schema !== 'object' || schema === null) {
69
+ throw new ValidationError(
70
+ `Trail version marker schema at ${markerSchemaPath(path)} is not a Zod schema`
71
+ );
72
+ }
73
+
74
+ const def = (schema as { readonly _zod?: { readonly def?: unknown } })._zod
75
+ ?.def;
76
+ if (!isPlainObject(def)) {
77
+ throw new ValidationError(
78
+ `Trail version marker schema at ${markerSchemaPath(path)} is not a supported Zod schema`
79
+ );
80
+ }
81
+ return def;
82
+ };
83
+
84
+ const zodType = (def: Readonly<Record<string, unknown>>): string =>
85
+ typeof def['type'] === 'string' ? def['type'] : '<unknown>';
86
+
87
+ const zodChecks = (
88
+ def: Readonly<Record<string, unknown>>
89
+ ): readonly unknown[] => {
90
+ const { checks } = def;
91
+ return Array.isArray(checks) ? checks : [];
92
+ };
93
+
94
+ const zodDirectCheckName = (
95
+ def: Readonly<Record<string, unknown>>
96
+ ): string | undefined =>
97
+ typeof def['check'] === 'string' ? def['check'] : undefined;
98
+
99
+ const zodCheckName = (check: unknown): string => {
100
+ if (typeof check !== 'object' || check === null) {
101
+ return '<unknown>';
102
+ }
103
+
104
+ const def = (
105
+ check as {
106
+ readonly _zod?: { readonly def?: Readonly<Record<string, unknown>> };
107
+ }
108
+ )._zod?.def;
109
+ if (!isPlainObject(def)) {
110
+ return '<unknown>';
111
+ }
112
+
113
+ const name = def['check'] ?? def['type'];
114
+ return typeof name === 'string' ? name : '<unknown>';
115
+ };
116
+
117
+ const nestedZodSchema = (value: unknown, path: readonly string[]): unknown => {
118
+ if (value === undefined) {
119
+ throw new ValidationError(
120
+ `Trail version marker schema at ${markerSchemaPath(path)} is missing an expected nested schema`
121
+ );
122
+ }
123
+ return value;
124
+ };
125
+
126
+ // Wrapper schema types that delegate to a single inner schema.
127
+ const wrappedMarkerSchemaTypes = new Set([
128
+ 'default',
129
+ 'nullable',
130
+ 'optional',
131
+ 'readonly',
132
+ ]);
133
+
134
+ // Schemas with a deterministic JSON-schema override (e.g. blobRefSchema) derive
135
+ // to a canonical descriptor, so the preflight accepts them without inspecting
136
+ // the underlying custom Zod internals once runtime-only checks have been ruled
137
+ // out.
138
+ const hasMarkerSchemaOverride = (schema: unknown): boolean =>
139
+ typeof schema === 'object' &&
140
+ schema !== null &&
141
+ schemaHasJsonSchemaOverride(schema as never);
142
+
143
+ const assertMarkerJsonSafe = (
144
+ value: unknown,
145
+ path: readonly string[]
146
+ ): void => {
147
+ if (
148
+ value === null ||
149
+ typeof value === 'boolean' ||
150
+ typeof value === 'string'
151
+ ) {
152
+ return;
153
+ }
154
+
155
+ if (typeof value === 'number') {
156
+ if (!Number.isFinite(value)) {
157
+ throw new ValidationError(
158
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported non-finite JSON value`
159
+ );
160
+ }
161
+ return;
162
+ }
163
+
164
+ if (!isPlainObject(value) && !Array.isArray(value)) {
165
+ throw new ValidationError(
166
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported JSON-lossy value`
167
+ );
168
+ }
169
+
170
+ throw new ValidationError(
171
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported reference-valued literal or enum value`
172
+ );
173
+ };
174
+
175
+ const assertMarkerLiteralSupported = (
176
+ def: Readonly<Record<string, unknown>>,
177
+ path: readonly string[]
178
+ ): void => {
179
+ // The JSON-schema derivation only emits the first literal value, so a
180
+ // multi-value literal (z.literal(['a', 'b'])) would hash identically to a
181
+ // single-value literal and silently collide.
182
+ const { values } = def;
183
+ if (Array.isArray(values) && values.length > 1) {
184
+ throw new ValidationError(
185
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported multi-value literal`
186
+ );
187
+ }
188
+ const [value] = Array.isArray(values) ? values : [];
189
+ if (typeof value === 'number' && !Number.isFinite(value)) {
190
+ throw new ValidationError(
191
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported non-finite literal`
192
+ );
193
+ }
194
+ assertMarkerJsonSafe(value, path);
195
+ };
196
+
197
+ const assertMarkerEnumSupported = (
198
+ def: Readonly<Record<string, unknown>>,
199
+ path: readonly string[]
200
+ ): void => {
201
+ const { entries } = def;
202
+ if (!isPlainObject(entries)) {
203
+ throw new ValidationError(
204
+ `Trail version marker schema at ${markerSchemaPath(path)} has unsupported enum entries`
205
+ );
206
+ }
207
+ for (const [key, value] of Object.entries(entries)) {
208
+ assertMarkerJsonSafe(value, [...path, key]);
209
+ }
210
+ };
211
+
212
+ const nestedMarkerWrappedSchema = (
213
+ type: string,
214
+ def: Readonly<Record<string, unknown>>,
215
+ path: readonly string[],
216
+ options: { readonly optionalWrapperAllowed?: boolean }
217
+ ): unknown => {
218
+ if (type === 'optional' && options.optionalWrapperAllowed !== true) {
219
+ throw new ValidationError(
220
+ `Trail version marker schema at ${markerSchemaPath(path)} uses an unsupported hidden optional wrapper`
221
+ );
222
+ }
223
+ return nestedZodSchema(def['innerType'], path);
224
+ };
225
+
226
+ const assertMarkerSchemaSupported = (
227
+ schema: unknown,
228
+ path: readonly string[],
229
+ options: { readonly optionalWrapperAllowed?: boolean } = {}
230
+ ): void => {
231
+ const def = zodDef(schema, path);
232
+ if (def['coerce'] === true) {
233
+ throw new ValidationError(
234
+ `Trail version marker schema at ${markerSchemaPath(path)} uses unsupported Zod coercion`
235
+ );
236
+ }
237
+
238
+ const [firstCheck] = zodChecks(def);
239
+ if (firstCheck !== undefined) {
240
+ throw new ValidationError(
241
+ `Trail version marker schema at ${markerSchemaPath(path)} uses unsupported Zod validation check "${zodCheckName(firstCheck)}"`
242
+ );
243
+ }
244
+
245
+ if (hasMarkerSchemaOverride(schema)) {
246
+ return;
247
+ }
248
+
249
+ const directCheckName = zodDirectCheckName(def);
250
+ if (directCheckName !== undefined) {
251
+ throw new ValidationError(
252
+ `Trail version marker schema at ${markerSchemaPath(path)} uses unsupported Zod validation check "${directCheckName}"`
253
+ );
254
+ }
255
+
256
+ const type = zodType(def);
257
+ if (!supportedMarkerSchemaTypes.has(type)) {
258
+ throw new ValidationError(
259
+ `Trail version marker schema at ${markerSchemaPath(path)} uses unsupported Zod schema type "${type}"`
260
+ );
261
+ }
262
+
263
+ if (type === 'literal') {
264
+ assertMarkerLiteralSupported(def, path);
265
+ return;
266
+ }
267
+
268
+ if (type === 'enum') {
269
+ assertMarkerEnumSupported(def, path);
270
+ return;
271
+ }
272
+
273
+ if (type === 'array') {
274
+ assertMarkerSchemaSupported(
275
+ nestedZodSchema(def['element'], [...path, '[]']),
276
+ [...path, '[]']
277
+ );
278
+ return;
279
+ }
280
+
281
+ if (type === 'object') {
282
+ if (def['catchall'] !== undefined) {
283
+ throw new ValidationError(
284
+ `Trail version marker schema at ${markerSchemaPath(path)} uses unsupported object catchall or unknown-key policy`
285
+ );
286
+ }
287
+
288
+ const { shape } = def;
289
+ if (shape === undefined) {
290
+ return;
291
+ }
292
+ if (!isPlainObject(shape)) {
293
+ throw new ValidationError(
294
+ `Trail version marker schema at ${markerSchemaPath(path)} has an unsupported object shape`
295
+ );
296
+ }
297
+
298
+ for (const [key, value] of Object.entries(shape).toSorted(
299
+ ([left], [right]) => left.localeCompare(right)
300
+ )) {
301
+ assertMarkerSchemaSupported(value, [...path, key], {
302
+ optionalWrapperAllowed: true,
303
+ });
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (wrappedMarkerSchemaTypes.has(type)) {
309
+ assertMarkerSchemaSupported(
310
+ nestedMarkerWrappedSchema(type, def, path, options),
311
+ path
312
+ );
313
+ return;
314
+ }
315
+
316
+ if (type === 'union') {
317
+ const unionOptions = def['options'];
318
+ if (!Array.isArray(unionOptions)) {
319
+ throw new ValidationError(
320
+ `Trail version marker schema at ${markerSchemaPath(path)} has unsupported union options`
321
+ );
322
+ }
323
+ for (const [index, option] of unionOptions.entries()) {
324
+ assertMarkerSchemaSupported(option, [...path, `option${index}`]);
325
+ }
326
+ }
327
+ };
328
+
329
+ const assertMarkerContentSupported = (
330
+ value: unknown,
331
+ path: readonly string[] = []
332
+ ): void => {
333
+ if (Array.isArray(value)) {
334
+ for (const [index, entry] of value.entries()) {
335
+ assertMarkerContentSupported(entry, [...path, String(index)]);
336
+ }
337
+ return;
338
+ }
339
+
340
+ if (value === null || typeof value !== 'object') {
341
+ return;
342
+ }
343
+
344
+ const record = value as Record<string, unknown>;
345
+ const keys = Object.keys(record);
346
+ if (keys.length === 0 && path.at(-1) !== 'properties') {
347
+ throw new ValidationError(
348
+ `Trail version marker content at ${markerValuePath(path)} contains an unsupported empty schema derivation`
349
+ );
350
+ }
351
+
352
+ for (const key of keys) {
353
+ assertMarkerContentSupported(record[key], [...path, key]);
354
+ }
355
+ };
356
+
357
+ const canonicalizeMarkerValue = (
358
+ value: unknown,
359
+ path: readonly string[],
360
+ seen: WeakSet<object>
361
+ ): unknown => {
362
+ if (
363
+ value === null ||
364
+ typeof value === 'boolean' ||
365
+ typeof value === 'number' ||
366
+ typeof value === 'string'
367
+ ) {
368
+ return value;
369
+ }
370
+
371
+ if (Array.isArray(value)) {
372
+ return value.map((entry, index) =>
373
+ canonicalizeMarkerValue(entry, [...path, String(index)], seen)
374
+ );
375
+ }
376
+
377
+ if (value === undefined) {
378
+ throw new ValidationError(
379
+ `Trail version marker content cannot contain undefined at ${markerValuePath(path)}`
380
+ );
381
+ }
382
+
383
+ if (typeof value === 'bigint' || typeof value === 'function') {
384
+ throw new ValidationError(
385
+ `Trail version marker content cannot contain ${typeof value} at ${markerValuePath(path)}`
386
+ );
387
+ }
388
+
389
+ if (typeof value === 'symbol') {
390
+ throw new ValidationError(
391
+ `Trail version marker content cannot contain symbol at ${markerValuePath(path)}`
392
+ );
393
+ }
394
+
395
+ if (!isPlainObject(value)) {
396
+ throw new ValidationError(
397
+ `Trail version marker content must be JSON-compatible at ${markerValuePath(path)}`
398
+ );
399
+ }
400
+
401
+ if (seen.has(value)) {
402
+ throw new ValidationError(
403
+ `Trail version marker content cannot contain circular references at ${markerValuePath(path)}`
404
+ );
405
+ }
406
+ seen.add(value);
407
+
408
+ const sorted: Record<string, unknown> = {};
409
+ for (const key of Object.keys(value).toSorted()) {
410
+ const next = value[key];
411
+ if (next !== undefined) {
412
+ sorted[key] = canonicalizeMarkerValue(next, [...path, key], seen);
413
+ }
414
+ }
415
+
416
+ seen.delete(value);
417
+ return sorted;
418
+ };
419
+
420
+ export const canonicalizeTrailVersionMarkerContent = (
421
+ content: unknown
422
+ ): unknown => canonicalizeMarkerValue(content, [], new WeakSet<object>());
423
+
424
+ export const deriveTrailVersionMarker = (content: unknown): string => {
425
+ assertMarkerContentSupported(content);
426
+ const hasher = new Bun.CryptoHasher('sha256');
427
+ hasher.update(JSON.stringify(canonicalizeTrailVersionMarkerContent(content)));
428
+ return hasher.digest('hex').slice(0, TRAIL_VERSION_MARKER_LENGTH);
429
+ };
430
+
431
+ const deriveSchema = (schema: unknown, path: readonly string[]): unknown => {
432
+ assertMarkerSchemaSupported(schema, path);
433
+ return canonicalizeTrailVersionMarkerContent(
434
+ zodToJsonSchema(schema as never)
435
+ );
436
+ };
437
+
438
+ const deriveVersionDetours = (
439
+ entry: unknown
440
+ ): readonly Record<string, unknown>[] | undefined => {
441
+ const raw = entry as unknown as Record<string, unknown>;
442
+ const { detours } = raw;
443
+ if (!Array.isArray(detours) || detours.length === 0) {
444
+ return undefined;
445
+ }
446
+
447
+ return detours.map((detour) => {
448
+ const candidate = detour as {
449
+ readonly maxAttempts?: number | undefined;
450
+ readonly on?: { readonly name?: string | undefined } | undefined;
451
+ };
452
+ return {
453
+ maxAttempts: Math.max(
454
+ 1,
455
+ Math.min(candidate.maxAttempts ?? 1, DETOUR_MAX_ATTEMPTS_CAP)
456
+ ),
457
+ on: candidate.on?.name ?? 'Error',
458
+ };
459
+ });
460
+ };
461
+
462
+ const deriveVersionRuntimeRefs = (
463
+ entry: unknown,
464
+ field: 'composes' | 'resources'
465
+ ): readonly string[] | undefined => {
466
+ const raw = entry as unknown as Record<string, unknown>;
467
+ const values = raw[field];
468
+ if (!Array.isArray(values) || values.length === 0) {
469
+ return undefined;
470
+ }
471
+
472
+ const refs: string[] = [];
473
+ for (const value of values) {
474
+ if (typeof value === 'string') {
475
+ refs.push(value);
476
+ continue;
477
+ }
478
+ if (
479
+ typeof value === 'object' &&
480
+ value !== null &&
481
+ typeof (value as { readonly id?: unknown }).id === 'string'
482
+ ) {
483
+ refs.push((value as { readonly id: string }).id);
484
+ }
485
+ }
486
+
487
+ return refs.toSorted();
488
+ };
489
+
490
+ export const deriveCurrentTrailVersionMarkerContent = (
491
+ trail: Pick<
492
+ AnyTrail,
493
+ 'composes' | 'detours' | 'input' | 'output' | 'resources'
494
+ >
495
+ ): Readonly<Record<string, unknown>> => {
496
+ const content: Record<string, unknown> = {
497
+ input: deriveSchema(trail.input, ['input']),
498
+ kind: 'current',
499
+ ...(trail.output === undefined
500
+ ? {}
501
+ : { output: deriveSchema(trail.output, ['output']) }),
502
+ };
503
+
504
+ const composes = deriveVersionRuntimeRefs(trail, 'composes');
505
+ const resources = deriveVersionRuntimeRefs(trail, 'resources');
506
+ const detours = deriveVersionDetours(trail);
507
+ if (composes !== undefined) {
508
+ content['composes'] = composes;
509
+ }
510
+ if (resources !== undefined) {
511
+ content['resources'] = resources;
512
+ }
513
+ if (detours !== undefined) {
514
+ content['detours'] = detours;
515
+ }
516
+
517
+ return content;
518
+ };
519
+
520
+ export const deriveTrailVersionEntryMarkerContent = (
521
+ entry: TrailVersionEntry
522
+ ): Readonly<Record<string, unknown>> => {
523
+ const kind = getTrailVersionEntryKind(entry);
524
+ const content: Record<string, unknown> = {
525
+ input: deriveSchema(entry.input, ['input']),
526
+ kind,
527
+ output: deriveSchema(entry.output, ['output']),
528
+ };
529
+
530
+ if (kind === 'revision' && entry.transpose !== undefined) {
531
+ content['transpose'] = { input: true, output: true };
532
+ }
533
+
534
+ if (kind === 'fork') {
535
+ const composes = deriveVersionRuntimeRefs(entry, 'composes');
536
+ const resources = deriveVersionRuntimeRefs(entry, 'resources');
537
+ const detours = deriveVersionDetours(entry);
538
+ if (composes !== undefined) {
539
+ content['composes'] = composes;
540
+ }
541
+ if (resources !== undefined) {
542
+ content['resources'] = resources;
543
+ }
544
+ if (detours !== undefined) {
545
+ content['detours'] = detours;
546
+ }
547
+ }
548
+
549
+ return content;
550
+ };
551
+
552
+ export const deriveCurrentTrailVersionMarker = (
553
+ trail: Pick<
554
+ AnyTrail,
555
+ 'composes' | 'detours' | 'input' | 'output' | 'resources'
556
+ >
557
+ ): string =>
558
+ deriveTrailVersionMarker(deriveCurrentTrailVersionMarkerContent(trail));
559
+
560
+ export const deriveTrailVersionEntryMarker = (
561
+ entry: TrailVersionEntry
562
+ ): string =>
563
+ deriveTrailVersionMarker(deriveTrailVersionEntryMarkerContent(entry));
564
+
565
+ export const assertTrailVersionMarker = (marker: string): void => {
566
+ if (!markerPattern.test(marker)) {
567
+ throw new ValidationError(
568
+ `Trail version marker must be a ${TRAIL_VERSION_MARKER_LENGTH}-character lowercase SHA-256 prefix`
569
+ );
570
+ }
571
+ };
572
+
573
+ export const normalizeTrailVersionMarkerPrefix = (prefix: string): string => {
574
+ const normalized = prefix.toLowerCase();
575
+ if (
576
+ normalized.length < TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH ||
577
+ normalized.length > TRAIL_VERSION_MARKER_LENGTH ||
578
+ !markerPrefixPattern.test(normalized)
579
+ ) {
580
+ throw new ValidationError(
581
+ `Trail version marker prefix must be ${TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH}-${TRAIL_VERSION_MARKER_LENGTH} lowercase hexadecimal characters`
582
+ );
583
+ }
584
+ return normalized;
585
+ };
586
+
587
+ export const assertUniqueTrailVersionMarkers = (
588
+ trailId: string,
589
+ markers: readonly TrailVersionMarkerBinding[]
590
+ ): void => {
591
+ const byMarker = new Map<string, number[]>();
592
+ for (const { marker, version } of markers) {
593
+ assertTrailVersionMarker(marker);
594
+ const versions = byMarker.get(marker) ?? [];
595
+ versions.push(version);
596
+ byMarker.set(marker, versions);
597
+ }
598
+
599
+ for (const [marker, versions] of byMarker) {
600
+ if (versions.length > 1) {
601
+ throw new ValidationError(
602
+ `Trail "${trailId}" versions ${versions.join(', ')} derive the same marker ${marker}`
603
+ );
604
+ }
605
+ }
606
+ };
607
+
608
+ export const deriveTrailVersionMarkers = (
609
+ trail: Pick<
610
+ AnyTrail,
611
+ | 'composes'
612
+ | 'detours'
613
+ | 'id'
614
+ | 'input'
615
+ | 'output'
616
+ | 'resources'
617
+ | 'version'
618
+ | 'versions'
619
+ >
620
+ ): readonly TrailVersionMarkerRecord[] => {
621
+ if (trail.version === undefined) {
622
+ return [];
623
+ }
624
+
625
+ const records: TrailVersionMarkerRecord[] = [
626
+ {
627
+ current: true,
628
+ kind: 'current',
629
+ marker: deriveCurrentTrailVersionMarker(trail),
630
+ supported: true,
631
+ version: trail.version,
632
+ },
633
+ ];
634
+
635
+ for (const [rawVersion, entry] of Object.entries(
636
+ trail.versions ?? {}
637
+ ).toSorted(([left], [right]) => Number(left) - Number(right))) {
638
+ const kind = getTrailVersionEntryKind(entry);
639
+ records.push({
640
+ current: false,
641
+ kind,
642
+ marker: deriveTrailVersionEntryMarker(entry),
643
+ supported: !isArchivedTrailVersionEntry(entry),
644
+ version: Number(rawVersion),
645
+ });
646
+ }
647
+
648
+ assertUniqueTrailVersionMarkers(trail.id, records);
649
+ return Object.freeze(records);
650
+ };
651
+
652
+ export const deriveShortestUnambiguousTrailVersionMarkerPrefix = (
653
+ marker: string,
654
+ markers: readonly string[],
655
+ minLength = TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH
656
+ ): string => {
657
+ assertTrailVersionMarker(marker);
658
+ const normalizedMarkers = markers.map((candidate) => {
659
+ assertTrailVersionMarker(candidate);
660
+ return candidate;
661
+ });
662
+ if (!normalizedMarkers.includes(marker)) {
663
+ throw new ValidationError(
664
+ `Trail version marker ${marker} is not in the provided marker set`
665
+ );
666
+ }
667
+
668
+ for (
669
+ let length = Math.max(minLength, TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH);
670
+ length <= TRAIL_VERSION_MARKER_LENGTH;
671
+ length += 1
672
+ ) {
673
+ const prefix = marker.slice(0, length);
674
+ const matches = normalizedMarkers.filter((candidate) =>
675
+ candidate.startsWith(prefix)
676
+ );
677
+ if (matches.length === 1) {
678
+ return prefix;
679
+ }
680
+ }
681
+
682
+ throw new AmbiguousError(
683
+ `Trail version marker ${marker} has no unambiguous display prefix`
684
+ );
685
+ };
686
+
687
+ export const resolveTrailVersionMarkerPrefix = (
688
+ markers: readonly TrailVersionMarkerBinding[],
689
+ prefix: string
690
+ ): TrailVersionMarkerResolution => {
691
+ const normalized = normalizeTrailVersionMarkerPrefix(prefix);
692
+ const matches = markers.filter((candidate) =>
693
+ candidate.marker.startsWith(normalized)
694
+ );
695
+
696
+ if (matches.length === 0) {
697
+ throw new ValidationError(
698
+ `No trail version marker matches prefix ${normalized}`
699
+ );
700
+ }
701
+
702
+ if (matches.length > 1) {
703
+ throw new AmbiguousError(
704
+ `Trail version marker prefix ${normalized} is ambiguous across versions ${matches.map((candidate) => candidate.version).join(', ')}`
705
+ );
706
+ }
707
+
708
+ const [match] = matches;
709
+ if (match === undefined) {
710
+ throw new ValidationError(
711
+ `No trail version marker matches prefix ${normalized}`
712
+ );
713
+ }
714
+
715
+ return { ...match, prefix: normalized };
716
+ };