@ontrails/core 1.0.0-beta.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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +179 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +2 -0
- package/dist/adapters.js.map +1 -0
- package/dist/blob-ref.d.ts +20 -0
- package/dist/blob-ref.d.ts.map +1 -0
- package/dist/blob-ref.js +22 -0
- package/dist/blob-ref.js.map +1 -0
- package/dist/branded.d.ts +36 -0
- package/dist/branded.d.ts.map +1 -0
- package/dist/branded.js +89 -0
- package/dist/branded.js.map +1 -0
- package/dist/collections.d.ts +31 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +60 -0
- package/dist/collections.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/context.js.map +1 -0
- package/dist/derive.d.ts +33 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +122 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +45 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +17 -0
- package/dist/event.js.map +1 -0
- package/dist/fetch.d.ts +15 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +102 -0
- package/dist/fetch.js.map +1 -0
- package/dist/guards.d.ts +17 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +25 -0
- package/dist/guards.js.map +1 -0
- package/dist/health.d.ts +18 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +5 -0
- package/dist/health.js.map +1 -0
- package/dist/hike.d.ts +36 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +20 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +24 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +17 -0
- package/dist/job.js.map +1 -0
- package/dist/layer.d.ts +17 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +21 -0
- package/dist/layer.js.map +1 -0
- package/dist/path-security.d.ts +28 -0
- package/dist/path-security.d.ts.map +1 -0
- package/dist/path-security.js +63 -0
- package/dist/path-security.js.map +1 -0
- package/dist/patterns/bulk.d.ts +15 -0
- package/dist/patterns/bulk.d.ts.map +1 -0
- package/dist/patterns/bulk.js +14 -0
- package/dist/patterns/bulk.js.map +1 -0
- package/dist/patterns/change.d.ts +10 -0
- package/dist/patterns/change.d.ts.map +1 -0
- package/dist/patterns/change.js +10 -0
- package/dist/patterns/change.js.map +1 -0
- package/dist/patterns/date-range.d.ts +10 -0
- package/dist/patterns/date-range.d.ts.map +1 -0
- package/dist/patterns/date-range.js +10 -0
- package/dist/patterns/date-range.js.map +1 -0
- package/dist/patterns/index.d.ts +9 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/pagination.d.ts +18 -0
- package/dist/patterns/pagination.d.ts.map +1 -0
- package/dist/patterns/pagination.js +18 -0
- package/dist/patterns/pagination.js.map +1 -0
- package/dist/patterns/progress.d.ts +11 -0
- package/dist/patterns/progress.d.ts.map +1 -0
- package/dist/patterns/progress.js +11 -0
- package/dist/patterns/progress.js.map +1 -0
- package/dist/patterns/sorting.d.ts +13 -0
- package/dist/patterns/sorting.d.ts.map +1 -0
- package/dist/patterns/sorting.js +10 -0
- package/dist/patterns/sorting.js.map +1 -0
- package/dist/patterns/status.d.ts +15 -0
- package/dist/patterns/status.d.ts.map +1 -0
- package/dist/patterns/status.js +9 -0
- package/dist/patterns/status.js.map +1 -0
- package/dist/patterns/timestamps.d.ts +10 -0
- package/dist/patterns/timestamps.d.ts.map +1 -0
- package/dist/patterns/timestamps.js +10 -0
- package/dist/patterns/timestamps.js.map +1 -0
- package/dist/redaction/index.d.ts +4 -0
- package/dist/redaction/index.d.ts.map +1 -0
- package/dist/redaction/index.js +3 -0
- package/dist/redaction/index.js.map +1 -0
- package/dist/redaction/patterns.d.ts +9 -0
- package/dist/redaction/patterns.d.ts.map +1 -0
- package/dist/redaction/patterns.js +39 -0
- package/dist/redaction/patterns.js.map +1 -0
- package/dist/redaction/redactor.d.ts +27 -0
- package/dist/redaction/redactor.d.ts.map +1 -0
- package/dist/redaction/redactor.js +89 -0
- package/dist/redaction/redactor.js.map +1 -0
- package/dist/resilience.d.ts +34 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +164 -0
- package/dist/resilience.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +145 -0
- package/dist/result.js.map +1 -0
- package/dist/serialization.d.ts +27 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +115 -0
- package/dist/serialization.js.map +1 -0
- package/dist/topo.d.ts +18 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +74 -0
- package/dist/topo.js.map +1 -0
- package/dist/trail.d.ts +83 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +16 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-topo.d.ts +24 -0
- package/dist/validate-topo.d.ts.map +1 -0
- package/dist/validate-topo.js +108 -0
- package/dist/validate-topo.js.map +1 -0
- package/dist/validation.d.ts +27 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +57 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/blob-ref.test.ts +103 -0
- package/src/__tests__/branded.test.ts +148 -0
- package/src/__tests__/collections.test.ts +126 -0
- package/src/__tests__/context.test.ts +66 -0
- package/src/__tests__/derive.test.ts +159 -0
- package/src/__tests__/errors.test.ts +309 -0
- package/src/__tests__/event.test.ts +82 -0
- package/src/__tests__/fetch.test.ts +217 -0
- package/src/__tests__/guards.test.ts +102 -0
- package/src/__tests__/hike.test.ts +117 -0
- package/src/__tests__/job.test.ts +98 -0
- package/src/__tests__/layer.test.ts +224 -0
- package/src/__tests__/path-security.test.ts +114 -0
- package/src/__tests__/patterns.test.ts +273 -0
- package/src/__tests__/redaction.test.ts +244 -0
- package/src/__tests__/resilience.test.ts +246 -0
- package/src/__tests__/result.test.ts +155 -0
- package/src/__tests__/serialization.test.ts +236 -0
- package/src/__tests__/topo.test.ts +184 -0
- package/src/__tests__/trail.test.ts +179 -0
- package/src/__tests__/validate-topo.test.ts +201 -0
- package/src/__tests__/validation.test.ts +283 -0
- package/src/__tests__/workspace.test.ts +183 -0
- package/src/adapters.ts +68 -0
- package/src/blob-ref.ts +39 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/context.ts +18 -0
- package/src/derive.ts +223 -0
- package/src/errors.ts +196 -0
- package/src/event.ts +77 -0
- package/src/fetch.ts +138 -0
- package/src/guards.ts +37 -0
- package/src/health.ts +23 -0
- package/src/hike.ts +77 -0
- package/src/index.ts +158 -0
- package/src/job.ts +20 -0
- package/src/layer.ts +44 -0
- package/src/path-security.ts +90 -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/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +47 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/result.ts +180 -0
- package/src/serialization.ts +183 -0
- package/src/topo.ts +123 -0
- package/src/trail.ts +130 -0
- package/src/types.ts +58 -0
- package/src/validate-topo.ts +151 -0
- package/src/validation.ts +182 -0
- package/src/workspace.ts +77 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Result } from './result.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Shared option types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Options for search queries */
|
|
8
|
+
export interface SearchOptions {
|
|
9
|
+
readonly limit?: number | undefined;
|
|
10
|
+
readonly offset?: number | undefined;
|
|
11
|
+
readonly filters?: Readonly<Record<string, unknown>> | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A single search hit */
|
|
15
|
+
export interface SearchResult {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly score: number;
|
|
18
|
+
readonly document: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Options for storage writes */
|
|
22
|
+
export interface StorageOptions {
|
|
23
|
+
/** Time-to-live in milliseconds */
|
|
24
|
+
readonly ttl?: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Adapter port interfaces
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Full-text / vector index adapter */
|
|
32
|
+
export interface IndexAdapter {
|
|
33
|
+
index(
|
|
34
|
+
id: string,
|
|
35
|
+
document: Readonly<Record<string, unknown>>
|
|
36
|
+
): Promise<Result<void, Error>>;
|
|
37
|
+
|
|
38
|
+
search(
|
|
39
|
+
query: string,
|
|
40
|
+
options?: SearchOptions
|
|
41
|
+
): Promise<Result<readonly SearchResult[], Error>>;
|
|
42
|
+
|
|
43
|
+
remove(id: string): Promise<Result<void, Error>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Key-value storage adapter */
|
|
47
|
+
export interface StorageAdapter {
|
|
48
|
+
get(key: string): Promise<Result<unknown, Error>>;
|
|
49
|
+
set(
|
|
50
|
+
key: string,
|
|
51
|
+
value: unknown,
|
|
52
|
+
options?: StorageOptions
|
|
53
|
+
): Promise<Result<void, Error>>;
|
|
54
|
+
delete(key: string): Promise<Result<void, Error>>;
|
|
55
|
+
has(key: string): Promise<Result<boolean, Error>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Cache adapter with typed get/set and bulk clear */
|
|
59
|
+
export interface CacheAdapter {
|
|
60
|
+
get<T>(key: string): Promise<Result<T | undefined, Error>>;
|
|
61
|
+
set<T>(
|
|
62
|
+
key: string,
|
|
63
|
+
value: T,
|
|
64
|
+
options?: StorageOptions
|
|
65
|
+
): Promise<Result<void, Error>>;
|
|
66
|
+
delete(key: string): Promise<Result<void, Error>>;
|
|
67
|
+
clear(): Promise<Result<void, Error>>;
|
|
68
|
+
}
|
package/src/blob-ref.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlobRef — a frozen reference to binary data for @ontrails/core.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Immutable reference to a blob of binary data. */
|
|
6
|
+
export interface BlobRef {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly mimeType: string;
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly data: Uint8Array | ReadableStream<Uint8Array>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Creates a frozen BlobRef. */
|
|
14
|
+
export const createBlobRef = (options: {
|
|
15
|
+
name: string;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
size: number;
|
|
18
|
+
data: Uint8Array | ReadableStream<Uint8Array>;
|
|
19
|
+
}): BlobRef =>
|
|
20
|
+
Object.freeze({
|
|
21
|
+
data: options.data,
|
|
22
|
+
mimeType: options.mimeType,
|
|
23
|
+
name: options.name,
|
|
24
|
+
size: options.size,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Type guard for BlobRef-shaped values. */
|
|
28
|
+
export const isBlobRef = (value: unknown): value is BlobRef => {
|
|
29
|
+
if (typeof value !== 'object' || value === null) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const obj = value as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
typeof obj['name'] === 'string' &&
|
|
35
|
+
typeof obj['mimeType'] === 'string' &&
|
|
36
|
+
typeof obj['size'] === 'number' &&
|
|
37
|
+
(obj['data'] instanceof Uint8Array || obj['data'] instanceof ReadableStream)
|
|
38
|
+
);
|
|
39
|
+
};
|
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 hashId = (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
|
+
});
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TrailContext } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a TrailContext with sensible defaults.
|
|
5
|
+
*
|
|
6
|
+
* - `requestId` defaults to `Bun.randomUUIDv7()` (sortable v7 UUID)
|
|
7
|
+
* - `signal` defaults to a fresh, non-aborted `AbortSignal`
|
|
8
|
+
* - All other fields come from `overrides`
|
|
9
|
+
*/
|
|
10
|
+
export const createTrailContext = (
|
|
11
|
+
overrides?: Partial<TrailContext>
|
|
12
|
+
): TrailContext => ({
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
env: process.env as Record<string, string | undefined>,
|
|
15
|
+
requestId: Bun.randomUUIDv7(),
|
|
16
|
+
signal: new AbortController().signal,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
package/src/derive.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven field derivation for @ontrails/core
|
|
3
|
+
*
|
|
4
|
+
* Introspects Zod v4 schemas to produce a surface-agnostic Field[] descriptor
|
|
5
|
+
* that UI layers (CLI prompts, web forms, etc.) can consume.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Public types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** A surface-agnostic field descriptor derived from a Zod schema. */
|
|
15
|
+
export interface Field {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly type: 'string' | 'number' | 'boolean' | 'enum' | 'multiselect';
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly required: boolean;
|
|
20
|
+
readonly default?: unknown | undefined;
|
|
21
|
+
readonly options?:
|
|
22
|
+
| readonly {
|
|
23
|
+
value: string;
|
|
24
|
+
label?: string | undefined;
|
|
25
|
+
hint?: string | undefined;
|
|
26
|
+
}[]
|
|
27
|
+
| undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Per-field overrides supplied by trail authors. */
|
|
31
|
+
export interface FieldOverride {
|
|
32
|
+
readonly label?: string | undefined;
|
|
33
|
+
readonly message?: string | undefined;
|
|
34
|
+
readonly hint?: string | undefined;
|
|
35
|
+
readonly options?:
|
|
36
|
+
| readonly {
|
|
37
|
+
value: string;
|
|
38
|
+
label: string;
|
|
39
|
+
hint?: string | undefined;
|
|
40
|
+
}[]
|
|
41
|
+
| undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Zod v4 internals accessor
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
interface ZodInternals {
|
|
49
|
+
readonly _zod: {
|
|
50
|
+
readonly def: Readonly<Record<string, unknown>>;
|
|
51
|
+
readonly traits: ReadonlySet<string>;
|
|
52
|
+
};
|
|
53
|
+
readonly description?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Convert camelCase / PascalCase to "Title Case" label. */
|
|
61
|
+
const humanize = (str: string): string =>
|
|
62
|
+
str
|
|
63
|
+
.replaceAll(/([a-z])([A-Z])/g, '$1 $2')
|
|
64
|
+
.replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
65
|
+
.replace(/^./, (ch) => ch.toUpperCase());
|
|
66
|
+
|
|
67
|
+
interface UnwrapResult {
|
|
68
|
+
defaultValue: unknown;
|
|
69
|
+
description: string | undefined;
|
|
70
|
+
inner: ZodInternals;
|
|
71
|
+
required: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get the inner type from an optional or default wrapper. */
|
|
75
|
+
const getInnerType = (current: ZodInternals): ZodInternals =>
|
|
76
|
+
current._zod.def['innerType'] as ZodInternals;
|
|
77
|
+
|
|
78
|
+
/** Propagate description from inner to state if present. */
|
|
79
|
+
const propagateDescription = (
|
|
80
|
+
inner: ZodInternals,
|
|
81
|
+
state: { description: string | undefined }
|
|
82
|
+
): void => {
|
|
83
|
+
if (inner.description) {
|
|
84
|
+
state.description = inner.description;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Step one level of optional/default unwrapping. Returns null if not a wrapper type. */
|
|
89
|
+
const unwrapStep = (
|
|
90
|
+
current: ZodInternals,
|
|
91
|
+
state: {
|
|
92
|
+
defaultValue: unknown;
|
|
93
|
+
description: string | undefined;
|
|
94
|
+
required: boolean;
|
|
95
|
+
}
|
|
96
|
+
): ZodInternals | null => {
|
|
97
|
+
const defType = current._zod.def['type'] as string;
|
|
98
|
+
if (defType !== 'optional' && defType !== 'default') {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
state.required = false;
|
|
102
|
+
if (defType === 'default') {
|
|
103
|
+
state.defaultValue = current._zod.def['defaultValue'];
|
|
104
|
+
}
|
|
105
|
+
const inner = getInnerType(current);
|
|
106
|
+
propagateDescription(inner, state);
|
|
107
|
+
return inner;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Unwrap optional / default wrappers, collecting metadata. */
|
|
111
|
+
const unwrap = (s: ZodInternals): UnwrapResult => {
|
|
112
|
+
const state = {
|
|
113
|
+
defaultValue: undefined as unknown,
|
|
114
|
+
description: s.description,
|
|
115
|
+
required: true,
|
|
116
|
+
};
|
|
117
|
+
let current = s;
|
|
118
|
+
|
|
119
|
+
// eslint-disable-next-line no-constant-condition
|
|
120
|
+
while (true) {
|
|
121
|
+
const next = unwrapStep(current, state);
|
|
122
|
+
if (next === null) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
current = next;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ...state, inner: current };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
interface DerivedFieldType {
|
|
132
|
+
options: string[] | undefined;
|
|
133
|
+
type: Field['type'];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
|
|
137
|
+
array: (s) => {
|
|
138
|
+
const element = s._zod.def['element'] as unknown as ZodInternals;
|
|
139
|
+
const elementType = element._zod.def['type'] as string;
|
|
140
|
+
if (elementType === 'enum') {
|
|
141
|
+
const entries = element._zod.def['entries'] as Record<string, string>;
|
|
142
|
+
return { options: Object.values(entries), type: 'multiselect' };
|
|
143
|
+
}
|
|
144
|
+
return { options: undefined, type: 'string' };
|
|
145
|
+
},
|
|
146
|
+
boolean: () => ({ options: undefined, type: 'boolean' }),
|
|
147
|
+
enum: (s) => {
|
|
148
|
+
const entries = s._zod.def['entries'] as Record<string, string>;
|
|
149
|
+
return { options: Object.values(entries), type: 'enum' };
|
|
150
|
+
},
|
|
151
|
+
number: () => ({ options: undefined, type: 'number' }),
|
|
152
|
+
string: () => ({ options: undefined, type: 'string' }),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Derive field type and raw options from the unwrapped Zod def. */
|
|
156
|
+
const deriveFieldType = (s: ZodInternals): DerivedFieldType => {
|
|
157
|
+
const defType = s._zod.def['type'] as string;
|
|
158
|
+
const derive = fieldTypeByDef[defType];
|
|
159
|
+
return derive ? derive(s) : { options: undefined, type: 'string' };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Build options array, merging with overrides when present. */
|
|
163
|
+
const buildOptions = (
|
|
164
|
+
rawOptions: string[] | undefined,
|
|
165
|
+
overrideOptions: FieldOverride['options'] | undefined
|
|
166
|
+
): Field['options'] | undefined => {
|
|
167
|
+
if (!rawOptions) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!overrideOptions) {
|
|
172
|
+
return rawOptions.map((v) => ({ value: v }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const overrideMap = new Map(overrideOptions.map((o) => [o.value, o]));
|
|
176
|
+
return rawOptions.map((v) => {
|
|
177
|
+
const ov = overrideMap.get(v);
|
|
178
|
+
return ov ? { hint: ov.hint, label: ov.label, value: v } : { value: v };
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Public API
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Derive a surface-agnostic Field[] from a Zod object schema.
|
|
188
|
+
*
|
|
189
|
+
* Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
|
|
190
|
+
*/
|
|
191
|
+
/** Derive a single field from a shape entry. */
|
|
192
|
+
const deriveField = (
|
|
193
|
+
key: string,
|
|
194
|
+
value: ZodInternals,
|
|
195
|
+
overrides?: Record<string, FieldOverride>
|
|
196
|
+
): Field => {
|
|
197
|
+
const { inner, required, defaultValue, description } = unwrap(value);
|
|
198
|
+
const { type, options: rawOptions } = deriveFieldType(inner);
|
|
199
|
+
const override = overrides?.[key];
|
|
200
|
+
const label = override?.label ?? description ?? humanize(key);
|
|
201
|
+
const options = buildOptions(rawOptions, override?.options);
|
|
202
|
+
return { default: defaultValue, label, name: key, options, required, type };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const deriveFields = (
|
|
206
|
+
schema: z.ZodType,
|
|
207
|
+
overrides?: Record<string, FieldOverride>
|
|
208
|
+
): Field[] => {
|
|
209
|
+
const s = schema as unknown as ZodInternals;
|
|
210
|
+
if ((s._zod.def['type'] as string) !== 'object') {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const shape = s._zod.def['shape'] as Record<string, ZodInternals> | undefined;
|
|
215
|
+
if (!shape) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const fields = Object.entries(shape).map(([key, value]) =>
|
|
220
|
+
deriveField(key, value, overrides)
|
|
221
|
+
);
|
|
222
|
+
return fields.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
223
|
+
};
|