@outfitter/contracts 0.1.0-rc.1
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/README.md +50 -0
- package/dist/actions.d.ts +388 -0
- package/dist/actions.js +32 -0
- package/dist/adapters.d.ts +182 -0
- package/dist/adapters.js +1 -0
- package/dist/assert/index.d.ts +63 -0
- package/dist/assert/index.js +36 -0
- package/dist/capabilities.d.ts +19 -0
- package/dist/capabilities.js +66 -0
- package/dist/context.d.ts +125 -0
- package/dist/context.js +36 -0
- package/dist/envelope.d.ts +328 -0
- package/dist/envelope.js +56 -0
- package/dist/errors.d.ts +261 -0
- package/dist/errors.js +171 -0
- package/dist/handler.d.ts +318 -0
- package/dist/handler.js +1 -0
- package/dist/index.d.ts +1354 -0
- package/dist/index.js +137 -0
- package/dist/recovery.d.ts +150 -0
- package/dist/recovery.js +56 -0
- package/dist/redactor.d.ts +100 -0
- package/dist/redactor.js +111 -0
- package/dist/resilience.d.ts +299 -0
- package/dist/resilience.js +82 -0
- package/dist/result/index.d.ts +103 -0
- package/dist/result/index.js +13 -0
- package/dist/result/utilities.d.ts +103 -0
- package/dist/result/utilities.js +31 -0
- package/dist/serialization.d.ts +313 -0
- package/dist/serialization.js +270 -0
- package/dist/validation.d.ts +59 -0
- package/dist/validation.js +42 -0
- package/package.json +143 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { TaggedErrorClass } from "better-result";
|
|
2
|
+
/**
|
|
3
|
+
* Error categories for classification, exit codes, and HTTP status mapping.
|
|
4
|
+
*
|
|
5
|
+
* Used for:
|
|
6
|
+
* - CLI exit code determination
|
|
7
|
+
* - HTTP status code mapping
|
|
8
|
+
* - Error grouping in logs and metrics
|
|
9
|
+
* - Client retry decisions (transient vs permanent)
|
|
10
|
+
*/
|
|
11
|
+
type ErrorCategory = "validation" | "not_found" | "conflict" | "permission" | "timeout" | "rate_limit" | "network" | "internal" | "auth" | "cancelled";
|
|
12
|
+
/**
|
|
13
|
+
* Serialized error format for JSON transport.
|
|
14
|
+
*/
|
|
15
|
+
interface SerializedError {
|
|
16
|
+
_tag: string;
|
|
17
|
+
category: ErrorCategory;
|
|
18
|
+
message: string;
|
|
19
|
+
context?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
declare const ValidationErrorBase: TaggedErrorClass<"ValidationError", {
|
|
22
|
+
message: string;
|
|
23
|
+
field?: string;
|
|
24
|
+
}>;
|
|
25
|
+
declare const AssertionErrorBase: TaggedErrorClass<"AssertionError", {
|
|
26
|
+
message: string;
|
|
27
|
+
}>;
|
|
28
|
+
declare const NotFoundErrorBase: TaggedErrorClass<"NotFoundError", {
|
|
29
|
+
message: string;
|
|
30
|
+
resourceType: string;
|
|
31
|
+
resourceId: string;
|
|
32
|
+
}>;
|
|
33
|
+
declare const ConflictErrorBase: TaggedErrorClass<"ConflictError", {
|
|
34
|
+
message: string;
|
|
35
|
+
context?: Record<string, unknown>;
|
|
36
|
+
}>;
|
|
37
|
+
declare const PermissionErrorBase: TaggedErrorClass<"PermissionError", {
|
|
38
|
+
message: string;
|
|
39
|
+
context?: Record<string, unknown>;
|
|
40
|
+
}>;
|
|
41
|
+
declare const TimeoutErrorBase: TaggedErrorClass<"TimeoutError", {
|
|
42
|
+
message: string;
|
|
43
|
+
operation: string;
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
}>;
|
|
46
|
+
declare const RateLimitErrorBase: TaggedErrorClass<"RateLimitError", {
|
|
47
|
+
message: string;
|
|
48
|
+
retryAfterSeconds?: number;
|
|
49
|
+
}>;
|
|
50
|
+
declare const NetworkErrorBase: TaggedErrorClass<"NetworkError", {
|
|
51
|
+
message: string;
|
|
52
|
+
context?: Record<string, unknown>;
|
|
53
|
+
}>;
|
|
54
|
+
declare const InternalErrorBase: TaggedErrorClass<"InternalError", {
|
|
55
|
+
message: string;
|
|
56
|
+
context?: Record<string, unknown>;
|
|
57
|
+
}>;
|
|
58
|
+
declare const AuthErrorBase: TaggedErrorClass<"AuthError", {
|
|
59
|
+
message: string;
|
|
60
|
+
reason?: "missing" | "invalid" | "expired";
|
|
61
|
+
}>;
|
|
62
|
+
declare const CancelledErrorBase: TaggedErrorClass<"CancelledError", {
|
|
63
|
+
message: string;
|
|
64
|
+
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Input validation failed.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* new ValidationError({ message: "Email format invalid", field: "email" });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
declare class ValidationError extends ValidationErrorBase {
|
|
74
|
+
readonly category: "validation";
|
|
75
|
+
exitCode(): number;
|
|
76
|
+
statusCode(): number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Assertion failed (invariant violation).
|
|
80
|
+
*
|
|
81
|
+
* Used by assertion utilities that return Result types instead of throwing.
|
|
82
|
+
* AssertionError indicates a programming bug — an invariant that should
|
|
83
|
+
* never be violated was broken. These are internal errors, not user input
|
|
84
|
+
* validation failures.
|
|
85
|
+
*
|
|
86
|
+
* **Category rationale**: Uses `internal` (not `validation`) because:
|
|
87
|
+
* - Assertions check **invariants** (programmer assumptions), not user input
|
|
88
|
+
* - A failed assertion means "this should be impossible if the code is correct"
|
|
89
|
+
* - User-facing validation uses {@link ValidationError} with helpful field info
|
|
90
|
+
* - HTTP 500 is correct: this is a server bug, not a client mistake
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* // In domain logic after validation has passed
|
|
95
|
+
* const result = assertDefined(cachedValue, "Cache should always have value after init");
|
|
96
|
+
* if (result.isErr()) {
|
|
97
|
+
* return result; // Propagate as internal error
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @see ValidationError - For user input validation failures (HTTP 400)
|
|
102
|
+
*/
|
|
103
|
+
declare class AssertionError extends AssertionErrorBase {
|
|
104
|
+
readonly category: "internal";
|
|
105
|
+
exitCode(): number;
|
|
106
|
+
statusCode(): number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Requested resource not found.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* new NotFoundError({ message: "note not found: abc123", resourceType: "note", resourceId: "abc123" });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare class NotFoundError extends NotFoundErrorBase {
|
|
117
|
+
readonly category: "not_found";
|
|
118
|
+
exitCode(): number;
|
|
119
|
+
statusCode(): number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* State conflict (optimistic locking, concurrent modification).
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```typescript
|
|
126
|
+
* new ConflictError({ message: "Resource was modified by another process" });
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
declare class ConflictError extends ConflictErrorBase {
|
|
130
|
+
readonly category: "conflict";
|
|
131
|
+
exitCode(): number;
|
|
132
|
+
statusCode(): number;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Authorization denied.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* new PermissionError({ message: "Cannot delete read-only resource" });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
declare class PermissionError extends PermissionErrorBase {
|
|
143
|
+
readonly category: "permission";
|
|
144
|
+
exitCode(): number;
|
|
145
|
+
statusCode(): number;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Operation timed out.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* new TimeoutError({ message: "Database query timed out after 5000ms", operation: "Database query", timeoutMs: 5000 });
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
declare class TimeoutError extends TimeoutErrorBase {
|
|
156
|
+
readonly category: "timeout";
|
|
157
|
+
exitCode(): number;
|
|
158
|
+
statusCode(): number;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Rate limit exceeded.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* new RateLimitError({ message: "Rate limit exceeded, retry after 60s", retryAfterSeconds: 60 });
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
declare class RateLimitError extends RateLimitErrorBase {
|
|
169
|
+
readonly category: "rate_limit";
|
|
170
|
+
exitCode(): number;
|
|
171
|
+
statusCode(): number;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Network/transport failure.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```typescript
|
|
178
|
+
* new NetworkError({ message: "Connection refused to api.example.com" });
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
declare class NetworkError extends NetworkErrorBase {
|
|
182
|
+
readonly category: "network";
|
|
183
|
+
exitCode(): number;
|
|
184
|
+
statusCode(): number;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Unexpected internal error.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* new InternalError({ message: "Unexpected state in processor" });
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
declare class InternalError extends InternalErrorBase {
|
|
195
|
+
readonly category: "internal";
|
|
196
|
+
exitCode(): number;
|
|
197
|
+
statusCode(): number;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Authentication failed (missing or invalid credentials).
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* new AuthError({ message: "Invalid API key", reason: "invalid" });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
declare class AuthError extends AuthErrorBase {
|
|
208
|
+
readonly category: "auth";
|
|
209
|
+
exitCode(): number;
|
|
210
|
+
statusCode(): number;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Operation cancelled by user or signal.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```typescript
|
|
217
|
+
* new CancelledError({ message: "Operation cancelled by user" });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
declare class CancelledError extends CancelledErrorBase {
|
|
221
|
+
readonly category: "cancelled";
|
|
222
|
+
exitCode(): number;
|
|
223
|
+
statusCode(): number;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Union type of all concrete error class instances.
|
|
227
|
+
*/
|
|
228
|
+
type AnyKitError = InstanceType<typeof ValidationError> | InstanceType<typeof AssertionError> | InstanceType<typeof NotFoundError> | InstanceType<typeof ConflictError> | InstanceType<typeof PermissionError> | InstanceType<typeof TimeoutError> | InstanceType<typeof RateLimitError> | InstanceType<typeof NetworkError> | InstanceType<typeof InternalError> | InstanceType<typeof AuthError> | InstanceType<typeof CancelledError>;
|
|
229
|
+
/**
|
|
230
|
+
* Type alias for backwards compatibility with handler signatures.
|
|
231
|
+
* Use AnyKitError for the union type.
|
|
232
|
+
*/
|
|
233
|
+
type OutfitterError = AnyKitError;
|
|
234
|
+
import { Result } from "better-result";
|
|
235
|
+
import { z } from "zod";
|
|
236
|
+
/**
|
|
237
|
+
* Options for error serialization.
|
|
238
|
+
*/
|
|
239
|
+
interface SerializeErrorOptions {
|
|
240
|
+
/** Include stack trace (default: false in production, true in development) */
|
|
241
|
+
includeStack?: boolean;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Serialize a OutfitterError to JSON-safe format.
|
|
245
|
+
*
|
|
246
|
+
* Strips stack traces in production, preserves in development.
|
|
247
|
+
* Automatically redacts sensitive values from context.
|
|
248
|
+
*
|
|
249
|
+
* @param error - The error to serialize
|
|
250
|
+
* @param options - Serialization options
|
|
251
|
+
* @returns JSON-safe serialized error
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* const serialized = serializeError(new NotFoundError("note", "abc123"));
|
|
256
|
+
* // { _tag: "NotFoundError", category: "not_found", message: "note not found: abc123", context: { resourceType: "note", resourceId: "abc123" } }
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
declare function serializeError(error: OutfitterError, options?: SerializeErrorOptions): SerializedError;
|
|
260
|
+
/**
|
|
261
|
+
* Deserialize error from JSON (e.g., from MCP response).
|
|
262
|
+
*
|
|
263
|
+
* Returns a typed OutfitterError subclass based on _tag.
|
|
264
|
+
*
|
|
265
|
+
* @param data - Serialized error data
|
|
266
|
+
* @returns Reconstructed OutfitterError instance
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```typescript
|
|
270
|
+
* const error = deserializeError(jsonData);
|
|
271
|
+
* if (error._tag === "NotFoundError") {
|
|
272
|
+
* // TypeScript knows error.resourceType exists
|
|
273
|
+
* }
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
declare function deserializeError(data: SerializedError): OutfitterError;
|
|
277
|
+
/**
|
|
278
|
+
* Safely stringify any value to JSON.
|
|
279
|
+
*
|
|
280
|
+
* Handles circular references, BigInt, and other non-JSON-safe values.
|
|
281
|
+
* Applies redaction to sensitive values.
|
|
282
|
+
*
|
|
283
|
+
* @param value - Value to stringify
|
|
284
|
+
* @param space - Indentation (default: undefined for compact)
|
|
285
|
+
* @returns JSON string
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```typescript
|
|
289
|
+
* const json = safeStringify({ apiKey: "sk-secret", data: "safe" });
|
|
290
|
+
* // '{"apiKey":"[REDACTED]","data":"safe"}'
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
declare function safeStringify(value: unknown, space?: number): string;
|
|
294
|
+
/**
|
|
295
|
+
* Safely parse JSON string with optional schema validation.
|
|
296
|
+
*
|
|
297
|
+
* Returns Result instead of throwing on invalid JSON.
|
|
298
|
+
*
|
|
299
|
+
* @typeParam T - Expected parsed type (or unknown if no schema)
|
|
300
|
+
* @param json - JSON string to parse
|
|
301
|
+
* @param schema - Optional Zod schema for validation
|
|
302
|
+
* @returns Result with parsed value or ValidationError
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* ```typescript
|
|
306
|
+
* const result = safeParse<Config>('{"port": 3000}', ConfigSchema);
|
|
307
|
+
* if (result.isOk()) {
|
|
308
|
+
* const config = result.unwrap();
|
|
309
|
+
* }
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
declare function safeParse<T = unknown>(json: string, schema?: z.ZodType<T>): Result<T, ValidationError>;
|
|
313
|
+
export { serializeError, safeStringify, safeParse, deserializeError, SerializeErrorOptions };
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PATTERNS,
|
|
4
|
+
DEFAULT_SENSITIVE_KEYS,
|
|
5
|
+
createRedactor
|
|
6
|
+
} from "./redactor.js";
|
|
7
|
+
import {
|
|
8
|
+
AuthError,
|
|
9
|
+
CancelledError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
InternalError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
ValidationError
|
|
18
|
+
} from "./errors.js";
|
|
19
|
+
|
|
20
|
+
// packages/contracts/src/serialization.ts
|
|
21
|
+
import { Result } from "better-result";
|
|
22
|
+
var errorRegistry = {
|
|
23
|
+
ValidationError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
PermissionError,
|
|
27
|
+
TimeoutError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
NetworkError,
|
|
30
|
+
InternalError,
|
|
31
|
+
AuthError,
|
|
32
|
+
CancelledError
|
|
33
|
+
};
|
|
34
|
+
function isValidErrorTag(tag) {
|
|
35
|
+
return tag in errorRegistry;
|
|
36
|
+
}
|
|
37
|
+
function extractContext(error) {
|
|
38
|
+
const context = {};
|
|
39
|
+
switch (error._tag) {
|
|
40
|
+
case "ValidationError": {
|
|
41
|
+
const ve = error;
|
|
42
|
+
if (ve.field !== undefined) {
|
|
43
|
+
context["field"] = ve.field;
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "NotFoundError": {
|
|
48
|
+
const nfe = error;
|
|
49
|
+
context["resourceType"] = nfe.resourceType;
|
|
50
|
+
context["resourceId"] = nfe.resourceId;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "TimeoutError": {
|
|
54
|
+
const te = error;
|
|
55
|
+
context["operation"] = te.operation;
|
|
56
|
+
context["timeoutMs"] = te.timeoutMs;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "RateLimitError": {
|
|
60
|
+
const rle = error;
|
|
61
|
+
if (rle.retryAfterSeconds !== undefined) {
|
|
62
|
+
context["retryAfterSeconds"] = rle.retryAfterSeconds;
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "AuthError": {
|
|
67
|
+
const ae = error;
|
|
68
|
+
if (ae.reason !== undefined) {
|
|
69
|
+
context["reason"] = ae.reason;
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "ConflictError":
|
|
74
|
+
case "PermissionError":
|
|
75
|
+
case "NetworkError":
|
|
76
|
+
case "InternalError": {
|
|
77
|
+
const ce = error;
|
|
78
|
+
if (ce.context !== undefined) {
|
|
79
|
+
Object.assign(context, ce.context);
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
default:
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
87
|
+
}
|
|
88
|
+
function serializeError(error, options) {
|
|
89
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
90
|
+
const includeStack = options?.includeStack ?? !isProduction;
|
|
91
|
+
const context = extractContext(error);
|
|
92
|
+
const serialized = {
|
|
93
|
+
_tag: error._tag,
|
|
94
|
+
category: error.category,
|
|
95
|
+
message: error.message
|
|
96
|
+
};
|
|
97
|
+
if (context !== undefined) {
|
|
98
|
+
serialized.context = context;
|
|
99
|
+
}
|
|
100
|
+
if (includeStack && error.stack !== undefined) {
|
|
101
|
+
if (serialized.context === undefined) {
|
|
102
|
+
serialized.context = {};
|
|
103
|
+
}
|
|
104
|
+
serialized.context["stack"] = error.stack;
|
|
105
|
+
}
|
|
106
|
+
return serialized;
|
|
107
|
+
}
|
|
108
|
+
function deserializeError(data) {
|
|
109
|
+
const tag = data._tag;
|
|
110
|
+
if (!isValidErrorTag(tag)) {
|
|
111
|
+
const props = {
|
|
112
|
+
message: data.message
|
|
113
|
+
};
|
|
114
|
+
if (data.context !== undefined) {
|
|
115
|
+
props.context = data.context;
|
|
116
|
+
}
|
|
117
|
+
return new InternalError(props);
|
|
118
|
+
}
|
|
119
|
+
const context = data.context ?? {};
|
|
120
|
+
switch (tag) {
|
|
121
|
+
case "ValidationError": {
|
|
122
|
+
const props = {
|
|
123
|
+
message: data.message
|
|
124
|
+
};
|
|
125
|
+
const field = context["field"];
|
|
126
|
+
if (field !== undefined) {
|
|
127
|
+
props.field = field;
|
|
128
|
+
}
|
|
129
|
+
return new ValidationError(props);
|
|
130
|
+
}
|
|
131
|
+
case "NotFoundError":
|
|
132
|
+
return new NotFoundError({
|
|
133
|
+
message: data.message,
|
|
134
|
+
resourceType: context["resourceType"] ?? "unknown",
|
|
135
|
+
resourceId: context["resourceId"] ?? "unknown"
|
|
136
|
+
});
|
|
137
|
+
case "ConflictError": {
|
|
138
|
+
const props = {
|
|
139
|
+
message: data.message
|
|
140
|
+
};
|
|
141
|
+
if (Object.keys(context).length > 0) {
|
|
142
|
+
props.context = context;
|
|
143
|
+
}
|
|
144
|
+
return new ConflictError(props);
|
|
145
|
+
}
|
|
146
|
+
case "PermissionError": {
|
|
147
|
+
const props = {
|
|
148
|
+
message: data.message
|
|
149
|
+
};
|
|
150
|
+
if (Object.keys(context).length > 0) {
|
|
151
|
+
props.context = context;
|
|
152
|
+
}
|
|
153
|
+
return new PermissionError(props);
|
|
154
|
+
}
|
|
155
|
+
case "TimeoutError":
|
|
156
|
+
return new TimeoutError({
|
|
157
|
+
message: data.message,
|
|
158
|
+
operation: context["operation"] ?? "unknown",
|
|
159
|
+
timeoutMs: context["timeoutMs"] ?? 0
|
|
160
|
+
});
|
|
161
|
+
case "RateLimitError": {
|
|
162
|
+
const props = {
|
|
163
|
+
message: data.message
|
|
164
|
+
};
|
|
165
|
+
const retryAfter = context["retryAfterSeconds"];
|
|
166
|
+
if (retryAfter !== undefined) {
|
|
167
|
+
props.retryAfterSeconds = retryAfter;
|
|
168
|
+
}
|
|
169
|
+
return new RateLimitError(props);
|
|
170
|
+
}
|
|
171
|
+
case "NetworkError": {
|
|
172
|
+
const props = {
|
|
173
|
+
message: data.message
|
|
174
|
+
};
|
|
175
|
+
if (Object.keys(context).length > 0) {
|
|
176
|
+
props.context = context;
|
|
177
|
+
}
|
|
178
|
+
return new NetworkError(props);
|
|
179
|
+
}
|
|
180
|
+
case "InternalError": {
|
|
181
|
+
const props = {
|
|
182
|
+
message: data.message
|
|
183
|
+
};
|
|
184
|
+
if (Object.keys(context).length > 0) {
|
|
185
|
+
props.context = context;
|
|
186
|
+
}
|
|
187
|
+
return new InternalError(props);
|
|
188
|
+
}
|
|
189
|
+
case "AuthError": {
|
|
190
|
+
const props = {
|
|
191
|
+
message: data.message
|
|
192
|
+
};
|
|
193
|
+
const reason = context["reason"];
|
|
194
|
+
if (reason !== undefined) {
|
|
195
|
+
props.reason = reason;
|
|
196
|
+
}
|
|
197
|
+
return new AuthError(props);
|
|
198
|
+
}
|
|
199
|
+
case "CancelledError":
|
|
200
|
+
return new CancelledError({
|
|
201
|
+
message: data.message
|
|
202
|
+
});
|
|
203
|
+
default: {
|
|
204
|
+
const props = {
|
|
205
|
+
message: data.message
|
|
206
|
+
};
|
|
207
|
+
if (Object.keys(context).length > 0) {
|
|
208
|
+
props.context = context;
|
|
209
|
+
}
|
|
210
|
+
return new InternalError(props);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function safeStringify(value, space) {
|
|
215
|
+
const seen = new WeakSet;
|
|
216
|
+
const redactor = createRedactor({
|
|
217
|
+
patterns: [...DEFAULT_PATTERNS],
|
|
218
|
+
keys: [...DEFAULT_SENSITIVE_KEYS]
|
|
219
|
+
});
|
|
220
|
+
const replacer = (key, val) => {
|
|
221
|
+
if (typeof val === "bigint") {
|
|
222
|
+
return val.toString();
|
|
223
|
+
}
|
|
224
|
+
if (typeof val === "object" && val !== null) {
|
|
225
|
+
if (seen.has(val)) {
|
|
226
|
+
return "[Circular]";
|
|
227
|
+
}
|
|
228
|
+
seen.add(val);
|
|
229
|
+
}
|
|
230
|
+
if (key !== "" && redactor.isSensitiveKey(key) && val !== null && val !== undefined) {
|
|
231
|
+
return "[REDACTED]";
|
|
232
|
+
}
|
|
233
|
+
if (typeof val === "string") {
|
|
234
|
+
return redactor.redactString(val);
|
|
235
|
+
}
|
|
236
|
+
return val;
|
|
237
|
+
};
|
|
238
|
+
return JSON.stringify(value, replacer, space);
|
|
239
|
+
}
|
|
240
|
+
function safeParse(json, schema) {
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(json);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const errorMessage = err instanceof Error ? err.message : "Unknown parse error";
|
|
246
|
+
return Result.err(new ValidationError({
|
|
247
|
+
message: `JSON parse error: ${errorMessage}`
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
if (schema === undefined) {
|
|
251
|
+
return Result.ok(parsed);
|
|
252
|
+
}
|
|
253
|
+
const parseResult = schema.safeParse(parsed);
|
|
254
|
+
if (parseResult.success) {
|
|
255
|
+
return Result.ok(parseResult.data);
|
|
256
|
+
}
|
|
257
|
+
const issues = parseResult.error.issues.map((issue) => {
|
|
258
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
259
|
+
return `${path}: ${issue.message}`;
|
|
260
|
+
}).join("; ");
|
|
261
|
+
return Result.err(new ValidationError({
|
|
262
|
+
message: `Schema validation failed: ${issues}`
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
serializeError,
|
|
267
|
+
safeStringify,
|
|
268
|
+
safeParse,
|
|
269
|
+
deserializeError
|
|
270
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { TaggedErrorClass } from "better-result";
|
|
2
|
+
declare const ValidationErrorBase: TaggedErrorClass<"ValidationError", {
|
|
3
|
+
message: string;
|
|
4
|
+
field?: string;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* Input validation failed.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* new ValidationError({ message: "Email format invalid", field: "email" });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
declare class ValidationError extends ValidationErrorBase {
|
|
15
|
+
readonly category: "validation";
|
|
16
|
+
exitCode(): number;
|
|
17
|
+
statusCode(): number;
|
|
18
|
+
}
|
|
19
|
+
import { Result } from "better-result";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
/**
|
|
22
|
+
* Create a validator function from a Zod schema.
|
|
23
|
+
*
|
|
24
|
+
* @typeParam T - The validated output type
|
|
25
|
+
* @param schema - Zod schema to validate against
|
|
26
|
+
* @returns A function that validates input and returns Result
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const NoteSchema = z.object({
|
|
31
|
+
* id: z.string().uuid(),
|
|
32
|
+
* title: z.string().min(1),
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* const validateNote = createValidator(NoteSchema);
|
|
36
|
+
* const result = validateNote(input); // Result<Note, ValidationError>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
declare function createValidator<T>(schema: z.ZodType<T>): (input: unknown) => Result<T, ValidationError>;
|
|
40
|
+
/**
|
|
41
|
+
* Validate input against a Zod schema.
|
|
42
|
+
*
|
|
43
|
+
* Standardized wrapper for Zod schemas that returns Result instead of throwing.
|
|
44
|
+
*
|
|
45
|
+
* @typeParam T - The validated output type
|
|
46
|
+
* @param schema - Zod schema to validate against
|
|
47
|
+
* @param input - Unknown input to validate
|
|
48
|
+
* @returns Result with validated data or ValidationError
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const result = validateInput(NoteSchema, userInput);
|
|
53
|
+
* if (result.isErr()) {
|
|
54
|
+
* console.error(result.unwrapErr().message);
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare function validateInput<T>(schema: z.ZodType<T>, input: unknown): Result<T, ValidationError>;
|
|
59
|
+
export { validateInput, createValidator };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
ValidationError
|
|
4
|
+
} from "./errors.js";
|
|
5
|
+
|
|
6
|
+
// packages/contracts/src/validation.ts
|
|
7
|
+
import { Result } from "better-result";
|
|
8
|
+
function formatZodIssues(issues) {
|
|
9
|
+
return issues.map((issue) => {
|
|
10
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
11
|
+
return `${path}: ${issue.message}`;
|
|
12
|
+
}).join("; ");
|
|
13
|
+
}
|
|
14
|
+
function extractField(issues) {
|
|
15
|
+
const firstIssue = issues[0];
|
|
16
|
+
if (firstIssue && firstIssue.path.length > 0) {
|
|
17
|
+
return firstIssue.path.join(".");
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
function createValidator(schema) {
|
|
22
|
+
return (input) => {
|
|
23
|
+
return validateInput(schema, input);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function validateInput(schema, input) {
|
|
27
|
+
const parseResult = schema.safeParse(input);
|
|
28
|
+
if (parseResult.success) {
|
|
29
|
+
return Result.ok(parseResult.data);
|
|
30
|
+
}
|
|
31
|
+
const message = formatZodIssues(parseResult.error.issues);
|
|
32
|
+
const field = extractField(parseResult.error.issues);
|
|
33
|
+
const errorProps = { message };
|
|
34
|
+
if (field !== undefined) {
|
|
35
|
+
errorProps.field = field;
|
|
36
|
+
}
|
|
37
|
+
return Result.err(new ValidationError(errorProps));
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
validateInput,
|
|
41
|
+
createValidator
|
|
42
|
+
};
|