@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.
- package/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { AnySignal } from './signal.js';
|
|
4
|
+
|
|
5
|
+
export const activationSourceKinds = Object.freeze([
|
|
6
|
+
'queue',
|
|
7
|
+
'signal',
|
|
8
|
+
'schedule',
|
|
9
|
+
'webhook',
|
|
10
|
+
] as const);
|
|
11
|
+
|
|
12
|
+
export type BuiltinActivationSourceKind =
|
|
13
|
+
(typeof activationSourceKinds)[number];
|
|
14
|
+
|
|
15
|
+
export type ActivationSourceKind = string;
|
|
16
|
+
|
|
17
|
+
export type ActivationSourceMeta = Readonly<Record<string, unknown>>;
|
|
18
|
+
|
|
19
|
+
export type ActivationSourceParse<TPayload = unknown> =
|
|
20
|
+
| z.ZodType<TPayload>
|
|
21
|
+
| {
|
|
22
|
+
readonly output?: z.ZodType<TPayload> | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface ActivationSource {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly kind: ActivationSourceKind;
|
|
28
|
+
readonly cron?: string | undefined;
|
|
29
|
+
readonly input?: unknown;
|
|
30
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
31
|
+
readonly method?: string | undefined;
|
|
32
|
+
readonly parse?: ActivationSourceParse | undefined;
|
|
33
|
+
readonly path?: string | undefined;
|
|
34
|
+
readonly payload?: z.ZodType<unknown> | undefined;
|
|
35
|
+
readonly queue?: string | undefined;
|
|
36
|
+
readonly timezone?: string | undefined;
|
|
37
|
+
readonly verify?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ActivationWhereExample {
|
|
41
|
+
readonly input?: unknown;
|
|
42
|
+
readonly on: boolean;
|
|
43
|
+
readonly payload?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* oxlint-disable no-explicit-any -- contextual predicate authoring needs source-specific payload inference; unknown would make inline predicates unusable until a helper API exists. */
|
|
47
|
+
export type ActivationWherePredicate<TPayload = any> = (
|
|
48
|
+
payload: TPayload
|
|
49
|
+
) => boolean | Promise<boolean>;
|
|
50
|
+
|
|
51
|
+
export interface ActivationWhere<TPayload = any> {
|
|
52
|
+
readonly examples?: readonly ActivationWhereExample[] | undefined;
|
|
53
|
+
readonly predicate: ActivationWherePredicate<TPayload>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ActivationWhereSpec<TPayload = any> =
|
|
57
|
+
| ActivationWhere<TPayload>
|
|
58
|
+
| ActivationWherePredicate<TPayload>;
|
|
59
|
+
|
|
60
|
+
export type ActivationSourceRef = string | AnySignal | ActivationSource;
|
|
61
|
+
|
|
62
|
+
export interface ActivationEntrySpec {
|
|
63
|
+
readonly source: ActivationSourceRef;
|
|
64
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
65
|
+
readonly where?: ActivationWhereSpec | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ActivationEntry {
|
|
69
|
+
readonly source: ActivationSource;
|
|
70
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
71
|
+
readonly where?: ActivationWhereSpec | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const getActivationWherePredicate = (
|
|
75
|
+
where: ActivationWhereSpec | undefined
|
|
76
|
+
): ActivationWherePredicate | undefined =>
|
|
77
|
+
typeof where === 'function' ? where : where?.predicate;
|
|
78
|
+
|
|
79
|
+
export const isKnownActivationSourceKind = (
|
|
80
|
+
kind: string
|
|
81
|
+
): kind is BuiltinActivationSourceKind =>
|
|
82
|
+
(activationSourceKinds as readonly string[]).includes(kind);
|
|
83
|
+
|
|
84
|
+
export const isActivationEntrySpec = (
|
|
85
|
+
value: unknown
|
|
86
|
+
): value is ActivationEntrySpec =>
|
|
87
|
+
typeof value === 'object' && value !== null && 'source' in value;
|
|
88
|
+
|
|
89
|
+
export const isActivationSource = (value: unknown): value is ActivationSource =>
|
|
90
|
+
typeof value === 'object' &&
|
|
91
|
+
value !== null &&
|
|
92
|
+
'id' in value &&
|
|
93
|
+
'kind' in value;
|
package/src/blob-ref.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlobRef — a frozen reference to binary data for @ontrails/core.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
/** Metadata key used to recognize BlobRef schemas during JSON Schema derivation. */
|
|
8
|
+
export const BLOB_REF_SCHEMA_META_KEY = 'ontrails/blob-ref';
|
|
9
|
+
|
|
10
|
+
/** Immutable reference to a blob of binary data. */
|
|
11
|
+
export interface BlobRef {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly mimeType: string;
|
|
14
|
+
readonly size: number;
|
|
15
|
+
readonly data: Uint8Array | ReadableStream<Uint8Array>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Schema-derived metadata for a BlobRef value. */
|
|
19
|
+
export interface BlobRefDescriptor {
|
|
20
|
+
readonly kind: 'blob';
|
|
21
|
+
readonly mimeType: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly size: number;
|
|
24
|
+
readonly uri: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Public descriptor schema emitted to transport clients instead of raw bytes. */
|
|
28
|
+
export const blobRefDescriptorSchema = z.object({
|
|
29
|
+
kind: z.literal('blob'),
|
|
30
|
+
mimeType: z.string(),
|
|
31
|
+
name: z.string(),
|
|
32
|
+
size: z.number(),
|
|
33
|
+
uri: z.string(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Canonical JSON Schema for BlobRef descriptors across derived surfaces. */
|
|
37
|
+
export const blobRefJsonSchema = Object.freeze({
|
|
38
|
+
properties: Object.freeze({
|
|
39
|
+
kind: Object.freeze({ const: 'blob' }),
|
|
40
|
+
mimeType: Object.freeze({ type: 'string' }),
|
|
41
|
+
name: Object.freeze({ type: 'string' }),
|
|
42
|
+
size: Object.freeze({ type: 'number' }),
|
|
43
|
+
uri: Object.freeze({ type: 'string' }),
|
|
44
|
+
}),
|
|
45
|
+
required: Object.freeze(['kind', 'mimeType', 'name', 'size', 'uri']),
|
|
46
|
+
type: 'object',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Creates a frozen BlobRef. */
|
|
50
|
+
export const createBlobRef = (options: {
|
|
51
|
+
name: string;
|
|
52
|
+
mimeType: string;
|
|
53
|
+
size: number;
|
|
54
|
+
data: Uint8Array | ReadableStream<Uint8Array>;
|
|
55
|
+
}): BlobRef =>
|
|
56
|
+
Object.freeze({
|
|
57
|
+
data: options.data,
|
|
58
|
+
mimeType: options.mimeType,
|
|
59
|
+
name: options.name,
|
|
60
|
+
size: options.size,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Type guard for BlobRef-shaped values. */
|
|
64
|
+
export const isBlobRef = (value: unknown): value is BlobRef => {
|
|
65
|
+
if (typeof value !== 'object' || value === null) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const obj = value as Record<string, unknown>;
|
|
69
|
+
return (
|
|
70
|
+
typeof obj['name'] === 'string' &&
|
|
71
|
+
typeof obj['mimeType'] === 'string' &&
|
|
72
|
+
typeof obj['size'] === 'number' &&
|
|
73
|
+
(obj['data'] instanceof Uint8Array || obj['data'] instanceof ReadableStream)
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Zod schema for runtime BlobRef values with metadata for descriptor derivation. */
|
|
78
|
+
export const blobRefSchema = z
|
|
79
|
+
.custom<BlobRef>(isBlobRef, { error: 'Expected BlobRef' })
|
|
80
|
+
.meta({ [BLOB_REF_SCHEMA_META_KEY]: true });
|
|
81
|
+
|
|
82
|
+
/** Convert a runtime BlobRef into its schema-aware transport descriptor. */
|
|
83
|
+
export const toBlobRefDescriptor = (blob: BlobRef): BlobRefDescriptor =>
|
|
84
|
+
Object.freeze({
|
|
85
|
+
kind: 'blob',
|
|
86
|
+
mimeType: blob.mimeType,
|
|
87
|
+
name: blob.name,
|
|
88
|
+
size: blob.size,
|
|
89
|
+
uri: `blob://${blob.name}`,
|
|
90
|
+
});
|
package/src/branded.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded types and validated constructors for @ontrails/core.
|
|
3
|
+
*
|
|
4
|
+
* Branded types enforce domain constraints at the type level while remaining
|
|
5
|
+
* plain primitives at runtime. Factory functions return Result so callers
|
|
6
|
+
* handle validation failures explicitly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ValidationError } from './errors.js';
|
|
10
|
+
import type { Result } from './result.js';
|
|
11
|
+
import { Result as R } from './result.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Branding primitive
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Attach a phantom tag to a base type. */
|
|
18
|
+
export type Branded<T, Tag extends string> = T & { readonly __brand: Tag };
|
|
19
|
+
|
|
20
|
+
/** Brand a value. No validation — use factory functions for safe construction. */
|
|
21
|
+
export const brand = <T, Tag extends string>(
|
|
22
|
+
_tag: Tag,
|
|
23
|
+
value: T
|
|
24
|
+
): Branded<T, Tag> => value as Branded<T, Tag>;
|
|
25
|
+
|
|
26
|
+
/** Strip the brand and recover the underlying value. */
|
|
27
|
+
export const unbrand = <T>(value: Branded<T, string>): T => value as T;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Built-in branded types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type UUID = Branded<string, 'UUID'>;
|
|
34
|
+
export type Email = Branded<string, 'Email'>;
|
|
35
|
+
export type NonEmptyString = Branded<string, 'NonEmptyString'>;
|
|
36
|
+
export type PositiveInt = Branded<number, 'PositiveInt'>;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Validation patterns
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const UUID_RE =
|
|
43
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
44
|
+
|
|
45
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Factory functions — each returns Result<BrandedType, ValidationError>
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export const uuid = (value: string): Result<UUID, ValidationError> => {
|
|
52
|
+
if (!UUID_RE.test(value)) {
|
|
53
|
+
return R.err(
|
|
54
|
+
new ValidationError(`Invalid UUID: "${value}"`, {
|
|
55
|
+
context: { value },
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return R.ok(value as UUID);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const email = (value: string): Result<Email, ValidationError> => {
|
|
63
|
+
if (!EMAIL_RE.test(value)) {
|
|
64
|
+
return R.err(
|
|
65
|
+
new ValidationError(`Invalid email: "${value}"`, {
|
|
66
|
+
context: { value },
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return R.ok(value as Email);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const nonEmptyString = (
|
|
74
|
+
value: string
|
|
75
|
+
): Result<NonEmptyString, ValidationError> => {
|
|
76
|
+
if (value.length === 0) {
|
|
77
|
+
return R.err(new ValidationError('String must not be empty'));
|
|
78
|
+
}
|
|
79
|
+
return R.ok(value as NonEmptyString);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const positiveInt = (
|
|
83
|
+
value: number
|
|
84
|
+
): Result<PositiveInt, ValidationError> => {
|
|
85
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
86
|
+
return R.err(
|
|
87
|
+
new ValidationError(`Expected positive integer, got ${value}`, {
|
|
88
|
+
context: { value },
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return R.ok(value as PositiveInt);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// ID utilities
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const ALPHANUMERIC =
|
|
100
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate a random alphanumeric ID.
|
|
104
|
+
* Runtime-agnostic: uses `crypto.getRandomValues`.
|
|
105
|
+
*/
|
|
106
|
+
export const shortId = (length = 8): string => {
|
|
107
|
+
const bytes = new Uint8Array(length);
|
|
108
|
+
crypto.getRandomValues(bytes);
|
|
109
|
+
let id = '';
|
|
110
|
+
for (let i = 0; i < length; i += 1) {
|
|
111
|
+
const byte = bytes[i];
|
|
112
|
+
if (byte !== undefined) {
|
|
113
|
+
id += ALPHANUMERIC[byte % ALPHANUMERIC.length];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return id;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Produce a deterministic hex hash from an input string.
|
|
121
|
+
* Uses a simple FNV-1a 32-bit hash — good enough for non-cryptographic IDs.
|
|
122
|
+
*/
|
|
123
|
+
export const deriveIdHash = (input: string): string => {
|
|
124
|
+
// FNV offset basis
|
|
125
|
+
let hash = 2_166_136_261;
|
|
126
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
127
|
+
// oxlint-disable-next-line no-bitwise -- FNV-1a hash requires XOR
|
|
128
|
+
hash ^= input.codePointAt(i) ?? 0;
|
|
129
|
+
// FNV prime
|
|
130
|
+
hash = Math.imul(hash, 0x01_00_01_93);
|
|
131
|
+
}
|
|
132
|
+
// Convert to unsigned 32-bit then hex
|
|
133
|
+
// oxlint-disable-next-line no-bitwise, prefer-math-trunc -- unsigned right shift needed for u32 conversion (Math.trunc differs semantically)
|
|
134
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
135
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection utilities and type helpers for @ontrails/core.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Type utilities
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Recursively make every property optional. */
|
|
10
|
+
export type DeepPartial<T> = {
|
|
11
|
+
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Flatten an intersection into a single object type for better IDE display. */
|
|
15
|
+
// oxlint-disable-next-line ban-types -- `& {}` is a standard TypeScript idiom to force type expansion in IDE tooltips
|
|
16
|
+
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
17
|
+
|
|
18
|
+
/** Require at least one property from T. */
|
|
19
|
+
export type AtLeastOne<T> = {
|
|
20
|
+
[K in keyof T]-?: Pick<T, K> & Partial<Omit<T, K>>;
|
|
21
|
+
}[keyof T];
|
|
22
|
+
|
|
23
|
+
/** A tuple with at least one element. */
|
|
24
|
+
export type NonEmptyArray<T> = [T, ...T[]];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Guards
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Narrows a readonly array to a NonEmptyArray. */
|
|
31
|
+
export const isNonEmptyArray = <T>(
|
|
32
|
+
array: readonly T[]
|
|
33
|
+
): array is NonEmptyArray<T> => array.length > 0;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Collection functions
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Split an array into chunks of at most `size` elements. */
|
|
40
|
+
export const chunk = <T>(array: readonly T[], size: number): T[][] => {
|
|
41
|
+
if (size < 1) {
|
|
42
|
+
throw new RangeError('chunk size must be >= 1');
|
|
43
|
+
}
|
|
44
|
+
const result: T[][] = [];
|
|
45
|
+
for (let i = 0; i < array.length; i += size) {
|
|
46
|
+
result.push(array.slice(i, i + size));
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove duplicate items. When `key` is provided, uniqueness is determined
|
|
53
|
+
* by the return value of the key function; otherwise strict equality is used.
|
|
54
|
+
*/
|
|
55
|
+
export const dedupe = <T>(
|
|
56
|
+
array: readonly T[],
|
|
57
|
+
key?: (item: T) => unknown
|
|
58
|
+
): T[] => {
|
|
59
|
+
if (!key) {
|
|
60
|
+
return [...new Set(array)];
|
|
61
|
+
}
|
|
62
|
+
const seen = new Set<unknown>();
|
|
63
|
+
const result: T[] = [];
|
|
64
|
+
for (const item of array) {
|
|
65
|
+
const k = key(item);
|
|
66
|
+
if (!seen.has(k)) {
|
|
67
|
+
seen.add(k);
|
|
68
|
+
result.push(item);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Group items by a string key. */
|
|
75
|
+
export const groupBy = <T>(
|
|
76
|
+
array: readonly T[],
|
|
77
|
+
key: (item: T) => string
|
|
78
|
+
): Record<string, T[]> => {
|
|
79
|
+
const groups: Record<string, T[]> = {};
|
|
80
|
+
for (const item of array) {
|
|
81
|
+
const k = key(item);
|
|
82
|
+
(groups[k] ??= []).push(item);
|
|
83
|
+
}
|
|
84
|
+
return groups;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Return a new sorted array based on a key function (ascending). */
|
|
88
|
+
export const sortBy = <T>(
|
|
89
|
+
array: readonly T[],
|
|
90
|
+
key: (item: T) => string | number
|
|
91
|
+
): T[] =>
|
|
92
|
+
[...array].toSorted((a, b) => {
|
|
93
|
+
const ka = key(a);
|
|
94
|
+
const kb = key(b);
|
|
95
|
+
if (typeof ka === 'number' && typeof kb === 'number') {
|
|
96
|
+
return ka - kb;
|
|
97
|
+
}
|
|
98
|
+
return String(ka).localeCompare(String(kb));
|
|
99
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for `ctx.compose([...])` batch execution.
|
|
3
|
+
*
|
|
4
|
+
* These helpers normalize batch options, produce validation results, and
|
|
5
|
+
* implement the unlimited/limited worker-pool execution strategies used by
|
|
6
|
+
* both the real executor (`packages/core/src/execute.ts`) and the scenario
|
|
7
|
+
* runner in `@ontrails/testing`. Extracting them here keeps the validation
|
|
8
|
+
* rule, error message, and worker-pool semantics authored in one place so
|
|
9
|
+
* the two call sites cannot drift.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ValidationError } from './errors.js';
|
|
13
|
+
import { Result } from './result.js';
|
|
14
|
+
import type { ComposeBatchOptions } from './types.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate the `concurrency` option on a batch `ctx.compose()` call.
|
|
18
|
+
*
|
|
19
|
+
* Returns `Ok(undefined)` when no limit is requested, `Ok(n)` when a
|
|
20
|
+
* positive integer is supplied, and `Err(ValidationError)` for any other
|
|
21
|
+
* value. The error message is load-bearing: callers and tests depend on
|
|
22
|
+
* the exact string.
|
|
23
|
+
*/
|
|
24
|
+
export const normalizeComposeBatchConcurrency = (
|
|
25
|
+
options: ComposeBatchOptions | undefined
|
|
26
|
+
): Result<number | undefined, Error> => {
|
|
27
|
+
const concurrency = options?.concurrency;
|
|
28
|
+
if (concurrency === undefined) {
|
|
29
|
+
return Result.ok();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
33
|
+
return Result.err(
|
|
34
|
+
new ValidationError(
|
|
35
|
+
'ctx.compose() batch concurrency must be a positive integer'
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Result.ok(concurrency);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Produce one validation-error result per call, preserving the original
|
|
45
|
+
* call order. Used when `normalizeComposeBatchConcurrency` fails so the caller
|
|
46
|
+
* can surface a uniform batch shape to the implementation.
|
|
47
|
+
*/
|
|
48
|
+
export const createComposeBatchValidationResults = <TCall>(
|
|
49
|
+
calls: readonly TCall[],
|
|
50
|
+
error: Error
|
|
51
|
+
): Result<unknown, Error>[] => calls.map(() => Result.err(error));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Claim the next branch index from a shared counter. Safe to call from
|
|
55
|
+
* multiple worker coroutines because JavaScript is single-threaded between
|
|
56
|
+
* awaits — the read/increment pair runs without interleaving.
|
|
57
|
+
*/
|
|
58
|
+
export const claimNextComposeBatchIndex = <TCall>(
|
|
59
|
+
nextIndex: { value: number },
|
|
60
|
+
calls: readonly TCall[]
|
|
61
|
+
): number | undefined => {
|
|
62
|
+
if (nextIndex.value >= calls.length) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const branchIndex = nextIndex.value;
|
|
67
|
+
nextIndex.value += 1;
|
|
68
|
+
return branchIndex;
|
|
69
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose-invocation schema merging for trails with `composeInput`.
|
|
3
|
+
*
|
|
4
|
+
* When a trail declares `composeInput`, callers via `ctx.compose()` pass both
|
|
5
|
+
* public input and composition-only fields. The merged schema validates the
|
|
6
|
+
* combined shape so `executeTrail` doesn't reject the extra fields.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import type { AnyTrail } from './trail.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the validation schema for a compose-invoked trail.
|
|
15
|
+
*
|
|
16
|
+
* When the target trail declares `composeInput`, returns the intersection of
|
|
17
|
+
* `trail.input` and `trail.composeInput`. Returns `undefined` when no
|
|
18
|
+
* `composeInput` is declared, signaling that normal input validation suffices.
|
|
19
|
+
*/
|
|
20
|
+
export const buildComposeValidationSchema = (
|
|
21
|
+
trailDef: AnyTrail
|
|
22
|
+
): z.ZodType | undefined => {
|
|
23
|
+
if (!trailDef.composeInput) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Prefer .merge() for ZodObject pairs — produces a proper merged object
|
|
27
|
+
// schema that strips unknown keys and exposes .shape. Fall back to
|
|
28
|
+
// z.intersection for non-object schemas.
|
|
29
|
+
if (
|
|
30
|
+
trailDef.input instanceof z.ZodObject &&
|
|
31
|
+
trailDef.composeInput instanceof z.ZodObject
|
|
32
|
+
) {
|
|
33
|
+
return trailDef.input.merge(trailDef.composeInput);
|
|
34
|
+
}
|
|
35
|
+
return z.intersection(trailDef.input, trailDef.composeInput);
|
|
36
|
+
};
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createResourceLookup } from './resource.js';
|
|
2
|
+
import type { TrailContext, TrailContextInit, TraceFn } from './types.js';
|
|
3
|
+
|
|
4
|
+
type MutableTrailContext = {
|
|
5
|
+
-readonly [K in keyof TrailContext]: TrailContext[K];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default passthrough `trace` used when a context is built outside
|
|
10
|
+
* `executeTrail`. `executeTrail` replaces this with a real sink-writing
|
|
11
|
+
* implementation. The passthrough runs `fn` without recording anything so
|
|
12
|
+
* direct `createTrailContext()` callers (tests, ad-hoc compositions) don't
|
|
13
|
+
* crash when invoking `ctx.trace(...)`.
|
|
14
|
+
*
|
|
15
|
+
* Declared `async` so both synchronous throws and async rejections from `fn`
|
|
16
|
+
* propagate as a rejected promise to the caller — matching the real
|
|
17
|
+
* sink-writing implementation's error semantics.
|
|
18
|
+
*/
|
|
19
|
+
export const passthroughTrace: TraceFn = async <T>(
|
|
20
|
+
_label: string,
|
|
21
|
+
fn: () => T | Promise<T>
|
|
22
|
+
): Promise<T> => await fn();
|
|
23
|
+
|
|
24
|
+
const defaultCwd = (): string =>
|
|
25
|
+
typeof process === 'undefined' ? '/' : process.cwd();
|
|
26
|
+
|
|
27
|
+
const defaultEnv = (): Record<string, string | undefined> =>
|
|
28
|
+
typeof process === 'undefined'
|
|
29
|
+
? {}
|
|
30
|
+
: (process.env as Record<string, string | undefined>);
|
|
31
|
+
|
|
32
|
+
const defaultRequestId = (): string =>
|
|
33
|
+
typeof Bun === 'undefined' ? crypto.randomUUID() : Bun.randomUUIDv7();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a TrailContext with sensible defaults.
|
|
37
|
+
*
|
|
38
|
+
* - `requestId` defaults to `Bun.randomUUIDv7()` (sortable v7 UUID), falling
|
|
39
|
+
* back to `crypto.randomUUID()` on runtimes without the `Bun` global
|
|
40
|
+
* - `abortSignal` defaults to a fresh, non-aborted `AbortSignal`
|
|
41
|
+
* - `cwd`/`env` default to the `process` globals when available, and to
|
|
42
|
+
* `'/'`/`{}` on runtimes without `process` (for example Cloudflare Workers)
|
|
43
|
+
* - All other fields come from `overrides`
|
|
44
|
+
*/
|
|
45
|
+
export const createTrailContext = (
|
|
46
|
+
overrides?: Partial<TrailContextInit>
|
|
47
|
+
): TrailContext => {
|
|
48
|
+
const ctx = {
|
|
49
|
+
abortSignal: new AbortController().signal,
|
|
50
|
+
cwd: defaultCwd(),
|
|
51
|
+
dryRun: false,
|
|
52
|
+
env: defaultEnv(),
|
|
53
|
+
requestId: defaultRequestId(),
|
|
54
|
+
trace: passthroughTrace,
|
|
55
|
+
...overrides,
|
|
56
|
+
} as MutableTrailContext;
|
|
57
|
+
const lookup = overrides?.resource ?? createResourceLookup(() => ctx);
|
|
58
|
+
ctx.resource = lookup;
|
|
59
|
+
if (ctx.trace === undefined) {
|
|
60
|
+
ctx.trace = passthroughTrace;
|
|
61
|
+
}
|
|
62
|
+
if (ctx.dryRun === undefined) {
|
|
63
|
+
ctx.dryRun = false;
|
|
64
|
+
}
|
|
65
|
+
return ctx;
|
|
66
|
+
};
|