@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,178 @@
1
+ /**
2
+ * Redactor — strips sensitive data from strings and objects.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * const r = createRedactor();
7
+ * r.redact("token: Bearer abc123"); // "token: [REDACTED]"
8
+ * r.redactObject({ password: "s3cret" }); // { password: "[REDACTED]" }
9
+ * ```
10
+ */
11
+
12
+ import { DEFAULT_PATTERNS, DEFAULT_SENSITIVE_KEYS } from './patterns.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface RedactorConfig {
19
+ readonly patterns?: RegExp[];
20
+ readonly sensitiveKeys?: string[];
21
+ readonly replacement?: string;
22
+ }
23
+
24
+ export interface Redactor {
25
+ /** Replace all pattern matches in a string with the replacement. */
26
+ redact(value: string): string;
27
+
28
+ /**
29
+ * Deep-clone `obj` and redact:
30
+ * 1. Values whose key matches a sensitive key (case-insensitive).
31
+ * 2. All remaining string values that match a pattern.
32
+ */
33
+ redactObject<T extends Record<string, unknown>>(obj: T): T;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const resetPatterns = (patterns: RegExp[]): void => {
41
+ for (const p of patterns) {
42
+ p.lastIndex = 0;
43
+ }
44
+ };
45
+
46
+ const applyPatterns = (
47
+ value: string,
48
+ patterns: RegExp[],
49
+ replacement: string
50
+ ): string => {
51
+ let result = value;
52
+ for (const pattern of patterns) {
53
+ // Reset in case the regex is stateful (global flag)
54
+ pattern.lastIndex = 0;
55
+ result = result.replace(pattern, replacement);
56
+ }
57
+ return result;
58
+ };
59
+
60
+ const isSensitiveKey = (
61
+ key: string | undefined,
62
+ sensitiveKeysLower: ReadonlySet<string>
63
+ ): boolean => key !== undefined && sensitiveKeysLower.has(key.toLowerCase());
64
+
65
+ type DeepRedact = (
66
+ value: unknown,
67
+ sensitiveKeysLower: ReadonlySet<string>,
68
+ patterns: RegExp[],
69
+ replacement: string,
70
+ currentKey?: string,
71
+ seen?: WeakSet<object>
72
+ ) => unknown;
73
+
74
+ const mapObjectValues = (
75
+ value: object,
76
+ visit: (item: unknown, key: string) => unknown
77
+ ): Record<string, unknown> => {
78
+ const result: Record<string, unknown> = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ result[key] = visit(item, key);
81
+ }
82
+ return result;
83
+ };
84
+
85
+ /** Track and guard against circular references in object graphs. */
86
+ const trackOrCircular = (
87
+ value: object,
88
+ seen: WeakSet<object>
89
+ ): '[Circular]' | null => {
90
+ if (seen.has(value)) {
91
+ return '[Circular]';
92
+ }
93
+ seen.add(value);
94
+ return null;
95
+ };
96
+
97
+ /* oxlint-disable no-use-before-define -- mutual recursion between redactCompound and deepRedact */
98
+
99
+ /** Redact a compound value (array or object), delegating scalars back to deepRedact. */
100
+ const redactCompound = (
101
+ value: unknown[] | object,
102
+ sensitiveKeysLower: ReadonlySet<string>,
103
+ patterns: RegExp[],
104
+ replacement: string,
105
+ seen: WeakSet<object>
106
+ ): unknown => {
107
+ const circular = trackOrCircular(value, seen);
108
+ if (circular) {
109
+ return circular;
110
+ }
111
+ if (Array.isArray(value)) {
112
+ return value.map((item) =>
113
+ deepRedact(
114
+ item,
115
+ sensitiveKeysLower,
116
+ patterns,
117
+ replacement,
118
+ undefined,
119
+ seen
120
+ )
121
+ );
122
+ }
123
+ return mapObjectValues(value, (item, key) =>
124
+ deepRedact(item, sensitiveKeysLower, patterns, replacement, key, seen)
125
+ );
126
+ };
127
+
128
+ const deepRedact: DeepRedact = (
129
+ value,
130
+ sensitiveKeysLower,
131
+ patterns,
132
+ replacement,
133
+ currentKey?,
134
+ seen = new WeakSet<object>()
135
+ ) => {
136
+ if (isSensitiveKey(currentKey, sensitiveKeysLower)) {
137
+ return replacement;
138
+ }
139
+ if (typeof value === 'string') {
140
+ return applyPatterns(value, patterns, replacement);
141
+ }
142
+ if (value !== null && typeof value === 'object') {
143
+ return redactCompound(
144
+ value,
145
+ sensitiveKeysLower,
146
+ patterns,
147
+ replacement,
148
+ seen
149
+ );
150
+ }
151
+ return value;
152
+ };
153
+
154
+ /* oxlint-enable no-use-before-define */
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Factory
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export const createRedactor = (config?: RedactorConfig): Redactor => {
161
+ const patterns = config?.patterns ?? DEFAULT_PATTERNS;
162
+ const sensitiveKeys = config?.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
163
+ const replacement = config?.replacement ?? '[REDACTED]';
164
+
165
+ const sensitiveKeysLower = new Set(sensitiveKeys.map((k) => k.toLowerCase()));
166
+
167
+ return {
168
+ redact(value: string): string {
169
+ resetPatterns(patterns);
170
+ return applyPatterns(value, patterns, replacement);
171
+ },
172
+
173
+ redactObject<T extends Record<string, unknown>>(obj: T): T {
174
+ resetPatterns(patterns);
175
+ return deepRedact(obj, sensitiveKeysLower, patterns, replacement) as T;
176
+ },
177
+ };
178
+ };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Resilience utilities for @ontrails/core
3
+ *
4
+ * Retry with exponential backoff and timeout wrappers,
5
+ * all returning Result types.
6
+ */
7
+
8
+ import { CancelledError, TimeoutError, isRetryable } from './errors.js';
9
+ import { Result } from './result.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // RetryOptions
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface RetryOptions {
16
+ /** Maximum number of attempts (default: 3) */
17
+ readonly maxAttempts?: number | undefined;
18
+ /** Base delay in ms before first retry (default: 1000) */
19
+ readonly baseDelay?: number | undefined;
20
+ /** Maximum delay in ms (default: 30000) */
21
+ readonly maxDelay?: number | undefined;
22
+ /** Exponential backoff factor (default: 2) */
23
+ readonly backoffFactor?: number | undefined;
24
+ /** Custom predicate — defaults to isRetryable from error taxonomy */
25
+ readonly shouldRetry?: ((error: Error) => boolean) | undefined;
26
+ /** AbortSignal for cancellation */
27
+ readonly signal?: AbortSignal | undefined;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers (defined before usage)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
35
+ // oxlint-disable-next-line avoid-new -- Promise constructor needed for setTimeout-based sleep
36
+ new Promise((resolve) => {
37
+ if (signal?.aborted) {
38
+ resolve();
39
+ return;
40
+ }
41
+ let settled = false;
42
+ const done = () => {
43
+ if (!settled) {
44
+ settled = true;
45
+ resolve();
46
+ }
47
+ };
48
+ const timer = setTimeout(done, ms);
49
+ const onAbort = () => {
50
+ clearTimeout(timer);
51
+ done();
52
+ };
53
+ signal?.addEventListener('abort', onAbort, { once: true });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // shouldRetry (default)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Default retry predicate using the error taxonomy. */
61
+ export const shouldRetry = (error: Error): boolean => isRetryable(error);
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // deriveBackoffDelay
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** Compute exponential backoff delay with full jitter. */
68
+ export const deriveBackoffDelay = (
69
+ attempt: number,
70
+ options?: Pick<RetryOptions, 'baseDelay' | 'maxDelay' | 'backoffFactor'>
71
+ ): number => {
72
+ const base = options?.baseDelay ?? 1000;
73
+ const max = options?.maxDelay ?? 30_000;
74
+ const factor = options?.backoffFactor ?? 2;
75
+ const exponential = base * factor ** attempt;
76
+ const capped = Math.min(exponential, max);
77
+ // Full jitter: random value in [0, capped]
78
+ return Math.floor(Math.random() * capped);
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // retry
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Retry an async function that returns a Result.
87
+ *
88
+ * On each failure, checks whether the error is retryable and whether the
89
+ * attempt budget remains. Applies exponential backoff with jitter between
90
+ * retries.
91
+ */
92
+ /** Attempt a single retry iteration. Returns the result to stop, or undefined to continue. */
93
+ const tryAttempt = async <T>(
94
+ fn: () => Promise<Result<T, Error>>,
95
+ attempt: number,
96
+ maxAttempts: number,
97
+ retryPredicate: (error: Error) => boolean,
98
+ options?: RetryOptions
99
+ ): Promise<
100
+ | { done: true; result: Result<T, Error> }
101
+ | { done: false; result: Result<T, Error> }
102
+ > => {
103
+ const result = await fn();
104
+ if (result.isOk()) {
105
+ return { done: true, result };
106
+ }
107
+ const isLast = attempt === maxAttempts - 1;
108
+ if (isLast || !retryPredicate(result.error)) {
109
+ return { done: true, result };
110
+ }
111
+ const delay = deriveBackoffDelay(attempt, options);
112
+ if (delay > 0) {
113
+ await sleep(delay, options?.signal);
114
+ }
115
+ return { done: false, result };
116
+ };
117
+
118
+ /** Resolve retry options to concrete values. */
119
+ const resolveRetryOptions = (options?: RetryOptions) => ({
120
+ maxAttempts: options?.maxAttempts ?? 3,
121
+ retryPredicate: options?.shouldRetry ?? shouldRetry,
122
+ signal: options?.signal,
123
+ });
124
+
125
+ export const retry = async <T>(
126
+ fn: () => Promise<Result<T, Error>>,
127
+ options?: RetryOptions
128
+ ): Promise<Result<T, Error>> => {
129
+ const { maxAttempts, retryPredicate, signal } = resolveRetryOptions(options);
130
+ let lastResult: Result<T, Error> | undefined;
131
+
132
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
133
+ if (signal?.aborted) {
134
+ return Result.err(new CancelledError('Retry cancelled'));
135
+ }
136
+ const outcome = await tryAttempt(
137
+ fn,
138
+ attempt,
139
+ maxAttempts,
140
+ retryPredicate,
141
+ options
142
+ );
143
+ lastResult = outcome.result;
144
+ if (outcome.done) {
145
+ return outcome.result;
146
+ }
147
+ }
148
+
149
+ return lastResult ?? Result.err(new CancelledError('Retry exhausted'));
150
+ };
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // withTimeout
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Run an async Result-returning function with a timeout.
158
+ *
159
+ * If the timeout fires first, returns a TimeoutError result.
160
+ * Also respects an external AbortSignal.
161
+ */
162
+ export const withTimeout = <T>(
163
+ fn: () => Promise<Result<T, Error>>,
164
+ ms: number,
165
+ signal?: AbortSignal
166
+ ): Promise<Result<T, Error>> => {
167
+ if (signal?.aborted) {
168
+ return Promise.resolve(Result.err(new CancelledError('Already cancelled')));
169
+ }
170
+
171
+ // oxlint-disable-next-line avoid-new, promise/no-multiple-resolved -- Promise constructor needed for timeout race; settled guard ensures single resolution
172
+ return new Promise<Result<T, Error>>((resolve) => {
173
+ let settled = false;
174
+ // oxlint-disable-next-line prefer-const -- assigned after declaration
175
+ let timer: ReturnType<typeof setTimeout>;
176
+
177
+ const onAbort = () => {
178
+ if (settled) {
179
+ return;
180
+ }
181
+ settled = true;
182
+ clearTimeout(timer);
183
+ signal?.removeEventListener('abort', onAbort);
184
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
185
+ resolve(Result.err(new CancelledError('Operation cancelled')));
186
+ };
187
+
188
+ signal?.addEventListener('abort', onAbort, { once: true });
189
+
190
+ timer = setTimeout(() => {
191
+ if (settled) {
192
+ return;
193
+ }
194
+ settled = true;
195
+ signal?.removeEventListener('abort', onAbort);
196
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
197
+ resolve(
198
+ Result.err(
199
+ new TimeoutError(`Operation timed out after ${ms}ms`, {
200
+ context: { timeoutMs: ms },
201
+ })
202
+ )
203
+ );
204
+ }, ms);
205
+
206
+ const run = async () => {
207
+ try {
208
+ const result = await fn();
209
+ if (settled) {
210
+ return;
211
+ }
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ signal?.removeEventListener('abort', onAbort);
215
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
216
+ resolve(result);
217
+ } catch (error: unknown) {
218
+ if (settled) {
219
+ return;
220
+ }
221
+ settled = true;
222
+ clearTimeout(timer);
223
+ signal?.removeEventListener('abort', onAbort);
224
+ // oxlint-disable-next-line promise/no-multiple-resolved -- settled guard ensures single resolution
225
+ resolve(
226
+ Result.err(error instanceof Error ? error : new Error(String(error)))
227
+ );
228
+ }
229
+ };
230
+
231
+ // oxlint-disable-next-line no-void -- fire-and-settle inside Promise constructor
232
+ void run();
233
+ });
234
+ };