📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# CLI Patterns
|
||||
|
||||
Deep dive into @outfitter/cli patterns.
|
||||
|
||||
## Creating a CLI
|
||||
|
||||
```typescript
|
||||
import { createCLI } from "@outfitter/cli";
|
||||
|
||||
const cli = createCLI({
|
||||
name: "myapp",
|
||||
version: "1.0.0",
|
||||
description: "My CLI application",
|
||||
});
|
||||
|
||||
cli.program.addCommand(listCommand);
|
||||
cli.program.addCommand(getCommand);
|
||||
cli.program.parse();
|
||||
```
|
||||
|
||||
## Command Builder
|
||||
|
||||
Type-safe command construction:
|
||||
|
||||
```typescript
|
||||
import { command } from "@outfitter/cli";
|
||||
|
||||
export const myCommand = command("my-command")
|
||||
.description("What this command does")
|
||||
.argument("<id>", "Required resource ID")
|
||||
.argument("[name]", "Optional name")
|
||||
.option("-l, --limit <n>", "Limit results", parseInt)
|
||||
.option("-v, --verbose", "Enable verbose output")
|
||||
.option("-t, --tags <tags...>", "Filter by tags")
|
||||
.action(async ({ args, flags }) => {
|
||||
// args.id: string
|
||||
// args.name: string | undefined
|
||||
// flags.limit: number | undefined
|
||||
// flags.verbose: boolean
|
||||
// flags.tags: string[] | undefined
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
## Output Modes
|
||||
|
||||
### Automatic Detection
|
||||
|
||||
```typescript
|
||||
import { output } from "@outfitter/cli";
|
||||
|
||||
await output(data); // Human for TTY, JSON for pipes
|
||||
```
|
||||
|
||||
### Mode Priority
|
||||
|
||||
1. Explicit `mode` option
|
||||
2. `OUTFITTER_JSONL=1` env var
|
||||
3. `OUTFITTER_JSON=1` env var
|
||||
4. `OUTFITTER_JSON=0` forces human
|
||||
5. TTY detection fallback
|
||||
|
||||
### Forcing Modes
|
||||
|
||||
```typescript
|
||||
// Force JSON
|
||||
await output(data, { mode: "json" });
|
||||
|
||||
// Force human
|
||||
await output(data, { mode: "human" });
|
||||
|
||||
// JSONL for streaming
|
||||
for await (const item of items) {
|
||||
await output(item, { mode: "jsonl" });
|
||||
}
|
||||
|
||||
// Output to stderr
|
||||
await output(errorData, { stream: process.stderr });
|
||||
```
|
||||
|
||||
### Custom Formatters
|
||||
|
||||
```typescript
|
||||
await output(data, {
|
||||
formatters: {
|
||||
human: (data) => formatTable(data),
|
||||
json: (data) => JSON.stringify(data, null, 2),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exit with Error
|
||||
|
||||
```typescript
|
||||
import { exitWithError } from "@outfitter/cli";
|
||||
|
||||
const result = await handler(input, ctx);
|
||||
|
||||
if (result.isErr()) {
|
||||
exitWithError(result.error); // Exit code from error category
|
||||
}
|
||||
```
|
||||
|
||||
### Exit Code Mapping
|
||||
|
||||
| Category | Exit Code |
|
||||
|----------|-----------|
|
||||
| validation | 1 |
|
||||
| not_found | 2 |
|
||||
| conflict | 3 |
|
||||
| permission | 4 |
|
||||
| timeout | 5 |
|
||||
| rate_limit | 6 |
|
||||
| network | 7 |
|
||||
| internal | 8 |
|
||||
| auth | 9 |
|
||||
| cancelled | 130 |
|
||||
|
||||
### Custom Error Output
|
||||
|
||||
```typescript
|
||||
import { formatError, getExitCode } from "@outfitter/cli";
|
||||
|
||||
if (result.isErr()) {
|
||||
const formatted = formatError(result.error, { verbose: flags.verbose });
|
||||
await output(formatted, { stream: process.stderr });
|
||||
process.exit(getExitCode(result.error.category));
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Cursor State
|
||||
|
||||
Cursors persist in XDG state directory:
|
||||
|
||||
```
|
||||
$XDG_STATE_HOME/{toolName}/cursors/{command}/cursor.json
|
||||
```
|
||||
|
||||
### Using Pagination
|
||||
|
||||
```typescript
|
||||
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli";
|
||||
|
||||
const options = { command: "list", toolName: "myapp" };
|
||||
|
||||
// Load previous cursor
|
||||
const state = loadCursor(options);
|
||||
|
||||
// Fetch data with cursor
|
||||
const results = await listItems({
|
||||
cursor: state?.cursor,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// Save for --next
|
||||
if (results.hasMore) {
|
||||
saveCursor(results.nextCursor, options);
|
||||
}
|
||||
|
||||
// Clear on --reset
|
||||
if (flags.reset) {
|
||||
clearCursor(options);
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor Expiration
|
||||
|
||||
```typescript
|
||||
const state = loadCursor({
|
||||
...options,
|
||||
maxAgeMs: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
```
|
||||
|
||||
### Pagination Command Pattern
|
||||
|
||||
```typescript
|
||||
export const listCommand = command("list")
|
||||
.option("-n, --next", "Continue from previous position")
|
||||
.option("--reset", "Reset pagination cursor")
|
||||
.option("-l, --limit <n>", "Results per page", parseInt, 20)
|
||||
.action(async ({ flags }) => {
|
||||
const paginationOpts = { command: "list", toolName: "myapp" };
|
||||
|
||||
if (flags.reset) {
|
||||
clearCursor(paginationOpts);
|
||||
console.log("Cursor reset");
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = flags.next ? loadCursor(paginationOpts)?.cursor : undefined;
|
||||
const result = await listHandler({ cursor, limit: flags.limit }, ctx);
|
||||
|
||||
if (result.isErr()) {
|
||||
exitWithError(result.error);
|
||||
}
|
||||
|
||||
await output(result.value.items);
|
||||
|
||||
if (result.value.nextCursor) {
|
||||
saveCursor(result.value.nextCursor, paginationOpts);
|
||||
console.log("\nUse --next for more results");
|
||||
}
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
## Input Parsing
|
||||
|
||||
### Stdin Reading
|
||||
|
||||
```typescript
|
||||
import { readStdin } from "@outfitter/cli";
|
||||
|
||||
const input = await readStdin(); // Returns string or null if no stdin
|
||||
```
|
||||
|
||||
### Piped Detection
|
||||
|
||||
```typescript
|
||||
import { isPiped } from "@outfitter/cli";
|
||||
|
||||
if (isPiped()) {
|
||||
const data = await readStdin();
|
||||
} else {
|
||||
// Interactive mode
|
||||
}
|
||||
```
|
||||
|
||||
## Progress Indicators
|
||||
|
||||
> **Note:** UI components merged into `@outfitter/cli`. Import from `@outfitter/cli` directly.
|
||||
|
||||
```typescript
|
||||
import { createSpinner, createProgressBar } from "@outfitter/cli";
|
||||
|
||||
// Spinner
|
||||
const spinner = createSpinner("Loading...");
|
||||
spinner.start();
|
||||
// ... work
|
||||
spinner.succeed("Done!");
|
||||
|
||||
// Progress bar
|
||||
const progress = createProgressBar({ total: 100 });
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
progress.update(i);
|
||||
}
|
||||
progress.stop();
|
||||
```
|
||||
|
||||
## Formatting Utilities
|
||||
|
||||
### Date Range Parsing
|
||||
|
||||
Parse human-readable date ranges:
|
||||
|
||||
```typescript
|
||||
import { parseDateRange } from "@outfitter/cli";
|
||||
|
||||
const range = parseDateRange("last 7 days");
|
||||
// { start: Date, end: Date }
|
||||
|
||||
const range2 = parseDateRange("2026-01-01..2026-01-31");
|
||||
// { start: Date, end: Date }
|
||||
|
||||
// Supported formats:
|
||||
// - "last N days/weeks/months"
|
||||
// - "today", "yesterday", "this week", "this month"
|
||||
// - "YYYY-MM-DD..YYYY-MM-DD" (range)
|
||||
// - "YYYY-MM-DD" (single day)
|
||||
```
|
||||
|
||||
### Duration Formatting
|
||||
|
||||
Format milliseconds as human-readable duration:
|
||||
|
||||
```typescript
|
||||
import { formatDuration } from "@outfitter/cli";
|
||||
|
||||
formatDuration(1500); // "1.5s"
|
||||
formatDuration(65000); // "1m 5s"
|
||||
formatDuration(3661000); // "1h 1m 1s"
|
||||
formatDuration(90061000); // "1d 1h 1m"
|
||||
```
|
||||
|
||||
### Byte Formatting
|
||||
|
||||
Format bytes as human-readable sizes:
|
||||
|
||||
```typescript
|
||||
import { formatBytes } from "@outfitter/cli";
|
||||
|
||||
formatBytes(1024); // "1 KB"
|
||||
formatBytes(1536); // "1.5 KB"
|
||||
formatBytes(1048576); // "1 MB"
|
||||
formatBytes(1073741824); // "1 GB"
|
||||
```
|
||||
|
||||
### Pluralization
|
||||
|
||||
Pluralize words based on count:
|
||||
|
||||
```typescript
|
||||
import { pluralize } from "@outfitter/cli";
|
||||
|
||||
pluralize(1, "file"); // "1 file"
|
||||
pluralize(5, "file"); // "5 files"
|
||||
pluralize(0, "item"); // "0 items"
|
||||
|
||||
// Custom plural form
|
||||
pluralize(2, "person", "people"); // "2 people"
|
||||
```
|
||||
|
||||
### Slugification
|
||||
|
||||
Convert strings to URL-safe slugs:
|
||||
|
||||
```typescript
|
||||
import { slugify } from "@outfitter/cli";
|
||||
|
||||
slugify("Hello World"); // "hello-world"
|
||||
slugify("My New Feature!"); // "my-new-feature"
|
||||
slugify("Café Résumé"); // "cafe-resume"
|
||||
```
|
||||
|
||||
### Custom Renderers
|
||||
|
||||
Register custom output renderers for specific data types:
|
||||
|
||||
```typescript
|
||||
import { registerRenderer, output } from "@outfitter/cli";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
registerRenderer<User>("user", {
|
||||
human: (user) => `${user.name} <${user.email}>`,
|
||||
json: (user) => JSON.stringify(user),
|
||||
});
|
||||
|
||||
// Now output() will use your renderer when type matches
|
||||
await output(user, { type: "user" });
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Handler first** - Business logic in handler, CLI is thin adapter
|
||||
2. **Output modes** - Support both human and JSON output
|
||||
3. **Exit codes** - Use `exitWithError` for consistent codes
|
||||
4. **Pagination** - Use cursor state for `--next` functionality
|
||||
5. **Stdin support** - Handle piped input gracefully
|
||||
6. **TTY detection** - Adapt behavior for interactive vs piped
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
# Conversion Patterns
|
||||
|
||||
Patterns for converting existing code to Outfitter Stack conventions.
|
||||
|
||||
## Exceptions to Result
|
||||
|
||||
Convert throw-based error handling to Result types.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
async function getUser(id: string): Promise<User> {
|
||||
const user = await db.users.findById(id);
|
||||
if (!user) throw new Error(`Not found: ${id}`);
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { Result, NotFoundError, type Handler } from "@outfitter/contracts";
|
||||
|
||||
const getUser: Handler<{ id: string }, User, NotFoundError> = async (input, ctx) => {
|
||||
const user = await db.users.findById(input.id);
|
||||
if (!user) return Result.err(new NotFoundError("user", input.id));
|
||||
return Result.ok(user);
|
||||
};
|
||||
```
|
||||
|
||||
## Console to Structured Logging
|
||||
|
||||
Replace console calls with structured logging via context.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
console.log("Processing", userId);
|
||||
console.error("Failed to process", error);
|
||||
console.warn("Deprecated API usage");
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
ctx.logger.info("Processing", { userId });
|
||||
ctx.logger.error("Failed to process", { error: error.message });
|
||||
ctx.logger.warn("Deprecated API usage", { api: "oldEndpoint" });
|
||||
```
|
||||
|
||||
### Logging Level Mapping
|
||||
|
||||
| Console Method | Logger Method | When to Use |
|
||||
|----------------|---------------|-------------|
|
||||
| `console.log` | `ctx.logger.info` | Normal operations |
|
||||
| `console.debug` | `ctx.logger.debug` | Development debugging |
|
||||
| `console.warn` | `ctx.logger.warn` | Unexpected but handled |
|
||||
| `console.error` | `ctx.logger.error` | Failures requiring attention |
|
||||
|
||||
## Hardcoded Paths to XDG
|
||||
|
||||
Replace hardcoded home directory paths with XDG-compliant paths.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const configPath = path.join(os.homedir(), ".myapp", "config.json");
|
||||
const cachePath = path.join(os.homedir(), ".cache", "myapp");
|
||||
const dataPath = path.join(os.homedir(), ".local", "share", "myapp");
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { getConfigDir, getCacheDir, getDataDir } from "@outfitter/config";
|
||||
import path from "node:path";
|
||||
|
||||
const configPath = path.join(getConfigDir("myapp"), "config.json");
|
||||
const cachePath = getCacheDir("myapp");
|
||||
const dataPath = getDataDir("myapp");
|
||||
```
|
||||
|
||||
### XDG Path Functions
|
||||
|
||||
| Function | Default Path | Env Override |
|
||||
|----------|--------------|--------------|
|
||||
| `getConfigDir(app)` | `~/.config/{app}` | `XDG_CONFIG_HOME` |
|
||||
| `getCacheDir(app)` | `~/.cache/{app}` | `XDG_CACHE_HOME` |
|
||||
| `getDataDir(app)` | `~/.local/share/{app}` | `XDG_DATA_HOME` |
|
||||
| `getStateDir(app)` | `~/.local/state/{app}` | `XDG_STATE_HOME` |
|
||||
|
||||
## Error Taxonomy Mapping
|
||||
|
||||
Map existing custom errors to the 10 taxonomy categories.
|
||||
|
||||
| Original Pattern | Outfitter Error | Category |
|
||||
|------------------|-----------------|----------|
|
||||
| `NotFoundError` | `NotFoundError` | `not_found` |
|
||||
| `InvalidInputError` | `ValidationError` | `validation` |
|
||||
| `DuplicateError` | `ConflictError` | `conflict` |
|
||||
| `UnauthorizedError` | `AuthError` | `auth` |
|
||||
| `ForbiddenError` | `PermissionError` | `permission` |
|
||||
| `TimeoutError` | `TimeoutError` | `timeout` |
|
||||
| `RateLimitError` | `RateLimitError` | `rate_limit` |
|
||||
| `ConnectionError` | `NetworkError` | `network` |
|
||||
| Generic `Error` | `InternalError` | `internal` |
|
||||
| `AbortError` | `CancelledError` | `cancelled` |
|
||||
|
||||
### Mapping by Error Name Keywords
|
||||
|
||||
| Keyword in Error Name | Maps To |
|
||||
|-----------------------|---------|
|
||||
| `notfound`, `missing` | `NotFoundError` |
|
||||
| `validation`, `invalid`, `input` | `ValidationError` |
|
||||
| `conflict`, `duplicate`, `exists` | `ConflictError` |
|
||||
| `permission`, `forbidden` | `PermissionError` |
|
||||
| `timeout` | `TimeoutError` |
|
||||
| `ratelimit`, `rate`, `throttle` | `RateLimitError` |
|
||||
| `network`, `connection` | `NetworkError` |
|
||||
| `auth`, `unauthorized`, `unauthenticated` | `AuthError` |
|
||||
| `cancel`, `abort` | `CancelledError` |
|
||||
|
||||
## Compatibility Layer
|
||||
|
||||
Wrap legacy throwing code during transition with a Result-returning wrapper.
|
||||
|
||||
```typescript
|
||||
import { Result, InternalError } from "@outfitter/contracts";
|
||||
|
||||
/**
|
||||
* Wraps a synchronous function that may throw, returning a Result.
|
||||
*/
|
||||
function wrapSync<T>(fn: () => T): Result<T, InternalError> {
|
||||
try {
|
||||
return Result.ok(fn());
|
||||
} catch (error) {
|
||||
return Result.err(new InternalError(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
{ cause: error }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an async function that may throw, returning a Result.
|
||||
*/
|
||||
async function wrapAsync<T>(fn: () => Promise<T>): Promise<Result<T, InternalError>> {
|
||||
try {
|
||||
return Result.ok(await fn());
|
||||
} catch (error) {
|
||||
return Result.err(new InternalError(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
{ cause: error }
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
// Wrap a third-party library call
|
||||
const result = await wrapAsync(() => thirdPartyApi.fetch(id));
|
||||
|
||||
if (result.isErr()) {
|
||||
ctx.logger.error("Third-party API failed", { error: result.error });
|
||||
return result;
|
||||
}
|
||||
|
||||
const data = result.value;
|
||||
```
|
||||
|
||||
## Try-Catch to Result
|
||||
|
||||
Convert try-catch blocks to Result chains.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
async function processOrder(orderId: string): Promise<Order> {
|
||||
try {
|
||||
const order = await fetchOrder(orderId);
|
||||
const validated = validateOrder(order);
|
||||
const processed = await processPayment(validated);
|
||||
return processed;
|
||||
} catch (error) {
|
||||
console.error("Order processing failed", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const processOrder: Handler<{ orderId: string }, Order, OrderError> = async (input, ctx) => {
|
||||
const orderResult = await fetchOrder(input.orderId, ctx);
|
||||
if (orderResult.isErr()) return orderResult;
|
||||
|
||||
const validatedResult = validateOrder(orderResult.value);
|
||||
if (validatedResult.isErr()) return validatedResult;
|
||||
|
||||
const processedResult = await processPayment(validatedResult.value, ctx);
|
||||
if (processedResult.isErr()) return processedResult;
|
||||
|
||||
return Result.ok(processedResult.value);
|
||||
};
|
||||
```
|
||||
|
||||
## Conversion Strategy
|
||||
|
||||
1. **New code first** - All new code uses stack patterns
|
||||
2. **Leaf functions** - Start with functions that don't call others
|
||||
3. **Bottom-up** - Convert dependencies before dependents
|
||||
4. **Feature boundaries** - Complete one feature at a time
|
||||
5. **Test coverage** - Add tests before converting
|
||||
@@ -0,0 +1,300 @@
|
||||
# Daemon Patterns
|
||||
|
||||
Deep dive into @outfitter/daemon patterns.
|
||||
|
||||
## Creating a Daemon
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createDaemon,
|
||||
getLockPath,
|
||||
} from "@outfitter/daemon";
|
||||
import { createLogger, createConsoleSink } from "@outfitter/logging";
|
||||
|
||||
const logger = createLogger({
|
||||
name: "my-daemon",
|
||||
level: "info",
|
||||
sinks: [createConsoleSink()],
|
||||
});
|
||||
|
||||
const daemon = createDaemon({
|
||||
name: "my-daemon",
|
||||
pidFile: getLockPath("my-daemon"),
|
||||
logger,
|
||||
shutdownTimeout: 10000, // 10s graceful shutdown
|
||||
});
|
||||
```
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
```typescript
|
||||
// Called before start
|
||||
daemon.onBeforeStart(async () => {
|
||||
logger.info("Preparing to start...");
|
||||
await initializeDatabase();
|
||||
});
|
||||
|
||||
// Called after start
|
||||
daemon.onAfterStart(async () => {
|
||||
logger.info("Daemon started successfully");
|
||||
});
|
||||
|
||||
// Called on shutdown (SIGTERM, SIGINT)
|
||||
daemon.onShutdown(async () => {
|
||||
logger.info("Shutting down...");
|
||||
await closeConnections();
|
||||
await flushBuffers();
|
||||
});
|
||||
|
||||
// Start the daemon
|
||||
const result = await daemon.start();
|
||||
if (result.isErr()) {
|
||||
logger.error("Failed to start", { error: result.error });
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
## IPC Server
|
||||
|
||||
### Setting Up IPC
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createIpcServer,
|
||||
getSocketPath,
|
||||
} from "@outfitter/daemon";
|
||||
|
||||
const ipcServer = createIpcServer(getSocketPath("my-daemon"));
|
||||
|
||||
// Handle messages
|
||||
ipcServer.onMessage(async (msg) => {
|
||||
const message = msg as { type: string; payload?: unknown };
|
||||
|
||||
switch (message.type) {
|
||||
case "status":
|
||||
return {
|
||||
status: "ok",
|
||||
uptime: process.uptime(),
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
case "reload":
|
||||
await reloadConfig();
|
||||
return { success: true };
|
||||
|
||||
case "metrics":
|
||||
return getMetrics();
|
||||
|
||||
default:
|
||||
return { error: "Unknown command" };
|
||||
}
|
||||
});
|
||||
|
||||
// Register cleanup
|
||||
daemon.onShutdown(async () => {
|
||||
await ipcServer.close();
|
||||
});
|
||||
|
||||
// Start listening
|
||||
await ipcServer.listen();
|
||||
logger.info("IPC listening", { socket: getSocketPath("my-daemon") });
|
||||
```
|
||||
|
||||
### IPC Client
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createIpcClient,
|
||||
getSocketPath,
|
||||
} from "@outfitter/daemon";
|
||||
|
||||
const client = createIpcClient(getSocketPath("my-daemon"));
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Send message and get response
|
||||
const status = await client.send<{
|
||||
status: string;
|
||||
uptime: number;
|
||||
}>({ type: "status" });
|
||||
|
||||
console.log("Daemon status:", status);
|
||||
|
||||
// Clean up
|
||||
client.close();
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Defining Checks
|
||||
|
||||
```typescript
|
||||
import { createHealthChecker } from "@outfitter/daemon";
|
||||
import { Result } from "@outfitter/contracts";
|
||||
|
||||
const healthChecker = createHealthChecker([
|
||||
{
|
||||
name: "memory",
|
||||
check: async () => {
|
||||
const used = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
return used < 500
|
||||
? Result.ok(undefined)
|
||||
: Result.err(new Error(`High memory: ${used.toFixed(2)}MB`));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "database",
|
||||
check: async () => {
|
||||
try {
|
||||
await db.ping();
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
return Result.err(new Error("Database unreachable"));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disk",
|
||||
check: async () => {
|
||||
const free = await getDiskSpace();
|
||||
return free > 100 * 1024 * 1024 // 100MB
|
||||
? Result.ok(undefined)
|
||||
: Result.err(new Error("Low disk space"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Exposing Health via IPC
|
||||
|
||||
```typescript
|
||||
ipcServer.onMessage(async (msg) => {
|
||||
if (msg.type === "health") {
|
||||
const result = await healthChecker.check();
|
||||
return {
|
||||
healthy: result.isOk(),
|
||||
checks: result.isOk() ? result.value : result.error,
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Periodic Health Checks
|
||||
|
||||
```typescript
|
||||
const HEALTH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
setInterval(async () => {
|
||||
const result = await healthChecker.check();
|
||||
|
||||
if (result.isErr()) {
|
||||
logger.warn("Health check failed", { checks: result.error });
|
||||
}
|
||||
}, HEALTH_INTERVAL);
|
||||
```
|
||||
|
||||
## PID File Management
|
||||
|
||||
### XDG Paths
|
||||
|
||||
```typescript
|
||||
import { getLockPath, getSocketPath, getLogPath } from "@outfitter/daemon";
|
||||
|
||||
// PID file: ~/.local/state/my-daemon/my-daemon.pid
|
||||
const pidPath = getLockPath("my-daemon");
|
||||
|
||||
// Socket: ~/.local/state/my-daemon/my-daemon.sock
|
||||
const socketPath = getSocketPath("my-daemon");
|
||||
|
||||
// Logs: ~/.local/state/my-daemon/logs/
|
||||
const logDir = getLogPath("my-daemon");
|
||||
```
|
||||
|
||||
### Checking if Running
|
||||
|
||||
```typescript
|
||||
import { isDaemonRunning, getDaemonPid } from "@outfitter/daemon";
|
||||
|
||||
if (await isDaemonRunning("my-daemon")) {
|
||||
const pid = await getDaemonPid("my-daemon");
|
||||
console.log(`Daemon already running (PID: ${pid})`);
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Integration
|
||||
|
||||
### Start Command
|
||||
|
||||
```typescript
|
||||
export const startCommand = command("start")
|
||||
.option("-d, --detach", "Run in background")
|
||||
.action(async ({ flags }) => {
|
||||
if (await isDaemonRunning("my-daemon")) {
|
||||
console.log("Daemon already running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.detach) {
|
||||
// Spawn detached process
|
||||
spawn("bun", ["run", "src/daemon.ts"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
console.log("Daemon started in background");
|
||||
} else {
|
||||
// Run in foreground
|
||||
await runDaemon();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
### Stop Command
|
||||
|
||||
```typescript
|
||||
export const stopCommand = command("stop")
|
||||
.action(async () => {
|
||||
const client = createIpcClient(getSocketPath("my-daemon"));
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.send({ type: "shutdown" });
|
||||
console.log("Daemon stopped");
|
||||
} catch {
|
||||
console.log("Daemon not running");
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
### Status Command
|
||||
|
||||
```typescript
|
||||
export const statusCommand = command("status")
|
||||
.action(async () => {
|
||||
const client = createIpcClient(getSocketPath("my-daemon"));
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const status = await client.send<Status>({ type: "status" });
|
||||
console.log("Status:", status);
|
||||
} catch {
|
||||
console.log("Daemon not running");
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Graceful shutdown** - Register cleanup handlers with `onShutdown`
|
||||
2. **Health checks** - Monitor critical dependencies
|
||||
3. **IPC protocol** - Use structured message types
|
||||
4. **PID files** - Use XDG paths for consistency
|
||||
5. **Logging** - Log lifecycle events for debugging
|
||||
6. **CLI commands** - Provide start/stop/status commands
|
||||
@@ -0,0 +1,242 @@
|
||||
# Error Taxonomy
|
||||
|
||||
Ten error categories that map to exit codes (CLI) and HTTP status codes (API).
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Exit | HTTP | Class | When to Use |
|
||||
|----------|------|------|-------|-------------|
|
||||
| `validation` | 1 | 400 | `ValidationError` | Invalid input, schema failures, constraint violations |
|
||||
| `not_found` | 2 | 404 | `NotFoundError` | Resource doesn't exist |
|
||||
| `conflict` | 3 | 409 | `ConflictError` | Already exists, version mismatch, optimistic lock failure |
|
||||
| `permission` | 4 | 403 | `PermissionError` | Forbidden action, insufficient privileges |
|
||||
| `timeout` | 5 | 504 | `TimeoutError` | Operation took too long |
|
||||
| `rate_limit` | 6 | 429 | `RateLimitError` | Too many requests, quota exceeded |
|
||||
| `network` | 7 | 503 | `NetworkError` | Connection failures, DNS errors, unreachable hosts |
|
||||
| `internal` | 8 | 500 | `InternalError` | Unexpected errors, bugs, unhandled cases |
|
||||
| `auth` | 9 | 401 | `AuthError` | Authentication required, invalid credentials |
|
||||
| `cancelled` | 130 | 499 | `CancelledError` | User interrupted (Ctrl+C), operation aborted |
|
||||
|
||||
## Error Classes
|
||||
|
||||
All errors extend `OutfitterError` and have:
|
||||
|
||||
```typescript
|
||||
interface OutfitterError {
|
||||
readonly _tag: string; // Discriminator for pattern matching
|
||||
readonly category: ErrorCategory; // One of the 10 categories
|
||||
readonly message: string; // Human-readable message
|
||||
readonly details?: unknown; // Additional context
|
||||
}
|
||||
```
|
||||
|
||||
### ValidationError
|
||||
|
||||
```typescript
|
||||
import { ValidationError } from "@outfitter/contracts";
|
||||
|
||||
// Basic
|
||||
new ValidationError("Invalid email format");
|
||||
|
||||
// With details
|
||||
new ValidationError("Validation failed", {
|
||||
field: "email",
|
||||
value: "not-an-email",
|
||||
constraint: "email",
|
||||
});
|
||||
|
||||
// From Zod
|
||||
const result = schema.safeParse(input);
|
||||
if (!result.success) {
|
||||
return Result.err(new ValidationError("Invalid input", {
|
||||
issues: result.error.issues,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### NotFoundError
|
||||
|
||||
```typescript
|
||||
import { NotFoundError } from "@outfitter/contracts";
|
||||
|
||||
// Resource type and ID
|
||||
new NotFoundError("user", "user-123");
|
||||
|
||||
// Access properties
|
||||
error.resourceType; // "user"
|
||||
error.resourceId; // "user-123"
|
||||
error.message; // "user not found: user-123"
|
||||
```
|
||||
|
||||
### ConflictError
|
||||
|
||||
```typescript
|
||||
import { ConflictError } from "@outfitter/contracts";
|
||||
|
||||
// Already exists
|
||||
new ConflictError("User already exists", { email: "user@example.com" });
|
||||
|
||||
// Version mismatch
|
||||
new ConflictError("Version mismatch", {
|
||||
expected: 5,
|
||||
actual: 7,
|
||||
});
|
||||
```
|
||||
|
||||
### PermissionError
|
||||
|
||||
```typescript
|
||||
import { PermissionError } from "@outfitter/contracts";
|
||||
|
||||
new PermissionError("Cannot delete admin users", {
|
||||
action: "delete",
|
||||
resource: "user",
|
||||
resourceId: "admin-1",
|
||||
});
|
||||
```
|
||||
|
||||
### TimeoutError
|
||||
|
||||
```typescript
|
||||
import { TimeoutError } from "@outfitter/contracts";
|
||||
|
||||
new TimeoutError("Database query timed out", {
|
||||
operation: "findUsers",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
### RateLimitError
|
||||
|
||||
```typescript
|
||||
import { RateLimitError } from "@outfitter/contracts";
|
||||
|
||||
new RateLimitError("API rate limit exceeded", {
|
||||
limit: 100,
|
||||
window: "1m",
|
||||
retryAfter: 30,
|
||||
});
|
||||
```
|
||||
|
||||
### NetworkError
|
||||
|
||||
```typescript
|
||||
import { NetworkError } from "@outfitter/contracts";
|
||||
|
||||
new NetworkError("Failed to connect to API", {
|
||||
host: "api.example.com",
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
```
|
||||
|
||||
### InternalError
|
||||
|
||||
```typescript
|
||||
import { InternalError } from "@outfitter/contracts";
|
||||
|
||||
// Wrap unexpected errors
|
||||
try {
|
||||
await riskyOperation();
|
||||
} catch (error) {
|
||||
return Result.err(new InternalError("Unexpected error", { cause: error }));
|
||||
}
|
||||
```
|
||||
|
||||
### AuthError
|
||||
|
||||
```typescript
|
||||
import { AuthError } from "@outfitter/contracts";
|
||||
|
||||
new AuthError("Invalid API key");
|
||||
new AuthError("Token expired", { expiredAt: "2024-01-01T00:00:00Z" });
|
||||
```
|
||||
|
||||
### CancelledError
|
||||
|
||||
```typescript
|
||||
import { CancelledError } from "@outfitter/contracts";
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return Result.err(new CancelledError("Operation cancelled by user"));
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
Use `_tag` for type-safe error handling:
|
||||
|
||||
```typescript
|
||||
if (result.isErr()) {
|
||||
switch (result.error._tag) {
|
||||
case "ValidationError":
|
||||
console.log("Invalid input:", result.error.details);
|
||||
break;
|
||||
case "NotFoundError":
|
||||
console.log(`${result.error.resourceType} not found`);
|
||||
break;
|
||||
case "ConflictError":
|
||||
console.log("Conflict:", result.error.message);
|
||||
break;
|
||||
default:
|
||||
console.log("Error:", result.error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Code Mapping
|
||||
|
||||
```typescript
|
||||
import { getExitCode } from "@outfitter/contracts";
|
||||
|
||||
const exitCode = getExitCode(error.category);
|
||||
process.exit(exitCode);
|
||||
```
|
||||
|
||||
## HTTP Status Mapping
|
||||
|
||||
```typescript
|
||||
import { getStatusCode } from "@outfitter/contracts";
|
||||
|
||||
const status = getStatusCode(error.category);
|
||||
res.status(status).json({ error: error.message });
|
||||
```
|
||||
|
||||
## ERROR_CODES Constant
|
||||
|
||||
Use `ERROR_CODES` for type-safe category validation and iteration:
|
||||
|
||||
```typescript
|
||||
import { ERROR_CODES, type ErrorCategory } from "@outfitter/contracts";
|
||||
|
||||
// ERROR_CODES is a readonly object mapping category names to exit codes
|
||||
ERROR_CODES.validation; // 1
|
||||
ERROR_CODES.not_found; // 2
|
||||
ERROR_CODES.conflict; // 3
|
||||
// ... etc
|
||||
|
||||
// Validate a category exists
|
||||
const isValidCategory = (cat: string): cat is ErrorCategory => {
|
||||
return cat in ERROR_CODES;
|
||||
};
|
||||
|
||||
// Iterate over all categories
|
||||
for (const [category, exitCode] of Object.entries(ERROR_CODES)) {
|
||||
console.log(`${category}: exit ${exitCode}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Creating Custom Errors
|
||||
|
||||
Extend the base classes for domain-specific errors:
|
||||
|
||||
```typescript
|
||||
import { ValidationError } from "@outfitter/contracts";
|
||||
|
||||
export class EmailValidationError extends ValidationError {
|
||||
constructor(email: string) {
|
||||
super("Invalid email format", { email, field: "email" });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The category is inherited, so exit codes and HTTP status work automatically.
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# File Operations Patterns
|
||||
|
||||
Deep dive into @outfitter/file-ops patterns for safe file handling.
|
||||
|
||||
## Secure Paths
|
||||
|
||||
Prevent path traversal attacks with `securePath`:
|
||||
|
||||
```typescript
|
||||
import { securePath } from "@outfitter/file-ops";
|
||||
|
||||
const path = securePath("/data", userInput);
|
||||
// Throws if userInput tries to escape /data via ../
|
||||
```
|
||||
|
||||
## Atomic Writes
|
||||
|
||||
Write files atomically to prevent corruption:
|
||||
|
||||
```typescript
|
||||
import { writeFileAtomic } from "@outfitter/file-ops";
|
||||
|
||||
await writeFileAtomic("/path/to/file.json", JSON.stringify(data, null, 2));
|
||||
// Writes to temp file, then renames (atomic on POSIX)
|
||||
```
|
||||
|
||||
## File Locking
|
||||
|
||||
### Exclusive Lock
|
||||
|
||||
For write operations that need exclusive access:
|
||||
|
||||
```typescript
|
||||
import { withExclusiveLock } from "@outfitter/file-ops";
|
||||
|
||||
const result = await withExclusiveLock("/path/to/file.lock", async () => {
|
||||
const data = await Bun.file("/path/to/data.json").json();
|
||||
data.counter += 1;
|
||||
await writeFileAtomic("/path/to/data.json", JSON.stringify(data));
|
||||
return data;
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
// Lock acquisition failed or operation threw
|
||||
}
|
||||
```
|
||||
|
||||
### Shared Lock (Reader-Writer)
|
||||
|
||||
Use `withSharedLock()` for read operations that can run concurrently:
|
||||
|
||||
```typescript
|
||||
import { withSharedLock, withExclusiveLock } from "@outfitter/file-ops";
|
||||
|
||||
// Multiple readers can hold shared locks simultaneously
|
||||
const readResult = await withSharedLock("/path/to/data.lock", async () => {
|
||||
return await Bun.file("/path/to/data.json").json();
|
||||
});
|
||||
|
||||
// Writers need exclusive lock (blocks readers)
|
||||
const writeResult = await withExclusiveLock("/path/to/data.lock", async () => {
|
||||
const data = await Bun.file("/path/to/data.json").json();
|
||||
data.updated = Date.now();
|
||||
await writeFileAtomic("/path/to/data.json", JSON.stringify(data));
|
||||
return data;
|
||||
});
|
||||
```
|
||||
|
||||
**Lock fairness note:** Reader-writer locks can cause starvation. With many concurrent readers, writers may wait indefinitely (and vice versa). For high-contention scenarios, consider using exclusive locks only or implementing application-level queuing.
|
||||
|
||||
### Lock Options
|
||||
|
||||
```typescript
|
||||
await withExclusiveLock("/path/to/file.lock", operation, {
|
||||
timeout: 5000, // Max wait time in ms (default: 10000)
|
||||
retryDelay: 100, // Delay between retries (default: 50)
|
||||
staleThreshold: 60000, // Consider lock stale after this many ms
|
||||
});
|
||||
```
|
||||
|
||||
### Lock File Conventions
|
||||
|
||||
- Use `.lock` extension for lock files
|
||||
- Place lock files alongside the protected resource
|
||||
- Use consistent lock file paths across all accessors
|
||||
|
||||
```typescript
|
||||
// Good: Lock file next to data file
|
||||
const dataPath = "/data/users.json";
|
||||
const lockPath = "/data/users.json.lock";
|
||||
|
||||
// Good: Named lock in XDG state
|
||||
import { getStatePath } from "@outfitter/config";
|
||||
const lockPath = getStatePath("myapp", "db.lock");
|
||||
```
|
||||
|
||||
## Safe Directory Operations
|
||||
|
||||
### Ensure Directory Exists
|
||||
|
||||
```typescript
|
||||
import { ensureDir } from "@outfitter/file-ops";
|
||||
|
||||
await ensureDir("/path/to/nested/dir");
|
||||
// Creates all parent directories if needed
|
||||
```
|
||||
|
||||
### Safe Removal
|
||||
|
||||
```typescript
|
||||
import { safeRemove } from "@outfitter/file-ops";
|
||||
|
||||
await safeRemove("/path/to/file-or-dir");
|
||||
// No error if doesn't exist, removes recursively if dir
|
||||
```
|
||||
|
||||
## Temp Files
|
||||
|
||||
### Create Temp File
|
||||
|
||||
```typescript
|
||||
import { createTempFile } from "@outfitter/file-ops";
|
||||
|
||||
const tempPath = await createTempFile("myapp", ".json");
|
||||
// Returns path like /tmp/myapp-abc123.json
|
||||
```
|
||||
|
||||
### With Cleanup
|
||||
|
||||
```typescript
|
||||
import { withTempFile } from "@outfitter/file-ops";
|
||||
|
||||
const result = await withTempFile("myapp", ".json", async (tempPath) => {
|
||||
await Bun.write(tempPath, JSON.stringify(data));
|
||||
return await processFile(tempPath);
|
||||
});
|
||||
// Temp file automatically cleaned up
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use atomic writes** for critical data
|
||||
2. **Lock before read-modify-write** operations
|
||||
3. **Use shared locks** for read-only operations to improve concurrency
|
||||
4. **Validate paths** with `securePath` before using user input
|
||||
5. **Clean up temp files** with `withTempFile` pattern
|
||||
6. **Use XDG paths** from `@outfitter/config` for state/cache files
|
||||
@@ -0,0 +1,211 @@
|
||||
# Handler Contract
|
||||
|
||||
The core abstraction in Outfitter Stack. Handlers are pure functions that accept typed input and context, returning `Result<TOutput, TError>`.
|
||||
|
||||
## Signature
|
||||
|
||||
```typescript
|
||||
type Handler<TInput, TOutput, TError extends OutfitterError> = (
|
||||
input: TInput,
|
||||
ctx: HandlerContext
|
||||
) => Promise<Result<TOutput, TError>>;
|
||||
```
|
||||
|
||||
## Type Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `TInput` | Input type (use `unknown` for raw input that needs validation) |
|
||||
| `TOutput` | Success return type |
|
||||
| `TError` | Union of possible error types (must extend `OutfitterError`) |
|
||||
|
||||
## Handler Structure
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Result,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
createValidator,
|
||||
type Handler,
|
||||
} from "@outfitter/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
// 1. Define input schema
|
||||
const InputSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
options: z.object({
|
||||
includeDeleted: z.boolean().default(false),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// 2. Create validator
|
||||
const validateInput = createValidator(InputSchema);
|
||||
|
||||
// 3. Define output type
|
||||
interface UserOutput {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// 4. Implement handler
|
||||
export const getUser: Handler<unknown, UserOutput, ValidationError | NotFoundError> = async (
|
||||
rawInput,
|
||||
ctx
|
||||
) => {
|
||||
// Validate input
|
||||
const inputResult = validateInput(rawInput);
|
||||
if (inputResult.isErr()) return inputResult;
|
||||
const input = inputResult.value;
|
||||
|
||||
// Log with context
|
||||
ctx.logger.debug("Fetching user", { userId: input.id });
|
||||
|
||||
// Business logic
|
||||
const user = await db.users.findById(input.id);
|
||||
if (!user) {
|
||||
return Result.err(new NotFoundError("user", input.id));
|
||||
}
|
||||
|
||||
// Return success
|
||||
return Result.ok(user);
|
||||
};
|
||||
```
|
||||
|
||||
## Why Handlers?
|
||||
|
||||
### Transport Agnostic
|
||||
|
||||
Handlers know nothing about:
|
||||
- CLI flags and arguments
|
||||
- HTTP headers and status codes
|
||||
- MCP tool schemas
|
||||
- WebSocket messages
|
||||
|
||||
This separation means one handler serves all transports.
|
||||
|
||||
### Testability
|
||||
|
||||
Test handlers directly without transport layer:
|
||||
|
||||
```typescript
|
||||
import { createContext } from "@outfitter/contracts";
|
||||
|
||||
test("getUser returns user", async () => {
|
||||
const ctx = createContext({});
|
||||
const result = await getUser({ id: "user-1" }, ctx);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value.name).toBe("Alice");
|
||||
});
|
||||
```
|
||||
|
||||
### Composability
|
||||
|
||||
Handlers can call other handlers:
|
||||
|
||||
```typescript
|
||||
const createOrder: Handler<CreateOrderInput, Order, OrderError> = async (input, ctx) => {
|
||||
// Call another handler
|
||||
const userResult = await getUser({ id: input.userId }, ctx);
|
||||
if (userResult.isErr()) {
|
||||
return Result.err(new ValidationError("Invalid user", { userId: input.userId }));
|
||||
}
|
||||
|
||||
// Continue with order creation
|
||||
const order = await db.orders.create({
|
||||
user: userResult.value,
|
||||
items: input.items,
|
||||
});
|
||||
|
||||
return Result.ok(order);
|
||||
};
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
TypeScript knows all possible outcomes:
|
||||
|
||||
```typescript
|
||||
const result = await getUser({ id: "123" }, ctx);
|
||||
|
||||
if (result.isOk()) {
|
||||
// result.value is UserOutput
|
||||
console.log(result.value.name);
|
||||
} else {
|
||||
// result.error is ValidationError | NotFoundError
|
||||
switch (result.error._tag) {
|
||||
case "ValidationError":
|
||||
console.log(result.error.details);
|
||||
break;
|
||||
case "NotFoundError":
|
||||
console.log(result.error.resourceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Pattern
|
||||
|
||||
Always validate at handler entry:
|
||||
|
||||
```typescript
|
||||
const handler: Handler<unknown, Output, ValidationError | OtherError> = async (rawInput, ctx) => {
|
||||
// First: validate
|
||||
const inputResult = validateInput(rawInput);
|
||||
if (inputResult.isErr()) return inputResult;
|
||||
const input = inputResult.value; // Now typed!
|
||||
|
||||
// Rest of handler uses validated input
|
||||
};
|
||||
```
|
||||
|
||||
## Context Usage
|
||||
|
||||
Access cross-cutting concerns via context:
|
||||
|
||||
```typescript
|
||||
const handler: Handler<Input, Output, Error> = async (input, ctx) => {
|
||||
// Logging
|
||||
ctx.logger.info("Processing", { input });
|
||||
|
||||
// Request tracing
|
||||
const requestId = ctx.requestId;
|
||||
|
||||
// Configuration
|
||||
const apiUrl = ctx.config.apiUrl;
|
||||
|
||||
// Cancellation
|
||||
if (ctx.signal.aborted) {
|
||||
return Result.err(new CancelledError("Operation cancelled"));
|
||||
}
|
||||
|
||||
// Workspace paths
|
||||
const filePath = path.join(ctx.workspaceRoot, input.filename);
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Never throw in handlers. Return `Result.err()`:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
if (!user) throw new Error("Not found");
|
||||
|
||||
// GOOD
|
||||
if (!user) return Result.err(new NotFoundError("user", id));
|
||||
```
|
||||
|
||||
Use taxonomy error classes for consistent categorization:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
PermissionError,
|
||||
InternalError,
|
||||
} from "@outfitter/contracts";
|
||||
```
|
||||
@@ -0,0 +1,263 @@
|
||||
# Logging Patterns
|
||||
|
||||
Deep dive into @outfitter/logging patterns.
|
||||
|
||||
## Creating a Logger
|
||||
|
||||
```typescript
|
||||
import { createLogger, createConsoleSink } from "@outfitter/logging";
|
||||
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
level: "info",
|
||||
sinks: [createConsoleSink()],
|
||||
redaction: { enabled: true },
|
||||
});
|
||||
```
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Level | Method | Use For |
|
||||
|-------|--------|---------|
|
||||
| `trace` | `logger.trace()` | Very detailed debugging |
|
||||
| `debug` | `logger.debug()` | Development debugging |
|
||||
| `info` | `logger.info()` | Normal operations |
|
||||
| `warn` | `logger.warn()` | Unexpected but handled |
|
||||
| `error` | `logger.error()` | Failures requiring attention |
|
||||
| `fatal` | `logger.fatal()` | Unrecoverable failures |
|
||||
|
||||
Level hierarchy: `trace` < `debug` < `info` < `warn` < `error` < `fatal`
|
||||
|
||||
Setting level to `info` hides `trace` and `debug`.
|
||||
|
||||
## Structured Logging
|
||||
|
||||
Always use metadata objects:
|
||||
|
||||
```typescript
|
||||
// GOOD: Structured metadata
|
||||
logger.info("User created", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
duration: performance.now() - start,
|
||||
});
|
||||
|
||||
// BAD: String concatenation
|
||||
logger.info("User " + user.name + " created in " + duration + "ms");
|
||||
```
|
||||
|
||||
## Child Loggers
|
||||
|
||||
Add context that persists across calls:
|
||||
|
||||
```typescript
|
||||
import { createChildLogger } from "@outfitter/logging";
|
||||
|
||||
const requestLogger = createChildLogger(logger, {
|
||||
requestId: ctx.requestId,
|
||||
handler: "createUser",
|
||||
});
|
||||
|
||||
// All logs include requestId and handler
|
||||
requestLogger.info("Processing"); // Has requestId, handler
|
||||
requestLogger.debug("Validated input"); // Has requestId, handler
|
||||
requestLogger.info("User created", { userId }); // Has requestId, handler, userId
|
||||
```
|
||||
|
||||
## Redaction
|
||||
|
||||
### Enable Redaction
|
||||
|
||||
```typescript
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
level: "info",
|
||||
sinks: [createConsoleSink()],
|
||||
redaction: { enabled: true },
|
||||
});
|
||||
|
||||
logger.info("Config", {
|
||||
apiKey: "secret-123", // Logged as "[REDACTED]"
|
||||
password: "hunter2", // Logged as "[REDACTED]"
|
||||
email: "user@example.com" // Not redacted
|
||||
});
|
||||
```
|
||||
|
||||
### Default Redaction Patterns
|
||||
|
||||
Automatically redacted:
|
||||
- `password`, `pwd`
|
||||
- `apiKey`, `api_key`
|
||||
- `secret`, `secretKey`
|
||||
- `token`, `accessToken`
|
||||
- `auth`, `authorization`
|
||||
- `key` (when containing sensitive data)
|
||||
- `credential`, `credentials`
|
||||
|
||||
### Custom Patterns
|
||||
|
||||
```typescript
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
redaction: {
|
||||
enabled: true,
|
||||
patterns: [
|
||||
"password",
|
||||
"apiKey",
|
||||
"myCustomSecret",
|
||||
"internalToken",
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Deep Redaction
|
||||
|
||||
Nested values are also redacted:
|
||||
|
||||
```typescript
|
||||
logger.info("Request", {
|
||||
headers: {
|
||||
authorization: "Bearer token", // Redacted
|
||||
},
|
||||
body: {
|
||||
user: {
|
||||
password: "secret", // Redacted
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Sinks
|
||||
|
||||
### Console Sink
|
||||
|
||||
```typescript
|
||||
import { createConsoleSink } from "@outfitter/logging";
|
||||
|
||||
const consoleSink = createConsoleSink({
|
||||
colorize: true, // ANSI colors
|
||||
prettyPrint: true, // Formatted output
|
||||
timestampFormat: "iso", // ISO 8601 timestamps
|
||||
});
|
||||
```
|
||||
|
||||
### File Sink
|
||||
|
||||
```typescript
|
||||
import { createFileSink } from "@outfitter/logging";
|
||||
|
||||
const fileSink = createFileSink({
|
||||
path: "/var/log/myapp/app.log",
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5, // Keep 5 rotated files
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Sinks
|
||||
|
||||
```typescript
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
level: "debug",
|
||||
sinks: [
|
||||
createConsoleSink({ level: "info" }), // Console: info+
|
||||
createFileSink({ // File: debug+
|
||||
path: "/var/log/myapp/debug.log",
|
||||
level: "debug",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Sink
|
||||
|
||||
```typescript
|
||||
const customSink = {
|
||||
log: (record) => {
|
||||
// Send to external service
|
||||
externalService.send({
|
||||
level: record.level,
|
||||
message: record.message,
|
||||
metadata: record.metadata,
|
||||
timestamp: record.timestamp,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
sinks: [customSink],
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
```typescript
|
||||
const logger = createLogger({
|
||||
name: "my-app",
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
sinks: [
|
||||
createConsoleSink({
|
||||
colorize: process.stdout.isTTY,
|
||||
prettyPrint: process.env.NODE_ENV !== "production",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Handler Context Integration
|
||||
|
||||
```typescript
|
||||
import { createContext } from "@outfitter/contracts";
|
||||
import { createLogger, createChildLogger } from "@outfitter/logging";
|
||||
|
||||
const baseLogger = createLogger({ name: "my-app", level: "info" });
|
||||
|
||||
export function createHandlerContext() {
|
||||
const ctx = createContext({ logger: baseLogger });
|
||||
|
||||
// Child logger with requestId
|
||||
return {
|
||||
...ctx,
|
||||
logger: createChildLogger(baseLogger, { requestId: ctx.requestId }),
|
||||
};
|
||||
}
|
||||
|
||||
// In handler
|
||||
const myHandler: Handler<Input, Output, Error> = async (input, ctx) => {
|
||||
ctx.logger.info("Processing", { input }); // Includes requestId
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Conditional Logging
|
||||
|
||||
```typescript
|
||||
// Level check before expensive operations
|
||||
if (logger.isEnabled("debug")) {
|
||||
const expensiveData = computeDebugInfo();
|
||||
logger.debug("Debug info", { data: expensiveData });
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Evaluation
|
||||
|
||||
```typescript
|
||||
logger.debug("State", () => ({
|
||||
// Only computed if debug level is enabled
|
||||
memory: process.memoryUsage(),
|
||||
connections: getActiveConnections(),
|
||||
}));
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Structured metadata** - Always use objects, not string concatenation
|
||||
2. **Child loggers** - Add request context that persists
|
||||
3. **Enable redaction** - Prevent secrets from leaking
|
||||
4. **Level per environment** - Debug in dev, info in prod
|
||||
5. **Request IDs** - Include for tracing across handlers
|
||||
6. **Lazy evaluation** - Avoid expensive computations at disabled levels
|
||||
@@ -0,0 +1,279 @@
|
||||
# MCP Server Patterns
|
||||
|
||||
Deep dive into @outfitter/mcp patterns.
|
||||
|
||||
## Creating a Server
|
||||
|
||||
```typescript
|
||||
import { createMcpServer, defineTool } from "@outfitter/mcp";
|
||||
|
||||
const server = createMcpServer({
|
||||
name: "my-server",
|
||||
version: "0.1.0",
|
||||
description: "Server for AI agents",
|
||||
});
|
||||
|
||||
// Register tools before start
|
||||
server.registerTool(searchTool);
|
||||
server.registerTool(createTool);
|
||||
|
||||
// Start server
|
||||
server.start();
|
||||
```
|
||||
|
||||
## Tool Definition
|
||||
|
||||
### Using defineTool()
|
||||
|
||||
The `defineTool()` helper provides full type inference from the Zod schema:
|
||||
|
||||
```typescript
|
||||
import { defineTool } from "@outfitter/mcp";
|
||||
import { Result, ValidationError } from "@outfitter/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
const InputSchema = z.object({
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
limit: z.number().int().positive().default(10).describe("Max results"),
|
||||
});
|
||||
|
||||
export const searchTool = defineTool({
|
||||
name: "search",
|
||||
description: "Search for items. Use when user asks to find or search.",
|
||||
inputSchema: InputSchema,
|
||||
|
||||
handler: async (input): Promise<Result<SearchOutput, ValidationError>> => {
|
||||
// input is automatically typed from InputSchema
|
||||
const results = await performSearch(input.query, input.limit);
|
||||
return Result.ok({ results, total: results.length });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Schema Best Practices
|
||||
|
||||
```typescript
|
||||
const InputSchema = z.object({
|
||||
// Always use .describe() for AI understanding
|
||||
query: z.string().describe("The search term to look for"),
|
||||
|
||||
// Provide defaults where sensible
|
||||
limit: z.number().default(10).describe("Maximum number of results"),
|
||||
|
||||
// Use enums for fixed choices
|
||||
sortBy: z.enum(["name", "date", "relevance"]).default("relevance")
|
||||
.describe("Field to sort results by"),
|
||||
|
||||
// Mark optional fields explicitly
|
||||
tags: z.array(z.string()).optional().describe("Filter by tags"),
|
||||
});
|
||||
```
|
||||
|
||||
### Tool with Context
|
||||
|
||||
```typescript
|
||||
export const myTool = defineTool({
|
||||
name: "my_tool",
|
||||
description: "Tool description",
|
||||
inputSchema: InputSchema,
|
||||
|
||||
handler: async (input, ctx) => {
|
||||
ctx.logger.debug("Tool invoked", { input });
|
||||
|
||||
const result = await myHandler(input, ctx);
|
||||
|
||||
if (result.isErr()) {
|
||||
ctx.logger.error("Tool failed", { error: result.error });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### Static Resource
|
||||
|
||||
```typescript
|
||||
server.registerResource({
|
||||
uri: "config://settings",
|
||||
name: "Configuration",
|
||||
description: "Current server configuration",
|
||||
mimeType: "application/json",
|
||||
|
||||
read: async () => {
|
||||
return JSON.stringify(config, null, 2);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Resource
|
||||
|
||||
```typescript
|
||||
server.registerResource({
|
||||
uri: "data://users/{id}",
|
||||
name: "User Data",
|
||||
description: "User information by ID",
|
||||
mimeType: "application/json",
|
||||
|
||||
read: async (uri) => {
|
||||
const id = uri.split("/").pop();
|
||||
const user = await getUser(id);
|
||||
return JSON.stringify(user);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Resource List
|
||||
|
||||
```typescript
|
||||
server.registerResourceList({
|
||||
uri: "data://users",
|
||||
name: "Users",
|
||||
description: "List of all users",
|
||||
|
||||
list: async () => {
|
||||
const users = await getAllUsers();
|
||||
return users.map(u => ({
|
||||
uri: `data://users/${u.id}`,
|
||||
name: u.name,
|
||||
description: u.email,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Prompts
|
||||
|
||||
```typescript
|
||||
server.registerPrompt({
|
||||
name: "analyze",
|
||||
description: "Analyze data with specific focus",
|
||||
arguments: [
|
||||
{ name: "focus", description: "What to focus on", required: true },
|
||||
{ name: "depth", description: "Analysis depth", required: false },
|
||||
],
|
||||
|
||||
get: async (args) => {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Analyze with focus on: ${args.focus}. Depth: ${args.depth || "normal"}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Returning Errors
|
||||
|
||||
```typescript
|
||||
handler: async (input) => {
|
||||
if (!input.query) {
|
||||
return Result.err(new ValidationError("Query is required"));
|
||||
}
|
||||
|
||||
const item = await findItem(input.id);
|
||||
if (!item) {
|
||||
return Result.err(new NotFoundError("item", input.id));
|
||||
}
|
||||
|
||||
return Result.ok(item);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Categories in MCP
|
||||
|
||||
| Category | MCP Behavior |
|
||||
|----------|--------------|
|
||||
| validation | Tool returns error with details |
|
||||
| not_found | Tool returns error with resource info |
|
||||
| internal | Tool returns generic error, logs full error |
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/server.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Environment Variables
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/server.ts"],
|
||||
"env": {
|
||||
"API_KEY": "secret",
|
||||
"LOG_LEVEL": "debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Tool Loading
|
||||
|
||||
For tools that are expensive to load:
|
||||
|
||||
```typescript
|
||||
import { defineDeferredTool } from "@outfitter/mcp";
|
||||
|
||||
const heavyTool = defineDeferredTool({
|
||||
name: "heavy_tool",
|
||||
description: "Expensive tool loaded on demand",
|
||||
|
||||
load: async () => {
|
||||
const { heavyTool } = await import("./heavy-tool.js");
|
||||
return heavyTool;
|
||||
},
|
||||
});
|
||||
|
||||
// Deferred tools use the same registerTool() API - the server
|
||||
// detects the deferred wrapper and handles lazy loading internally
|
||||
server.registerTool(heavyTool);
|
||||
```
|
||||
|
||||
## Testing MCP Servers
|
||||
|
||||
```typescript
|
||||
import { createMcpHarness } from "@outfitter/testing";
|
||||
|
||||
const harness = createMcpHarness(myTool);
|
||||
|
||||
test("tool returns results", async () => {
|
||||
const result = await harness.invoke({ query: "test" });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value.results).toHaveLength(3);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Descriptive schemas** - Use `.describe()` on every field
|
||||
2. **Sensible defaults** - Provide `.default()` where appropriate
|
||||
3. **Error categories** - Use taxonomy errors for proper handling
|
||||
4. **Logging** - Log tool invocations for debugging
|
||||
5. **Deferred loading** - Lazy load expensive tools
|
||||
6. **Test harnesses** - Use `createMcpHarness` for testing
|
||||
@@ -0,0 +1,238 @@
|
||||
# Result Utilities
|
||||
|
||||
Operations for working with `Result<T, E>` from `better-result`.
|
||||
|
||||
## Creating Results
|
||||
|
||||
```typescript
|
||||
import { Result } from "@outfitter/contracts";
|
||||
|
||||
// Success
|
||||
const ok = Result.ok({ name: "Alice", id: "1" });
|
||||
|
||||
// Failure
|
||||
const err = Result.err(new NotFoundError("user", "123"));
|
||||
```
|
||||
|
||||
## Checking Results
|
||||
|
||||
```typescript
|
||||
// Boolean check
|
||||
if (result.isOk()) {
|
||||
console.log(result.value); // TypeScript knows type
|
||||
}
|
||||
|
||||
if (result.isErr()) {
|
||||
console.log(result.error); // TypeScript knows error type
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Values
|
||||
|
||||
```typescript
|
||||
// Safe access (only after isOk check)
|
||||
if (result.isOk()) {
|
||||
const user = result.value;
|
||||
}
|
||||
|
||||
// Unsafe access (throws if error)
|
||||
const user = result.unwrap(); // Throws if err!
|
||||
|
||||
// With default
|
||||
const user = result.unwrapOr(defaultUser);
|
||||
|
||||
// With default factory
|
||||
const user = result.unwrapOrElse(() => createDefaultUser());
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
```typescript
|
||||
const message = result.match({
|
||||
ok: (user) => `Found ${user.name}`,
|
||||
err: (error) => `Error: ${error.message}`,
|
||||
});
|
||||
```
|
||||
|
||||
## Transforming Results
|
||||
|
||||
### Map (transform success value)
|
||||
|
||||
```typescript
|
||||
const nameResult = result.map((user) => user.name);
|
||||
// Result<string, Error>
|
||||
```
|
||||
|
||||
### MapErr (transform error)
|
||||
|
||||
```typescript
|
||||
const mappedResult = result.mapErr((error) => new WrappedError(error));
|
||||
// Result<User, WrappedError>
|
||||
```
|
||||
|
||||
### FlatMap / AndThen (chain operations)
|
||||
|
||||
```typescript
|
||||
const orderResult = getUserResult.flatMap((user) => getOrders(user.id));
|
||||
// Result<Orders, UserError | OrderError>
|
||||
```
|
||||
|
||||
## Combining Results
|
||||
|
||||
### combine2, combine3, etc.
|
||||
|
||||
Combine multiple results into a tuple:
|
||||
|
||||
```typescript
|
||||
import { combine2, combine3 } from "@outfitter/contracts";
|
||||
|
||||
const result = combine2(userResult, orderResult);
|
||||
// Result<[User, Order], UserError | OrderError>
|
||||
|
||||
if (result.isOk()) {
|
||||
const [user, order] = result.value;
|
||||
}
|
||||
```
|
||||
|
||||
### combineAll
|
||||
|
||||
Combine an array of results:
|
||||
|
||||
```typescript
|
||||
import { combineAll } from "@outfitter/contracts";
|
||||
|
||||
const results = await Promise.all(ids.map((id) => getUser(id)));
|
||||
const combined = combineAll(results);
|
||||
// Result<User[], Error>
|
||||
```
|
||||
|
||||
### combineObject
|
||||
|
||||
Combine an object of results:
|
||||
|
||||
```typescript
|
||||
import { combineObject } from "@outfitter/contracts";
|
||||
|
||||
const combined = combineObject({
|
||||
user: userResult,
|
||||
orders: ordersResult,
|
||||
settings: settingsResult,
|
||||
});
|
||||
// Result<{ user: User; orders: Order[]; settings: Settings }, Error>
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### OrElse (try alternative on error)
|
||||
|
||||
```typescript
|
||||
const result = primaryResult.orElse(() => fallbackResult);
|
||||
```
|
||||
|
||||
### Recover (convert error to success)
|
||||
|
||||
```typescript
|
||||
const result = userResult.recover((error) => {
|
||||
if (error._tag === "NotFoundError") {
|
||||
return Result.ok(defaultUser);
|
||||
}
|
||||
return Result.err(error);
|
||||
});
|
||||
```
|
||||
|
||||
## Async Patterns
|
||||
|
||||
### Sequential execution
|
||||
|
||||
```typescript
|
||||
const result = await getUser(id)
|
||||
.then((r) => r.isOk() ? getOrders(r.value.id) : Promise.resolve(r));
|
||||
```
|
||||
|
||||
### With async/await
|
||||
|
||||
```typescript
|
||||
async function getUserWithOrders(id: string): Promise<Result<UserWithOrders, Error>> {
|
||||
const userResult = await getUser(id);
|
||||
if (userResult.isErr()) return userResult;
|
||||
|
||||
const ordersResult = await getOrders(userResult.value.id);
|
||||
if (ordersResult.isErr()) return ordersResult;
|
||||
|
||||
return Result.ok({
|
||||
user: userResult.value,
|
||||
orders: ordersResult.value,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Helper
|
||||
|
||||
The `createValidator` utility returns `Result`:
|
||||
|
||||
```typescript
|
||||
import { createValidator } from "@outfitter/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
age: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const validate = createValidator(schema);
|
||||
|
||||
const result = validate({ email: "test@example.com", age: 25 });
|
||||
// Result<{ email: string; age: number }, ValidationError>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Early return on error
|
||||
|
||||
```typescript
|
||||
const handler: Handler<Input, Output, Error> = async (input, ctx) => {
|
||||
const validateResult = validate(input);
|
||||
if (validateResult.isErr()) return validateResult;
|
||||
|
||||
const userResult = await getUser(validateResult.value.userId);
|
||||
if (userResult.isErr()) return userResult;
|
||||
|
||||
const orderResult = await createOrder(userResult.value);
|
||||
if (orderResult.isErr()) return orderResult;
|
||||
|
||||
return Result.ok(orderResult.value);
|
||||
};
|
||||
```
|
||||
|
||||
### Collect all errors
|
||||
|
||||
```typescript
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!input.name) errors.push(new ValidationError("Name required"));
|
||||
if (!input.email) errors.push(new ValidationError("Email required"));
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.err(new ValidationError("Multiple errors", { errors }));
|
||||
}
|
||||
```
|
||||
|
||||
### Wrap throwing functions
|
||||
|
||||
```typescript
|
||||
function wrapThrowable<T>(fn: () => T): Result<T, InternalError> {
|
||||
try {
|
||||
return Result.ok(fn());
|
||||
} catch (error) {
|
||||
return Result.err(new InternalError("Unexpected error", { cause: error }));
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapAsync<T>(fn: () => Promise<T>): Promise<Result<T, InternalError>> {
|
||||
try {
|
||||
return Result.ok(await fn());
|
||||
} catch (error) {
|
||||
return Result.err(new InternalError("Unexpected error", { cause: error }));
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,254 @@
|
||||
# Stack Testing
|
||||
|
||||
Test patterns for @outfitter/* packages.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── handlers/
|
||||
│ └── get-user.ts
|
||||
└── __tests__/
|
||||
├── get-user.test.ts
|
||||
└── __snapshots__/
|
||||
└── get-user.test.ts.snap
|
||||
```
|
||||
|
||||
## Handler Testing
|
||||
|
||||
Test handlers directly without transport layer:
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { createContext } from "@outfitter/contracts";
|
||||
import { getUser } from "../handlers/get-user.js";
|
||||
|
||||
describe("getUser", () => {
|
||||
test("returns user when found", async () => {
|
||||
const ctx = createContext({});
|
||||
const result = await getUser({ id: "user-1" }, ctx);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value).toEqual({
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns NotFoundError when user missing", async () => {
|
||||
const ctx = createContext({});
|
||||
const result = await getUser({ id: "missing" }, ctx);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error._tag).toBe("NotFoundError");
|
||||
expect(result.error.resourceId).toBe("missing");
|
||||
});
|
||||
|
||||
test("returns ValidationError for invalid input", async () => {
|
||||
const ctx = createContext({});
|
||||
const result = await getUser({ id: "" }, ctx);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error._tag).toBe("ValidationError");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Use `createFixture` for deep-merged test data:
|
||||
|
||||
```typescript
|
||||
import { createFixture } from "@outfitter/testing";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
settings: { theme: string; notifications: boolean };
|
||||
}
|
||||
|
||||
const createUser = createFixture<User>({
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
settings: { theme: "light", notifications: true },
|
||||
});
|
||||
|
||||
test("user with custom settings", async () => {
|
||||
const user = createUser({ settings: { theme: "dark" } });
|
||||
// user.settings.notifications is still true (deep merge)
|
||||
});
|
||||
```
|
||||
|
||||
## Temporary Directories
|
||||
|
||||
Use `withTempDir` for isolated file operations:
|
||||
|
||||
```typescript
|
||||
import { withTempDir } from "@outfitter/testing";
|
||||
|
||||
test("writes config file", async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const result = await writeConfig({ dir, data: { key: "value" } }, ctx);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const content = await Bun.file(`${dir}/config.json`).json();
|
||||
expect(content).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Mocking
|
||||
|
||||
Use `withEnv` for environment variable testing:
|
||||
|
||||
```typescript
|
||||
import { withEnv } from "@outfitter/testing";
|
||||
|
||||
test("uses custom log level", async () => {
|
||||
await withEnv({ LOG_LEVEL: "debug" }, async () => {
|
||||
const config = loadConfig();
|
||||
expect(config.logLevel).toBe("debug");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## CLI Testing
|
||||
|
||||
Use `createCliHarness` for CLI command testing:
|
||||
|
||||
```typescript
|
||||
import { createCliHarness } from "@outfitter/testing";
|
||||
import { listCommand } from "../commands/list.js";
|
||||
|
||||
const harness = createCliHarness(listCommand);
|
||||
|
||||
test("lists items in JSON mode", async () => {
|
||||
const result = await harness.run(["--json"]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('"items"');
|
||||
});
|
||||
|
||||
test("exits with error for invalid flag", async () => {
|
||||
const result = await harness.run(["--invalid"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown option");
|
||||
});
|
||||
```
|
||||
|
||||
## MCP Testing
|
||||
|
||||
Use `createMcpHarness` for MCP tool testing:
|
||||
|
||||
```typescript
|
||||
import { createMcpHarness } from "@outfitter/testing";
|
||||
import { searchTool } from "../tools/search.js";
|
||||
|
||||
const harness = createMcpHarness(searchTool);
|
||||
|
||||
test("returns search results", async () => {
|
||||
const result = await harness.invoke({
|
||||
query: "test",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value.results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("validates input schema", async () => {
|
||||
const result = await harness.invoke({
|
||||
query: "", // Invalid: min length 1
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error._tag).toBe("ValidationError");
|
||||
});
|
||||
```
|
||||
|
||||
## Context Mocking
|
||||
|
||||
Create mock context with logger spy:
|
||||
|
||||
```typescript
|
||||
import { createContext } from "@outfitter/contracts";
|
||||
import { createMockLogger } from "@outfitter/testing";
|
||||
|
||||
test("logs debug messages", async () => {
|
||||
const mockLogger = createMockLogger();
|
||||
const ctx = createContext({ logger: mockLogger });
|
||||
|
||||
await myHandler({ id: "1" }, ctx);
|
||||
|
||||
expect(mockLogger.calls.debug).toContainEqual([
|
||||
"Processing",
|
||||
{ id: "1" },
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
## Snapshot Testing
|
||||
|
||||
Use Bun's snapshot testing:
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("output matches snapshot", async () => {
|
||||
const result = await formatOutput(data);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
```
|
||||
|
||||
Snapshots stored in `__snapshots__/*.snap`.
|
||||
|
||||
## Result Assertions
|
||||
|
||||
Custom matchers for Result types:
|
||||
|
||||
```typescript
|
||||
// Check success
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value).toEqual(expected);
|
||||
|
||||
// Check failure
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error._tag).toBe("NotFoundError");
|
||||
expect(result.error.category).toBe("not_found");
|
||||
|
||||
// Error details
|
||||
expect(result.error.details).toMatchObject({
|
||||
field: "email",
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
bun test
|
||||
|
||||
# Single file
|
||||
bun test src/__tests__/get-user.test.ts
|
||||
|
||||
# Watch mode
|
||||
bun test --watch
|
||||
|
||||
# With coverage
|
||||
bun test --coverage
|
||||
|
||||
# Update snapshots
|
||||
bun test --update-snapshots
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test handlers directly** - Skip transport layer for unit tests
|
||||
2. **Use fixtures** - Create reusable test data with `createFixture`
|
||||
3. **Isolate side effects** - Use `withTempDir` and `withEnv`
|
||||
4. **Mock context** - Inject mock logger to verify logging
|
||||
5. **Test error paths** - Verify correct error types and categories
|
||||
6. **Snapshot outputs** - Use snapshots for complex output verification
|
||||
Reference in New Issue
Block a user