|
| 1 | +# Zagora Library |
| 2 | + |
| 3 | +Zagora enables building type-safe and error-safe procedures that encapsulate business logic with robust validation, error handling, and context management. Agents are callable functions that ensure input/output safety and provide structured error responses. |
| 4 | + |
| 5 | +## Highlights |
| 6 | + |
| 7 | +- 🪶 **Minimal:** Lightweight and focused, built on [StandardSchema](https://standardschema.dev) for seamless validation. |
| 8 | +- 🛡️ **Error-Safe:** Eliminates exceptions - always `{ ok, data, error }` for predictable, crash-free execution. |
| 9 | +- 🦢 **Graceful:** Functions never throw or disrupt your process, akin to `effect.ts` and `neverthrow`. |
| 10 | +- 📝 **Typed Errors:** Define error schemas for strongly-typed error helpers, enhancing handler reliability. |
| 11 | +- 🧹 **Clean Error Model:** Three distinct error types - unknown, validation, and user-defined—for clarity. |
| 12 | +- 🔒 **Type-Safe:** Full type inference across inputs, outputs, errors, context, optionals, and defaults. |
| 13 | +- ✋ **Ergonomic:** Pure functions with auto-filled defaults, optional args, and detailed diagnostics. |
| 14 | +- 🏠 **Familiar:** Echoes remote-RPC patterns from oRPC and tRPC, but focused on libraries, not apps. |
| 15 | +- ⚖️ **Unopinionated:** Zero assumptions - no routers, middlewares, or network dependencies. |
| 16 | +- 🎁 **No Unwrapping:** Direct access to results, unlike `neverthrow` - no extra steps required. |
| 17 | + |
| 18 | +## Usage |
| 19 | + |
| 20 | +```ts |
| 21 | +import { z } from 'zod'; |
| 22 | +import { zagora } from 'zagora'; |
| 23 | + |
| 24 | +const za = zagora(); |
| 25 | + |
| 26 | +const getUser = za |
| 27 | + .input(z.tuple([ |
| 28 | + z.string(), |
| 29 | + z.number().default(18), |
| 30 | + ])) |
| 31 | + .output(z.object({ name: z.string(), age: z.number(), email: z.string() })) |
| 32 | + .handler(async (_, name, age) => { |
| 33 | + // name: string; |
| 34 | + // age: number; -- even if not passed! |
| 35 | + return { name, age, email: `${name.toLowerCase()}@example.com` }; |
| 36 | + }) |
| 37 | + .callable(); |
| 38 | + |
| 39 | +const result = await getUser('Charlie'); |
| 40 | +if (result.ok) { |
| 41 | + console.log(result.data); |
| 42 | + // ^ { name: 'Charlie', age: 18, email: 'charlie@example.com' } |
| 43 | +} else { |
| 44 | + console.error(result.error); |
| 45 | + // ^ { kind: 'UNKNOWN_ERROR', message, cause } |
| 46 | + // or |
| 47 | + // ^ { kind: 'VALIDATION_ERROR', message, issues: Schema.Issue[] } |
| 48 | +} |
| 49 | + |
| 50 | +// primitive input |
| 51 | +const helloUppercased = za |
| 52 | + .input(z.string()) |
| 53 | + .handler((_, str) => str.toUpperCase()) |
| 54 | + .callable(); |
| 55 | + |
| 56 | +const res = helloUppercased('Hello world'); |
| 57 | + |
| 58 | +if (res.ok) { |
| 59 | + console.log(res); |
| 60 | + // ^ { ok: true, data: 'HELLO WORLD', error: undefined } |
| 61 | +} |
| 62 | + |
| 63 | +// array input |
| 64 | +const uppercase = zagora({ autoCallable: true, disableOptions: true }) |
| 65 | + .input(z.array(z.string())) |
| 66 | + .handler((arrayOfStrings) => { |
| 67 | + // NOTE: `x` is typed as string too! |
| 68 | + return arrayOfStrings.map((x) => x.toUpperCase()); |
| 69 | + }) |
| 70 | + |
| 71 | +const upRes = uppercase(['foo', 'bar', 'qux']); |
| 72 | +if (upRes.ok) { |
| 73 | + console.log(upRes); |
| 74 | + // ^ { ok: true, data: ['FOO', 'BAR', 'QUX' ] } |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +You'll also have access to all the types, utils, and error-related stuff through package exports. |
| 79 | + |
| 80 | +```ts |
| 81 | +import { |
| 82 | + isValidationError, |
| 83 | + isInternalError, |
| 84 | + isDefinedError, |
| 85 | + isZagoraError, |
| 86 | +} from 'zagora/errors'; |
| 87 | + |
| 88 | +import * ZagoraTypes from 'zagora/types'; |
| 89 | +import * zagoraUtils from 'zagora/utils'; |
| 90 | +``` |
| 91 | + |
| 92 | +## Creating procedures |
| 93 | + |
| 94 | +Fluent builder API for chaining methods on a Zagora instance: |
| 95 | + |
| 96 | +```typescript |
| 97 | +import { zagora } from 'zagora'; |
| 98 | +import z from 'zod'; |
| 99 | + |
| 100 | +const agent = zagora() |
| 101 | + .input(z.object({ name: z.string(), age: z.number().default(20) })) |
| 102 | + .output(z.object({ greeting: z.string() })) |
| 103 | + .handler(({ context }, input) => ({ |
| 104 | + greeting: `Hello ${input.name}, you are ${input.age} years old!` |
| 105 | + })) |
| 106 | + .callable(); |
| 107 | + |
| 108 | +const result = agent({ name: 'Alice' }); |
| 109 | +``` |
| 110 | + |
| 111 | +**Important:** the handler signature differs from oRPC/tRPC and Zagora requires `.callable` by default. |
| 112 | + |
| 113 | +- oRPC/tRPC - `.handler(({ input, context }) => {})` - always a single object |
| 114 | +- zagora with primitive input (string, object, array) - `.handler(({ context }, input) => {})` |
| 115 | +- zagora with tuple schemas (spreaded args) - `.handler(({ context }, name, age) => {})` |
| 116 | +- zagora with errprs map - `.errors({ NOT_FOUND: z.object({ id: z.string() })}).handler(({ context, errors }, name, age) => {})` |
| 117 | +- zagora without options object - `zagora({ disableOptions: true }).input(z.string()).handle((input) => input)` |
| 118 | + |
| 119 | +## Input and Output Validation |
| 120 | + |
| 121 | +Define schemas for type-safe inputs and outputs using Zod, Valibot, or any Standard Schema V1 compliant library: |
| 122 | + |
| 123 | +- **Input Schema**: Validates arguments before execution. |
| 124 | +- **Output Schema**: Ensures return values match expectations. |
| 125 | + |
| 126 | +```typescript |
| 127 | +const mathAgent = zagora() |
| 128 | + .input(z.tuple([z.number(), z.number()])) |
| 129 | + .output(z.number()) |
| 130 | + .handler((_, a, b) => a + b) |
| 131 | + .callable(); |
| 132 | + |
| 133 | +const sum = mathAgent(5, 10); // { ok: true, data: 15 } |
| 134 | +``` |
| 135 | + |
| 136 | +## Error Handling |
| 137 | + |
| 138 | +Define custom errors with schemas for structured error responses: |
| 139 | + |
| 140 | +```typescript |
| 141 | +const apiAgent = zagora() |
| 142 | + .input(z.object({ id: z.string() })) |
| 143 | + .output(z.object({ data: z.any() })) |
| 144 | + .errors({ |
| 145 | + NOT_FOUND: z.object({ message: z.string() }), |
| 146 | + UNAUTHORIZED: z.object({ userId: z.string() }) |
| 147 | + }) |
| 148 | + .handler(({ errors }, { id }) => { |
| 149 | + if (!id) throw errors.UNAUTHORIZED({ userId: 'unknown' }); |
| 150 | + // ... logic |
| 151 | + if (!found) throw errors.NOT_FOUND({ message: 'Item not found' }); |
| 152 | + return { data: item }; |
| 153 | + }) |
| 154 | + .callable(); |
| 155 | +``` |
| 156 | + |
| 157 | +Procedures return `ZagoraResult<TOutput, TErrors>` with `ok: true` for success or `ok: false` with typed errors. |
| 158 | + |
| 159 | +## Context Management |
| 160 | + |
| 161 | +Pass shared data like databases or user info via context: |
| 162 | + |
| 163 | +```typescript |
| 164 | +const dbAgent = zagora() |
| 165 | + .context({ db: myDatabase }) |
| 166 | + .input(z.string()) |
| 167 | + .output(z.any()) |
| 168 | + .handler(({ context }, query) => { |
| 169 | + console.log(context.bar); // => 123 |
| 170 | + |
| 171 | + return context.db.query(query); |
| 172 | + }) |
| 173 | + .callable({ context: { bar: 123 }}); |
| 174 | +``` |
| 175 | + |
| 176 | +Override context per call: `agent.callable({ context: { db: testDb } })` |
| 177 | + |
| 178 | +## Caching and Memoization |
| 179 | + |
| 180 | +Add caching to avoid redundant computations: |
| 181 | + |
| 182 | +```typescript |
| 183 | +const cache = new Map(); |
| 184 | +const cachedCall = zagora() |
| 185 | + .cache(cache) |
| 186 | + .input(z.string()) |
| 187 | + .output(z.string()) |
| 188 | + .handler((_, input) => expensiveOperation(input)) |
| 189 | + .callable(); |
| 190 | + |
| 191 | +// first time called |
| 192 | +cachedCall('foo'); |
| 193 | +// second is cache hit |
| 194 | +cachedCall('foo'); |
| 195 | +``` |
| 196 | + |
| 197 | +Cache can also be passed at execution-site (server handlers) through `.callable({ cache })`. |
| 198 | + |
| 199 | +## Cleaner API - auto callable and disable options |
| 200 | + |
| 201 | +For simpler procedures and API look, enable auto-callable mode to skip `.callable()` and disable passing options to handler: |
| 202 | + |
| 203 | +```typescript |
| 204 | +const simpleProcedure = zagora({ autoCallable: true, disableOptions: true }) |
| 205 | + .input(z.tuple([z.string(), z.number().default(10)])) |
| 206 | + .output(z.string()) |
| 207 | + .handler((str, num) => input.toUpperCase()); |
| 208 | + |
| 209 | +const result = simpleProcedure('hello'); // Direct call |
| 210 | +``` |
| 211 | + |
| 212 | +## Async procedures |
| 213 | + |
| 214 | +Async handlers for I/O operations: |
| 215 | + |
| 216 | +```typescript |
| 217 | +const asyncAgent = zagora() |
| 218 | + .input(z.string()) |
| 219 | + .output(z.object({ result: z.string() })) |
| 220 | + .handler(async (_, url) => { |
| 221 | + const response = await fetch(url); |
| 222 | + return { result: await response.text() }; |
| 223 | + }) |
| 224 | + .callable(); |
| 225 | +``` |
| 226 | + |
| 227 | +## Best Practices |
| 228 | + |
| 229 | +- Use descriptive schemas for clarity. |
| 230 | +- Define errors for all failure cases. |
| 231 | +- Leverage context for dependencies. |
| 232 | +- Enable caching for performance-critical agents. |
| 233 | +- Test agents with various inputs and error scenarios. |
| 234 | + |
| 235 | +Agents built with Zagora are composable, testable, and maintain type safety throughout the application lifecycle. |
| 236 | + |
| 237 | +## Rules and Special Notes for Zagora usage |
| 238 | + |
| 239 | +The following rules outlines critical points, edge cases, and things to be careful about when using Zagora. These are derived from specially noted sections, examples, and warnings in the documentation. |
| 240 | + |
| 241 | +## Error Handling |
| 242 | + |
| 243 | +### Uppercase Error Keys |
| 244 | +- **Caution**: All keys in the error map must be uppercased (e.g., `NOT_FOUND`, not `not_found`). TypeScript will report a type error if not. |
| 245 | +- **Why**: These keys represent error "kinds" and are used in `result.error.kind`. |
| 246 | + |
| 247 | +### Error Helper Validation |
| 248 | +- **Caution**: If you pass invalid or missing keys to error helpers (e.g., `errors.NOT_FOUND({ invalidKey: 'value' })`), you get a `VALIDATION_ERROR` with a `key` property indicating which error validation failed. |
| 249 | +- **Example**: `throw errors.RATE_LIMIT({ retryAfter: 'invalid' })` → `VALIDATION_ERROR` because `retryAfter` expects a number. |
| 250 | +- **Tip**: Use `.strict()` on error schemas to throw on unknown keys: `z.object({...}).strict()`. |
| 251 | + |
| 252 | +### Error Type Guards |
| 253 | +- **Caution**: Use `isValidationError`, `isInternalError`, `isDefinedError`, `isZagoraError` to narrow error types safely. |
| 254 | +- **Note**: Even syntax errors in handlers return `ZagoraResult` with error, never crashing the process. |
| 255 | + |
| 256 | +## Context Management |
| 257 | + |
| 258 | +### Context Merging |
| 259 | +- **Caution**: Initial context (from `.context()`) is deep-merged with runtime context (from `.callable({ context })`). |
| 260 | +- **Example**: `.context({ userId: 'default' })` + `.callable({ context: { foo: 'bar' } })` → merged `{ userId: 'default', foo: 'bar' }`. |
| 261 | +- **Tip**: Useful for dependency injection; override at execution site (e.g., in server handlers). |
| 262 | + |
| 263 | +## Input/Output Validation |
| 264 | + |
| 265 | +### Tuple Inputs (Multiple Arguments) |
| 266 | +- **Caution**: Complex feature; schemas like `z.tuple([z.string(), z.number().default(18)])` spread to handler args with defaults/optionals applied. |
| 267 | +- **Example**: Handler receives `(name, age)` where `age` is `number` (not `number | undefined`) due to default. |
| 268 | +- **Tip**: Supports per-argument validation and diagnostics; missing required args cause `VALIDATION_ERROR`. |
| 269 | + |
| 270 | +### Default Values |
| 271 | +- **Caution**: Defaults work at any schema level (objects, tuples, primitives); handler gets fully populated args. |
| 272 | +- **Example**: `z.number().default(10)` → no need to pass; handler sees `number`, not `number | undefined`. |
| 273 | + |
| 274 | +## Async Support |
| 275 | + |
| 276 | +### Async Schemas |
| 277 | +- **Caution**: If input/output/error schemas are async (e.g., `z.string().refine(async (val) => ...)`, procedure signature remains sync (`ZagoraResult`), but you **must await** at callsite. TypeScript may warn "may not need await" – ignore and await. |
| 278 | +- **Why**: StandardSchema limitation; cannot infer async on type-level. |
| 279 | +- **Tip**: ArkType doesn't support async schemas, avoiding this issue. |
| 280 | + |
| 281 | +### Handler Async Behavior |
| 282 | +- **Caution**: Sync handler → sync procedure; async handler or Promise-returning → async procedure (`Promise<ZagoraResult>`). |
| 283 | +- **Note**: Cache async methods force procedure async. |
| 284 | + |
| 285 | +## Caching/Memoization |
| 286 | + |
| 287 | +### Cache Key Composition |
| 288 | +- **Caution**: Cache key includes input, input/output/error schemas, and handler function body. Changes to any invalidate cache. |
| 289 | +- **Tip**: Useful for custom strategies; memoization out-of-the-box. |
| 290 | + |
| 291 | +### Cache Failures |
| 292 | +- **Caution**: Cache adapter throws → `UNKNOWN_ERROR` with `cause` set to original error; process never crashes. |
| 293 | +- **Future**: May change to `CACHE_ERROR`. |
| 294 | +- **Tip**: If cache has async methods (e.g., `has` is async), procedure becomes async – **await** despite TypeScript warnings. |
| 295 | + |
| 296 | +### Cache Provision |
| 297 | +- **Caution**: Provide cache via `.cache()` (definition) or `.callable({ cache })` (execution). Execution-site useful for routers/server handlers. |
| 298 | + |
| 299 | +## Options and Configuration |
| 300 | + |
| 301 | +### Options Object |
| 302 | +- **Caution**: Handlers receive `options` as first param: `{ context, errors }`. Typed and merged. |
| 303 | +- **Example**: `handler((options, input) => { const { context, errors } = options; ... })`. |
| 304 | + |
| 305 | +### Disable Options |
| 306 | +- **Caution**: `zagora({ disableOptions: true })` omits options; handler starts directly with inputs. |
| 307 | +- **Example**: `handler((input) => ...)` instead of `handler((options, input) => ...)`. |
| 308 | + |
| 309 | +### Auto-Callable Mode |
| 310 | +- **Caution**: `zagora({ autoCallable: true })` returns procedure directly from `.handler()`; skip `.callable()`. |
| 311 | +- **Tip**: Combine with `disableOptions` for cleaner APIs. |
| 312 | + |
| 313 | +## Guarantees and Type Safety |
| 314 | + |
| 315 | +### Never-Throwing |
| 316 | +- **Caution**: Procedures never throw; all errors (validation, handler, cache) wrapped in `ZagoraResult`. |
| 317 | +- **Example**: `throw new Error('Oops')` → `result.error.kind === 'UNKNOWN_ERROR'`, `result.error.cause.message === 'Oops'`. |
| 318 | + |
| 319 | +### Type Inference |
| 320 | +- **Caution**: Full TS support; `result.ok`, `result.data`, `result.error` are discriminated unions. |
| 321 | +- **Note**: Complex type system tested; changes caught by type tests. |
| 322 | + |
| 323 | +## General Tips |
| 324 | + |
| 325 | +- **Motivation Reminder**: Zagora produces "just functions" – no network/router assumptions. Focused on low-level, library-building. |
| 326 | +- **Comparison**: Unlike oRPC/tRPC (network-focused, always async, single-object inputs), Zagora supports sync, tuples, no middlewares. |
| 327 | +- **Alternatives**: Over plain TS (no runtime validation); over standalone schemas (ergonomic layer, unified validation). |
| 328 | +- **Testing**: Inspect `test/types-testing.test.ts` for type guarantees. |
| 329 | +- **Edge Cases**: Always test with invalid inputs, async paths, and error scenarios. |
| 330 | + |
| 331 | +By heeding these cautions, you can avoid common pitfalls and leverage Zagora's full potential for type-safe, error-safe procedures. |
0 commit comments