# Warlock Core — full skills

> Package: `@warlock.js/core`

> Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/core/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.

## add-connector  `@warlock.js/core/add-connector/SKILL.md`

---
name: add-connector
description: 'Extend Warlock''s lifecycle with a `BaseConnector` subclass — `name`, `priority`, `lifecyclePhase`, `start()`, `shutdown()`, `watchedFiles`. Register the instance via `connectorsManager.register(...)`; framework-level `warlock.config.ts > connectors` is planned but not shipped yet. Triggers: `BaseConnector`, `connectorsManager.register`, `ConnectorLifecyclePhase`, `ConnectorPriority`, `ConnectorName`; "add a queue worker", "wire a scheduler into bootstrap", "control startup ordering", "graceful shutdown hook"; typical import `import { BaseConnector, connectorsManager } from "@warlock.js/core"`. Skip: app context accessors — `@warlock.js/core/use-app-context/SKILL.md`; warlock.config.ts surface — `@warlock.js/core/configure-app/SKILL.md`; competing pattern: hand-rolled `process.on("SIGINT")` blocks, NestJS `OnModuleInit` lifecycle.'
---

# Warlock — add a connector

A **connector** is a long-lived subsystem owned by the framework: database, HTTP server, cache, storage, mailer, logger, herald (message broker), socket. The lifecycle is identical across all of them — `boot` → `start` → (run) → `shutdown` — and `ConnectorsManager` orchestrates the order. Adding your own lets you plug a new subsystem (queue worker, scheduler, search client) into the same lifecycle.

## The shape

```ts title="src/connectors/queue-connector.ts"
import {
  BaseConnector,
  ConnectorLifecyclePhase,
  type ConnectorName,
} from "@warlock.js/core";

export class QueueConnector extends BaseConnector {
  public readonly name: ConnectorName = "queue";
  public readonly priority = 10;
  public readonly lifecyclePhase = ConnectorLifecyclePhase.Early;

  protected readonly watchedFiles = ["src/config/queue.ts"];

  public async start(): Promise<void> {
    // open the connection, register handlers, prime caches…
    this.active = true;
  }

  public async shutdown(): Promise<void> {
    if (!this.active) return;
    // drain queues, close connections, flush state…
    this.active = false;
  }
}
```

That's the connector class. The file's home is `src/connectors/<name>.ts` by convention — but **placing it there does not register it**. Custom connectors must be registered explicitly via `connectorsManager.register(...)` (see [Registering a connector](#registering-a-connector) below).

## `BaseConnector` — required surface

| Member          | Type                          | Notes                                                                                |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------------ |
| `name`          | `ConnectorName`               | Unique. Used in preload lists (`preload.connectors: ["queue"]`).                     |
| `priority`      | `number`                      | Lower starts first. See built-in `ConnectorPriority` enum for the existing ordering. |
| `lifecyclePhase`| `ConnectorLifecyclePhase`     | `Early` (default) or `Late`. See phase semantics below.                              |
| `watchedFiles`  | `string[]` (protected)        | Relative paths; touching any triggers a restart in dev.                              |
| `start()`       | `() => Promise<void>`         | The work that brings the subsystem online. Set `this.active = true` on success.     |
| `shutdown()`    | `() => Promise<void>`         | Inverse — close connections, drain queues. Set `this.active = false`.                |

Optional overrides:

- `boot()` — runs before `start()`. Use for construction-only work that doesn't touch external state (build clients, populate `container`). The built-in `HttpConnector` uses `boot` to construct Fastify and register plugins, then `start` to scan routes and call `listen()`.
- `shouldRestart(changedFiles)` — default checks `watchedFiles`. Override for custom logic (HTTP excludes `routes.ts` because HMR handles it).
- `restart()` — default is `shutdown()` + `start()`. Override if you need a re-`boot()` step.

`isActive()` is read-only on `BaseConnector`; flip the protected `this.active` flag inside `start`/`shutdown` instead.

## Priority — when does it start?

Lower number = earlier. The built-in ordering, from `ConnectorPriority` in `@warlock.js/core/src/connectors/types.ts`:

| Connector     | Priority | Phase   |
| ------------- | -------- | ------- |
| `logger`      | 0        | Early   |
| `mailer`      | 1        | Early   |
| `database`    | 2        | Early   |
| `communicator`| 3        | Early   |
| `cache`       | 4        | Early   |
| `http`        | 5        | **Late**|
| `storage`     | 6        | Early   |
| `socket`      | 7        | **Late**|

Pick a number that places your connector where it belongs. If your queue needs the database, set `priority > 2` (e.g. `10`). If you replace the cache, set `< 4` to win.

Negative priorities are fine for "start before everything" — the project's `src/connectors/custom-connector.ts` example uses `priority: -10`.

## Phases — `Early` vs `Late`

`ConnectorLifecyclePhase` exists because **HTTP and socket need user code loaded first**: they scan the router (which user `routes.ts` files populate) and the container (which app `main.ts` files mutate). So the framework boots in two passes:

1. **Early phase** runs before user code imports — for things user code needs *at import time* (database/cache so models work, logger so app modules can `log.info`).
2. **Late phase** runs after user code imports — for things that consume registrations user code just made (HTTP reads the router, socket reads HTTP's instance).

If your connector is a self-contained service (queue client, scheduler), `Early` is correct. If it depends on app-level registrations, `Late`. Default is `Early` — don't change it without a reason.

## Registering a connector

`connectorsManager.register(new YourConnector())` is the only path today. Nothing scans `src/connectors/` automatically — the folder is a convention for *where the file lives*, not a discovery mechanism. Place the registration call in your project's `src/app/main.ts` (auto-loaded once at boot, before the manager runs the early-phase startup):

```ts title="src/app/main.ts"
import { connectorsManager } from "@warlock.js/core";
import { QueueConnector } from "../connectors/queue-connector";

connectorsManager.register(new QueueConnector());
```

`connectorsManager` is the singleton instance of `ConnectorsManager` exported from `@warlock.js/core`. `register(...connectors)` accepts one or many; it appends each to the list and re-sorts by priority.

Conditional registration is just an `if`:

```ts title="src/app/main.ts"
import { config, connectorsManager } from "@warlock.js/core";
import { ExperimentalIndexerConnector } from "../connectors/experimental-indexer-connector";

if (config.key("search.experimental.enabled")) {
  connectorsManager.register(new ExperimentalIndexerConnector());
}
```

> **Heads up — planned change.** A framework-level `warlock.config.ts > connectors: [...]` field is planned so connectors register the same way `cli.commands` do today. Once shipped, the canonical pattern becomes:
>
> ```ts title="warlock.config.ts (planned)"
> export default defineConfig({
>   connectors: [new QueueConnector(), new SchedulerConnector()],
> });
> ```
>
> Tracking: [`domains/core/plans/2026-05-23-connectors-in-warlock-config.md`](../../../../domains/core/plans/2026-05-23-connectors-in-warlock-config.md). Until that lands, use `connectorsManager.register(...)` in `main.ts`.

## `watchedFiles` and dev restarts

In the dev server, the file watcher emits a list of changed paths after every save. The manager iterates connectors and asks each `shouldRestart(changedFiles)`. Default implementation matches the file against `watchedFiles` (exact match, or glob if the entry contains `*`).

Typical patterns:

- Config file: `"src/config/<name>.ts"` (the connector's own config — restart when it changes).
- `.env`: usually omitted. The framework reloads env separately and reboots the world; per-connector watching of `.env` causes duplicate restarts.
- Don't watch user code (`src/app/**`). That's what HMR is for.

## Graceful shutdown

`ConnectorsManager` wires SIGINT/SIGTERM (and SIGHUP on Windows) to a `gracefulShutdown` handler that calls `shutdown()` on every connector **in reverse priority order**. Your `shutdown()` should:

1. Stop accepting new work (close listening sockets, stop consuming queues).
2. Drain any in-flight work, bounded by a timeout you own.
3. Close external connections.
4. Set `this.active = false`.

The manager swallows errors from individual `shutdown()`s (logs and continues) — one slow connector doesn't block the rest from shutting down. You do not need to call `process.exit()` yourself; the manager does that after the loop.

## Common patterns

### Queue worker (depends on DB)

```ts title="src/connectors/queue-worker-connector.ts"
import {
  BaseConnector,
  ConnectorLifecyclePhase,
  type ConnectorName,
} from "@warlock.js/core";
import { startWorker, stopWorker } from "app/queue/services/worker.service";

export class QueueWorkerConnector extends BaseConnector {
  public readonly name: ConnectorName = "queueWorker";
  public readonly priority = 10;
  public readonly lifecyclePhase = ConnectorLifecyclePhase.Early;

  protected readonly watchedFiles = ["src/config/queue.ts"];

  public async start(): Promise<void> {
    await startWorker();
    this.active = true;
  }

  public async shutdown(): Promise<void> {
    if (!this.active) return;
    await stopWorker();
    this.active = false;
  }
}
```

### Scheduler (Late — wants the router up first)

```ts
import {
  BaseConnector,
  ConnectorLifecyclePhase,
  type ConnectorName,
} from "@warlock.js/core";

export class SchedulerConnector extends BaseConnector {
  public readonly name: ConnectorName = "scheduler";
  public readonly priority = 15;
  public readonly lifecyclePhase = ConnectorLifecyclePhase.Late;

  protected readonly watchedFiles = ["src/config/scheduler.ts"];

  protected timer?: NodeJS.Timeout;

  public async start(): Promise<void> {
    this.timer = setInterval(() => {
      // run scheduled jobs
    }, 60_000);
    this.active = true;
  }

  public async shutdown(): Promise<void> {
    if (!this.active) return;
    if (this.timer) clearInterval(this.timer);
    this.active = false;
  }
}
```

### Feature-flagged registration

See the [Registering a connector](#registering-a-connector) section above for the canonical `if` pattern. The flag is read via `config.key("...")` (dot-notation) — `config.get("...")` returns whole namespaces, not nested values.

## Gotchas

- **Set `this.active = true` only on success.** If `start()` throws partway, leaving `active` true means `shutdown()` thinks it has work to do and may double-close half-initialized resources.
- **`shutdown()` must be idempotent.** SIGINT can fire twice on Windows. The manager guards re-entry with its own flag, but individual connectors get called once per shutdown loop — guard with `if (!this.active) return`.
- **Don't reach across connector boundaries in `start()`.** The manager's `start()` loop runs all `boot()`s first, then all `start()`s — wiring across connectors goes through the `container` (`container.get("http.server")`), not through imports.
- **Production build still needs registration.** Placing the connector under `src/connectors/<name>.ts` doesn't auto-register it in dev or prod. The connector exists wherever its `connectorsManager.register(...)` call runs — typically `src/app/main.ts`. The production bundle picks up that registration because `main.ts` is auto-loaded.
- **`watchedFiles` is restart-trigger, not dependency.** It says "I want to restart when this file changes." It does *not* mean the framework reloads that file first — that's the file orchestrator's job.

## See also

- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `warlock.config.ts`, config files, env.
- [`use-app-context/SKILL.md`](../use-app-context/SKILL.md) — checking environment + paths inside `start()`.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, canonical imports.


## benchmark-code  `@warlock.js/core/benchmark-code/SKILL.md`

---
name: benchmark-code
description: 'Wrap a function with `measure(name, fn, options?)` to time it and classify the latency — onComplete/onError/onFinish hooks, `latencyRange` thresholds, `BenchmarkProfiler` for percentiles, `BenchmarkSnapshots` for raw captures. Triggers: `measure`, `BenchmarkProfiler`, `BenchmarkSnapshots`, `BenchmarkChannel`, `ConsoleChannel`, `latencyRange`, `shouldBenchmarkError`; "time this operation", "profile a slow service", "emit p50/p95/p99 metrics", "classify latency against thresholds"; typical import `import { measure, BenchmarkProfiler } from "@warlock.js/core"`. Skip: retry composition — `@warlock.js/core/retry-operation/SKILL.md`; benchmark config wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs `prom-client`, `pino`, `perf_hooks`, `console.time`.'
---

# Warlock — benchmark code

`measure()` is the workhorse. It wraps any sync-or-async function, captures its latency, classifies it against an excellent/poor band, calls your hooks, and returns a tagged result. Around it, two optional accumulators: `BenchmarkProfiler` (percentiles across many calls) and `BenchmarkSnapshots` (raw error captures for post-mortem).

## The shape

```ts
import { measure } from "@warlock.js/core";

const result = await measure("db.findUser", () => db.users.findOne({ id }));

if (result.success) {
  console.log(result.value);          // T
  console.log(result.latency);        // ms
  console.log(result.state);          // "excellent" | "good" | "poor"
} else {
  console.error(result.error);
}
```

`measure()` always *returns* — it never re-throws. The return type is `BenchmarkSuccessResult<T> | BenchmarkErrorResult`, discriminated by `result.success`. The one exception: `shouldBenchmarkError` returning false re-throws (see below).

## `BenchmarkResult` — what you get back

Both success and error results share:

```ts
{
  name: string;                              // your measurement name
  latency: number;                           // ms (rounded)
  state: "excellent" | "good" | "poor";      // see latencyRange
  tags?: Record<string, string>;             // whatever you passed in options.tags
  startedAt: Date;
  endedAt: Date;
}
```

Plus, discriminated by `success`:

```ts
// success
{ success: true,  value: T }

// error
{ success: false, error: unknown }
```

## `latencyRange` — classifying speed

Pass thresholds to get a `state` you can route on:

```ts
await measure("db.findUser", () => db.users.findOne({ id }), {
  latencyRange: { excellent: 100, poor: 500 },
});

// latency <= 100ms  → state: "excellent"
// 100ms < lat < 500 → state: "good"
// latency >= 500ms  → state: "poor"
```

Without `latencyRange`, every successful result is `"good"` and every error is `"poor"`. Set it globally in `src/config/benchmark.ts` (see `BenchmarkConfigurations`) and `measure()` will fall back to that — no need to repeat it per call.

## Hooks

Three optional callbacks, called in order — `onComplete`/`onError` first (one of them, never both), then `onFinish` (always):

```ts
await measure("send-email", () => mailer.send(payload), {
  latencyRange: { excellent: 200, poor: 2000 },
  onComplete: (result) => metrics.record(result.latency),
  onError: (result) => logger.error("email failed", result.error),
  onFinish: (result) => logger.info(`${result.name} → ${result.state}`),
});
```

`tags` rides along on the result and is yours to use however:

```ts
await measure("http.outbound", () => fetch(url), {
  tags: { service: "stripe", endpoint: "/charges" },
});
```

## Selective error capture — `shouldBenchmarkError`

Business errors (4xx, validation) are not infrastructure problems and shouldn't pollute your latency stats. Return `false` to *re-throw* the error without producing a benchmark result:

```ts
await measure("create-user", () => createUser(input), {
  shouldBenchmarkError: (err) => !(err instanceof ValidationError),
});
```

Default is `true` — every thrown error becomes a `BenchmarkErrorResult`.

## `enabled: false` — pass-through

Wrapping costs almost nothing (one `performance.now()`, one closure), but if you want a literal no-op for a hot path:

```ts
const result = await measure("hot-path", () => work(), { enabled: false });
// result.latency === 0
// result.state   === "excellent"
// no hooks fire
```

`fn()` still runs and its return value still lands in `result.value`. The wrapper just skips timing and hooks.

## `BenchmarkProfiler` — rolling percentiles

For high-volume operations where you want p50/p95/p99 rather than per-call hooks:

```ts
import { BenchmarkProfiler, ConsoleChannel, measure } from "@warlock.js/core";

const profiler = new BenchmarkProfiler({
  maxSamples: 1000,                  // ring buffer per operation name
  channels: [new ConsoleChannel()],  // where stats go on flush()
  flushEvery: 60_000,                // auto-flush every minute
});

for (let i = 0; i < 5; i++) {
  await measure(
    "db.findUser",
    () => db.users.findOne({ id: i }),
    { profiler, latencyRange: { excellent: 50, poor: 300 } },
  );
}

const stats = profiler.stats("db.findUser");
// { p50, p90, p95, p99, avg, min, max, count, errors, errorRate, firstSeenAt, lastSeenAt }
```

`profiler.flush()` (manual or auto) hands `allStats()` to every registered `BenchmarkChannel`. The built-in `ConsoleChannel` prints a `console.table` per operation. `NoopChannel` is the default — useful when you want stats accessible via `profiler.stats(name)` without external emission.

Wire a profiler globally through `BenchmarkConfigurations.profiler` in `src/config/benchmark.ts` so every `measure()` call records by default.

### Custom channel

```ts
import type { BenchmarkChannel, BenchmarkStats } from "@warlock.js/core";

export class DatadogChannel implements BenchmarkChannel {
  public async onFlush(stats: Record<string, BenchmarkStats>): Promise<void> {
    for (const [name, operationStats] of Object.entries(stats)) {
      await datadog.gauge(`latency.${name}.p95`, operationStats.p95);
    }
  }
}
```

Pass it via `channels: [new DatadogChannel()]`.

## `BenchmarkSnapshots` — raw captures

When percentiles aren't enough — you need the actual failing inputs/errors for a post-mortem:

```ts
import { BenchmarkSnapshots, measure } from "@warlock.js/core";

const snapshots = new BenchmarkSnapshots({
  maxSnapshots: 100,
  capture: "error",        // "error" (default, safe) | "value" | "all"
});

await measure("payment.charge", () => stripe.charge(payload), { snapshotContainer: snapshots });

const failed = snapshots.getSnapshots("payment.charge");
// array of full BenchmarkErrorResult — error, latency, startedAt, tags
```

`capture: "value"` and `"all"` store the success return value in memory — fine for low-volume diagnostics, dangerous in production. The "error" default keeps memory bounded by the failure rate.

## Globals via `src/config/benchmark.ts`

```ts title="src/config/benchmark.ts"
import {
  BenchmarkProfiler,
  ConsoleChannel,
  type BenchmarkConfigurations,
} from "@warlock.js/core";

const benchmarkConfig: BenchmarkConfigurations = {
  enabled: true,
  latencyRange: { excellent: 100, poor: 500 },
  profiler: new BenchmarkProfiler({
    maxSamples: 1000,
    channels: [new ConsoleChannel()],
    flushEvery: 60_000,
  }),
};

export default benchmarkConfig;
```

Every `measure()` call without an explicit `latencyRange`/`profiler` falls back to these. Per-call options always win.

## Common patterns

### Measure a service call

```ts
const result = await measure("create-order", () => createOrderService(input));

if (!result.success) {
  return response.badRequest({ error: t("order.failed") });
}

return response.successCreate({ order: result.value });
```

### Measure an external HTTP request

```ts
const result = await measure(
  "stripe.charge",
  () => stripe.charges.create({ amount, currency, source }),
  {
    latencyRange: { excellent: 200, poor: 3000 },
    tags: { gateway: "stripe" },
    shouldBenchmarkError: (err) => err instanceof NetworkError,
  },
);
```

### Compose with `retry()`

```ts
import { measure } from "@warlock.js/core";
import { retry } from "@mongez/reinforcements";

await measure("publish-event", () =>
  retry(() => bus.publish(event), { attempts: 4, delay: 200 }),
);
```

The `latency` is the *total* time including retries — useful for the SLO you actually care about. (`retry` moved to `@mongez/reinforcements`; `count` is now `attempts` — total tries.)

## Gotchas

- **Name collisions aggregate.** Two calls to `measure("foo", …)` share one profiler bucket. Make `name` specific (`db.findUser`, not `db.query`) so percentiles mean something.
- **`measure()` doesn't propagate AbortSignal.** If `fn` is cancellable, plumb that through yourself — the wrapper only times.
- **Don't `measure()` synchronous trivia.** A `Math.round` call isn't worth a microsecond of overhead. Reserve for things that *can* be slow.
- **Snapshots with `"value"` retain references.** If `value` holds a request stream or a large buffer, you've kept it in memory until eviction.
- **`shouldBenchmarkError` re-throws.** Make sure the caller is ready for that, or set it conservatively (`true` by default).

## See also

- [`retry-operation/SKILL.md`](../retry-operation/SKILL.md) — wrapping flaky operations with retry; composes inside `measure()`.
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — wiring `src/config/benchmark.ts`.


## build-restful  `@warlock.js/core/build-restful/SKILL.md`

---
name: build-restful
description: 'Generate standard CRUD endpoints — via `router.route(path).list().show().create().update().destroy()` chain or the `Restful` base class. Pick the chain by default; reach for `Restful` when you want repository-bound defaults. Triggers: `router.route`, `Restful`, `router.restfulResource`, `RouteResource`, `.crud`, `.nest`, `beforeCreate`, `onCreate`; "build a CRUD API", "register list/show/create/update/destroy", "repository-bound default handlers", "override a single REST action"; typical import `import { router, Restful } from "@warlock.js/core"`. Skip: wider router surface — `@warlock.js/core/register-route/SKILL.md`; per-action controllers — `@warlock.js/core/create-controller/SKILL.md`; wire mapping — `@warlock.js/core/define-resource/SKILL.md`; competing pattern: hand-rolled controllers, `@nestjs/swagger` decorator-driven CRUD.'
---

# Warlock — build a RESTful resource

Standard CRUD comes in two flavors. The **router chain** is thin: you write one controller per action and chain them via `router.route(path)`. The **`Restful` base class** is thick: it wires a repository to default handlers — list/get/create/update/patch/delete plus bulkDelete — and you override hooks only where you need to.

## The shape

```ts title="src/app/<module>/routes.ts"
import { router } from "@warlock.js/core";
import { guarded } from "app/shared/utils/router";
import { createFaqController } from "./controllers/create-faq.controller";
import { deleteFaqController } from "./controllers/delete-faq.controller";
import { getFaqController } from "./controllers/get-faq.controller";
import { listFaqsController } from "./controllers/list-faqs.controller";
import { updateFaqController } from "./controllers/update-faq.controller";

guarded(() => {
  router
    .route("/faqs")
    .list(listFaqsController)
    .show(getFaqController)
    .create(createFaqController)
    .update(updateFaqController)
    .destroy(deleteFaqController);
});
```

This is the project default. Five controllers, one chain, auto-loaded.

## The chain — `router.route(path)`

`router.route(path)` returns a `RouteBuilder`. Each named slot maps to a verb + path:

| Method                                | Verb     | Path        |
| ------------------------------------- | -------- | ----------- |
| `.list(handler)`                      | `GET`    | `/path`     |
| `.show(handler)`                      | `GET`    | `/path/:id` |
| `.create(handler)`                    | `POST`   | `/path`     |
| `.update(handler)`                    | `PUT`    | `/path/:id` |
| `.patch(handler)`                     | `PATCH`  | `/path/:id` |
| `.destroy(handler)`                   | `DELETE` | `/path/:id` |
| `.get(handler)`                       | `GET`    | `/path`     |
| `.post(handler)`                      | `POST`   | `/path`     |
| `.put(handler)`                       | `PUT`    | `/path`     |
| `.delete(handler)`                    | `DELETE` | `/path`     |
| `.getOne` / `.postOne` / `.deleteOne` | verb     | `/path/:id` |

The semantic aliases (`list`, `show`, `create`, `update`, `destroy`, `patch`) are just thin wrappers — same registration. Methods you don't call don't register. Each method returns the builder, so you chain.

### `.crud({...})`

Sugar for setting up all six slots in one call:

```ts
router.route("/products").crud({
  list: listProductsController,
  show: getProductController,
  create: createProductController,
  update: updateProductController,
  destroy: removeProductController,
  patch: patchProductController,
});
```

### `.nest(path)`

For nested resources like `/posts/:id/comments`:

```ts
router
  .route("/posts/:id")
  .show(showPostController)
  .nest("/comments")
  .list(listCommentsController)
  .create(createCommentController);
```

## The `Restful` base class

When you want default CRUD handlers wired straight to a repository, extend `Restful<T>`:

```ts
import { Restful, type RouteResource, v } from "@warlock.js/core";
import { UsersRepository } from "./repositories/users.repository";
import type { User } from "./models/user/user.model";

export class UsersRestful extends Restful<User> implements RouteResource {
  protected repository = new UsersRepository();

  protected recordName = "user";
  protected recordsListName = "users";

  public cache = true;

  protected returnOn = {
    create: "record",
    update: "record",
    delete: "record",
    patch: "record",
  };

  public validation = {
    create: {
      schema: v.object({
        name: v.string().required().min(2),
        email: v.email().required(),
      }),
    },
    update: {
      schema: v.object({
        name: v.string().min(2),
      }),
    },
  };
}

// `validation` is consumed via duck-typing by `router.restfulResource()`.
// The base `Restful` class doesn't formally declare it — you add it on the
// subclass and the router picks it up at registration time.
```

Wire it to the router via `router.restfulResource(path, instance, options?)`:

```ts
import { router } from "@warlock.js/core";

router.restfulResource("/users", new UsersRestful());
```

This registers:

| Path        | Verb     | Method        |
| ----------- | -------- | ------------- |
| `/users`    | `GET`    | `list()`      |
| `/users/:id`| `GET`    | `get()`       |
| `/users`    | `POST`   | `create()`    |
| `/users/:id`| `PUT`    | `update()`    |
| `/users/:id`| `PATCH`  | `patch()`     |
| `/users/:id`| `DELETE` | `delete()`    |
| `/users`    | `DELETE` | `bulkDelete()`|

`bulkDelete` expects an array of ids in the `id` field; it 400s otherwise. All actions except `bulkDelete` only register when the corresponding method is defined on the class — `Restful` provides them all by default.

### Filtering registered actions

```ts
router.restfulResource("/users", new UsersRestful(), {
  only: ["list", "get"],
});

router.restfulResource("/users", new UsersRestful(), {
  except: ["delete"],
});

router.restfulResource("/users", new UsersRestful(), {
  replace: {
    create: customCreateHandler,
  },
});
```

`replace` overrides a single action without rebuilding the class. `only` and `except` filter the action set.

### Configurable bits on `Restful`

- `recordName` / `recordsListName` — response keys for `{ [recordName]: model }` and `{ [recordsListName]: [...] }`. Default `"record"` and `"records"`.
- `returnOn` — after create/update/delete/patch, return the single record (default) or the full list (`"records"`).
- `cache` — when `true` (default), `list()` and `get()` use the repository's cached variants (`listCached`/`getCached`).
- `validation` — per-action schemas read by `router.restfulResource()`. Add `validation = { create?, update?, patch? }` as a public field on your subclass; the router consumes whichever entries exist. The field isn't declared on the base `Restful` class — it's duck-typed off the resource at registration time (see `router.ts > manageValidation`).

### Lifecycle hooks

Override any of these `protected async` methods:

| Hook                            | When                                      |
| ------------------------------- | ----------------------------------------- |
| `beforeCreate(request, response, model)` | New model instance, before `create`       |
| `onCreate(request, response, record)`    | After successful `create`                 |
| `beforeUpdate(request, response, record, old?)` | Before `update`                    |
| `onUpdate(request, response, record, old)`      | After successful `update`           |
| `beforePatch(request, response, record, old?)`  | Before `patch`                      |
| `onPatch(request, response, record, old)`       | After successful `patch`            |
| `beforeDelete(request, response, record)`       | Before `delete` (single + bulk)     |
| `onDelete(request, response, record)`           | After successful `delete`           |
| `beforeSave(request, response, record?, old?)`  | Before create/update/patch — shared |
| `onSave(request, response, record, old?)`       | After create/update/patch — shared  |

Returning a response from a `before*` hook short-circuits the action. Use for permission checks or computed-field assignment.

## When to use which

- **Default to the chain (`router.route(...)`)** with explicit per-action controllers — every module in the project uses this. Controllers stay thin, services own the logic, validation lives on the controller, and you can read `routes.ts` in five seconds.
- **Reach for `Restful`** when the entity is genuinely vanilla CRUD against a repository with no per-action service variation. The hook ladder is more ceremony than five controllers if the actions diverge at all.
- **Drop to hand-rolled controllers** when even one action breaks the CRUD shape — custom response wrappers, multi-step pipelines, non-`:id` lookups. Mixing is fine: chain the standard four, register the oddball as a separate `router.post(...)`.

## Common patterns

### Project default — five controllers behind a chain

```ts
import { router } from "@warlock.js/core";
import { guarded } from "app/shared/utils/router";
import { createProductController } from "./controllers/create-product.controller";
import { getProductController } from "./controllers/get-product.controller";
import { listProductsController } from "./controllers/list-products.controller";
import { removeProductController } from "./controllers/remove-product.controller";
import { updateProductController } from "./controllers/update-product.controller";

guarded(() => {
  router
    .route("/products")
    .list(listProductsController)
    .show(getProductController)
    .create(createProductController)
    .update(updateProductController)
    .destroy(removeProductController);
});
```

### Public list + guarded mutations

```ts
router.prefix("/products", () => {
  router.get("/", listProductsController);

  guarded(() => {
    router.post("/", createProductController);
    router.patch("/:id", updateProductController);
    router.delete("/:id", removeProductController);
  });
});
```

### Restful with override

```ts
class OrdersRestful extends Restful<Order> {
  protected repository = new OrdersRepository();
  protected recordName = "order";

  protected async beforeCreate(request, response, order) {
    order.set("organization_id", request.user.organizationId);
    order.set("created_by", request.user.id);
  }
}

router.restfulResource("/orders", new OrdersRestful());
```

## Gotchas

- **`router.restfulResource` wraps `prefix(path, ...)` internally** — register inside `guarded(...)` to guard the whole set, or pass middleware via the options. Avoid registering the same prefix twice.
- **`Restful.update` is `PUT`, `Restful.patch` is `PATCH`.** They differ — `update` uses `request.allExceptParams()`, `patch` uses `request.heavyExceptParams()`. Don't conflate them.
- **`cache = true` reuses repository cache.** If the repository isn't cacheable, the cached methods silently fall through to the live query — set `cache = false` explicitly if you want to make that visible.
- **`bulkDelete` reads `id` as an array.** The route is `DELETE /path` (no `:id`); send `id: ["1", "2", "3"]` in the body. Returns 400 if the value isn't an array.
- **Validation hooks on `Restful`** are bound to the instance — call `this.repository` freely inside `validate()`. Plain controller validators don't get `this`.
- **Don't import `routes.ts`.** Framework auto-loads it. See `register-route` for the rules.

## See also

- [`register-route/SKILL.md`](../register-route/SKILL.md) — the wider router surface (`group`, `prefix`, plain verbs).
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — what the chain's handlers look like.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — wire shape for the records the chain returns.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — controller/service/repository layering rules.


## build-url  `@warlock.js/core/build-url/SKILL.md`

---
name: build-url
description: 'HTTP URL helpers — `url`, `publicUrl`, `assetsUrl`, `uploadsUrl`, anchored at `app.baseUrl`. Use to render `src` / `href` / API URLs in resources and responses. `setBaseUrl` is wired by the HTTP connector from `config.get("app.baseUrl")`. Triggers: `url`, `publicUrl`, `assetsUrl`, `uploadsUrl`, `setBaseUrl`, `BASE_URL`; "render an avatar src URL", "absolute download link", "embed asset URL in email", "URL helpers vs path helpers"; typical import `import { url, publicUrl, uploadsUrl } from "@warlock.js/core"`. Skip: filesystem paths — `@warlock.js/core/resolve-path/SKILL.md`; signed CDN URLs — `@warlock.js/core/store-file/SKILL.md`; resource output — `@warlock.js/core/define-resource/SKILL.md`; competing patterns: hand-rolled `${baseUrl}/...` template strings.'
---

# Warlock — build a URL

Path helpers ([`resolve-path/SKILL.md`](../resolve-path/SKILL.md)) give you absolute filesystem paths. URL helpers give you absolute HTTP URLs. Different jobs, often confused.

Use URL helpers whenever you need to render a string that goes into a browser, an HTTP client, or an email — `<img src=...>`, `<a href=...>`, an API response that includes a download link, a webhook payload.

## The shape

```ts
import { url, publicUrl, assetsUrl, uploadsUrl } from "@warlock.js/core";

url();                       // → "http://localhost:3000"
url("/health");              // → "http://localhost:3000/health"

publicUrl("favicon.ico");    // → "http://localhost:3000/public/favicon.ico"
assetsUrl("logo.png");       // → "http://localhost:3000/public/assets/logo.png"
uploadsUrl("avatars/42.png"); // → "http://localhost:3000/uploads/avatars/42.png"
```

Every helper resolves against the **base URL** the HTTP connector set at boot.

## Setting the base URL

`setBaseUrl(url)` is wired automatically — the HTTP connector reads `app.baseUrl` from config and sets it during boot:

```ts title="src/config/app.ts"
import type { AppConfigurations } from "@warlock.js/core";
import { env } from "@warlock.js/core";

const appConfigurations: AppConfigurations = {
  appName: env("APP_NAME", "Mongez"),
  baseUrl: env("BASE_URL", "http://localhost:3000"),
  // ...
};

export default appConfigurations;
```

The env var `BASE_URL` controls the deployed base. In production, set it to `https://api.example.com` (or whatever the deployed host is). In dev, `http://localhost:3000` is the default.

You can also call `setBaseUrl` manually if you need to override at runtime — but in normal app code, the HTTP connector handles this; you don't touch it.

```ts
import { setBaseUrl } from "@warlock.js/core";

setBaseUrl("https://cdn.example.com");
// → every subsequent url() / publicUrl() / assetsUrl() / uploadsUrl() uses this
```

## Full inventory

| Helper                | Resolves to                              | Use for                                     |
| --------------------- | ---------------------------------------- | ------------------------------------------- |
| `url(path?)`          | `<baseUrl>/<path>`                       | Arbitrary absolute URL on the same host.    |
| `publicUrl(path?)`    | `<baseUrl>/public/<path>`                | Files served out of the `public/` folder.   |
| `assetsUrl(path?)`    | `<baseUrl>/public/assets/<path>`         | Assets inside `public/assets/`.             |
| `uploadsUrl(path?)`   | `<baseUrl>/uploads/<path>`               | User-uploaded files served over HTTP.       |
| `setBaseUrl(url)`     | (sets the anchor for all of the above)   | Override at runtime — rarely needed.        |

`assetsUrl` is just `publicUrl` with `assets/` prepended — they share the same `/public/` mount point.

`url(path)` is the escape hatch — it composes the base URL with any path you give it, no convention enforced. Use it for one-off URLs that don't fit `public` / `uploads`.

## URL helpers vs. path helpers

The most common confusion. They look related but resolve completely differently:

| Goal                                          | Helper            | Returns                                     |
| --------------------------------------------- | ----------------- | ------------------------------------------- |
| **Read** a file from the filesystem           | `uploadsPath(...)` | `<cwd>/storage/uploads/<path>` (filesystem) |
| **Render** an `<img src>` for the same file   | `uploadsUrl(...)`  | `<baseUrl>/uploads/<path>` (HTTP URL)       |
| **Write** to public folder                    | `publicPath(...)`  | `<cwd>/public/<path>` (filesystem)          |
| **Embed** an asset URL in a response          | `publicUrl(...)`   | `<baseUrl>/public/<path>` (HTTP URL)        |

Pick by the consumer:

- **Filesystem read/write code** (services that read templates, scripts that copy files) → path helpers.
- **API responses, HTML, emails** (anything the browser or an HTTP client sees) → URL helpers.

## Patterns

### Render an avatar URL in a resource

```ts title="src/app/users/resources/user.resource.ts"
import { defineResource, uploadsUrl } from "@warlock.js/core";

export const UserResource = defineResource({
  schema: {
    id: "string",
    email: "string",
    // `uploadsUrl` cast joins the stored path with the base URL automatically
    avatarUrl: ["avatar_path", "uploadsUrl"],
  },
});
```

The stored value (`avatars/42.png`) is the filesystem-relative key — relative to `storage/uploads/`. The `uploadsUrl` cast renders the full URL by joining it with the base URL. For conditional logic (e.g. fall back to `null` when there's no avatar), use a resolver function instead:

```ts
export const UserResource = defineResource({
  schema: {
    id: "string",
    email: "string",
    avatarUrl: function (_value, resource) {
      const path = resource.get("avatar_path"); // e.g. "avatars/42.png"

      return path ? uploadsUrl(path) : null;
    },
  },
});
```

### Asset URL in an HTML email

```tsx title="src/app/users/emails/welcome.email.tsx"
import { assetsUrl } from "@warlock.js/core";
import { Body, Container, Html, Img } from "@react-email/components";

export function WelcomeEmail() {
  return (
    <Html>
      <Body>
        <Container>
          <Img src={assetsUrl("logo.png")} alt="Logo" />
          <h1>Welcome!</h1>
        </Container>
      </Body>
    </Html>
  );
}
```

Email clients can't reach `localhost` — make sure `BASE_URL` is the public host before sending production mail. Otherwise the `src` resolves to `http://localhost:3000/...` and recipients see broken images.

### Absolute API URL in a webhook payload

```ts
await sendWebhook({
  url: vendor.callback_url,
  payload: {
    order_id: order.id,
    receipt_url: url(`/orders/${order.id}/receipt`),  // → "https://api.example.com/orders/42/receipt"
  },
});
```

The receiver needs the full URL — they'll never see your `Host` header to deduce the rest.

### Override per-request for a multi-tenant CDN

```ts
// Inside a request handler that needs a tenant-specific URL prefix
const tenantCdn = `https://${tenant.slug}.cdn.example.com`;
const fullUrl = `${tenantCdn}/uploads/${path}`;
```

Don't call `setBaseUrl` per request — it's process-global and races every other request. Build the URL manually with template strings instead.

## Gotchas

- **`setBaseUrl` is process-global.** Calling it inside a request handler changes the URL for **every** in-flight request. The HTTP connector sets it once on boot — leave it alone in app code.
- **No host detection from the request.** These helpers don't read `request.host` / `X-Forwarded-Host`. They use the configured `app.baseUrl` only. If your app sits behind a reverse proxy and you need the actual host the user typed, read it from the request directly.
- **Trailing slashes are stripped from the base, leading slashes from the path.** `url("/foo")` and `url("foo")` both return `<base>/foo`. Don't bother double-checking the slash.
- **`uploadsUrl` doesn't know about `uploads.root` overrides.** Even if your filesystem is mounted at `/mnt/uploads`, the URL stays `<baseUrl>/uploads/<path>` — that's the HTTP route the framework serves uploads from. The two are decoupled by design.
- **`publicUrl` vs `assetsUrl`.** `publicUrl("logo.png")` resolves to `/public/logo.png`; `assetsUrl("logo.png")` to `/public/assets/logo.png`. Convention is: framework assets go in `public/assets/`, ad-hoc files (favicon.ico, robots.txt) at `public/` root.
- **The `baseUrl` config is read once at HTTP-connector boot.** Changing the config after boot doesn't update the cached value — either restart, or call `setBaseUrl` manually (with the global-state caveat above).
- **Email + localhost = broken links.** Always use a public host in `BASE_URL` for environments that send mail. Production should never have `localhost` in there.

## See also

- [`resolve-path/SKILL.md`](../resolve-path/SKILL.md) — filesystem path helpers (`uploadsPath`, `publicPath`, ...). Use for read/write; use the URL helpers for rendering.
- [`store-file/SKILL.md`](../store-file/SKILL.md) — the `storage.url(path)` driver method, which can produce signed CDN URLs for non-local drivers (S3 etc.). Use that over `uploadsUrl` when you're on a remote driver.
- [`upload-file/SKILL.md`](../upload-file/SKILL.md) — the `UploadedFile` lifecycle; where the path that feeds `uploadsUrl` comes from.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — the `url` / `uploadsUrl` / `storageUrl` casts (and resolver functions) are the right place to call URL helpers.
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/app.ts` and the `baseUrl` env wiring.


## configure-app  `@warlock.js/core/configure-app/SKILL.md`

---
name: configure-app
description: 'Configure a Warlock app — the two layers (`warlock.config.ts` for framework-level wiring, `src/config/*.ts` for subsystems), `.env` + `env()`, and the `config()` getter for runtime reads. Triggers: `defineConfig`, `config.get`, `config.key`, `env`, `ConfigRegistry`, `HttpConfigurations`, `AppConfigurations`; "add a new config file", "warlock.config.ts vs src/config", "read env values", "runtime config lookup"; typical import `import { defineConfig, config, env } from "@warlock.js/core"`. Skip: cache driver registration — `@warlock.js/cache/cache-basics/SKILL.md`; mail config — `@warlock.js/core/send-mail/SKILL.md`; storage config — `@warlock.js/core/store-file/SKILL.md`; competing libs `dotenv` direct, `convict`, `node-config`.'
---

# Warlock — configure the app

Two config layers, one source of env, one read API. Get those four mental model bits straight and the rest is filing.

## The shape

**Layer 1 — framework wiring.** One file at the project root: `warlock.config.ts`. Exports a `defineConfig({...})` call. Governs the bootstrap/server/build/devServer/cli/database hooks.

**Layer 2 — subsystem config.** One file per subsystem in `src/config/<name>.ts`. Each file `export default` a typed config object. Auto-loaded; registered under the file's basename (`http.ts` → `config.get("http")`).

```ts title="warlock.config.ts"
import { authMigrations, registerAuthCleanupCommand } from "@warlock.js/auth";
import { defineConfig } from "@warlock.js/core";

export default defineConfig({
  devServer: {
    healthCheckers: false,
    generateTypings: false,
  },
  cli: {
    commands: [registerAuthCleanupCommand()],
  },
  build: {
    minify: true,
  },
  database: {
    migrations: authMigrations,
  },
});
```

```ts title="src/config/http.ts"
import type { HttpConfigurations } from "@warlock.js/core";
import { Application, env } from "@warlock.js/core";

const httpConfigurations: HttpConfigurations = {
  port: env("HTTP_PORT", 3000),
  host: env("HTTP_HOST", "localhost"),
  log: true,
  fileUploadLimit: 12 * 1024 * 1024 * 1024,
  rateLimit: {
    max: 260,
    duration: 60 * 1000,
  },
  cors: {
    origin: "*",
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
  },
  cookies: {
    secret: env("COOKIE_SECRET", "super-secret-key-change-me"),
    options: {
      httpOnly: true,
      secure: Application.isProduction,
      path: "/",
    },
  },
};

export default httpConfigurations;
```

## Which layer holds what

| Setting                                                | Layer                          |
| ------------------------------------------------------ | ------------------------------ |
| Build/bundler options (minify, sourcemap, outFile)     | `warlock.config.ts > build`    |
| Dev server (HMR scope, health checkers, typings)       | `warlock.config.ts > devServer`|
| Package migrations (external `@warlock.js/*` packages) | `warlock.config.ts > database` |
| CLI commands (registered via `warlock <cmd>`)          | `warlock.config.ts > cli`      |
| HTTP server tuning per env (port, host, retry)         | `warlock.config.ts > server`   |
| HTTP runtime (CORS, cookies, rate limits, upload size) | `src/config/http.ts`           |
| App identity (name, baseUrl, timezone, locales)        | `src/config/app.ts`            |
| Subsystem configs (auth, mail, storage, cache, ai, …)  | `src/config/<name>.ts`         |

Heuristic: if the setting changes how the framework **boots, builds, or scaffolds**, it goes in `warlock.config.ts`. If it changes how a **subsystem behaves at runtime**, it goes in `src/config/`.

## `.env` and `env()`

`env(key, default?)` reads from `process.env` and **auto-coerces** known shapes:

- `"true"` / `"false"` → `boolean`
- Numeric strings → `number`
- Falls back to the `default` argument when unset

```ts
import { env } from "@warlock.js/core";

env("HTTP_PORT", 3000);             // number
env("CACHE_LOGGING", false);        // boolean
env("BASE_URL", "http://localhost:3000"); // string
env("REDIS_URL");                   // undefined if unset
```

`env` is re-exported from `@warlock.js/core` (sourced from `@mongez/dotenv`). Use this — not `process.env.X` — so callers consistently get coerced values.

`.env.local` overrides `.env`. Project ships a `.env.example` listing the keys.

## Reading config at runtime — `config()`

`config` has two methods. Both accept a default:

```ts
import { config } from "@warlock.js/core";

// Whole group — typed by ConfigRegistry augmentation
const http = config.get("http");
const httpPort = http.port;

// Dot-notation key
const port = config.key<number>("http.port", 3000);
const baseUrl = config.key("app.baseUrl");
```

Use `config.get("name")` when you need the whole subsystem object; `config.key("a.b.c")` for a single value.

The `ConfigRegistry` interface is augmented by generated typings — once you add `src/config/foo.ts` and rerun typings, `config.get("foo")` autocompletes. Until then, pass an explicit generic.

## Anatomy of a `src/config/<name>.ts`

1. Import the typed shape (`HttpConfigurations`, `AppConfigurations`, `CacheConfigurations<DriverNames>`, `MailConfigurations`, `StorageConfigurations`, …) from `@warlock.js/core` (or `@warlock.js/cache` for cache).
2. Build the object, typed as that shape — TS will surface missing keys.
3. Pull values from `env(...)` where they belong in `.env`. Hard-code immutable choices.
4. `export default` the object.

That's the contract. Filename → config key. No registration, no manifest, no side effects.

## Special handlers

Some subsystems need to do extra work when their config loads (e.g. set the active locale, register a driver). Those have a special handler attached internally — for the most part you don't see them. The takeaway: changing a config file at dev time fires the handler again automatically.

## Multi-file or conditional config

A config file is just a TS module. Branch on `env(...)` if production should differ from development:

```ts title="src/config/storage.ts"
import {
  env,
  type StorageConfigurations,
  storageConfigurations,
  storagePath,
} from "@warlock.js/core";

const storageOptions: StorageConfigurations = {
  default: env("STORAGE_DRIVER", "local"),
  drivers: {
    local: storageConfigurations.local({
      root: storagePath(),
      urlPrefix: "/uploads",
    }),
    r2: storageConfigurations.r2({
      bucket: env("R2_BUCKET"),
      endpoint: env("R2_ENDPOINT"),
      accessKeyId: env("R2_ACCESS_KEY_ID"),
      secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
      accountId: env("R2_ACCOUNT_ID"),
      region: env("R2_REGION", "auto"),
      publicDomain: env("R2_BASE_URL"),
    }),
  },
};

export default storageOptions;
```

Same shape — TS validates the union; runtime picks the `default` key.

## Per-env knobs

For one-off switches gated on environment, the canonical pattern is `Application.isProduction` (also `isDevelopment`, `isTest`):

```ts
import { Application } from "@warlock.js/core";

const config = {
  cookies: {
    secure: Application.isProduction,
  },
};
```

Avoid scattering `process.env.NODE_ENV === "production"` checks — they don't get the same default-handling.

## Common patterns

### Adding a new subsystem

1. Create `src/config/<name>.ts` exporting a default object.
2. (Optional) Augment `ConfigRegistry` in a `.d.ts` if you want `config.get("<name>")` to autocomplete.
3. Read it where needed: `config.get("<name>")` or `config.key("<name>.field")`.

That's the whole flow. No barrel, no register call.

### Per-tenant config

For multi-tenancy, don't put per-tenant values in `src/config/`. Resolve at runtime from the tenant context and pass into the subsystem's per-call options (e.g. `Mail.config(tenant.mailSettings)`).

### Package config

Internal `@warlock.js/*` packages read from `config.get("<name>")` the same way app code does. When wiring a package config (e.g. `src/config/cache.ts`), the package surface (`cache.init()`) reads the registered values on boot.

## Gotchas

- **One `default` export per config file.** Anything else won't be picked up. A named export is invisible to the loader.
- **`env(key)` returns the env value coerced, not the literal string.** `env("HTTP_LOG")` returns `true`/`false`, not `"true"`. Type the default to match.
- **Don't mutate `config` at runtime.** It's read-only by convention. For dynamic overrides, fold into the call-site options (e.g. `cache.set(key, val, { driver: "redis" })`).
- **`Application.isProduction` is preferred over `process.env.NODE_ENV` checks** in config files — same source, but it survives `setEnvironment(...)` and reads consistently.
- **`warlock.config.ts` is the boot config; `src/config/` is the runtime config.** Putting runtime tuning into `warlock.config.ts` doesn't crash but won't be visible via `config.get(...)`.

## See also

- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, canonical imports, framework-wide invariants.
- [`@warlock.js/cache/pick-cache-driver/SKILL.md`](../../../cache/skills/pick-cache-driver/SKILL.md) — picking and registering a cache driver in `src/config/cache.ts`.
- [`send-mail/SKILL.md`](../send-mail/SKILL.md) — the `src/config/mail.ts` shape.
- [`store-file/SKILL.md`](../store-file/SKILL.md) — the `src/config/storage.ts` shape.


## create-controller  `@warlock.js/core/create-controller/SKILL.md`

---
name: create-controller
description: 'Author HTTP controllers in @warlock.js/core — RequestHandler signature, validated input via seal schemas, response helpers, attaching metadata. Controllers are thin functions; business logic moves to services or use-cases. Triggers: `RequestHandler`, `Request<TSchema>`, `GuardedRequestHandler`, `request.validated`, `request.input`, `controller.validation`, `response.success`, `response.successCreate`; "write a controller", "attach a schema to a handler", "thin controller pattern", "guarded request type"; typical import `import { type RequestHandler } from "@warlock.js/core"`. Skip: response helper menu — `@warlock.js/core/send-response/SKILL.md`; schema authoring — `@warlock.js/core/validate-input/SKILL.md`; URL wiring — `@warlock.js/core/register-route/SKILL.md`; competing patterns: `express` middleware functions, `@nestjs/common` `@Controller`/`@Get` decorators.'
---

# Warlock — create a controller

A controller is a thin function: pull inputs from `request`, call work, return through a `response.<helper>()`. No classes. No decorators. No DI.

## The shape

```ts title="src/app/<module>/controllers/<action>.controller.ts"
import { type RequestHandler } from "@warlock.js/core";

export const listProductsController: RequestHandler = async (request, response) => {
  return response.success({ products: [] });
};
```

That's the full contract. The `RequestHandler` annotation carries both parameter types — `request` and `response` infer automatically; you never annotate them by hand.

## File location and naming

- One controller per file.
- File: `src/app/<module>/controllers/<action>.controller.ts`.
- Export name matches the action in camelCase + `Controller` suffix: `listProductsController`, `createProductController`, `getProductController`.

Scaffold with: `yarn warlock generate.controller <module>/<action>` (add `--with-validation` to get the schema generated alongside).

## Reading input

| Method                            | Returns                                          | Use when                                            |
| --------------------------------- | ------------------------------------------------ | --------------------------------------------------- |
| `request.input("key", default?)`  | one field                                        | reading a single param/body field by name           |
| `request.all()`                   | full input object                                | passing the whole input straight to a service       |
| `request.validated()`             | schema-typed object (only after schema attached) | controllers with a schema — preferred over `.all()` |
| `request.user`                    | authenticated user                               | guarded routes (see "Typing a guarded handler")     |
| `request.file("key")`             | `UploadedFile`                                   | multipart uploads                                   |
| `request.header("X-Foo")`         | header value                                     | reading request metadata                            |
| `request.ip`, `request.userAgent` | strings                                          | logging, device info                                |

Prefer `request.validated()` once a schema is attached — it's typed.

## Returning output

Pick the helper that matches the outcome. Full surface in [send-response](../send-response/SKILL.md). Quick map:

| Helper                             | Status | Use when                      |
| ---------------------------------- | ------ | ----------------------------- |
| `response.success(data)`           | 200    | normal read                   |
| `response.successCreate(data)`     | 201    | resource created              |
| `response.noContent()`             | 204    | delete succeeded              |
| `response.badRequest({ error })`   | 400    | invalid input                 |
| `response.unauthorized({ error })` | 401    | missing/invalid token         |
| `response.forbidden({ error })`    | 403    | authenticated but not allowed |
| `response.notFound({ error })`     | 404    | record missing                |
| `response.conflict({ error })`     | 409    | uniqueness / state            |

Always `return response.<helper>(...)` — the return value is the request's response.

## Attaching a validation schema

The schema is a property on the handler function, not a decorator. The handler's type generic carries the schema shape so `request.validated()` is typed end-to-end:

```ts title="src/app/products/controllers/create-product.controller.ts"
import { type Request, type RequestHandler } from "@warlock.js/core";
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
import { createProductService } from "../services/create-product.service";

export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
  request,
  response,
) => {
  const product = await createProductService(request.validated());

  return response.successCreate({ product });
};

createProductController.validation = {
  schema: createProductSchema,
};
```

Two pieces:

1. **`RequestHandler<Request<TSchema>>`** as the controller's annotation — types `request.validated()` and keeps `request`/`response` inferred. No separate `*.request.ts` alias file is needed; the schema's exported `TSchema` type is the single source of truth.
2. **`controller.validation = { schema }`** at module top level so the framework knows to validate before calling the handler.

If validation fails, the framework returns a 400 with an `errors` payload and your handler never runs.

### Typing a guarded handler

Routes behind `authMiddleware` need `request.user` typed. Project conventions add a `GuardedRequest<TSchema>` (adding `user: User`) and a paired `GuardedRequestHandler<TSchema>` alias in `app/auth/requests/guarded.request`:

```ts
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";

export const createProductController: GuardedRequestHandler<CreateProductSchema> = async (
  request,
  response,
) => {
  // request.user is typed
  const product = await createProductService(request.validated());
  return response.successCreate({ product });
};

createProductController.validation = { schema: createProductSchema };
```

Use `RequestHandler<Request<TSchema>>` for public routes, `GuardedRequestHandler<TSchema>` for guarded ones. The `generate.controller`/`generate.module` scaffolds emit `GuardedRequestHandler` by default, since generated routes are wired behind `guarded(...)` — swap to `RequestHandler` when the endpoint is public.

## Optional metadata

```ts
createProductController.description = "Create a new product (admin only)";
```

Used by OpenAPI/Swagger generation (planned per `domains/core/backlog.md`) and surfaces in dev-server logs.

## What belongs in a controller (and what doesn't)

**Belongs:**

- Input pulling (`request.validated()` / `request.input(...)`)
- Calling exactly one service or use-case
- Returning the success path via `response.<helper>()`

Errors? Throw from the service. The framework catches every `HttpError` subclass and produces the matching response — controllers stay branch-free. See the next section.

**Doesn't belong:**

- Database queries (push to a repository, called by a service)
- Transactions (use-cases own them)
- External API calls (services)
- Cross-cutting orchestration of multiple services (use a `useCase()` — see `write-use-case` skill in Slice 2)
- Logging beyond what the framework does automatically

If your controller is over ~30 lines, the work probably belongs in a service.

## Common patterns

### Read

```ts
import { type RequestHandler } from "@warlock.js/core";
import { listProductsService } from "../services/list-products.service";

export const listProductsController: RequestHandler = async (request, response) => {
  const { data: products, pagination } = await listProductsService({
    ...request.all(),
    organization_id: request.user.organizationId,
  });

  return response.success({ products, pagination });
};
```

### Create

```ts
import { type Request, type RequestHandler } from "@warlock.js/core";
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
import { createProductService } from "../services/create-product.service";

export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
  request,
  response,
) => {
  const product = await createProductService(request.validated());

  return response.successCreate({ product });
};

createProductController.validation = {
  schema: createProductSchema,
};
```

### Service throws — controller stays branch-free

For HTTP-shaped errors (`404` not found, `403` forbidden, `400` bad request, `409` conflict, `500` server error), **throw from the service**. The request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the matching response. Controllers stay focused on the success path:

```ts title="src/app/products/services/get-product.service.ts"
import { ResourceNotFoundError } from "@warlock.js/core";
import { productsRepository } from "../repositories/products.repository";

export async function getProductService(id: string) {
  const product = await productsRepository.find(id);

  if (!product) {
    throw new ResourceNotFoundError("product.notFound");
  }

  return product;
}
```

```ts title="src/app/products/controllers/get-product.controller.ts"
import type { RequestHandler } from "@warlock.js/core";
import { getProductService } from "../services/get-product.service";

export const getProductController: RequestHandler = async (request, response) => {
  const product = await getProductService(request.input("id"));

  return response.success({ product });
};
```

No `if (!product)` branch in the controller. The error class carries the HTTP semantic (`404` for `ResourceNotFoundError`, `403` for `ForbiddenError`, `400` for `BadRequestError`, `409` for `ConflictError`, `500` for `ServerError`). Full list in [`send-response`](../send-response/SKILL.md#throwing-http-errors). `product.notFound` is a translation key — see localization conventions in your project's `utils/locales.ts`.

## Gotchas

- **DO throw HTTP-shaped errors from services.** `ResourceNotFoundError`, `ForbiddenError`, `BadRequestError`, etc. — the framework's request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the right response. Controllers stay clean of branching; only catch when you genuinely need to recover and continue.
- **Don't read `request.body` directly.** Use `request.all()` / `request.validated()` — they handle multipart, JSON, and form bodies uniformly.
- **Don't annotate `request`/`response` by hand.** They flow from the `RequestHandler<...>` generic. Adding `request: SomeRequest` separately re-introduces a contravariance error and defeats the inference.
- **Don't keep a `*.request.ts` alias file.** The schema's exported `TSchema` type plus `RequestHandler<Request<TSchema>>` (or `GuardedRequestHandler<TSchema>`) is the single source of truth. Separate request alias files drift from the schema and add an indirection that pays no rent.

## See also

- [`register-route/SKILL.md`](../register-route/SKILL.md) — wiring a controller to a URL.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — full Response helper surface.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — controller-service-repository layering.


## create-module  `@warlock.js/core/create-module/SKILL.md`

---
name: create-module
description: 'Scaffold a new feature module under `src/app/<name>/` via `warlock generate.module` and the follow-up generators for controllers, models, repositories, resources, and validation schemas. Triggers: `warlock generate.module`, `generate.controller`, `generate.service`, `generate.model`, `generate.repository`, `generate.resource`, `generate.migration`, `--minimal`, `gen.m`; "scaffold a new module", "create CRUD bootstrap", "add a controller to a module", "generate a model"; typical CLI `yarn warlock generate.module <name>`. Skip: framework-wide layout rules — `@warlock.js/core/warlock-conventions/SKILL.md`; routes file shape — `@warlock.js/core/register-route/SKILL.md`; controller shape — `@warlock.js/core/create-controller/SKILL.md`; competing tooling: `@nestjs/cli`, `hygen`, hand-rolled folder layouts.'
---

# Warlock — create a module

A module is a self-contained feature folder under `src/app/<name>/`. The CLI scaffolds the standard subfolders; the framework auto-loads `routes.ts`, `main.ts`, every `.ts(x)` file inside `events/`, and `utils/locales.ts`. You never `import` those files. (`src/app/main.ts` at the project root is also auto-loaded — that's the home for global one-time setup like `connectorsManager.register(...)`.)

## The shape

```bash
yarn warlock generate.module products            # full CRUD bootstrap (default — controllers, model, services, repository, resource, schemas, routes, seed)
yarn warlock generate.module products --minimal  # bare bones (routes.ts + main.ts + utils/locales.ts + empty subfolders)
```

Full CRUD is the default — opt down to a bare skeleton with `--minimal` (`-m`) when you want to build the module piece by piece. `--force` (`-f`) overwrites existing files. The plural form is auto-derived: `generate.module product` and `generate.module products` produce the same `src/app/products/` folder.

## What the generator creates

For `generate.module products` (full CRUD by default):

```
src/app/products/
  controllers/
    create-product.controller.ts
    update-product.controller.ts
    list-products.controller.ts
    get-product.controller.ts
    delete-product.controller.ts
  services/
    create-product.service.ts
    update-product.service.ts
    list-products.service.ts
    get-product.service.ts
    delete-product.service.ts
  models/
    product/
      product.model.ts
      index.ts
      migrations/              ← migration file is created here by the default CRUD scaffold
  repositories/
    products.repository.ts
  resources/
    product.resource.ts
  schema/                       ← seal schemas live here; each file exports both the schema value and the inferred `<Name>Schema` type — no separate `requests/` folder needed
    create-product.schema.ts
    update-product.schema.ts
  events/                       ← auto-loaded by the framework
  types/
  utils/
    locales.ts                  ← `groupedTranslations("products", { ... })`
  seeds/
    products.seed.ts
  routes.ts                     ← auto-loaded; RESTful chain pre-wired with `guarded(...)`
  main.ts                       ← auto-loaded once on boot
```

With `--minimal`, only `routes.ts`, `main.ts`, `utils/locales.ts`, and the empty subfolders are created — fill them in manually with the per-component generators below.

## Auto-loaded files

The framework discovers these by convention — do not import them anywhere:

- `routes.ts` — runs `router.<verb>(...)` calls on boot
- `main.ts` — one-time setup (event listeners, custom registrations, boot-time hooks)
- `events/*.ts(x)` — **any** `.ts(x)` file inside this folder is auto-loaded (convention is `*.event.ts`; the framework doesn't enforce the suffix)
- `utils/locales.ts` — auto-loaded; holds `groupedTranslations(...)`

Branching at registration time breaks HMR. Conditional behavior belongs inside controllers/services, not around `router.get(...)`.

## Per-component generators

Run after `generate.module` to fill in gaps or add to an existing module.

| Command                                         | Alias     | Flags                                                             |
| ----------------------------------------------- | --------- | ----------------------------------------------------------------- |
| `warlock generate.module <name>`                | `gen.m`   | `--minimal` (`-m`), `--force`                                     |
| `warlock generate.controller <module>/<action>` | `gen.c`   | `--with-validation` (also generates the `schema/` file)            |
| `warlock generate.service <module>/<action>`    | `gen.s`   | `--force`                                                         |
| `warlock generate.model <module>/<entity>`      | `gen.md`  | `--with-resource`, `--table <name>`, `--timestamps`               |
| `warlock generate.repository <module>/<entity>` | `gen.r`   | `--force`                                                         |
| `warlock generate.resource <module>/<entity>`   | `gen.rs`  | `--force`                                                         |
| `warlock generate.migration <model-path>`       | `gen.mig` | `--add <dsl>`, `--drop <names>`, `--rename <dsl>`, `--timestamps` |

The DSL for `--add` is `name:type:modifier,…` — see `write-cli-command` and the column-DSL parser in `@warlock.js/core/src/cli/commands/generate/generators/column-dsl-parser.ts`.

Every `generate.*` command (and the master `generate`) also accepts **`--dry-run`** — it prints the files it *would* create and writes nothing. Run it before a full `generate.module` scaffold or any `--force` to preview the blast radius.

## Path alias

The scaffolded `tsconfig.json` defines:

```jsonc
{
  "paths": {
    "app/*": ["./src/app/*"],
  },
}
```

Cross-module imports go through `app/<module>/...` — never deep relative paths:

```ts
// ✅ from src/app/orders/services/place-order.service.ts
import { Product } from "app/products/models/product";
import { guarded } from "app/shared/utils/router";

// ❌ deep relative — fragile across moves
import { Product } from "../../../products/models/product";
```

Inside the same module, plain relative imports (`./`, `../`).

## Common patterns

### Full CRUD bootstrap

```bash
yarn warlock generate.module products
# edit schemas + model fields, then
yarn warlock migrate
```

The CRUD scaffold's `routes.ts` already chains the five controllers behind `guarded(...)`:

```ts title="src/app/products/routes.ts (generated)"
import { router } from "@warlock.js/core";
import { guarded } from "app/shared/utils/router";
import { createProductController } from "./controllers/create-product.controller";
import { deleteProductController } from "./controllers/delete-product.controller";
import { getProductController } from "./controllers/get-product.controller";
import { listProductsController } from "./controllers/list-products.controller";
import { updateProductController } from "./controllers/update-product.controller";

guarded(() => {
  router
    .route("/products")
    .list(listProductsController)
    .show(getProductController)
    .create(createProductController)
    .update(updateProductController)
    .destroy(deleteProductController);
});
```

### Skeleton module, add pieces piecemeal

```bash
yarn warlock generate.module orders --minimal
yarn warlock generate.model orders/order --with-resource
yarn warlock generate.repository orders/order
yarn warlock generate.controller orders/place-order --with-validation
yarn warlock generate.controller orders/list-orders
```

Then wire URLs by editing `src/app/orders/routes.ts` and the schema rules in `src/app/orders/schema/`.

### Adding a one-time boot hook

```ts title="src/app/products/main.ts"
import { warmupProductCache } from "./services/warmup-product-cache.service";

await warmupProductCache();
```

`main.ts` runs once per boot, **after** every connector has finished its bootstrap (DB, socket, HTTP). You can call DB-touching code and reach `app.socket` / `app.database` directly — no `onceConnected` wrapper needed.

## Gotchas

- **Subfolder is `schema/`, not `validation/`.** The CRUD scaffold and `generate.controller --with-validation` both write schema files to `schema/`. A few older modules in this codebase still have a `validation/` folder — that's historical drift, not the convention. New modules use `schema/`.
- **There's no standalone `generate.validation` command.** Validation is no longer scaffolded on its own — each controller carries its own schema (imported from `schema/` and bound via `controller.validation`). Generate the controller with `--with-validation` to get the paired schema file, or hand-write the `schema/*.schema.ts`.
- **No `requests/` folder.** Controllers import the schema's exported type + value directly from `schema/*.schema.ts`; there is no `*.request.ts` alias.
- **Subfolder is `seeds/` (plural), not `seed/`.** The seed file is `<module>.seed.ts`.
- **`generate.module` does not run the migration.** It only creates the migration file. Run `yarn warlock migrate` separately to apply it.
- **`models/<entity>/` is its own folder, not a flat file.** The generator puts `product.model.ts` inside `models/product/` so migrations can sit beside the model in `models/product/migrations/`.
- **Don't import `routes.ts`, `main.ts`, or anything in `events/`.** They're auto-loaded; double-loading errors out at boot.
- **`utils/locales.ts` is mandatory for translation keys.** Skip it and `t("products.notFound")` silently falls back to the key itself.
- **The CRUD list service uses `listCached`, not `list`.** Switch to `list(...)` if your data churns faster than the cache invalidates.

## See also

- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, file suffixes, path aliases.
- [`register-route/SKILL.md`](../register-route/SKILL.md) — what to put in `routes.ts`.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — the handler shape, attaching validation.
- [`use-repository/SKILL.md`](../use-repository/SKILL.md) — what the generated `<module>.repository.ts` looks like and how to extend it.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — the output mapper for the model.
- [`validate-input/SKILL.md`](../validate-input/SKILL.md) — wiring `schema/*.schema.ts` to controllers.
- [`write-seeder/SKILL.md`](../write-seeder/SKILL.md) — populating the generated `seeds/` folder.
- [`use-localization/SKILL.md`](../use-localization/SKILL.md) — populating the generated `utils/locales.ts`.


## define-resource  `@warlock.js/core/define-resource/SKILL.md`

---
name: define-resource
description: 'Map model fields to wire-shape via `defineResource()` or `Resource` subclasses. Output-only — never put business logic, hydration, or reconciliation in a resource. Triggers: `defineResource`, `Resource`, `RegisterResource`, `toJSON`, `"self"`, `"localized"`, `"uploadsUrl"`; "shape an API response", "nest related resources", "rename a field on output", "self-referential tree resource"; typical import `import { defineResource } from "@warlock.js/core"`. Skip: localized columns — `@warlock.js/core/use-localization/SKILL.md`; URL casting — `@warlock.js/core/build-url/SKILL.md`; controller side — `@warlock.js/core/create-controller/SKILL.md`; competing libs `@nestjs/swagger` `@ApiProperty`, `class-transformer`, hand-rolled DTO mappers.'
---

# Warlock — define a resource

A resource is a one-way mapper: model in, wire-shape out. Pick the fields the API exposes, cast each to a wire type, and let the framework normalize the schema at definition time. Resources do not own logic.

## The shape

```ts title="src/app/<module>/resources/<entity>.resource.ts"
import { defineResource } from "@warlock.js/core";

export const ProductResource = defineResource({
  schema: {
    id: "string",
    name: "string",
    price: "float",
    created_at: "date",
  },
});
```

Invoke with a model or plain object and serialize:

```ts
const json = new ProductResource(product).toJSON();
```

The class returned by `defineResource` is constructed with `new`. `toJSON()` returns a plain object — pass that into a `response.<helper>()`.

## Two patterns

**`defineResource({...})`** — the project default. Used in every `src/app/*/resources/*.resource.ts`. Returns a `Resource` subclass with `schema` pre-normalized once at definition time.

**`class extends Resource`** — only when you need to override `boot()` or `extend()` with non-trivial logic that doesn't fit the `defineResource` hooks. Decorate with `@RegisterResource()` so the schema gets normalized:

```ts
import { RegisterResource, Resource } from "@warlock.js/core";

@RegisterResource()
export class ProductResource extends Resource {
  public static schema = {
    id: "string",
    name: "string",
    price: "float",
  };
}
```

Default to `defineResource`. Reach for a subclass only when a hook would be uglier as an inline arrow.

## Cast types

| Cast        | Source                  | Output                                        |
| ----------- | ----------------------- | --------------------------------------------- |
| `string`    | any                     | `String(value)`                               |
| `number`    | any                     | `Number(value)` (NaN → `undefined` or `null`) |
| `int`       | any                     | `parseInt(value)`                             |
| `float`     | any                     | `parseFloat(value)`                           |
| `boolean`   | any                     | `Boolean(value)`                              |
| `date`      | `Date` / ISO string     | configurable object (format/iso/timestamp/humanTime) |
| `localized` | `LocalizedObject[]`     | string for the request's locale               |
| `url`       | path                    | absolute URL via `url()` helper               |
| `uploadsUrl`| path                    | absolute URL under the uploads prefix         |
| `storageUrl`| path                    | absolute URL via `storage.url(...)`           |
| `object`    | any object              | passes through if non-empty object            |
| `array`     | any                     | passes through if array                       |

### Suffix modifiers

- `[]` — declares the field as an array. Each element is cast with the base type. `"string[]"` maps to `string[]`.
- `?` — nullable. When the source value is `null`/`undefined`, the field is `null` in the output instead of being dropped.
- Combined: `"string[]?"` — nullable array of strings. Order is fixed: `[]` first, then `?`.

```ts
schema: {
  tags: "string[]",          // array of strings
  bio: "string?",            // nullable string
  scores: "number[]?",       // nullable array of numbers
}
```

Without `?`, undefined/null values are omitted from the output entirely.

## Renaming fields

Use a `[inputKey, castType]` tuple to map an internal field to a different output key:

```ts
schema: {
  displayName: ["name", "string"],
  joinedAt: ["created_at", "date"],
}
```

The output object will have `displayName` and `joinedAt`; the model is read by the source key on the left of the tuple.

## Nested resources

Reference another resource constructor directly. Works for single objects and arrays — the field's runtime shape decides:

```ts
import { defineResource } from "@warlock.js/core";
import { OrganizationResource } from "app/organizations/resources/organization.resource";

export const UserResource = defineResource({
  schema: {
    id: "string",
    name: "string",
    organization: OrganizationResource,
  },
});
```

If `user.organization` is a single object, you get a nested object. If it's an array, you get an array of nested objects. The framework branches on `Array.isArray` at transform time.

For cyclic resource graphs (A embeds B embeds A), wrap the cross-reference in `lazy(() => ...)` from `@mongez/reinforcements`. Without it, ESM module-init order can leave one side's binding as `undefined` when the other side's schema captures it — the first `.toJSON()` then explodes.

```ts title="src/app/books/resources/book.resource.ts"
import { lazy } from "@mongez/reinforcements";
import { defineResource } from "@warlock.js/core";
import { AuthorResource } from "app/authors/resources/author.resource";

export const BookResource = defineResource({
  schema: {
    id: "string",
    title: "string",
    author: lazy(() => AuthorResource),
  },
});
```

`lazy()` captures the binding without reading it; both modules finish evaluating, then the binding is read at serialize-time when both resources are fully defined. Reference implementation in the project: `src/app/examples/resources-circular/` — a working `AuthorResource` ↔ `BookResource` smoke demo with single embed, array embed, and back-reference cases.

## Self-references

A node that embeds itself uses the `"self"` / `"self[]"` markers:

```ts
schema: {
  id: "string",
  name: "string",
  parent: "self",       // single self-reference
  children: "self[]",   // array of self-references
}
```

The framework tracks visited identities and caps recursion at depth 10 so cyclic data (`A.parent → B`, `B.parent → A`) doesn't infinite-loop.

## Localized fields

Models that store i18n as an array of `{ localeCode, value }` use the `localized` cast. The request's locale (from `useRequestStore().request.locale`) picks the right entry:

```ts
schema: {
  title: "localized",
}
```

If no locale is set on the request, the first entry wins as a fallback.

## Date output options

Default date cast emits an object — `{ format, timestamp, humanTime, iso }`. To shape output globally use the static `parsedSchema` field after defining, or override via a subclass. For most APIs, the default is fine.

## Computed fields (resolver functions)

Schema entries can be functions for fields the model doesn't store directly. The function receives `(value, resource)` bound to the resource instance:

```ts
schema: {
  id: "string",
  fullName: function (_value, resource) {
    return `${resource.get("first_name")} ${resource.get("last_name")}`;
  },
}
```

Keep resolvers trivial. Anything beyond string-glue belongs in a service or a model accessor — the resource is the wrong place for logic.

## Hooks (defineResource)

`defineResource` accepts three hooks. Use sparingly:

```ts
export const ChatResource = defineResource({
  schema: { id: "string", title: "string" },
  boot(resource) {
    // before transform
  },
  extend(resource) {
    // after transform — mutate resource.data
  },
  transform(data, resource) {
    // final pass — mutate `data` in place; the return value is ignored
    data.slug = String(data.title).toLowerCase();
  },
});
```

`transform` runs inside `extend()` as `transform.call(this, this.data, this)` — the framework discards whatever it returns, so you must mutate the passed `data` object directly. Returning a fresh object silently drops your changes.

Hooks are an escape hatch. If you find yourself running queries or reconciling state inside a hook, stop — that work belongs in a service or in a `prepare-*` step before the resource is constructed.

## Attaching to a model

Models reference their default resource via a static `resource` field. Repositories and other helpers can then serialize without importing the resource:

```ts
// src/app/products/models/product/product.model.ts
import { Model, RegisterModel } from "@warlock.js/cascade";
import { ProductResource } from "app/products/resources/product.resource";

@RegisterModel()
export class Product extends Model {
  public static resource = ProductResource;
}
```

This is convention, not a framework requirement. Controllers/services that import the resource explicitly work the same way.

## Output-only rule

Resources map fields. They do not:

- run database queries
- hydrate compact stored shapes into wire shapes
- reconcile state across records
- emit events
- call services

If a wire field requires hydration (e.g. compact stored blocks → full wire blocks), run a `hydrate-*.service.ts` against the model before the resource is constructed. The resource stays a pure mapper.

This is the single most-violated rule in the project. Don't push work into resource hooks because it's convenient — it makes resources untestable and couples wire output to side effects.

## Common patterns

### Flat shape

```ts
export const FaqResource = defineResource({
  schema: {
    id: "string",
    question: "object",
    answer: "object",
    status: "string",
    created_at: "date",
  },
});
```

### Belongs-to + array relations

```ts
export const ChatMessageResource = defineResource({
  schema: {
    id: "string",
    content: "string",
    user: UserResource,
    attachments: UploadResource,
    chat: ChatResource,
    created_at: "date",
  },
});
```

`attachments` is an array on the model — the framework auto-maps each element through `UploadResource`.

### Self-referential tree

```ts
export const CommentResource = defineResource({
  schema: {
    id: "string",
    body: "string",
    parent: "self",
    replies: "self[]",
  },
});
```

## Gotchas

- **Don't put logic in resources.** Reconciliation/hydration belongs in services. Resources break when they grow logic.
- **`defineResource` returns a class, not a function.** Use `new XResource(model).toJSON()`. Forgetting `new` returns `undefined`.
- **Nested resource value `undefined`/`null` is dropped** unless the parent field uses `?` suffix on a cast type. Nested resource constructors don't accept `?` directly — return `null` from a resolver if you need an explicit null.
- **Tuple form `[inputKey, castType]` only accepts string cast types**, not nested resource constructors. For relations, key the output by the same field name as the model.
- **`localized` reads `useRequestStore().request.locale`**. Outside a request context (jobs, CLI), the first entry wins as a fallback.
- **Schema normalization is one-shot.** Mutating `static schema` after definition doesn't propagate. Re-run `Resource.normalizeSchema(...)` if you really need that.

## See also

- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — the output-only rule and where logic actually lives.
- [`build-restful/SKILL.md`](../build-restful/SKILL.md) — wiring a resource into a CRUD pipeline.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — calling `new XResource(record).toJSON()` from a controller.


## encrypt-data  `@warlock.js/core/encrypt-data/SKILL.md`

---
name: encrypt-data
description: 'Reversible AES-256-GCM `encrypt` / `decrypt` for secrets you need to read back; one-way HMAC-SHA256 `hmacHash` for deterministic fingerprints (lookup/dedup of encrypted columns). Keys come from `src/config/encryption.ts`. Triggers: `encrypt`, `decrypt`, `hmacHash`, `EncryptionConfigurations`, `APP_ENCRYPTION_KEY`, `APP_HMAC_KEY`; "store an API key reversibly", "fingerprint an encrypted column for lookup", "AES-256-GCM secret", "HMAC-SHA256 dedup key"; typical import `import { encrypt, decrypt, hmacHash } from "@warlock.js/core"`. Skip: password hashing — `@warlock.js/core/hash-password/SKILL.md`; config wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs Node `crypto` direct, `crypto-js`, `libsodium-wrappers`.'
---

# Warlock — encrypt and fingerprint

Two helpers, two different jobs:

| Tool                    | Direction  | Algorithm    | Use for                                                        |
| ----------------------- | ---------- | ------------ | -------------------------------------------------------------- |
| `encrypt` / `decrypt`   | reversible | AES-256-GCM  | Secrets you need to read back (API keys, OAuth tokens).        |
| `hmacHash`              | one-way    | HMAC-SHA256  | Deterministic fingerprint for lookup/dedup of encrypted values. |

Both live in `@warlock.js/core`. For **password hashing** (a one-way job with very different requirements), reach for [`hash-password/SKILL.md`](../hash-password/SKILL.md) instead — bcrypt is the right tool there, not encrypt.

## The shape

```ts
import { encrypt, decrypt, hmacHash } from "@warlock.js/core";

// reversible — fast, integrity-checked, fresh IV per call
const cipherText = encrypt("sk-proj-12345");
const plainText = decrypt(cipherText);

// fingerprint — fast, deterministic (same input → same output)
const fingerprint = hmacHash("sk-proj-12345");
```

## Configuration — `src/config/encryption.ts`

```ts title="src/config/encryption.ts"
import type { EncryptionConfigurations } from "@warlock.js/core";
import { env } from "@warlock.js/core";

const encryptionConfig: EncryptionConfigurations = {
  key: env("APP_ENCRYPTION_KEY"),     // 64 hex chars = 32 bytes — required
  algorithm: "aes-256-gcm",            // optional, default
  hmacKey: env("APP_HMAC_KEY"),       // 64 hex chars — falls back to `key` if absent
};

export default encryptionConfig;
```

Generate the keys once (Node REPL):

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

Put both in `.env` and **never rotate the encryption key without a migration** — every value encrypted under the old key becomes garbage. The HMAC key can rotate if you accept that fingerprints recomputed under the new key won't match historical ones.

## `encrypt` / `decrypt` — reversible secrets

For values you need to read back: API keys you'll later send to a third party, OAuth refresh tokens, credentials in a vault row. **Never use this for passwords** — you'd be storing recoverable plaintext, which means a database breach equals a credentials leak.

```ts title="src/app/ai-api-keys/services/create-ai-api-key.service.ts"
import { ConflictError, encrypt, hmacHash } from "@warlock.js/core";
import { AiApiKey } from "../models/ai-api-key";
import { aiApiKeysRepository } from "../repositories/ai-api-keys.repository";

export async function createAiApiKeyService(data: { key: string; organization_id: string }) {
  if (
    await aiApiKeysRepository.first({
      organization_id: data.organization_id,
      hash: hmacHash(data.key),
    })
  ) {
    throw new ConflictError("API key already exists");
  }

  return AiApiKey.create({
    ...data,
    key: encrypt(data.key),                // the secret, recoverable
    hash: hmacHash(data.key),              // the fingerprint, lookup-able
    last_four_chars: data.key.slice(-4),
  });
}
```

This is the canonical pattern: **encrypt the secret, hmacHash the same value for lookup**. Now you can find the row by user-provided plaintext (`first({ hash: hmacHash(input) })`) without ever decrypting, and `decrypt(row.get("key"))` recovers the secret when you need it.

```ts title="reading it back"
import { decrypt } from "@warlock.js/core";

const apiKey = await aiApiKeysRepository.first({ id });
const plainKey = decrypt(apiKey.get("key"));
```

### Format

`encrypt(plain)` returns `iv:ciphertext:authTag` — three hex chunks joined with `:`. The IV is a fresh 16-byte random per call, so encrypting the same input twice yields two different cipher texts (this is what you want — it defeats pattern analysis). The `authTag` is GCM's integrity check: if the stored ciphertext is tampered with, `decrypt()` throws.

`decrypt()` throws on:

- Invalid format (not three colon-separated parts).
- Wrong key.
- Tampered ciphertext (auth tag mismatch).
- Wrong algorithm vs the one used to encrypt.

Handle those at the boundary — they almost always mean misconfiguration or a corrupted row, not a runtime bug.

### Empty-string passthrough

Both `encrypt("")` and `decrypt("")` return `""` unchanged — convenient for nullable columns where `""` means "no value." `null` / `undefined` will throw at the type level (the signatures require `string`).

## `hmacHash` — deterministic fingerprints

A keyed one-way hash. Same input + same key always produces the same 64-hex-char output. Two properties that matter:

1. **Deterministic.** Use it to dedup or look up encrypted values without decrypting them.
2. **Keyed.** An attacker with the database but not the HMAC key can't precompute a rainbow table — they don't know which hash function you're using.

Three solid use cases:

```ts
// 1. Unique constraint on an encrypted column
await aiApiKeysRepository.first({ hash: hmacHash(userInput) });

// 2. Idempotency key derived from a payload
await idempotencyKeysRepository.first({ hash: hmacHash(JSON.stringify(request)) });

// 3. Fingerprint of a secret for audit logs (without storing the secret)
log.info("api-key", "used", { fingerprint: hmacHash(apiKey).slice(0, 8) });
```

**Do not use `hmacHash` for passwords.** It's fast — a brute-force attack with a stolen database would clear common passwords in minutes. That's what bcrypt is for (see [`hash-password/SKILL.md`](../hash-password/SKILL.md)).

## Common patterns

### Encrypt + fingerprint together (the canonical pattern)

```ts
await Model.create({
  key: encrypt(plain),
  hash: hmacHash(plain),                   // for lookups
  last_four_chars: plain.slice(-4),         // for UI display ("ending in 1234")
});
```

`last_four_chars` is useful in dashboards — show users which key is which without revealing it.

### Encrypt before persisting via a model accessor

```ts title="src/app/secrets/models/secret/secret.model.ts"
import { encrypt, decrypt } from "@warlock.js/core";

export class Secret extends Model {
  public setValue(plain: string) {
    this.set("value", encrypt(plain));
  }

  public getValue(): string {
    return decrypt(this.get("value"));
  }
}
```

Keeps the encryption boundary in one place — controllers and services deal in plaintext, the model handles the crypto.

### Audit log without leaking the secret

```ts
log.info("ai-api-key", "used", {
  organization_id,
  fingerprint: hmacHash(apiKey).slice(0, 8),  // first 8 hex chars = enough to correlate
});
```

You can search logs for the same fingerprint to track usage of a specific key across requests without ever storing the plaintext in your log pipeline.

### Idempotency keys

```ts
const idempotencyKey = hmacHash(JSON.stringify({
  user_id: user.id,
  action: "transfer",
  amount,
  to,
  // include a monotonic component if the same exact payload should be allowed twice
}));

const existing = await idempotencyRepository.first({ key: idempotencyKey });
if (existing) return existing.get("result");
```

Same payload → same key → returns the cached result. Different keys (because the HMAC is keyed) per environment if you set different `hmacKey` values.

## Gotchas

- **Never log decrypted values.** A log line is a leak. If you must debug, log the HMAC fingerprint, not the plaintext.
- **`encrypt`/`decrypt` keys must be 32 bytes (64 hex chars).** Anything else throws with a clear message. Don't truncate or pad.
- **Empty key value is fatal.** If `env("APP_ENCRYPTION_KEY")` returns `undefined`, the first `encrypt()` call throws "Missing encryption key" at runtime, not boot. Cover this in your startup health check.
- **HMAC falls back to the encryption key.** If `hmacKey` isn't set, `hmacHash` uses `key`. Convenient, but slightly weakens your isolation — best practice is two separate keys.
- **Don't reuse the same value across encrypt and password.** A bcrypt hash of a value isn't comparable to an encryption of that value. They're different domains.
- **Rotating the encryption key requires a migration.** Every existing ciphertext becomes unrecoverable under a new key. Plan rotation as: keep the old key as `OLD_APP_ENCRYPTION_KEY`, decrypt each row with old, encrypt with new, retire old key once the table is clean.

## Installation

`encrypt` / `decrypt` / `hmacHash` use Node's built-in `crypto` — no extra dependency. They're available out of the box.

Password hashing (`hashPassword`) needs `bcryptjs`, which is its own install — see [`hash-password/SKILL.md`](../hash-password/SKILL.md).

## See also

- [`hash-password/SKILL.md`](../hash-password/SKILL.md) — bcrypt password hashing (the third member of the encryption module, but a different job entirely).
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/encryption.ts` and env wiring.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — service layering, where the encryption boundary should sit.


## hash-password  `@warlock.js/core/hash-password/SKILL.md`

---
name: hash-password
description: 'One-way bcrypt password hashing — `hashPassword` / `verifyPassword`, plus the declarative `useHashedPassword()` schema transformer that auto-hashes a model''s password field on save. Salt rounds come from `src/config/encryption.ts`. Triggers: `hashPassword`, `verifyPassword`, `useHashedPassword`, `password.salt`, `bcryptjs`; "hash a user password", "verify login credentials", "auto-hash on save", "rotate a password"; typical import `import { hashPassword, verifyPassword } from "@warlock.js/core"`. Skip: reversible secrets — `@warlock.js/core/encrypt-data/SKILL.md`; the other transformers — `@warlock.js/core/use-model-transformers/SKILL.md`; config wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs `bcrypt` native, `argon2`, `scrypt`.'
---

# Warlock — hash a password

For storing user-typed passwords, bcrypt is the one right answer — and the only one. It's **deliberately slow** (~250ms per call with the default salt rounds), which is what makes it expensive to brute-force a stolen password database. Use it for nothing else: not API keys, not session tokens, not "this needs to be one-way."

If your value needs to be read back later → [`encrypt-data/SKILL.md`](../encrypt-data/SKILL.md).
If you need a fast searchable fingerprint of an encrypted value → also `encrypt-data` (`hmacHash`).

## The shape

```ts
import { hashPassword, verifyPassword } from "@warlock.js/core";

const hashed = await hashPassword("user-password-123");
// → "$2b$12$..." — store this

const valid = await verifyPassword("user-password-123", hashed);
// → true / false
```

Both are async because bcrypt is CPU-bound. The framework uses `bcryptjs` (pure JS — portable across every platform, no native build) under the hood.

## Configuration — `src/config/encryption.ts`

```ts title="src/config/encryption.ts"
import type { EncryptionConfigurations } from "@warlock.js/core";

const encryptionConfig: EncryptionConfigurations = {
  password: {
    salt: 12,    // bcrypt rounds — 10–12 is the standard band
  },
  // ...other crypto config (key/hmacKey for encrypt/decrypt — see encrypt-data skill)
};

export default encryptionConfig;
```

The `salt` knob is bcrypt's **work factor** — `2^salt` iterations per hash. Each unit doubles the cost. `12` is the modern default; bump to `13`/`14` if your hardware is fast enough that login feels instant and you want a bigger brute-force tax. Don't go below `10` — that's the lower bound for modern security.

## `hashPassword` / `verifyPassword`

```ts title="src/app/users/services/register-user.service.ts"
import { hashPassword } from "@warlock.js/core";
import { User } from "../models/user";

export async function registerUserService(input: { email: string; password: string }) {
  const user = await User.create({
    email: input.email,
    password: await hashPassword(input.password),
  });

  return user;
}
```

```ts title="src/app/auth/services/verify-credentials.service.ts"
import { verifyPassword } from "@warlock.js/core";
import { usersRepository } from "app/users/repositories/users.repository";

export async function verifyCredentialsService(email: string, password: string) {
  const user = await usersRepository.first({ email });
  if (!user) return null;

  const isValid = await verifyPassword(password, user.get("password"));
  return isValid ? user : null;
}
```

The `@warlock.js/auth` package wires both into its `auth.service.ts` — `authService.hashPassword(p)` and `authService.verifyPassword(plain, hash)` are thin proxies over these helpers. Use the auth service if you're using `@warlock.js/auth`; use the core helpers directly if you're rolling your own.

## The declarative pattern — `useHashedPassword()`

Manually calling `hashPassword` in every service that touches a password is repetitive and easy to forget. The framework offers a **schema transformer** that auto-hashes the field on save:

```ts title="src/app/users/models/user/user.model.ts"
import { useHashedPassword } from "@warlock.js/core";
import { type Infer, v } from "@warlock.js/seal";

export const userSchema = v.object({
  email: v.email().unique("User"),
  password: v.string().requiredIfEmpty("id").addTransformer(useHashedPassword()),
});

export type UserSchema = Infer<typeof userSchema>;
```

What it does:

- **New row** → hashes `password` once on `.create()` / `.save()`.
- **Existing row** → only re-hashes when the field actually changes; leaves the stored hash alone otherwise.
- **Empty / undefined value** → returns the value untouched (won't double-hash, won't overwrite with `undefined`).

Internally it calls `authService.hashPassword(value)`, which is the same bcryptjs path as the standalone helper — just attached to the schema so controllers and services never deal with plaintext storage. Pass `request.validated().password` straight into `User.create({...})` and the transformer handles the hashing.

This is the **recommended pattern** for user models. The standalone helper is for non-schema contexts (CLI tools generating users, one-off scripts, custom auth services).

## Common patterns

### Rotate a user's password

```ts
import { hashPassword, verifyPassword } from "@warlock.js/core";

const user = await usersRepository.findById(id);

if (!(await verifyPassword(oldPassword, user.get("password")))) {
  throw new BadRequestError("Current password incorrect");
}

await user.save({ password: await hashPassword(newPassword) });
```

If the model uses `useHashedPassword()`, you can skip the explicit `hashPassword` call:

```ts
await user.save({ password: newPassword });  // transformer hashes on save
```

The transformer detects that the field is changing and re-hashes.

### Login flow

```ts
import { verifyPassword } from "@warlock.js/core";

const user = await usersRepository.first({ email });
if (!user) {
  // Same response shape as "wrong password" to prevent email enumeration
  throw new BadRequestError("Invalid credentials");
}

const valid = await verifyPassword(password, user.get("password"));
if (!valid) {
  throw new BadRequestError("Invalid credentials");
}

return user;
```

The "same error for missing user vs wrong password" pattern is deliberate — it stops attackers from learning which emails are registered.

## Installation

`hashPassword` / `verifyPassword` need `bcryptjs`. Install it directly:

```bash
yarn add bcryptjs
```

If you skip the install, the first call throws with the framework's install hint:

```
Password encryption requires the bcryptjs package.
Install it with:

  yarn add bcryptjs

Or with your preferred package manager:

  yarn add bcryptjs
```

There is no `warlock add` feature for password hashing — `bcryptjs` is a plain dependency, so install it directly with `yarn add bcryptjs`.

## Gotchas

- **Never use `hashPassword` for non-password values.** A 250ms tax on every API request because someone reached for it as a "general one-way hash" — that's a performance bug waiting to happen. For one-way fingerprinting of encrypted values, use `hmacHash` (see [`encrypt-data/SKILL.md`](../encrypt-data/SKILL.md)).
- **Don't `hashPassword` on the request hot path twice.** Each call is ~250ms. If you `hashPassword(input)` then call it again as part of `verifyPassword`'s internals (you don't), you're stacking the tax. `verifyPassword(plain, storedHash)` takes the plaintext and the stored hash — that's the API.
- **Don't swap `bcryptjs` for native `bcrypt` without a reason.** `bcryptjs` is portable across every platform (no native build), and the perf delta isn't meaningful in the password-verification workflow.
- **The transformer detects changes by comparison.** If your model's password field is `null` and you save `null` again, it's a no-op. If it's a hash and you save the same hash string, also no-op. Only an actual value change triggers re-hashing.
- **Don't strip the hash before sending the user back to the client.** Use a resource layer (`UserResource`) that excludes the password field — that's where filtering belongs, not in your service code.

## See also

- [`use-model-transformers/SKILL.md`](../use-model-transformers/SKILL.md) — `useHashedPassword` + the two other model transformers (`useComputedSlug`, `useComputedModel`).
- [`encrypt-data/SKILL.md`](../encrypt-data/SKILL.md) — reversible AES-256-GCM `encrypt`/`decrypt` + one-way HMAC `hmacHash` (the other two members of the encryption module).
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/encryption.ts` and the `password.salt` knob.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — where to filter the password field out of API responses.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — service layering, where the password boundary should sit.


## process-image  `@warlock.js/core/process-image/SKILL.md`

---
name: process-image
description: 'Transform images with the `Image` class — resize, crop, rotate, format, quality, watermark, blur, etc. — using a deferred pipeline that runs only at `save()` / `toBuffer()` / `toBase64()` / `toDataUrl()` time. Requires sharp via `warlock add image`. Triggers: `Image`, `Image.fromFile`, `Image.fromBuffer`, `Image.fromUrl`, `.resize`, `.crop`, `.watermark`, `.toBuffer`, `.toDataUrl`, `.apply`; "resize an image", "generate a thumbnail", "watermark a product photo", "build an image pipeline"; typical import `import { Image } from "@warlock.js/core"`. Skip: multipart upload entry — `@warlock.js/core/upload-file/SKILL.md`; storage persistence — `@warlock.js/core/store-file/SKILL.md`; competing libs `sharp` direct, `jimp`, `imagemagick`, `gm`.'
---

# Warlock — process an image

`Image` (from `@warlock.js/core`) wraps `sharp` with a **deferred pipeline**: every chainable method (`resize`, `crop`, `format`, `quality`, `watermark`, ...) records an operation; only `save()` / `toBuffer()` / `toBase64()` / `toDataUrl()` actually run sharp. The constructor and chain stay synchronous; one `await` at the end fires the whole pipeline.

`sharp` is lazy-loaded — the framework throws a clear install hint if it isn't there.

## The shape

```ts
import { Image } from "@warlock.js/core";

// All chaining is synchronous — single await at the end
await new Image("./photo.jpg")
  .resize({ width: 800 })
  .format("webp")
  .quality(85)
  .save("./output.webp");
```

That's the full contract. The chain doesn't touch sharp until the output method fires.

## Installation

```bash
warlock add image
```

Adds `sharp` to your dependencies. Skip it and the first `new Image(...)` throws with an install hint.

## Constructors

```ts
new Image("./avatar.jpg")            // path
new Image(buffer)                     // Buffer
new Image(uint8Array)                 // Uint8Array
new Image(arrayBuffer)                // ArrayBuffer
new Image(existingSharpInstance)      // wrap a sharp.Sharp you already built

// Or via static factories:
Image.fromFile("./avatar.jpg");
Image.fromBuffer(buffer);
await Image.fromUrl("https://example.com/photo.jpg");   // @mongez/http under the hood
```

`Image.fromUrl(...)` is the only async factory — it fetches the URL via `@mongez/http` (`responseType: "arrayBuffer"`), then wraps the bytes. The rest are synchronous.

## Transforms (chainable)

Every method returns `this` so calls chain. Transforms are descriptors; nothing runs until the output method.

| Method                                       | Effect                                                                       |
| -------------------------------------------- | ---------------------------------------------------------------------------- |
| `.resize({ width?, height?, fit?, ... })`    | sharp resize — accepts the full `sharp.ResizeOptions` shape                  |
| `.crop({ left, top, width, height })`        | extract a region                                                             |
| `.rotate(deg)`                               | clockwise rotation                                                           |
| `.flip()`                                    | mirror vertically (top↔bottom)                                               |
| `.flop()`                                    | mirror horizontally (left↔right)                                             |
| `.blur(sigma)`                               | gaussian blur (sigma is required, must be ≥ 0.3 — throws otherwise)          |
| `.sharpen(options?)`                         | sharp's sharpen                                                              |
| `.grayscale()` / `.blackAndWhite()`          | grayscale (aliases)                                                          |
| `.tint(color)`                               | color tint                                                                   |
| `.negate(options?)`                          | invert colors                                                                |
| `.opacity(value)`                            | 0–100, applied via composite                                                 |
| `.trim(options?)`                            | trim borders                                                                 |
| `.watermark(image, options?)`                | overlay one watermark — two positional args (`image` accepts the same inputs as the constructor, plus another `Image`; `options` is `sharp.OverlayOptions`) |
| `.watermarks([{ image, options }, ...])`     | overlay many — one `WatermarkConfig` object (`{ image, options }`) per entry  |
| `.format(format)`                            | output format — `"jpeg"`, `"png"`, `"webp"`, `"avif"`, `"tiff"`, `"heif"`, ... |
| `.quality(1–100)`                            | lossy formats only (`jpeg` / `webp` / `avif` / `tiff` / `heif`); ignored otherwise |
| `.apply(options)`                            | apply many transforms in one call, in a fixed canonical order                |

`apply(options)` accepts an `ImageTransformOptions` object — every transform above as a key. Useful when transforms come from config or a request payload.

## Outputs

Each of these fires the pipeline exactly once and returns the result. Subsequent calls run the pipeline again (operations aren't memoised).

```ts
await image.save("./output.webp");          // write to disk; returns the absolute path
const buffer = await image.toBuffer();      // → Buffer
const base64 = await image.toBase64();      // → "iVBORw0KGgo..."
const dataUrl = await image.toDataUrl();    // → "data:image/webp;base64,..."
```

## Common patterns

### Resize an uploaded avatar to two sizes

```ts
import { Image } from "@warlock.js/core";

const buffer = await uploadedAvatar.buffer();

await new Image(buffer)
  .resize({ width: 400, height: 400 })
  .format("webp")
  .quality(85)
  .save("./storage/uploads/avatars/full.webp");

await new Image(buffer)
  .resize({ width: 100, height: 100 })
  .format("webp")
  .quality(80)
  .save("./storage/uploads/avatars/thumb.webp");
```

Two separate `Image` instances → two pipelines. They don't share state.

### Watermark + resize + format

```ts
await new Image(productPhoto)
  .resize({ width: 1200 })
  .watermark("./assets/logo.png", { gravity: "southeast" })
  .quality(90)
  .save("./output.jpg");
```

The chained `.watermark(image, options)` takes two positional arguments. (The object form `{ image, options }` is only used inside `apply({ watermark: {...} })` and `.watermarks([...])`.)

### Batch transforms via `apply()`

```ts
await new Image(buffer)
  .apply({
    resize: { width: 800 },
    grayscale: true,
    blur: 2,
    format: "webp",
    quality: 80,
  })
  .save("./output.webp");
```

`apply()` runs operations in a fixed canonical order: resize → crop → rotate → flip/flop → grayscale/blackAndWhite → blur → sharpen → tint → negate → trim → watermark(s) → opacity → format/quality. If you need a different order (e.g. blur **before** resize), use the fluent chain.

### Read metadata

```ts
const metadata = await new Image(buffer).image.metadata();
console.log(metadata.width, metadata.height, metadata.format);
```

The wrapper exposes the underlying `sharp.Sharp` via `.image` for anything not surfaced through chainable methods. Operations through `.image` bypass the deferred pipeline — they run immediately.

## Composition with `UploadedFile`

`UploadedFile` (from multipart uploads) has its own chainable `.resize().format().quality().save(...)` that runs through `Image` internally — same deferred execution, same transforms. Use it when the source is a multipart upload; reach for `new Image(...)` directly when the source is a buffer/path/URL.

See [`upload-file`](../upload-file/SKILL.md) for the multipart entry point and [`store-file`](../store-file/SKILL.md) for persisting the output through storage drivers.

## Gotchas

- **`sharp` is required.** Without `warlock add image`, the constructor throws with an install hint. Lazy-loaded — error fires at first use, not at module import.
- **One `Image` = one pipeline run.** Calling `save()` twice runs the pipeline twice. To reuse output, capture the buffer once with `toBuffer()` and reuse it.
- **`.quality(...)` is silently ignored on PNG.** PNG is lossless. JPEG, WebP, AVIF, TIFF, HEIF honor it.
- **`apply()` ordering is canonical, not insertion order.** If the order matters, chain manually.
- **`.image` is the raw sharp instance.** Operations on it bypass the deferred pipeline and run immediately — useful for metadata, dangerous if you mix it with chained transforms.
- **`Image.fromUrl(...)` makes a network call** (via `@mongez/http`). It throws if the response is empty or errors. Wrap with `retry(...)` if the source URL can be flaky.

## See also

- [`upload-file/SKILL.md`](../upload-file/SKILL.md) — multipart uploads, where image transforms typically start.
- [`store-file/SKILL.md`](../store-file/SKILL.md) — persisting image output through storage drivers (local / S3 / R2 / Spaces).
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — where image-processing services live.


## register-route  `@warlock.js/core/register-route/SKILL.md`

---
name: register-route
description: 'Register HTTP routes via @warlock.js/core''s router — single routes, prefix groups, middleware-guarded blocks, and RESTful resource chains. Routes always live in `src/app/<module>/routes.ts`. Triggers: `router.get`, `router.post`, `router.prefix`, `router.group`, `router.route`, `guarded`; "add a route", "wire a controller to a URL", "group routes by prefix", "register a RESTful resource"; typical import `import { router } from "@warlock.js/core"`. Skip: handler shape — `@warlock.js/core/create-controller/SKILL.md`; CRUD chain details — `@warlock.js/core/build-restful/SKILL.md`; middleware authoring — `@warlock.js/core/write-middleware/SKILL.md`; competing libs `express`, `fastify`, `koa`, `@nestjs/common`.'
---

# Warlock — register a route

How to declare HTTP routes in a Warlock module. The framework auto-loads `routes.ts` from every module on boot — never `import` it.

## The shape

```ts title="src/app/<module>/routes.ts"
import { router } from "@warlock.js/core";
import { listProductsController } from "./controllers/list-products.controller";

router.get("/products", listProductsController);
```

That's the entire contract for a simple route. `router` is a singleton; method calls register routes synchronously. The handler is a plain function (controllers are typed as `RequestHandler` — see [create-controller](../create-controller/SKILL.md)).

## HTTP verbs

```ts
router.get(path, handler);
router.post(path, handler);
router.put(path, handler);
router.patch(path, handler);
router.delete(path, handler);
```

`path` can be a string or a string array (multiple paths, one handler). Path params use `:name`: `router.get("/products/:id", getProductController)`.

## Prefix groups

A whole module typically prefixes its URLs with `/<module-name>`. Use `router.prefix(...)`:

```ts
router.prefix("/products", () => {
  router.get("/", listProductsController);
  router.get("/:id", getProductController);
  router.post("/", createProductController);
});
```

Nested `prefix` calls compose: `router.prefix("/api", () => router.prefix("/v1", () => router.get("/health", ...)))` registers `/api/v1/health`.

## Route groups with middleware

For routes that share both a prefix and middleware, use `router.group(...)`:

```ts
import { authMiddleware } from "@warlock.js/auth";

router.group(
  {
    prefix: "/admin",
    middleware: [authMiddleware("admin")],
  },
  () => {
    router.get("/dashboard", dashboardController);
    router.delete("/users/:id", removeUserController);
  },
);
```

`group` options:

- `prefix` — prepended to every route inside
- `middleware` — array of middleware applied to every route inside
- `name` — route-name prefix (used for URL generation)

## Auth-guarded shortcut

Projects with `@warlock.js/auth` usually define a `guarded(...)` helper in `src/app/shared/utils/router.ts` that wraps `router.group({ middleware: [authMiddleware("user")] }, …)`:

```ts
import { guarded } from "app/shared/utils/router";

router.prefix("/products", () => {
  router.get("/", listProductsController);             // public
  guarded(() => {
    router.post("/", createProductController);          // requires logged-in user
    router.delete("/:id", removeProductController);
  });
});
```

If `guarded` doesn't exist in your project yet, see the shared utility convention in [warlock-conventions](../warlock-conventions/SKILL.md).

## RESTful resource chain

For a standard CRUD resource, `router.route(path)` returns a builder with five named slots:

```ts
router.route("/products")
  .list(listProductsController)        // GET    /products
  .show(getProductController)          // GET    /products/:id
  .create(createProductController)     // POST   /products
  .update(updateProductController)     // PUT    /products/:id
  .destroy(removeProductController);   // DELETE /products/:id
```

Each method returns the builder so you can chain. Methods you don't call don't register. Pair with `guarded`/`group` the same way as plain routes.

## Common patterns

### A typical module's `routes.ts`

```ts
import { router } from "@warlock.js/core";
import { guarded } from "app/shared/utils/router";
import { createProductController } from "./controllers/create-product.controller";
import { getProductController } from "./controllers/get-product.controller";
import { listProductsController } from "./controllers/list-products.controller";
import { removeProductController } from "./controllers/remove-product.controller";
import { updateProductController } from "./controllers/update-product.controller";

guarded(() => {
  router
    .route("/products")
    .list(listProductsController)
    .show(getProductController)
    .create(createProductController)
    .update(updateProductController)
    .destroy(removeProductController);
});
```

### Public + guarded mix

```ts
router.prefix("/auth", () => {
  router.post("/login", loginController);
  router.post("/register", registerController);
  guarded(() => {
    router.post("/logout", logoutController);
    router.get("/me", meController);
  });
});
```

### Multiple paths on one handler

```ts
router.get(["/health", "/healthz"], healthController);
```

## Gotchas

- **Never import `routes.ts`.** The framework auto-loads it. Importing causes a double-registration error.
- **Route order matters for static-vs-param overlap.** `router.get("/products/featured", ...)` must register before `router.get("/products/:id", ...)` if you want `featured` to be a static match, not `:id = "featured"`. Easier: use distinct paths.
- **`group` middleware runs in array order** before the controller; if you stack `[rateLimitMiddleware, authMiddleware]`, rate-limiting runs first.
- **Don't put logic in `routes.ts`.** It's a registration file. If you want conditional registration (env-flagged endpoints), do it via the controller's logic, not by branching at registration time — branching breaks the dev-server's HMR diff.

## See also

- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — the handler shape, validation, request/response surface.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — picking the right `response.<helper>()`.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, the `guarded` convention, path aliases.


## resolve-path  `@warlock.js/core/resolve-path/SKILL.md`

---
name: resolve-path
description: 'Path helpers anchored at `process.cwd()` — `rootPath`, `srcPath`, `appPath`, `configPath`, `publicPath`, `storagePath`, `uploadsPath`, `cachePath`, `logsPath`, `tempPath`, `warlockPath`, `sanitizePath`. Optional `uploads.root` config overrides the uploads anchor. Triggers: `appPath`, `configPath`, `uploadsPath`, `storagePath`, `publicPath`, `cachePath`, `logsPath`, `tempPath`, `sanitizePath`, `paths`; "resolve a path inside src/app", "absolute upload destination", "sanitize a user filename", "ship uploads to a mounted volume"; typical import `import { appPath, uploadsPath } from "@warlock.js/core"`. Skip: HTTP URL helpers — `@warlock.js/core/build-url/SKILL.md`; app metadata — `@warlock.js/core/use-app-context/SKILL.md`; storage abstraction — `@warlock.js/core/store-file/SKILL.md`; competing patterns: `path.join(process.cwd(), ...)`, hand-rolled directory constants.'
---

# Warlock — resolve a path

Twelve helpers, all anchored at `process.cwd()`, all in `@warlock.js/core`. They exist so you don't re-parse `process.cwd()` in every call site or hand-roll `path.join` for the same five locations.

## The shape

```ts
import { appPath, configPath, uploadsPath, storagePath } from "@warlock.js/core";

appPath("orders/routes.ts");           // → <cwd>/src/app/orders/routes.ts
configPath("http.ts");                  // → <cwd>/src/config/http.ts
uploadsPath("avatars/42.png");          // → <cwd>/storage/uploads/avatars/42.png
storagePath("backups/2026-01.tar.gz");  // → <cwd>/storage/backups/2026-01.tar.gz
```

Every helper takes any number of path segments and joins them. No-argument calls return the base directory itself.

## Full inventory

| Helper             | Resolves to                              | Notes                                                              |
| ------------------ | ---------------------------------------- | ------------------------------------------------------------------ |
| `rootPath(...p)`   | `<cwd>/<...p>`                           | Also `Application.rootPath`. Base of every other helper.           |
| `srcPath(...p)`    | `<cwd>/src/<...p>`                       | Also `Application.srcPath`.                                        |
| `appPath(p?)`      | `<cwd>/src/app/<p>`                      | Also `Application.appPath`. Module roots live here.                |
| `configPath(...p)` | `<cwd>/src/config/<...p>`                | `src/config/*.ts` subsystem configs.                               |
| `publicPath(p?)`   | `<cwd>/public/<p>`                       | Also `Application.publicPath`. Static assets served as-is.         |
| `storagePath(p?)`  | `<cwd>/storage/<p>`                      | Also `Application.storagePath`. Root for framework-managed files.  |
| `uploadsPath(p?)`  | `<cwd>/storage/uploads/<p>` *(default)*  | Also `Application.uploadsPath`. Honors `config.uploads.root` override (string or function). |
| `cachePath(p?)`    | `<cwd>/storage/cache/<p>`                | Persistent cache files.                                            |
| `logsPath(p?)`     | `<cwd>/storage/logs/<p>`                 | Application logs.                                                  |
| `tempPath(p?)`     | `<cwd>/storage/tmp/<p>`                  | Throw-away scratch.                                                |
| `warlockPath(...p)` | `<cwd>/.warlock/<...p>`                 | Framework internals (manifest, transpile cache, typings). **Never write app files here** — the framework deletes the folder freely. |
| `sanitizePath(p)`  | Strips `<>:"/\|?*` from `p`              | Cleans user-supplied filename fragments before joining.            |

## The aggregate — `paths`

For builders or maintenance scripts that shuttle several locations in close proximity, use the `paths` object — a short-alias view over every helper:

```ts
import { paths } from "@warlock.js/core";

paths.root("dist", "app.js");        // → <cwd>/dist/app.js
paths.app("orders/routes.ts");        // → <cwd>/src/app/orders/routes.ts
paths.uploads("avatars/42.png");      // → <cwd>/storage/uploads/avatars/42.png
paths.config("mail.ts");              // → <cwd>/src/config/mail.ts
paths.cache("queries.bin");           // → <cwd>/storage/cache/queries.bin
paths.logs("errors.log");             // → <cwd>/storage/logs/errors.log
paths.temp("tmp-export.csv");         // → <cwd>/storage/tmp/tmp-export.csv
paths.warlock("manifest.json");       // → <cwd>/.warlock/manifest.json
paths.sanitize("../etc/passwd");      // → "..etcpasswd"
```

Use the named import (`appPath`, `uploadsPath`, ...) when one helper dominates the file; reach for `paths.*` when you're shuttling multiple paths together.

## Patterns

### Read a file inside a module

```ts
import { appPath } from "@warlock.js/core";
import { readFile } from "node:fs/promises";

const template = await readFile(
  appPath("mailers/templates/welcome.html"),
  "utf-8",
);
```

### Locate a config file

```ts
import { configPath } from "@warlock.js/core";

const httpConfigFile = configPath("http.ts");
```

### Compute an absolute upload destination

```ts
import { uploadsPath } from "@warlock.js/core";

const avatar = uploadsPath(`avatars/${userId}/profile.png`);
```

### Sanitize a user-supplied filename before joining

```ts
import { sanitizePath, uploadsPath } from "@warlock.js/core";

const safe = sanitizePath(req.input("filename"));
const dest = uploadsPath(`exports/${safe}`);
```

`sanitizePath` strips characters that are illegal in filenames on Windows (`<>:"/\|?*`) and that enable path traversal. **Always** sanitize before joining user input into an `uploadsPath` / `tempPath` / any persistence helper.

### Read framework metadata (rare)

```ts
import { warlockPath } from "@warlock.js/core";

const manifest = warlockPath("manifest.json");
```

`.warlock/` is the framework's scratch space — manifests, transpile cache, generated typings. Reading is occasionally useful for diagnostics. **Writing** is a defect; the framework deletes the folder whenever it feels like it.

## Overriding the uploads root

`uploadsPath` is the only helper with a config override. Set `uploads.root` in `src/config/uploads.ts` to a string (static directory) or a function `(relativePath) => string` (dynamic per-call). Useful when you've moved user uploads onto a mounted volume:

```ts title="src/config/uploads.ts"
export default {
  uploads: {
    root: "/mnt/uploads",                   // static
    // root: (rel) => `/mnt/uploads/${rel}`, // dynamic
  },
};
```

After this, `uploadsPath("avatars/42.png")` resolves to `/mnt/uploads/avatars/42.png`. Other helpers continue resolving against `process.cwd()`.

When to reach for the function form:

- Per-tenant uploads (`(rel) => /mnt/${tenant.id}/${rel}`).
- Per-environment routing (production → S3-mounted volume, dev → local).
- Date-based shards (`(rel) => /mnt/uploads/${YYYY-MM}/${rel}`).

When to keep it as a static string: everything else. The function form is per-call overhead.

### What "override" means

The override is applied **inside** `uploadsPath()`. So `Application.uploadsPath` (the no-arg getter on the `Application` class) also respects it. Other helpers — `storagePath`, `cachePath`, `logsPath`, `tempPath` — continue resolving against `<cwd>/storage`, even though they're conceptually "under" the same root. If you want all of those redirected too, your deployment should mount the whole `storage/` folder, not just `storage/uploads`.

## When to use which

| Goal                                       | Helper            |
| ------------------------------------------ | ----------------- |
| File inside a module (controllers/services) | `appPath`        |
| Top-level subsystem config                 | `configPath`      |
| Static asset served as-is over HTTP        | `publicPath`      |
| User-uploaded file (avatar, document)      | `uploadsPath`     |
| Persistent cache for an expensive op       | `cachePath`       |
| Application log line                       | `logsPath`        |
| Short-lived scratch (cleaned aggressively) | `tempPath`        |
| Bundled artifact (build outputs)           | `rootPath("dist", ...)` |
| Reading a TS source from disk              | `srcPath` / `appPath` |
| Framework internals (manifest, transpile)  | `warlockPath` *(read-only)* |

If you find yourself reaching for `path.join(process.cwd(), ...)` — that's a sign you want one of these helpers instead.

## Gotchas

- **Paths depend on `process.cwd()`.** If something `process.chdir()`s, the paths shift. Warlock never does this; if you do, you've broken every path lookup.
- **`warlockPath` is read-only from app code.** The framework deletes `.warlock/` on dev restart, build clean, etc. Anything you write there is collateral damage on the next run.
- **`sanitizePath` strips, doesn't reject.** It replaces illegal characters with empty string — `../foo/bar` becomes `..foobar`, not an error. If you want a hard refusal on traversal attempts, validate upstream first (e.g. seal schema rule).
- **`storagePath` vs `uploadsPath`.** Storage is the framework-managed root (logs, cache, temp, uploads sit under it). Uploads is the user-content subfolder. Don't write app logs into `uploadsPath` or user files into `logsPath`.
- **`uploads.root` override is read at call time.** Changing config mid-process is honored on the next `uploadsPath()` call. Useful in tests; don't rely on it in request handlers.
- **No helper covers `node_modules`.** If you need an `node_modules/<pkg>` path, use `rootPath("node_modules", "<pkg>")` — there's no `vendorPath`.

## See also

- [`build-url/SKILL.md`](../build-url/SKILL.md) — HTTP URL helpers (`url`, `uploadsUrl`, `publicUrl`, `assetsUrl`) — companion for rendering URLs (not filesystem paths).
- [`use-app-context/SKILL.md`](../use-app-context/SKILL.md) — `Application` static (env, runtime strategy, version, uptime) + `app` runtime accessor (Fastify, socket.io, router, database).
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/uploads.ts` for the `root` override.
- [`store-file/SKILL.md`](../store-file/SKILL.md) — the storage abstraction (most uploads code goes through `storage.put(...)`, not raw paths).
- [`upload-file/SKILL.md`](../upload-file/SKILL.md) — the `UploadedFile` shape that pairs with `uploadsPath`.


## retry-operation  `@warlock.js/core/retry-operation/SKILL.md`

---
name: retry-operation
description: 'Wrap a flaky operation with `retry(fn, options)` — now provided by `@mongez/reinforcements` (not `@warlock.js/core`). `attempts` total tries, `delay` + `backoff` (linear/exponential/fn), `maxDelay`, `jitter`, `shouldRetry` to bail on permanent errors, `signal` to cancel, plus `retryable()` to pre-bind options. Triggers: `retry`, `retryable`, `RetryOptions`, `attempts`, `backoff`, `jitter`, `maxDelay`, `shouldRetry`, `signal`; "retry a flaky API call", "handle transient errors", "exponential backoff with jitter", "wrap an external request"; typical import `import { retry } from "@mongez/reinforcements"`. Skip: timing the retried op — `@warlock.js/core/benchmark-code/SKILL.md`; use-case-level `retry` option — `@warlock.js/core/write-use-case/SKILL.md`; competing libs `p-retry`, `async-retry`, `cockatiel`.'
---

# Warlock — retry an operation

> **Moved.** The retry primitive now lives in **`@mongez/reinforcements`**, not
> `@warlock.js/core`. The old `import { retry } from "@warlock.js/core"` no longer
> exists. Full reference: [`mongez-reinforcements-async`](mongez-reinforcements-async).

```ts
import { retry } from "@mongez/reinforcements";

const user = await retry(() => fetchUser(id), {
  attempts: 4,            // TOTAL tries (initial + retries) — default 3
  delay: 200,             // base ms between attempts
  backoff: "exponential", // 200, 400, 800 ms
});
```

## What changed from the old core `retry`

| Old (`@warlock.js/core`)       | New (`@mongez/reinforcements`)                            |
| ------------------------------ | -------------------------------------------------------- |
| `count` = **extra** attempts   | `attempts` = **total** attempts (`count + 1`)            |
| fixed `delay` only             | `delay` + `backoff` (`"linear" \| "exponential" \| fn`)  |
| —                              | `maxDelay`, `jitter` (`"full" \| "equal"`), `signal`     |
| `shouldRetry(error, attempt)`  | `shouldRetry(error, attempt)` (kept) + `onError` observe |
| —                              | `retryable(fn, options)` to pre-bind a reusable wrapper  |

Migration: `count: 3` → `attempts: 4`.

## Bail out on permanent errors

Observe with `onError`, decide with `shouldRetry` (called in that order). Return `false` to stop immediately and re-throw:

```ts
await retry(() => stripe.charges.create(payload), {
  attempts: 4,
  delay: 500,
  shouldRetry: (error) => error instanceof NetworkError || error instanceof TimeoutError,
});
```

## What's safe to retry

- **Idempotent reads** (GET, SELECT, cache lookups) — always safe.
- **Idempotent writes** with server-side dedup (Stripe `Idempotency-Key`, conditional writes, PUT to a known id) — safe.
- **Non-idempotent writes** (a POST that charges a card, sends an email) — dangerous. Gate with `shouldRetry` so only transient infra errors retry.

## Backoff + jitter + cap

```ts
await retry(() => fetch(url), {
  attempts: 6,
  delay: 100,
  backoff: "exponential",
  maxDelay: 2_000,   // never wait more than 2s as backoff grows
  jitter: "full",    // randomise each delay to avoid thundering herd
});
```

## Cancel a long loop

```ts
const controller = new AbortController();
const promise = retry(poll, { attempts: 10, delay: 1_000, signal: controller.signal });
controller.abort(); // rejects promptly with signal.reason
```

## Pre-bind with `retryable`

```ts
import { retryable } from "@mongez/reinforcements";

const fetchUser = retryable(getUser, { attempts: 4, backoff: "exponential" });
await fetchUser(id);
```

## Composing with `measure()`

`measure()` (from `@warlock.js/core`) returns a result object and doesn't throw on `fn` failure — so put `measure` **inside** `retry` (per-attempt timing) and re-throw, or **outside** for total wall-clock:

```ts
import { measure } from "@warlock.js/core";
import { retry } from "@mongez/reinforcements";

const result = await measure("publish-event", () =>
  retry(() => bus.publish(event), { attempts: 4, delay: 200 }),
); // result.latency includes all retries
```

## Use-case integration

`useCase()` accepts a `retry` option (the same `RetryOptions`) that wraps the **handler** — see [`@warlock.js/core/write-use-case/SKILL.md`](@warlock.js/core/write-use-case/SKILL.md). Reach for that when "retry" means "re-run the handler"; reach for raw `retry()` when only one step inside is flaky.

## Gotchas

- **`attempts` is the TOTAL, not extra.** `attempts: 3` = 3 calls. (The old core `count: 3` meant 4 calls.)
- **`shouldRetry` decides, `onError` observes.** `onError` can't stop the loop; only `shouldRetry` returning `false` does.
- **Don't retry inside a DB transaction.** Most drivers invalidate the transaction on error — retry the whole transaction, not the inner statement.
- **`exponential` + many `attempts` without `maxDelay`** can produce very long waits. Set `maxDelay`.

## See also

- [`mongez-reinforcements-async`](mongez-reinforcements-async) — full `retry` / `retryable` reference.
- [`@warlock.js/core/benchmark-code/SKILL.md`](@warlock.js/core/benchmark-code/SKILL.md) — timing retried operations.
- [`@warlock.js/core/write-use-case/SKILL.md`](@warlock.js/core/write-use-case/SKILL.md) — the use-case `retry` option.


## run-app  `@warlock.js/core/run-app/SKILL.md`

---
name: run-app
description: 'Three operational commands — `warlock dev` (HMR + type-gen + health checks), `warlock build` (esbuild bundle), `warlock start` (spawn the production bundle). All flags, all `warlock.config.ts` knobs that shape them. Triggers: `warlock dev`, `warlock build`, `warlock start`, `devServer`, `--fresh`, `--skip-typings`, `--skip-health`, `outDirectory`, `outFile`, `sourcemap`; "start the dev server", "build for production", "run the bundle", "skip type generation", "tune watch globs"; typical config `warlock.config.ts > devServer / build`. Skip: writing a custom CLI — `@warlock.js/core/write-cli-command/SKILL.md`; config shape — `@warlock.js/core/configure-app/SKILL.md`; competing tooling `nodemon`, `tsx`, `ts-node-dev`, `esbuild` direct.'
---

# Warlock — run the app

Three commands move the app through its lifecycle: `dev` while you're editing, `build` once when you're ready to ship, `start` on the server. Each is a real `CLICommand` shipped in core — same factory and preload shape as any custom command you'd write.

## The shape

```bash
# Local development
yarn warlock dev

# Production build
yarn warlock build

# Run the built bundle
yarn warlock start
```

`dev` and `start` are **persistent** (long-running, no auto-exit). `build` is one-shot — it exits when the bundle is written.

## `warlock dev` — development server

Boots the framework in dev mode: file watcher, HMR-style module reload, on-disk transpile cache, background type generation, health checkers. The `runtimeStrategy` is set to `"development"` for the lifetime of the process.

### Flags

| Flag                  | Default | Purpose                                                                                                  |
| --------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `--fresh, -f`         | off     | Delete `.warlock/manifest.json` before start — forces a full re-parse from disk. Use after odd boot states. |
| `--skip-typings, -st` | off     | Skip background type generation **for this run**. Overrides `devServer.generateTypings` config.           |
| `--skip-health, -sh`  | off     | Skip file health checkers **for this run**. Overrides `devServer.healthCheckers` config.                  |

When a flag is **not** passed, the corresponding `warlock.config.ts > devServer.*` value applies. When passed, the flag wins.

### What it preloads

```ts
preload: {
  runtimeStrategy: "development",
  config: true,        // all src/config/*.ts
  bootstrap: true,     // env + app + prestart hooks
  prestart: true,      // src/app/prestart.ts if present
  connectors: true,    // Early-phase connectors only (db, cache, logger, ...)
}
```

HTTP + Socket connectors are **Late** phase — they boot later in the dev-server startup sequence, after app modules load. That ordering is what guarantees `app.http` / `app.socket` are live by the time your `main.ts` runs.

**Boot failures are fatal and loud.** Anything that throws during preload — a bad import in a `src/config/*.ts` file, a removed package export, a connector that fails to start — stops `dev`/`start` immediately with the error message and the offending file/line, then exits `1`. A common cause is upgrading `@warlock.js/*` and hitting a removed export (e.g. a config file that pulls in a model importing a symbol the new version no longer exports). If `warlock dev` ever just freezes right after the banner with no message, treat it as a bug and report it — preload errors are meant to print, never hang.

### `devServer.*` config knobs

```ts title="warlock.config.ts"
import { defineConfig } from "@warlock.js/core";

export default defineConfig({
  devServer: {
    watch: {
      include: ["**/*.{ts,tsx}"],
      exclude: ["**/node_modules/**", "**/dist/**", "**/.warlock/**", "**/.git/**"],
    },
    generateTypings: true,             // background type generation
    healthCheckers: [...] /* or false */,
    transpileCacheDebug: false,        // name cache files <slug>.<hash>.js w/ // @source markers
  },
});
```

- **`watch.include` / `watch.exclude`** — globs piped into the file watcher. Override when you have non-`.ts(x)` files driving reloads (e.g. SQL fixtures), or to exclude a generated folder.
- **`generateTypings`** — turn off if you're committing generated typings and don't want them rewritten on every boot. The `--skip-typings` flag is the per-run version.
- **`healthCheckers`** — custom file health checker contracts (or `false` to disable). The `--skip-health` flag is the per-run version.
- **`transpileCacheDebug`** — diagnostic only. Names `.warlock/transpile/*.js` files `<slug>.<hash>.js` and appends `// @source <path>` markers so you can eyeball which cache entry came from which source. Leave off in normal use.

## `warlock build` — production bundle

esbuild bundle of the app down to a single JS file in `dist/`. No flags — every setting comes from `warlock.config.ts > build`.

### `build.*` config knobs

```ts title="warlock.config.ts"
export default defineConfig({
  build: {
    outDirectory: "dist",        // default — relative or absolute
    outFile: "app.js",            // default — bundle filename
    minify: true,                 // default — esbuild minify
    sourcemap: true,              // default — true | false | "inline" | "linked"
  },
});
```

Defaults are sensible for the typical "Node service" deployment. Knobs to actually reach for:

- **`outDirectory`** — override when your deployment pipeline expects a different folder (e.g. `build/`, `.build/`).
- **`outFile`** — override when bundling multiple Warlock apps into one image and they need distinct entry filenames.
- **`minify: false`** — flip to debug a production-only bug. Larger bundle, readable stack traces.
- **`sourcemap: "inline"`** — embed the source map in the bundle. Useful when your error reporter only captures the bundle and can't fetch a `.map` sidecar.
- **`sourcemap: false`** — skip source maps entirely. Smaller artifact, but stack traces in production logs lose their file:line precision (and `warlock start` will not enable `--enable-source-maps` since there's nothing to map).

### What it preloads

Just `warlockConfig: true`. Build doesn't need the app booted — it reads `warlock.config.ts`, runs esbuild, writes the file. Fast.

### Where the bundle lands

```
<cwd>/<outDirectory>/<outFile>
└─ default: <cwd>/dist/app.js
```

`warlock start` uses the same `resolveBuildConfig()` helper to find the bundle, so the two commands stay in sync no matter how you override the config. If `build` and `start` disagree on where the bundle is, it's because `warlock.config.ts` is being read with different cwds — never the case in normal operation.

## `warlock start` — run the production bundle

Spawns `node <entryPath>` as a child process, forwarding signals (SIGINT / SIGTERM). `entryPath` is resolved from the same `build` config that produced the bundle.

### Behavior

```bash
yarn warlock start                        # → spawns node --enable-source-maps dist/app.js
yarn warlock start --inspect              # → spawns node --enable-source-maps --inspect dist/app.js
yarn warlock start --max-old-space-size=4096  # → spawns node --enable-source-maps --max-old-space-size=4096 dist/app.js
```

Everything you pass after `start` is forwarded to the spawned Node process. Use this to attach a debugger (`--inspect`), tune memory (`--max-old-space-size`), or pass any other Node flag without editing the command.

### Source maps

If `build.sourcemap !== false`, `warlock start` adds `--enable-source-maps` automatically. You see real `.ts` paths and lines in stack traces. If you set `sourcemap: false` in build config, source maps stay off in `start` too — the two configs are tied.

### What it preloads

Just `warlockConfig: true` — same as `build`. The actual app bootstrap happens inside the spawned child process, when the bundle imports and runs framework startup.

### Signal handling

- `SIGINT` (Ctrl+C) — passes through to the child naturally; both processes get it. The parent waits for the child to exit, then exits with the child's code.
- `SIGTERM` — explicitly forwarded to the child by the parent (Windows doesn't auto-propagate this one).

Means `docker stop` / `kubectl delete pod` works as expected: SIGTERM reaches the bundle, your graceful-shutdown hooks fire, then the parent exits.

## Picking which mode you're in

`Application.environment` and `Application.runtimeStrategy` are separate axes:

| Mode             | `environment`   | `runtimeStrategy` | How                                       |
| ---------------- | --------------- | ----------------- | ----------------------------------------- |
| `warlock dev`    | `development`*  | `development`     | preload force-sets `runtimeStrategy`      |
| `warlock build`  | n/a (no app boots) | n/a            | only loads warlock.config.ts              |
| `warlock start`  | `production`*   | `production`*     | usually set via `NODE_ENV` in the env     |

`*` — `environment` follows `NODE_ENV`. The dev command doesn't force it, but the default in most projects is `development`. `start` doesn't force it either; deployments set `NODE_ENV=production` themselves.

If you need conditional behavior, branch on `Application.environment` (the orthogonal "what world am I talking to?" axis), not `runtimeStrategy` (the "how is the framework itself running?" axis). See [`use-app-context/SKILL.md`](../use-app-context/SKILL.md).

## Common patterns

### Standard `package.json` scripts

```json title="package.json"
{
  "scripts": {
    "dev": "warlock dev",
    "build": "warlock build",
    "start": "warlock start"
  }
}
```

Now `yarn dev` / `yarn build` / `yarn start`. Standard Node hosting providers (Render, Fly, Railway, Heroku) recognize this layout.

### Production Dockerfile

```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn warlock build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/warlock.config.ts ./
ENV NODE_ENV=production
CMD ["yarn", "warlock", "start"]
```

Two-stage build trims `devDependencies` out of the runtime image. Keep `warlock.config.ts` in the runtime stage — `start` reads it to resolve the bundle path.

### Bundle into a different folder per environment

```ts title="warlock.config.ts"
import { defineConfig, env } from "@warlock.js/core";

export default defineConfig({
  build: {
    outDirectory: env("BUILD_OUT", "dist"),
    outFile: env("BUILD_FILE", "app.js"),
  },
});
```

CI sets `BUILD_OUT=build/<sha>` per pipeline. `warlock start` reads the same config and finds the bundle without any hardcoded paths.

### Skip type-gen on machines without write access

```bash
yarn warlock dev --skip-typings
```

Or persist it:

```ts title="warlock.config.ts"
export default defineConfig({
  devServer: {
    generateTypings: false,
  },
});
```

Useful in a containerized dev environment where `.warlock/typings.d.ts` is read-only.

### Memory-tune the production process

```bash
yarn warlock start --max-old-space-size=4096
```

Or via `NODE_OPTIONS` in the deployment env if you don't want to change the start invocation:

```bash
NODE_OPTIONS=--max-old-space-size=4096 yarn warlock start
```

## Gotchas

- **`warlock dev` is persistent — `Ctrl+C` to stop.** The framework's `persistent: true` flag keeps the process alive after `action` returns. Same for `start`.
- **`--fresh` only deletes the manifest, not the transpile cache.** If you're chasing a stale-compile bug, `rm -rf .warlock/` clears everything. The manifest restoring is what `--fresh` solves.
- **`warlock build` does NOT run migrations.** Production bundles ship the migration files but don't apply them. Run `yarn warlock migrate` against the production DB separately.
- **`warlock start` requires a built bundle.** Run `warlock build` first, or you'll spawn `node` against a non-existent file and crash immediately.
- **`outDirectory` is the directory, `outFile` is the filename within it.** A common mistake is putting the full path in one and leaving the other default — you end up with `<full-path>/app.js` or `dist/<full-path>`. They concatenate.
- **`sourcemap: false` cascades to `start`.** Stack traces lose `.ts` precision. Keep sourcemaps on unless artifact size is a hard constraint.
- **`NODE_ENV` is not set by these commands.** The deployment env (your Dockerfile, CI, hosting provider) sets it. Forget it on a production server and `Application.isProduction` returns `false`, which flips cookie security, CORS, logging — silently. Always set `NODE_ENV=production` in production deployments.
- **`prestart` runs once on dev boot, not on reload.** If you're seeding test data in `src/app/prestart.ts`, it fires on `warlock dev` startup only. HMR reloads don't re-run it.

## See also

- [`write-cli-command/SKILL.md`](../write-cli-command/SKILL.md) — author a custom CLI command + the rest of the built-in commands (migrate / seed / generate.* / add / storage.put / jwt.generate).
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `warlock.config.ts` shape and `defineConfig`.
- [`use-app-context/SKILL.md`](../use-app-context/SKILL.md) — `Application.environment` vs `Application.runtimeStrategy`.
- [`add-connector/SKILL.md`](../add-connector/SKILL.md) — Early vs Late connector phases (why HTTP/socket boot late in dev).


## send-mail  `@warlock.js/core/send-mail/SKILL.md`

---
name: send-mail
description: 'Send transactional email — `Mail` fluent builder, `sendMail()` direct call, React Email components. Test mode auto-captures into an in-memory mailbox; dev mode logs. Triggers: `Mail.to`, `sendMail`, `setMailMode`, `mailEvents`, `assertMailSent`, `getTestMailbox`, `wasMailSentTo`, `closeAllMailers`; "send a transactional email", "build a React Email template", "configure SMTP or SES", "assert an email was sent in tests"; typical import `import { Mail, sendMail } from "@warlock.js/core"`. Skip: per-config wiring — `@warlock.js/core/configure-app/SKILL.md`; layered service patterns — `@warlock.js/core/warlock-conventions/SKILL.md`; competing libs `nodemailer` direct, `@sendgrid/mail`, `resend`, `mailgun.js`.'
---

# Warlock — send a mail

Two APIs over the same engine. The fluent `Mail` builder reads top-to-bottom in services; the functional `sendMail({...})` is best when the payload is already built. Both run through the same render → normalize → pool → transport pipeline, with a mode switch (`production` / `development` / `test`) deciding whether to actually send, log, or capture.

## The shape

```ts
import { Mail } from "@warlock.js/core";

await Mail.to("user@example.com")
  .subject("Welcome!")
  .text("Thanks for joining.")
  .send();
```

Or:

```ts
import { sendMail } from "@warlock.js/core";

await sendMail({
  to: "user@example.com",
  subject: "Welcome!",
  text: "Thanks for joining.",
});
```

`send()` / `sendMail()` resolves to a `MailResult` (`{ success, messageId, accepted, rejected, response, envelope }`).

## Configuration

`src/config/mail.ts` is the global default config — a single `MailConfigurations` object:

```ts title="src/config/mail.ts"
import type { MailConfigurations } from "@warlock.js/core";
import { env } from "@warlock.js/core";

const mailConfigurations: MailConfigurations = {
  host: env("MAIL_HOST"),
  username: env("MAIL_USERNAME"),
  password: env("MAIL_PASSWORD"),
  port: env("MAIL_PORT"),
  secure: env("MAIL_SECURE"),
  from: {
    name: env("MAIL_FROM_NAME"),
    address: env("MAIL_FROM_ADDRESS"),
  },
};

export default mailConfigurations;
```

For named mailers (multi-provider apps), export a `MailersConfig` instead:

```ts
const config: MailersConfig = {
  default: { host: "smtp.sendgrid.net", port: 587, username: "apikey", password: env("SENDGRID_KEY") },
  mailers: {
    marketing: { host: "smtp.mailchimp.com", port: 587, username: env("MC_USER"), password: env("MC_PASS") },
    transactional: { host: "smtp.postmark.com", port: 587, username: env("PM_USER"), password: env("PM_PASS") },
  },
};
```

Then route a single mail through a specific mailer:

```ts
await Mail.mailer("marketing").to("user@example.com").subject("Promo").html(...).send();
```

For AWS SES, set `driver: "ses"`:

```ts
const config: MailConfigurations = {
  driver: "ses",
  accessKeyId: env("AWS_ACCESS_KEY_ID"),
  secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
  region: env("AWS_REGION"),
  from: { name: "My App", address: "noreply@app.com" },
};
```

Requires `@aws-sdk/client-sesv2` installed (`yarn add @aws-sdk/client-sesv2`).

## Mail modes

The mail pipeline switches behavior on a global mode:

```ts
import { setMailMode } from "@warlock.js/core";

setMailMode("production");   // actually send (default)
setMailMode("development");  // log subject + recipient, no send
setMailMode("test");         // capture to test mailbox, no send
```

Production sends. Development logs and returns a fake-success `MailResult`. Test captures every call into an in-memory mailbox (cleared between tests) and returns success — handy for asserting "the welcome email got sent" without hitting SMTP.

## The fluent builder — `Mail`

`Mail` is built per call. `to(...)` / `config(...)` / `mailer(...)` start a new builder; each setter returns `this`. The final `.send()` validates and pipes through `sendMail()`.

### Recipients + addressing

```ts
await Mail.to("a@example.com")
  .to(["a@example.com", "b@example.com"])      // override
  .cc("manager@example.com")
  .bcc("audit@example.com")
  .replyTo("support@example.com")
  .from({ name: "Support", address: "support@example.com" })
  .subject("Subject line")
  .send();
```

`from` overrides the configured default. Pass a plain string or `{ name, address }`.

### Content (pick one — `text`, `html`, or `component`)

```ts
// Plain text
Mail.to("u@e.com").subject("Plain").text("Hi.").send();

// HTML
Mail.to("u@e.com").subject("Html").html("<p>Hi.</p>").send();

// React component (renders via @react-email/render if installed, fallback to renderToStaticMarkup)
Mail.to("u@e.com").subject("React").component(<WelcomeEmail name="Hasan" />).send();
```

`.send()` throws if all three are missing.

### Attachments

```ts
await Mail.to("u@e.com")
  .subject("Invoice")
  .html("<p>See attached.</p>")
  .attach(pdfBuffer, "invoice.pdf", "application/pdf")
  .attachFile("/abs/path/to/terms.pdf", "terms.pdf")
  .send();
```

`attach(content, filename, contentType?)` for buffers/strings; `attachFile(path, filename?, contentType?)` reads from disk at send time. `attachments([...])` accepts a pre-built array.

### Other knobs

| Method                                | Purpose                                       |
| ------------------------------------- | --------------------------------------------- |
| `.priority("high" \| "normal" \| "low")` | priority header                            |
| `.headers({ "X-Foo": "bar" })`        | replace custom headers                        |
| `.header("X-Foo", "bar")`             | add one header                                |
| `.tags(["welcome"])` / `.tag("transactional")` | categorization tags                  |
| `.correlationId("req-123")`           | tracking id (logged with sends)               |
| `.config(MailConfigurations)`         | one-off override of the global config         |
| `.mailer("marketing")`                | route via a named mailer from config          |

### Per-mail event handlers

The builder exposes four lifecycle handlers:

```ts
await Mail.to("u@e.com")
  .subject("…")
  .text("…")
  .beforeSending((mail) => {
    // mutate `mail` or return false to cancel
  })
  .onSent((mail, result, error) => {})    // always fires after attempt
  .onSuccess((mail, result) => {})        // only on success
  .onError((mail, error) => {})           // only on failure
  .send();
```

Returning `false` from `beforeSending` cancels the send and resolves with `success: false`.

## The functional API — `sendMail`

Same options as the builder, passed as one object:

```ts
import { sendMail } from "@warlock.js/core";

await sendMail({
  to: "user@example.com",
  cc: ["manager@example.com"],
  subject: "Welcome",
  component: <WelcomeEmail name="Hasan" />,
  config: tenant.mailSettings,    // multi-tenant override
  tags: ["welcome"],
  onSuccess: (mail, result) => {},
});
```

Use this when the payload is already an object — e.g. inside a queue worker iterating over a list. Both APIs share the same `MailOptions` shape.

## React templates

Pass a React element directly via `.component(<Template/>)` (builder) or `component:` (sendMail). The renderer:

1. Tries `@react-email/render` (full React Email pipeline — install with `warlock add react-email`, which also drops a sample `emails/welcome-email.tsx` and patches `tsconfig.json` to include it).
2. Falls back to `react-dom/server`'s `renderToStaticMarkup` wrapped in a minimal `<html>` shell.

```tsx
import { Html, Heading, Text } from "@react-email/components";

export function WelcomeEmail({ name }: { name: string }) {
  return (
    <Html>
      <Heading>Welcome, {name}</Heading>
      <Text>Thanks for joining.</Text>
    </Html>
  );
}
```

The renderer takes care of inlining styles and building the HTML page. You don't need to add a `<head>` or wrap in `<Mjml>`.

## Global event hooks

For app-wide observability (metrics, logging, audit), subscribe to global mail events:

```ts
import { mailEvents } from "@warlock.js/core";

mailEvents.onSuccess((mail, result) => {
  console.log("mail sent", result.messageId);
});

mailEvents.onError((mail, error) => {
  console.error("mail failed", error.code, error.message);
});

mailEvents.onBeforeSending((mail) => {
  // return false to cancel globally
});
```

Global hooks fire for **every** mail. Per-mail handlers from the builder / `sendMail` fire only for that one send.

For correlated tracking of a specific mail, generate an id and subscribe by id:

```ts
import { generateMailId, mailEvents, sendMail } from "@warlock.js/core";

const mailId = generateMailId();

mailEvents.onMailSuccess(mailId, (mail, result) => {
  // fires only for this specific mail
});

await sendMail({ id: mailId, to: "u@e.com", subject: "…", text: "…" });
```

## Testing — the test mailbox

In test mode, every send is captured instead of dispatched. Helpers live as named exports — there is no `testMailbox` object:

```ts
import {
  setMailMode,
  clearTestMailbox,
  getTestMailbox,
  getLastMail,
  findMailsTo,
  findMailsBySubject,
  wasMailSentTo,
  wasMailSentWithSubject,
  getMailboxSize,
  assertMailSent,
  assertMailCount,
} from "@warlock.js/core";

beforeEach(() => {
  setMailMode("test");
  clearTestMailbox();
});

it("sends a welcome email on signup", async () => {
  await signupUserService({ email: "u@e.com" });

  expect(wasMailSentTo("u@e.com")).toBe(true);
  expect(wasMailSentWithSubject("Welcome!")).toBe(true);

  const mail = assertMailSent((m) => m.options.to === "u@e.com");
  expect(mail.options.subject).toBe("Welcome!");
});
```

Captured mail shape:

```ts
type CapturedMail = {
  options: MailOptions;       // original payload
  normalized: NormalizedMail; // post-normalization (arrays, resolved from)
  timestamp: Date;
  result?: MailResult;        // in test mode, always success
  error?: MailError;
};
```

`assertMailSent(predicate)` throws if no mail matches. `assertMailCount(n)` throws if the captured count isn't `n`.

## Connection pooling

Transports (one per unique config hash) are cached in an in-process pool. The first send creates a connection; subsequent sends reuse it. On shutdown:

```ts
import { closeAllMailers } from "@warlock.js/core";

closeAllMailers();
```

`closeAllMailers()` is synchronous (returns `void`) — it closes every transport in the pool and clears it. You rarely call this manually — the framework closes the pool on shutdown.

## Common patterns

### Welcome email after signup

```ts
import { Mail } from "@warlock.js/core";
import { WelcomeEmail } from "../emails/welcome.email";

export async function sendWelcomeEmail(user: User) {
  return Mail.to(user.email)
    .subject(`Welcome, ${user.name}`)
    .component(<WelcomeEmail name={user.name} />)
    .tags(["welcome", "transactional"])
    .send();
}
```

### Per-tenant SMTP

```ts
await Mail.config(tenant.mailSettings)
  .to(invitation.email)
  .subject(`You've been invited to ${tenant.name}`)
  .text(`Click here to accept: ${url}`)
  .send();
```

### Cancel via beforeSending (e.g. user has opted out)

```ts
await Mail.to(user.email)
  .subject("Newsletter")
  .html(content)
  .beforeSending(async (mail) => {
    if (await hasOptedOut(user.id)) return false;
  })
  .send();
```

## Gotchas

- **`.send()` validates** — `to`, `subject`, and at least one of `text`/`html`/`component` are required. Missing any throws synchronously.
- **`@react-email/render` is optional.** Without it you get the basic fallback (inline styles, no MSO conditionals). Install it for production-quality HTML.
- **`nodemailer` is loaded lazily** at import time. If you see `nodemailer is not installed` errors, run `warlock add mail` (or `yarn add nodemailer`).
- **`secure: true` requires port 465.** For port 587 use `secure: false` and `tls: true` (STARTTLS).
- **Test mode is process-global.** Set it in `beforeAll`/`beforeEach`; reset with `setMailMode("production")` (or rely on test runner isolation).
- **Per-mail handlers don't replace global ones** — both fire. Avoid double-counting metrics.

## See also

- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/mail.ts` shape and `env()` patterns.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — where mail-sending services live.


## send-response  `@warlock.js/core/send-response/SKILL.md`

---
name: send-response
description: 'Send HTTP responses via @warlock.js/core''s Response helpers — success/error variants, status helpers, redirects, files, streams, and SSE. Picking the right helper carries the HTTP semantic without manual status codes. Triggers: `response.success`, `response.successCreate`, `response.notFound`, `response.forbidden`, `response.badRequest`, `response.sendFile`, `response.stream`, `response.sse`, `response.replay`, `ResourceNotFoundError`, `ForbiddenError`; "return a 201 from a controller", "send a file", "stream Server-Sent Events", "throw HTTP-shaped errors from services"; typical import `import type { RequestHandler, Response } from "@warlock.js/core"`. Skip: controller shape — `@warlock.js/core/create-controller/SKILL.md`; route registration — `@warlock.js/core/register-route/SKILL.md`; competing patterns: hand-rolled status codes via `reply.code(404).send(...)`, raw Fastify reply.'
---

# Warlock — send a response

`Response` is the helper-rich object passed to every controller. Pick the helper that matches the outcome — the helper carries the status code, so you almost never set one by hand.

## The shape

```ts
import type { RequestHandler, Response } from "@warlock.js/core";

export const myController: RequestHandler = async (request, response: Response) => {
  // …choose a helper and return it
  return response.success({ data: "…" });
};
```

Always `return response.<helper>(...)`. The return value drives Fastify's send.

## Success helpers

| Method                              | Status | When                                      |
| ----------------------------------- | ------ | ----------------------------------------- |
| `response.success(data?)`           | 200    | normal read / update                      |
| `response.successCreate(data)`      | 201    | resource created (POST)                   |
| `response.noContent()`              | 204    | delete succeeded, no body needed          |

```ts
return response.success({ products: [...] });

return response.successCreate({ product });

return response.noContent();
```

`response.success()` (no argument) defaults to `{ success: true }` — useful for void operations that still need a body.

## Client-error helpers

| Method                                              | Status | When                                  |
| --------------------------------------------------- | ------ | ------------------------------------- |
| `response.badRequest(data)`                         | 400    | malformed or invalid input            |
| `response.unauthorized(data?)`                      | 401    | missing/invalid auth token            |
| `response.forbidden(data?)`                         | 403    | authenticated but not allowed         |
| `response.notFound(data?)`                          | 404    | record missing                        |
| `response.conflict(data?)`                          | 409    | uniqueness violation, state conflict  |

```ts
return response.badRequest({ error: t("validation.invalid") });

return response.unauthorized({ error: t("auth.invalidCredentials") });

return response.forbidden({ error: t("permission.denied") });

return response.notFound({ error: t("product.notFound") });

return response.conflict({ error: t("product.duplicateSku") });
```

Most error helpers accept an optional payload — if you omit it, they send a default `{ error: "<status name>" }` shape.

## Redirects

```ts
return response.redirect("/login");                    // 302
return response.redirect("/new-home", 301);            // permanent
```

## Files

```ts
// stream a file from disk
return response.sendFile("/abs/path/to/file.pdf");

// cache for 1 year (default)
return response.sendCachedFile("/abs/path/to/asset.css");

// send a buffer with content type
return response.sendBuffer(buffer, { contentType: "image/png" });
```

`SendFileOptions` lets you set `cacheTime`, `immutable`, `inline`, `filename` (download attachment name).

## Streams

```ts
const stream = response.stream("text/plain");

stream.send("first chunk\n");
stream.send("second chunk\n");
stream.end();
```

`response.stream(contentType?)` returns a controller with `.send(chunk)`, `.render(reactNode)`, `.end()`, and an `.ended` getter. Use for large dynamic payloads where buffering would blow memory. Calling `.send()` after `.end()` throws.

## Throwing HTTP errors

Most of the time, controllers don't need to *choose* an error helper — they throw from the service layer instead. The request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the matching response. The error classes mirror the helpers above:

```ts
import {
  ResourceNotFoundError,   // 404
  UnAuthorizedError,        // 401
  ForbiddenError,           // 403
  BadRequestError,          // 400
  ConflictError,            // 409
  NotAcceptableError,       // 406
  NotAllowedError,          // 405
  ServerError,              // 500
  HttpError,                // base class — `new HttpError(status, message, payload?)` for arbitrary codes
} from "@warlock.js/core";

throw new ResourceNotFoundError("product.notFound");
throw new ForbiddenError("permission.denied", { resource: "product", id });
throw new ConflictError("user.duplicateEmail");
```

Each class takes `(message, payload?)`. The payload merges into the response body alongside `error`. In development mode, the stack trace is included too.

Pick the class, throw from the service or use-case, and forget about response shaping at the call site. The controller stays focused on the success path:

```ts
export const getProductController: RequestHandler = async (request, response) => {
  const product = await getProductService(request.input("id"));   // throws ResourceNotFoundError on miss
  return response.success({ product });
};
```

See [`create-controller`](../create-controller/SKILL.md) for the "throw from service, return from controller" pattern.

## Server-Sent Events

```ts
const sse = response.sse();

sse.send("tick", { count: 1 });             // event name, data, optional id
sse.send("tick", { count: 2 }, "msg-2");    // third arg is the SSE event id
sse.comment("keep-alive");                  // invisible to the client, prevents timeout
sse.end();
```

`response.sse()` returns a controller with `send(event, data, id?)`, `comment(text)`, `end()`, `onDisconnect(handler)`, and an `.ended` getter. The `send` signature is positional — `event` name first, then the `data` payload (JSON-stringified for you), then an optional event `id`. After the client disconnects, `send`/`comment` become silent no-ops and any `onDisconnect` handlers fire — register cleanup there:

```ts
const sse = response.sse();
const listener = (chunk: string) => sse.send("chunk", { chunk });

eventBus.on(messageId, listener);
sse.onDisconnect(() => eventBus.off(messageId, listener));
```

Browsers consume SSE via `new EventSource(url)`. Cheaper than websockets when you only need server-to-client push.

## Setting headers and cookies

```ts
response.header("X-Total-Count", "42");

// JSON-wrapped (default) — round-trips with request.cookie("prefs")
response.cookie("prefs", { theme: "dark" }, { httpOnly: true });

// Plain string — use raw: true for session tokens / opaque IDs that
// shouldn't be JSON-quoted on the wire
response.cookie("session_id", "abc.def.ghi", { raw: true, httpOnly: true });

response.clearCookie("session_id");
```

`response.cookie()` JSON-stringifies the value by default so structured cookies round-trip cleanly with `request.cookie(name)`. Pass `{ raw: true }` to skip the wrapping for plain-string cookies (session tokens, opaque IDs, simple flags). The new `CookieOptions` type extends Fastify's `CookieSerializeOptions` with the `raw` flag.

These mutate the response in place; chain or call before the final `return response.<helper>()`.

## Common patterns

### Localized error

```ts
import { t } from "@warlock.js/core";

if (!result) {
  return response.unauthorized({ error: t("auth.invalidCredentials") });
}

return response.success(result);
```

### Created with link header

```ts
const product = await createProductService(request.validated());

response.header("Location", `/products/${product.id}`);

return response.successCreate({ product });
```

### Streaming an LLM reply

```ts
import { ai } from "@warlock.js/ai";

const stream = response.stream("text/event-stream");
const streamedAgent = await myAgent.stream(request.input("message"));

for await (const event of streamedAgent.events) {
  if (event.type === "text-delta") {
    stream.send(`data: ${JSON.stringify({ delta: event.text })}\n\n`);
  }
}

stream.end();
```

(For the agent surface itself, see `@warlock.js/ai/skills/subskills/agent.md`.)

### Replaying a cached response

For cache-pattern middleware (idempotency, response cache) that needs to send a previously-captured response without re-running the controller:

```ts
return response.header("X-Cache", "HIT").replay({
  status: cached.status,
  body: cached.body,
  contentType: cached.contentType,
  headers: cached.extraHeaders,
});
```

`replay()` sets the status, content-type, and extra headers, then calls `send(body)` so the full event lifecycle still fires — cross-cutting observers (logger, metrics) stay consistent between fresh and replayed responses. Built-in `middleware.idempotency` and `middleware.cache` use this internally; reach for it when writing your own cache-pattern middleware.

## Gotchas

- **Don't manually set the status code.** Use the matching helper. `response.send(...)` with a hand-rolled status loses error-handler integration.
- **Don't call multiple helpers.** First helper wins; subsequent calls log a "response already sent" warning.
- **Cookie options vary by environment.** Set `secure: Application.isProduction` so cookies work in dev (HTTP) and prod (HTTPS) without changing code.
- **Streaming and SSE consume the response.** No `response.success(...)` afterward — call `stream.end()` / `sse.end()` to finish.

## See also

- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — what calls `response.<helper>()` from.
- [`register-route/SKILL.md`](../register-route/SKILL.md) — wiring the controller to a URL.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — when to use HTTP error helpers vs `throw`.


## store-file  `@warlock.js/core/store-file/SKILL.md`

---
name: store-file
description: 'Read/write/delete files via the `storage` singleton — disks, drivers (local/S3/R2/DO Spaces), `storage.use(name)`, `StorageFile` handles, presigned URLs. Triggers: `storage.put`, `storage.get`, `storage.use`, `StorageFile`, `storageConfigurations`, `getPresignedUrl`, `getPresignedUploadUrl`; "save an uploaded file", "switch between local and S3", "generate a presigned URL", "read file metadata"; typical import `import { storage } from "@warlock.js/core"`. Skip: multipart parsing + image chain — `@warlock.js/core/upload-file/SKILL.md`; image transforms — `@warlock.js/core/process-image/SKILL.md`; storage config shape — `@warlock.js/core/configure-app/SKILL.md`; competing libs `@aws-sdk/client-s3`, `multer`, `formidable`.'
---

# Warlock — store a file

`storage` is a singleton manager that wraps one or more drivers. Each driver lives behind a name (`"local"`, `"s3"`, `"r2"`, `"spaces"`). Calls go to the active driver by default; `storage.use("name")` returns a scoped view. Every write returns a `StorageFile` — a thin OOP wrapper with rich properties (`name`, `url`, `hash`, `size`) and chainable methods (`copy`, `move`, `delete`).

## The shape

```ts
import { storage } from "@warlock.js/core";

const file = await storage.put(buffer, "uploads/photo.jpg");

console.log(file.url);     // public URL
console.log(file.name);    // "photo.jpg"
console.log(file.hash);    // sha256
```

That's the entire surface for the common case. `storage.put(...)` writes to the default driver and returns a `StorageFile`.

## Configuration

`src/config/storage.ts` declares the disks. Each entry under `drivers` uses one of four built-in driver factories:

```ts title="src/config/storage.ts"
import {
  env,
  type StorageConfigurations,
  storageConfigurations,
  storagePath,
} from "@warlock.js/core";

const storageOptions: StorageConfigurations = {
  default: "local",
  drivers: {
    local: storageConfigurations.local({
      root: storagePath(),
      urlPrefix: "/uploads",
    }),
    aws: storageConfigurations.aws({
      accessKeyId: env("AWS_ACCESS_KEY_ID"),
      secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
      region: env("AWS_REGION"),
      bucket: env("AWS_S3_BUCKET"),
      urlPrefix: "/uploads",
    }),
    r2: storageConfigurations.r2({
      bucket: env("R2_BUCKET"),
      endpoint: env("R2_ENDPOINT"),
      accessKeyId: env("R2_ACCESS_KEY_ID"),
      secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
      accountId: env("R2_ACCOUNT_ID"),
      region: env("R2_REGION", "auto"),
      publicDomain: env("R2_BASE_URL"),
    }),
    spaces: storageConfigurations.spaces({
      accessKeyId: env("DO_KEY"),
      secretAccessKey: env("DO_SECRET"),
      region: env("DO_REGION"),
      bucket: env("DO_BUCKET"),
      endpoint: env("DO_ENDPOINT"),
    }),
  },
};

export default storageOptions;
```

`default` names the disk used when callers don't specify one. The factories (`storageConfigurations.local|aws|r2|spaces`) just stamp the `driver` field on the options object — pass whatever the driver supports.

## File operations

The `storage` singleton (and any `storage.use(name)` scoped view) exposes the same operations:

### Writing

```ts
// From buffer
const file = await storage.put(buffer, "uploads/photo.jpg");

// With explicit metadata
await storage.put(buffer, "uploads/photo.jpg", {
  mimeType: "image/jpeg",
  cacheControl: "max-age=31536000",
});

// From stream (large files)
import { createReadStream } from "node:fs";
await storage.putStream(createReadStream("./video.mp4"), "uploads/video.mp4");

// From a URL (downloads then stores)
await storage.putFromUrl("https://example.com/image.jpg", "uploads/image.jpg");

// From base64 data URL
await storage.putFromBase64(
  "data:image/png;base64,iVBORw0KGgo...",
  "uploads/photo.png",
);

// From an UploadedFile (multipart upload)
await storage.put(request.file("image"), "uploads/avatar.jpg");
```

Every `put*` returns a `StorageFile`.

### Reading

```ts
const buffer = await storage.get("uploads/photo.jpg");
const stream = await storage.getStream("uploads/video.mp4");
const json = await storage.getJson("config/settings.json");
```

`get()` loads the whole file into memory. Use `getStream()` for anything > a few MB.

### Existence + metadata

```ts
await storage.exists("uploads/photo.jpg");                  // boolean
const info = await storage.metadata("uploads/photo.jpg");   // { size, mimeType, lastModified, ... }
const bytes = await storage.size("uploads/photo.jpg");
```

### Delete / copy / move

```ts
await storage.delete("uploads/photo.jpg");
await storage.deleteMany(["a.txt", "b.txt", "c.txt"]);
await storage.copy("uploads/photo.jpg", "backups/photo.jpg");
await storage.move("uploads/temp.jpg", "uploads/photo.jpg");
```

Directory operations:

```ts
await storage.copyDirectory("uploads/temp", "uploads/final");
await storage.moveDirectory("uploads/temp", "uploads/final");
await storage.emptyDirectory("uploads/temp");
await storage.deleteDirectory("uploads/temp");
```

### Listing

```ts
const files = await storage.list("uploads", { recursive: true, limit: 100 });
// → StorageFileInfo[]
```

### URLs

```ts
storage.url("uploads/photo.jpg");                        // public URL (sync)
await storage.temporaryUrl("private/doc.pdf", 3600);     // signed URL, expires in seconds
```

## Switching drivers — `storage.use(name)`

For a single call against a non-default driver, scope it:

```ts
const r2File = await storage.use("r2").put(buffer, "exports/data.csv");
const localFile = await storage.use("local").put(buffer, "tmp/preview.jpg");

// Both return StorageFile with identical API
console.log(r2File.url);
console.log(localFile.url);
```

`storage.use(name)` returns a `ScopedStorage` with the same surface as `storage`. To change the default permanently, call `storage.setDefault(name)` — but you almost always want per-call scoping for clarity.

## `StorageFile` — the OOP handle

Every `put*`/`copy`/`move` returns a `StorageFile`. You can also build one for an existing path:

```ts
const file = storage.file("uploads/photo.jpg");
```

### Sync properties (no I/O)

| Property         | Description                                    |
| ---------------- | ---------------------------------------------- |
| `file.path`      | full storage path (`"uploads/photo.jpg"`)      |
| `file.name`      | basename (`"photo.jpg"`)                       |
| `file.extension` | lowercased ext, no dot (`"jpg"`)               |
| `file.directory` | parent directory                               |
| `file.driver`    | driver name (`"local"` / `"s3"` / …)           |
| `file.url`       | public URL (uses cached value if present)      |
| `file.hash`      | sha256, set by `put*` operations               |
| `file.isDeleted` | `true` after `file.delete()`                   |

### Async data

```ts
await file.data();        // full StorageFileData with size/mimeType/url/hash
await file.size();
await file.mimeType();
await file.lastModified();
await file.etag();        // cloud only
```

### Operations

```ts
await file.copy("backups/photo.jpg");      // → new StorageFile at the copy
await file.move("archive/photo.jpg");      // → returns this; path is updated in place
await file.delete();                       // marks isDeleted
const buffer = await file.contents();
const stream = await file.stream();
```

`file.copy(dest)` returns a fresh `StorageFile` for the copy. `file.move(dest)` (and `file.rename(name)`) mutate the receiver — they rewrite `this._path`, refresh the cached data, and return the same instance. The handle stays valid at its new location.

## Cloud-only operations

Some methods only work against cloud drivers (`s3`, `r2`, `spaces`). Calling them on `local` throws.

### Presigned URLs (direct upload/download)

```ts
const downloadUrl = await storage.getPresignedUrl("private/doc.pdf", {
  expiresIn: 3600, // seconds
});

const uploadUrl = await storage.getPresignedUploadUrl("uploads/file.pdf", {
  expiresIn: 3600,
  contentType: "application/pdf",
});

// Client can PUT directly to uploadUrl, bypassing your server
```

For these you usually scope to a specific cloud disk:

```ts
const url = await storage.use("r2").getPresignedUrl(path, { expiresIn: 600 });
```

### Visibility + storage class

```ts
await storage.setVisibility("uploads/photo.jpg", "public");
await storage.setVisibility("private/doc.pdf", "private");
const v = await storage.getVisibility("uploads/photo.jpg");

await storage.setStorageClass("archive/old.zip", "GLACIER");
```

### Cloud driver helper

For cloud-only chains, get the typed cloud interface:

```ts
const cloud = storage.useCloud("s3");
await cloud.getPresignedUrl("private/doc.pdf");
await cloud.getBucket();
await cloud.getRegion();
```

## Temporary URLs on the local driver

Local storage signs URLs with an HMAC token (no presigned URL — there is no S3 here). Validate inbound tokens before serving:

```ts
import { storage } from "@warlock.js/core";

const result = await storage.validateTemporaryToken(token);

if (!result.valid) {
  return response.forbidden({ error: result.error });
}

if (result.absolutePath) {
  return response.sendFile(result.absolutePath);
}

const stream = await result.getStream!();
stream.pipe(response.raw);
```

Cloud drivers always return `{ valid: false }` from `validateTemporaryToken` — they validate via presigned URL on the cloud side.

## Runtime driver registration

For multi-tenancy or runtime config, register a driver after boot:

```ts
storage.register("tenant-s3", {
  driver: "s3",
  bucket: tenant.bucket,
  region: tenant.region,
  accessKeyId: tenant.key,
  secretAccessKey: tenant.secret,
});

await storage.use("tenant-s3").put(buffer, "data.csv");
```

`storage.register(name, config)` clears any cached instance for that name, so the next access rebuilds with the new config.

## Events

`storage.on(event, handler)` subscribes to lifecycle hooks. Event types:

| Event          | Fires                              |
| -------------- | ---------------------------------- |
| `beforePut`    | before write                       |
| `afterPut`     | after successful write             |
| `beforeDelete` | before delete                      |
| `afterDelete`  | after delete                       |
| `beforeCopy`   | before copy                        |
| `afterCopy`    | after copy                         |
| `beforeMove`   | before move                        |
| `afterMove`    | after move                         |

```ts
storage.on("afterPut", ({ location, file }) => {
  console.log(`uploaded ${file?.size} bytes to ${location}`);
});

storage.on("afterDelete", ({ location }) => {
  analytics.track("file_deleted", { path: location });
});
```

## Common patterns

### Switching disk per env

```ts title="src/config/storage.ts"
const storageOptions: StorageConfigurations = {
  default: env("STORAGE_DRIVER", "local"),
  drivers: {
    local: storageConfigurations.local({ root: storagePath(), urlPrefix: "/uploads" }),
    r2: storageConfigurations.r2({ ... }),
  },
};
```

Set `STORAGE_DRIVER=r2` in production, leave unset in dev — same code uses local files in dev and R2 in prod.

### Uploading a request file

```ts
import type { RequestHandler, Response } from "@warlock.js/core";
import { storage } from "@warlock.js/core";

export const uploadAvatarController: RequestHandler = async (request, response: Response) => {
  const upload = request.file("avatar");
  const file = await storage.put(upload, `avatars/${request.user.id}/${upload.fileName}`);

  return response.successCreate({ url: file.url, hash: file.hash });
};
```

### Direct browser upload via presigned URL

```ts
const uploadUrl = await storage.use("r2").getPresignedUploadUrl(
  `uploads/${userId}/${filename}`,
  { expiresIn: 600, contentType: mimeType },
);

return response.success({ uploadUrl });
```

Client `PUT`s the bytes straight to R2 — your server never sees them.

## Gotchas

- **`storage` is the lowercase singleton.** Don't `new Storage()` — there's no point, and configuration won't reach it.
- **`storage.path(location)` only works on the local driver.** Calls on cloud drivers throw. Check the driver if you don't control it.
- **`StorageFile.move()` mutates the receiver and returns it.** After `await file.move(dest)`, `file.path` now points at `dest` — the same instance stays live (unlike the manager's `storage.move(from, to)`, which returns a fresh `StorageFile`). `file.copy(dest)`, by contrast, leaves the receiver untouched and returns a new handle.
- **Local URLs are relative (`/uploads/...`).** Prefix with `baseUrl` from `config.get("app").baseUrl` if you need absolute. Cloud drivers return absolute URLs.
- **Presigned uploads enforce content-type at the cloud side.** Mismatched `contentType` between the presigned URL and the actual upload returns 403 from the cloud — match exactly.
- **Driver name keys are case-sensitive** — `"r2"`, not `"R2"`. Match what's in `src/config/storage.ts`.

## See also

- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/storage.ts` shape and `env()` patterns.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout for upload flows (`src/app/uploads/`).
- [`send-response/SKILL.md`](../send-response/SKILL.md) — `response.sendFile(absPath)` for serving local files.


## test-http  `@warlock.js/core/test-http/SKILL.md`

---
name: test-http
description: 'Integration tests against a real HTTP server — `startHttpTestServer()` boots one shared server in globalSetup, then `testGet` / `testPost` / `expectJson` make typed requests against it. Triggers: `startHttpTestServer`, `stopHttpTestServer`, `testGet`, `testPost`, `testPut`, `testPatch`, `testDelete`, `expectJson`, `getTestServerUrl`, `testRequest`; "integration-test a controller", "end-to-end HTTP test", "globalSetup HTTP server", "assert status and body shape"; typical import `import { testGet, testPost, expectJson } from "@warlock.js/core"`. Skip: pure unit tests — `@warlock.js/core/test-service/SKILL.md`; controller shape — `@warlock.js/core/create-controller/SKILL.md`; competing libs `supertest`, `light-my-request`, `nock`.'
---

# Warlock — HTTP integration tests

Some tests need the full stack: route matching, middleware chain, validation, controller, response serialization. For those, you boot the real HTTP server once per test run and make real `fetch` calls against it.

`startHttpTestServer()` is the bootstrap. `testGet` / `testPost` / `expectJson` are the call helpers. Both ship in `@warlock.js/core`.

## The shape

```ts title="src/app/users/tests/users.controller.test.ts"
import { describe, expect, it } from "vitest";
import { expectJson, testGet, testPost } from "@warlock.js/core";

describe("Users API", () => {
  it("GET /users returns the list", async () => {
    const response = await testGet("/users");
    const body = await expectJson<{ users: unknown[] }>(response);
    expect(Array.isArray(body.users)).toBe(true);
  });

  it("POST /users creates a user", async () => {
    const response = await testPost("/users", {
      email: "new@example.com",
      password: "secret",
    });

    const body = await expectJson<{ user: { email: string } }>(response, 201);
    expect(body.user.email).toBe("new@example.com");
  });
});
```

No `beforeAll`, no manual server start — the project's `src/test-global-setup.ts` brought the HTTP server up once before any test ran.

## The bootstrap — `startHttpTestServer` / `stopHttpTestServer`

```ts
import { startHttpTestServer, stopHttpTestServer } from "@warlock.js/core";
```

`startHttpTestServer()` boots a **minimal but real** HTTP server:

- Sets `runtimeStrategy: "development"` and `environment: "test"`.
- Loads `warlock.config.ts` + bootstrap + `filesOrchestrator` (without file watching).
- Loads `src/config/*.ts`.
- Loads every module (`routes.ts`, `main.ts`, `events/*.ts`).
- Starts **ALL** connectors, including HTTP.

Unlike the dev server, it doesn't watch files, doesn't do HMR, doesn't run health checkers. Just a working HTTP endpoint listening on the configured port.

`stopHttpTestServer()` shuts it down — clean tear-down on test-run completion.

Both are idempotent. A second `start` returns early; `stop` on a non-running server logs and returns.

## Project wiring — `src/test-global-setup.ts` + `vite.config.ts`

```ts title="src/test-global-setup.ts"
/**
 * Global Test Setup
 * Runs ONCE in the main process before all test workers start.
 */
import { startHttpTestServer, stopHttpTestServer } from "@warlock.js/core";

export async function setup() {
  await startHttpTestServer();
}

export async function teardown() {
  await stopHttpTestServer();
}
```

```ts title="vite.config.ts"
import mongezVite from "@mongez/vite";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [mongezVite()],
  test: {
    globalSetup: "./src/test-global-setup.ts",  // ← starts the HTTP server
    setupFiles: ["./src/test-setup.ts"],         // ← per-worker setupTest (see test-service skill)
    environment: "node",
    globals: false,
    include: ["src/app/**/*.test.ts"],
  },
});
```

Both files are created by `warlock add test`. The split is intentional: `globalSetup` runs ONCE in the main vitest process; `setupFiles` runs per worker thread.

## HTTP request helpers

Everything is built on native `fetch` — no extra dependency, no special wire format.

### URL resolution

```ts
import { getTestServerUrl } from "@warlock.js/core";

const url = getTestServerUrl();  // → "http://localhost:2031" (defaults)
```

Reads `http.host` (default `"localhost"`) and `http.port` (default `2031`) from config. If you change the HTTP config, helpers follow automatically.

### Verb helpers

```ts
import {
  testRequest,
  testGet,
  testPost,
  testPut,
  testPatch,
  testDelete,
} from "@warlock.js/core";

await testGet("/products");
await testGet("/products?published=true", { headers: { "X-Tenant": "abc" } });
await testPost("/products", { name: "Pen", price: 5 });
await testPut("/products/42", { name: "Updated" });
await testPatch("/products/42", { price: 10 });
await testDelete("/products/42");
```

All accept a relative path (leading `/` optional) and a standard `RequestInit`. The body, if passed, is JSON-stringified automatically; the `Content-Type: application/json` header is set unless you override.

`testRequest(path, options)` is the underlying primitive — use it when you need a method the helpers don't cover (e.g., `OPTIONS`).

### Parsing + asserting — `expectJson<T>`

```ts
import { expectJson, parseJsonResponse } from "@warlock.js/core";

// Parse-only
const body = await parseJsonResponse<MyShape>(response);

// Assert status + parse in one call
const body = await expectJson<MyShape>(response);          // expects 200
const body = await expectJson<MyShape>(response, 201);     // expects 201
const body = await expectJson<MyShape>(response, 404);     // expects 404 (testing error paths)
```

`expectJson` throws with the actual status + response body when the assertion fails — no chasing "got 500" messages without context.

## Patterns

### Happy path — create + read

```ts
import { describe, expect, it } from "vitest";
import { expectJson, testGet, testPost } from "@warlock.js/core";

describe("Products API — happy path", () => {
  it("creates and reads back a product", async () => {
    const create = await testPost("/products", {
      name: "Test Product",
      price: 99,
    });
    const { product } = await expectJson<{ product: { id: string } }>(create, 201);

    const read = await testGet(`/products/${product.id}`);
    const { product: fetched } = await expectJson<{
      product: { name: string; price: number };
    }>(read);

    expect(fetched.name).toBe("Test Product");
    expect(fetched.price).toBe(99);
  });
});
```

### Validation errors

```ts
it("rejects missing required fields with 400", async () => {
  const response = await testPost("/products", { name: "" });  // price missing
  const body = await expectJson<{ errors: Array<{ key: string }> }>(response, 400);

  expect(body.errors.some((e) => e.key === "price")).toBe(true);
});
```

The HTTP server runs the real validation middleware — 400-with-error-list is the production code path.

### Authenticated routes — JWT bearer

```ts
import { authService } from "@warlock.js/auth";
import { User } from "src/app/users/models/user";

it("GET /me returns the current user", async () => {
  const user = await User.create({ email: "auth@e.com", password: "secret" });
  const { accessToken } = await authService.generateTokens(user);

  const response = await testGet("/me", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  const { user: me } = await expectJson<{ user: { email: string } }>(response);
  expect(me.email).toBe("auth@e.com");
});
```

Use the auth service's token generator instead of hand-crafting JWTs — keeps tests aligned with production token shape and signature.

### Building an auth helper

When most of your tests need a token, hoist the boilerplate:

```ts title="src/test-utils/auth-test-helpers.ts"
import { authService } from "@warlock.js/auth";
import { User } from "src/app/users/models/user";

export async function createUserAndToken(overrides: Partial<{ email: string }> = {}) {
  const user = await User.create({
    email: overrides.email ?? `user-${Date.now()}@e.com`,
    password: "test-secret",
  });

  const { accessToken } = await authService.generateTokens(user);
  return { user, accessToken, authHeader: { Authorization: `Bearer ${accessToken}` } };
}
```

Then in tests:

```ts
const { authHeader } = await createUserAndToken();
const response = await testGet("/me", { headers: authHeader });
```

### Setting up data via direct DB access

The HTTP server runs in the main process; the test workers have their own DB connections. To seed data the HTTP server can read, either:

1. **Direct create from the test** — your worker writes via the model, the row commits, then the HTTP server reads it (both point at the same physical DB).

   ```ts
   await Product.create({ name: "Seed product", price: 10 });
   const response = await testGet("/products");
   ```

2. **Create over HTTP** — the request goes through the controller, so the HTTP server's connection is the one writing.

Approach #1 is faster and gives you more control over the initial state. Approach #2 is more end-to-end.

## Two-worlds: workers vs HTTP server

```
┌──────────────────────────┐       ┌──────────────────────────┐
│  Vitest worker thread    │       │  Main process            │
│  (one per test file)     │       │  (vitest globalSetup)    │
│                          │       │                          │
│  setupTest() →           │       │  startHttpTestServer() → │
│   db connection A        │       │   db connection B        │
│   models, services       │       │   HTTP server :2031      │
│                          │       │                          │
│   testGet("/products") ───── HTTP ──→ controller → response │
│                          │       │                          │
└──────────────────────────┘       └──────────────────────────┘
```

- Worker writes a row via `Product.create(...)` → uses **connection A**.
- HTTP server reads via the controller → uses **connection B**.
- Both connections point at the same physical DB, so as long as the worker write has committed, the HTTP server sees it on the next read.

This is fine for normal test flow. It bites when you're inside a transaction the worker hasn't committed — the HTTP server's connection won't see uncommitted data. Don't wrap test fixtures in long-lived transactions for that reason.

## Gotchas

- **`globalSetup` must export `setup` and `teardown`.** Vitest reads them by name. A typo in the export gets you a confusing "server not running" error on the first `testGet` call.
- **Port conflicts.** If `http.port` matches your running dev server, `startHttpTestServer()` fails to bind. Either stop the dev server or set a test-only port: `http: { port: 3999 }` in a test-config branch.
- **Auth tokens need a real user.** Generating a JWT with a non-existent `user_id` works — but the auth middleware's user-loading step will reject the request with 401 because it can't find the user in the DB.
- **`expectJson` parses the body once.** If you call it twice on the same response, the second call gets an already-consumed stream error. Capture the result.
- **The HTTP server's connection is NOT torn down between test files.** Data persists across files within a single `vitest` run. Either truncate in `afterEach` / `afterAll`, or design your tests to be order-independent.
- **No HMR / file watching.** Editing a controller during a watch run does NOT reload the HTTP server — restart `vitest --watch` to pick up controller changes. The dev server is the place for live reloading; the test server is intentionally minimal.

## See also

- [`test-service/SKILL.md`](../test-service/SKILL.md) — pure unit tests against services/repositories/models (no HTTP).
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — the controller shape your HTTP tests are exercising.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, where `tests/*.test.ts` files live.
- [`write-cli-command/SKILL.md`](../write-cli-command/SKILL.md) — `warlock add test` scaffolds both global + worker setup files.


## test-service  `@warlock.js/core/test-service/SKILL.md`

---
name: test-service
description: 'Pure unit tests against services, repositories, models, and use-cases — `setupTest({ connectors })` bootstraps each Vitest worker with its own DB/cache connections so you can call your code directly. Triggers: `setupTest`, `src/test-setup.ts`, `tests.connectors`, `Application.setEnvironment`; "unit-test a service", "test a repository query", "vitest setupFiles", "skip connectors for pure-logic tests"; typical import `import { setupTest } from "@warlock.js/core"`. Skip: HTTP integration — `@warlock.js/core/test-http/SKILL.md`; warlock add test scaffold — `@warlock.js/core/write-cli-command/SKILL.md`; competing tooling: jest direct, `supertest`, `nock`.'
---

# Warlock — test a service

For unit tests, you import the thing under test and call it directly. No HTTP, no fetch, no controllers. Framework testing in Warlock is about getting your **service layer** under test efficiently — and that means each Vitest worker needs its own bootstrapped framework with a DB connection.

`setupTest()` is the one-call bootstrap that gives each worker that environment.

## The shape

```ts title="src/app/users/tests/register-user.service.test.ts"
import { beforeAll, describe, expect, it } from "vitest";
import { registerUserService } from "../services/register-user.service";
import { usersRepository } from "../repositories/users.repository";

describe("registerUserService", () => {
  it("creates a user with hashed password", async () => {
    const user = await registerUserService({
      email: "test@example.com",
      password: "secret",
    });

    expect(user.get("email")).toBe("test@example.com");
    expect(user.get("password")).not.toBe("secret");  // hashed by useHashedPassword()

    const found = await usersRepository.first({ email: "test@example.com" });
    expect(found).toBeDefined();
  });
});
```

No `beforeAll(setupTest)` in this file — the project's `src/test-setup.ts` (registered as `setupFiles` in `vite.config.ts`) already ran it once per worker before any test executed.

## `setupTest({ connectors })` — the worker bootstrap

```ts
import { setupTest } from "@warlock.js/core";

await setupTest({ connectors: true });
```

What it does (in order):

1. Sets `Application.setEnvironment("test")`.
2. Loads `warlock.config.ts`.
3. Runs `bootstrap()` — env, app, prestart hooks.
4. Initializes the `filesOrchestrator` (module/route/config discovery, no file watching).
5. Loads all `src/config/*.ts` files.
6. Reads `tests.connectors` from config (overrides the parameter if set).
7. Starts the chosen connectors — but **never `http`** when you pass a boolean. HTTP is the global-setup's job.

The result: each worker has its own DB/cache/logger/storage connections. Models save, repositories query, services run. Same code as production, just isolated to the test process.

### The `connectors` parameter

| Value                  | Boots                                                            | Use when                                              |
| ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- |
| `true` *(default)*     | Every connector except `http` — via `startWithout(["http"])` (db, cache, logger, storage, and socket if configured) | Most service / repository / model tests.              |
| `false`                | None                                                             | Pure logic tests with no DB / cache touches (parsers, validators, util functions). |
| `["database", "cache"]` | Just those, in that order                                        | A test that only needs DB but not, say, the storage driver. |

The default `true` is the sane choice. Reach for `false` when the unit you're testing genuinely doesn't talk to any framework subsystem — pulling up a DB connection per worker just to test a string parser is wasted setup time.

### Override via config — `src/config/tests.ts`

```ts title="src/config/tests.ts"
const testsConfigurations = {
  connectors: ["database", "logger"],
};

export default testsConfigurations;
```

If `tests.connectors` is set, **it wins over the `setupTest({ connectors })` parameter**. Use this when every test file in the project agrees on the same minimal connector list — saves repeating the explicit array in `test-setup.ts`.

## Project wiring — `src/test-setup.ts` + `vite.config.ts`

The `warlock add test` feature creates both files. The standard wiring:

```ts title="src/test-setup.ts"
/**
 * Per-Worker Test Setup
 * Runs in EACH Vitest worker thread before tests execute.
 */
import { setupTest } from "@warlock.js/core";

await setupTest({ connectors: true });
```

```ts title="vite.config.ts"
import mongezVite from "@mongez/vite";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [mongezVite()],
  test: {
    globalSetup: "./src/test-global-setup.ts",  // ← HTTP server (see test-http skill)
    setupFiles: ["./src/test-setup.ts"],         // ← runs setupTest per worker
    environment: "node",
    globals: false,
    include: ["src/app/**/*.test.ts"],
  },
});
```

The `mongezVite()` plugin handles TypeScript path resolution and the framework's module shape. Without it, your imports break the moment vitest tries to load a Warlock module.

Tests live colocated with the module: `src/app/<module>/tests/*.test.ts`. The `include` pattern picks them up.

## Patterns

### Testing a service that talks to the DB

```ts title="src/app/products/tests/create-product.service.test.ts"
import { describe, expect, it } from "vitest";
import { Product } from "../models/product";
import { createProductService } from "../services/create-product.service";

describe("createProductService", () => {
  it("persists the product and sets a slug", async () => {
    const product = await createProductService({
      name: "Test product",
      price: 99,
    });

    expect(product.id).toBeDefined();
    expect(product.get("slug")).toBe("test-product");
  });

  it("rejects duplicate names", async () => {
    await Product.create({ name: "Existing", price: 10 });

    await expect(
      createProductService({ name: "Existing", price: 20 }),
    ).rejects.toThrow(/already exists/i);
  });
});
```

Direct call, direct assert, direct DB read for verification. No mocks — the test runs against the real DB connection that `setupTest` brought up.

### Testing a use-case pipeline

```ts title="src/app/orders/tests/place-order.use-case.test.ts"
import { describe, expect, it } from "vitest";
import { placeOrderUseCase } from "../use-cases/place-order.use-case";

describe("placeOrderUseCase", () => {
  it("runs guards → validation → handler in order", async () => {
    const result = await placeOrderUseCase({
      cart_id: "cart_123",
      payment_method: "card",
    });

    expect(result.order.get("status")).toBe("pending_payment");
  });

  it("aborts if guard throws", async () => {
    await expect(
      placeOrderUseCase({
        cart_id: "empty_cart",
        payment_method: "card",
      }),
    ).rejects.toThrow(/cart is empty/i);
  });
});
```

Use-cases are first-class testable in this layer — no HTTP plumbing in the way.

### Testing a repository query

```ts title="src/app/users/tests/users.repository.test.ts"
import { describe, expect, it } from "vitest";
import { User } from "../models/user";
import { usersRepository } from "../repositories/users.repository";

describe("usersRepository", () => {
  it("findActiveByEmail returns only non-deleted users", async () => {
    const active = await User.create({ email: "a@e.com", deleted_at: null });
    const deleted = await User.create({ email: "b@e.com", deleted_at: new Date() });

    const found = await usersRepository.findActiveByEmail("a@e.com");
    expect(found?.id).toBe(active.id);

    const missing = await usersRepository.findActiveByEmail("b@e.com");
    expect(missing).toBeNull();
  });
});
```

### Cleaning up between tests

```ts
import { afterEach } from "vitest";
import { User } from "../models/user";

afterEach(async () => {
  await User.query().delete();
});
```

Vitest runs tests in a single worker file sequentially, so an `afterEach` truncate gives each test a clean slate. For cross-file isolation, run the suite with `vitest --pool=forks --maxWorkers=N` and rely on the per-worker connection — each file's data stays within its worker until the run ends.

### Skipping connectors for pure logic tests

```ts title="src/app/utils/tests/slugify.test.ts"
import { beforeAll, describe, expect, it } from "vitest";
import { setupTest } from "@warlock.js/core";
import { slugify } from "../utils/slugify";

beforeAll(async () => {
  await setupTest({ connectors: false });  // override the project default
});

describe("slugify", () => {
  it("lowercases and dashes", () => {
    expect(slugify("Hello World")).toBe("hello-world");
  });
});
```

`setupTest` is idempotent per worker (`isSetupComplete` flag) — calling it again with different options after `src/test-setup.ts` already ran is a no-op. To genuinely skip connectors, either set `tests.connectors: false` in config (project-wide) or rely on the default in `src/test-setup.ts` being what you want most of the time.

## Gotchas

- **`setupTest` is idempotent per worker.** Second + later calls early-return. You can't "swap" the connector set mid-run — the first call wins, including the one in `src/test-setup.ts`. Choose your worker default carefully.
- **Per-worker connections are separate from the HTTP server's connections.** A row inserted by a service-level test is on the worker's connection; the HTTP test server has its own. They don't see each other unless they're both pointing at the same physical DB and the inserting test has already committed.
- **`NODE_ENV` is set to `"test"`** by `setupTest`. Code that branches on `Application.isProduction` / `Application.isDevelopment` sees `false` for both. If your tests need production-like config (cookies, CORS), set those values in `src/config/*.ts` explicitly under the test branch — don't rely on the env flag.
- **No HTTP from this layer.** `setupTest({ connectors: true })` never starts the HTTP connector by design. Don't try to `request.app.http` your way to a fetch test — use the `test-http` skill instead.
- **Don't import `vitest-setup` from `@warlock.js/core/src/...`.** The public surface is `import { setupTest } from "@warlock.js/core"`. Reaching into source paths breaks when the package layout shifts.
- **Test files need the `.test.ts` suffix.** `include: ["src/app/**/*.test.ts"]` is what vitest scans. A file named `service.tests.ts` (plural) silently doesn't run.

## See also

- [`test-http/SKILL.md`](../test-http/SKILL.md) — integration tests via the real HTTP server (`startHttpTestServer` + `testGet` / `testPost` / `expectJson`).
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — where tests live in a module (`tests/*.test.ts`).
- [`write-cli-command/SKILL.md`](../write-cli-command/SKILL.md) — `warlock add test` for the initial scaffold.


## upload-file  `@warlock.js/core/upload-file/SKILL.md`

---
name: upload-file
description: 'Handle multipart file uploads — read via `request.file()` or `request.validated()`, validate with `v.file()`, save via `UploadedFile.save()` or the storage layer, transform images inline. Triggers: `UploadedFile`, `request.file`, `v.file`, `.save`, `.saveAs`, `.resize`, `.format`, `.quality`, `.image`, `.mimeType`, `.maxSize`; "accept a file upload", "validate file size and mime", "save to S3 or local disk", "resize an uploaded image on save"; typical import `import type { UploadedFile, RequestHandler } from "@warlock.js/core"`. Skip: storage drivers + presigned URLs — `@warlock.js/core/store-file/SKILL.md`; image-only transforms — `@warlock.js/core/process-image/SKILL.md`; schema rules — `@warlock.js/core/validate-input/SKILL.md`; competing libs `multer`, `formidable`, `busboy`.'
---

# Warlock — upload a file

Multipart uploads come in as `UploadedFile` instances. The class wraps Fastify's multipart data and adds a fluent API for validation, image transforms, and storage. Save with `.save(directory)` for auto-naming or `.saveAs(path)` for an explicit path; both return a `StorageFile` with the final path/URL.

## The shape

```ts title="src/app/uploads/schema/index.ts"
import { v } from "@warlock.js/seal";

export const uploadAvatarSchema = v.object({
  avatar: v
    .file()
    .image()
    .maxSize({ unit: "MB", size: 5 })
    .mimeType(["image/jpeg", "image/png", "image/webp"]),
});
```

```ts title="src/app/uploads/controllers/upload-avatar.controller.ts"
import { type GuardedRequestHandler } from "app/auth/types/guarded-request.type";
import { type UploadAvatarSchema, uploadAvatarSchema } from "../schema/upload-avatar.schema";

export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> = async (
  request,
  response,
) => {
  const { avatar } = request.validated();

  const file = await avatar
    .resize(400, 400)
    .format("webp")
    .quality(85)
    .save(`avatars/${request.user.id}`);

  return response.successCreate({ path: file.path, url: file.url });
};

uploadAvatarController.validation = {
  schema: uploadAvatarSchema,
};
```

That's the full flow: schema declares the validation (value + type from one file in `schema/`), controller uses `GuardedRequestHandler<UploadAvatarSchema>` (or `RequestHandler<Request<UploadAvatarSchema>>` for public routes), pull from `request.validated()`, chain transforms, save to disk. The result is a `StorageFile` with `path`, `url`, `mimeType`, and the rest.

## Reading the file

Inside a controller:

```ts
import type { RequestHandler, UploadedFile } from "@warlock.js/core";

export const uploadController: RequestHandler = async (request, response) => {
  // option A — direct from request, no validation
  const file: UploadedFile | undefined = request.file("avatar");

  if (!file) {
    return response.badRequest({ error: "missing file" });
  }

  // option B — typed via the schema (preferred when you've defined one)
  const { avatar } = request.validated<{ avatar: UploadedFile }>();
};
```

`request.file(key)` returns `UploadedFile | undefined`. With a schema attached, `request.validated()` is typed via `Infer<typeof schema>` so the file field comes out as `UploadedFile` directly.

For multi-file uploads, use an array schema (`v.array(v.file())`) — the validated value is `UploadedFile[]`.

## The `UploadedFile` API

From `@warlock.js/core/src/http/uploaded-file.ts`:

| Member                              | Returns                | Notes                                                  |
| ----------------------------------- | ---------------------- | ------------------------------------------------------ |
| `file.name`                         | `string`               | sanitized original filename                            |
| `file.mimeType`                     | `string`               | e.g. `"image/jpeg"`                                    |
| `file.extension`                    | `string`               | lowercased, no dot — e.g. `"jpg"`                      |
| `file.isImage` / `isVideo` / `isAudio` | `boolean`           | MIME-type prefix checks                                |
| `await file.size()`                 | `number` (bytes)       | buffers the file on first call                         |
| `await file.buffer()`               | `Buffer`               | cached after first call                                |
| `await file.dimensions()`           | `{ width?, height? }`  | empty object if not an image                           |
| `await file.metadata()`             | `{ name, mimeType, extension, size, width?, height? }` | one-shot metadata |
| `await file.toImage()`              | `Image`                | for advanced transforms outside the fluent API         |
| `await file.toJSON()`               | metadata + base64      | for serialization / debugging                          |

Image transforms (chainable, no-op for non-images):

| Method                          | Notes                                  |
| ------------------------------- | -------------------------------------- |
| `.resize(width, height?)`       | proportional if `height` omitted       |
| `.format(format)`               | `"jpeg" | "png" | "webp" | "avif" | ...` — extension is auto-updated |
| `.quality(1–100)`               | JPEG/WebP/AVIF only                    |
| `.rotate(deg)`                  | positive = clockwise                   |
| `.blur(sigma?)`                 | default `3`, min `0.3`                 |
| `.grayscale()`                  | —                                      |
| `.transform(opts | callback)`   | full sharp/`ImageTransformOptions` control |

Save:

```ts
await file.save(directory, options?);   // auto-named, returns StorageFile
await file.saveAs(fullPath, options?);  // explicit path, returns StorageFile
```

Driver selection:

```ts
await file.use("s3").save("avatars");        // single upload
await file.use("r2").saveAs("cdn/x.webp");
```

## Save options

```ts
type SaveOptions = {
  name?: "random" | "original" | string; // default "random"
  prefix?:
    | true                                  // default datetime prefix
    | string                                // static prefix
    | {
        format?: string;                    // dayjs-style format
        randomLength?: number;
        as?: "file" | "directory";          // default "file"
      };
  driver?: StorageDriverName;
  validate?: FileValidationOptions;         // throws on mismatch
};
```

```ts
// Random name (default): avatars/x7k9m2p4.jpg
await file.save("avatars");

// Keep original: avatars/photo.jpg
await file.save("avatars", { name: "original" });

// Date directory + random name: avatars/2026/05/23/x7k9m2p4.jpg
await file.save("avatars", {
  prefix: { format: "YYYY/MM/DD", as: "directory" },
});

// Validate-then-save in one call
await file.save("avatars", {
  validate: {
    allowedMimeTypes: ["image/jpeg", "image/png"],
    maxSize: 5 * 1024 * 1024,
  },
});
```

For an explicit path, `saveAs` skips naming/prefix logic:

```ts
await file.saveAs("avatars/profile-123.png");
```

## What `save()` returns: `StorageFile`

```ts
const stored = await file.save("avatars");

stored.path;          // "avatars/x7k9m2p4.webp" (after format transform)
stored.url;           // public URL (driver-dependent)
await stored.size();
await stored.mimeType();
stored.extension;
await stored.data();  // full info object incl. hash
```

`StorageFile` is the standard handle returned by every `Storage.put()` / `UploadedFile.save()` call. Use it from then on — the `UploadedFile` is best discarded after save.

## Saving directly via the storage layer

`UploadedFile.save()` is the shortcut. For full control, drop to the storage API and pass the buffer:

```ts
import { storage } from "@warlock.js/core";

const buffer = await file.buffer();

const stored = await storage.use("s3").put(buffer, "uploads/manual/file.bin", {
  mimeType: file.mimeType,
});
```

`storage.put(content, location, options)` accepts `Buffer | string | UploadedFile | Readable`. Also: `putStream`, `putFromUrl`, `putFromBase64`. Use the storage layer directly for non-file payloads (already-processed buffers, programmatically generated content) and `UploadedFile.save()` for raw multipart uploads.

## Validation rules

Inside a seal schema, file rules chain on `v.file()`:

```ts
v.file()                                          // must be UploadedFile
  .image()                                        // must be image MIME
  .accept(["jpg", "png", "webp"])                 // extension allowlist
  .mimeType(["image/jpeg", "image/png"])          // MIME allowlist
  .pdf() / .excel() / .word()                     // shortcuts
  .minSize({ unit: "KB", size: 10 })
  .maxSize({ unit: "MB", size: 5 })
  .minWidth(200).maxWidth(4000)
  .minHeight(200).maxHeight(4000);
```

Size accepts either bytes (`.maxSize(5_242_880)`) or `{ unit, size }` (`{ unit: "MB", size: 5 }`). See [`validate-input`](../validate-input/SKILL.md) for the full validation pattern.

For ad-hoc validation outside a schema:

```ts
await file.validate({
  allowedMimeTypes: ["image/jpeg", "image/png"],
  allowedExtensions: ["jpg", "jpeg", "png"],
  maxSize: 5 * 1024 * 1024,
});
// throws on mismatch
```

## Common patterns

### Multi-file upload

```ts title="src/app/uploads/schema/index.ts"
import { v } from "@warlock.js/seal";

export const uploadFilesSchema = v.object({
  files: v
    .array(v.file().maxSize({ unit: "MB", size: 50 }).mimeType(ALLOWED_MIME_TYPES))
    .maxLength(5),
});
```

```ts title="src/app/uploads/controllers/create-upload.controller.ts"
import type { RequestHandler } from "@warlock.js/core";

export const createUploadController: RequestHandler = async (request, response) => {
  const { files } = request.validated();

  const saved = await Promise.all(
    files.map((file) =>
      file.save(`uploads/${request.user.organizationId}`, {
        prefix: { as: "directory", format: "DD-MM-YYYY" },
      }),
    ),
  );

  return response.successCreate({ uploads: saved.map((f) => ({ path: f.path })) });
};
```

### Image processing pipeline

```ts
const thumb = await avatar.resize(200, 200).format("webp").quality(80).save("avatars/thumb");

const full = await avatar.resize(1200).format("webp").quality(90).save("avatars/full");
```

Each chain produces a fresh transform — but `UploadedFile` is stateful, so do this once and keep references to the saved `StorageFile`s.

### Saving to S3 with a content type override

```ts
const file = await uploadedFile
  .use("s3")
  .save("documents", { name: "original" });

// or, full storage layer:
await storage.use("s3").put(await uploadedFile.buffer(), "documents/report.pdf", {
  mimeType: "application/pdf",
  cacheControl: "max-age=31536000",
});
```

### Stream a download from a stored file

The reverse direction — sending a stored file back via `response.sendFile(...)` is the cleanest path. For dynamic content, use `response.stream(...)`. See [`send-response`](../send-response/SKILL.md).

## Gotchas

- **`file.buffer()` reads the entire file into memory.** For large uploads, prefer `Storage.putStream(...)` (drop straight from `request.file().fileData.file` if you have access — but in most cases `save()` is fine).
- **Image transforms only apply to images.** `file.resize(...).save(...)` on a PDF is a no-op for the resize and a successful save for the file.
- **`save()` rewrites the extension on format change.** `file.format("webp").save("avatars")` produces `avatars/<name>.webp` regardless of the upload's original extension.
- **`.use(driver)` mutates the instance.** It returns `this`; subsequent saves on the same instance keep the driver. Re-instantiate or call `.use("local")` to reset.
- **Multipart parsing needs `fileUploadLimit` headroom.** Default is `10MB` (configurable via `http.fileUploadLimit` in `src/config/http.ts`). Schema `maxSize` is checked after parsing, so set the multipart limit at least as high.
- **`request.file("key")` returns `undefined` for missing files** — no throw. Always check before chaining.
- **The hash is empty until save.** `file.hash` is populated by the SHA-256 from `StorageFile.data()` after `save()` resolves.

## See also

- [`validate-input/SKILL.md`](../validate-input/SKILL.md) — `v.file()` rules and the validation pipeline.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — pulling files from `request.validated()` vs `request.file()`.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — `response.sendFile(...)` for serving the stored file back.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — storage configuration and driver selection.


## use-app-context  `@warlock.js/core/use-app-context/SKILL.md`

---
name: use-app-context
description: 'Read app-wide context — the `Application` static class (env, version, uptime, runtime strategy) plus the `app` runtime accessor (live Fastify, socket.io, router, database via the DI container). Triggers: `Application.isProduction`, `Application.environment`, `Application.runtimeStrategy`, `Application.uptime`, `Application.version`, `app.http`, `app.socket`, `app.database`, `app.router`; "branch on environment", "reach the live Fastify instance", "framework version in health endpoint", "dev vs production runtime check"; typical import `import { Application, app } from "@warlock.js/core"`. Skip: path helpers — `@warlock.js/core/resolve-path/SKILL.md`; connector start order — `@warlock.js/core/add-connector/SKILL.md`; competing patterns: bare `process.env.NODE_ENV`, ad-hoc Fastify imports.'
---

# Warlock — use the application context

`Application` is the static class that exposes "where am I running, and where is everything?" without each call site re-parsing `process.env`, `process.cwd()`, or a package.json. Import it from `@warlock.js/core` and read off the static members directly — no instantiation.

## The shape

```ts
import { Application } from "@warlock.js/core";

if (Application.isProduction) {
  // …production-only behavior
}

const uploadsDir = Application.uploadsPath;
const uptimeMs   = Application.uptime;
const version    = Application.version;
```

Every member is a getter — values are computed on access, not snapshotted at boot. That's deliberate: tests flip the environment, the dev server flips the runtime strategy, and the framework caches its own version. You always read the current value.

## Environment

```ts
Application.environment      // "development" | "production" | "test"
Application.isProduction     // === "production"
Application.isDevelopment    // === "development"
Application.isTest           // === "test"
```

Backed by `process.env.NODE_ENV`. `setEnvironment` mutates it:

```ts
Application.setEnvironment("test");
```

Used internally by the CLI (`preload.environemnt: "production"` calls `setEnvironment` before the action runs) and by Vitest setups. Don't call it from request handlers — it's process-global.

The most common consumer is `src/config/http.ts`, picking the cookie security flag:

```ts title="src/config/http.ts"
import { Application, env, type HttpConfigurations } from "@warlock.js/core";

const httpConfigurations: HttpConfigurations = {
  cookies: {
    secret: env("COOKIE_SECRET", "super-secret-key-change-me"),
    options: {
      httpOnly: true,
      secure: Application.isProduction,   // true on prod (HTTPS only), false in dev
      path: "/",
    },
  },
};

export default httpConfigurations;
```

## Runtime strategy

`runtimeStrategy` is a separate axis from `environment`. It marks how the *framework itself* is running:

- `"development"` — dev server (file watcher, HMR, transpile cache on disk).
- `"production"` — built bundle running from `dist/`.

```ts
Application.runtimeStrategy           // "production" | "development"
Application.setRuntimeStrategy("production");
```

The dev-server CLI command sets it to `"development"`. The `build` command and `start.production` set `"production"`. Use it inside connectors that need to behave differently in the bundled output — the built-in `HttpConnector` uses `router.scanDevServer()` in dev and `router.scan()` in production.

Most app code shouldn't care about `runtimeStrategy` — branch on `environment` instead, which is the orthogonal "what world is this code talking to?" axis.

## Paths

For path helpers (`appPath`, `configPath`, `uploadsPath`, …) anchored at `process.cwd()`, the `paths.*` aggregate, and the `uploads.root` config override, see [`resolve-path/SKILL.md`](../resolve-path/SKILL.md).

The most-used helpers are also surfaced as no-argument getters on `Application`:

```ts
Application.rootPath        // <cwd>
Application.srcPath         // <cwd>/src
Application.appPath         // <cwd>/src/app
Application.publicPath      // <cwd>/public
Application.storagePath     // <cwd>/storage
Application.uploadsPath     // <cwd>/storage/uploads (or override)
```

Reach for those when you want the directory itself; reach for the helpers (e.g. `appPath("orders/routes.ts")`) when you need a file inside.

## Version and uptime

```ts
Application.version        // "1.0.0" (semver, cached after first read)
Application.uptime         // ms since process start
Application.startedAt      // Date object — wall time when the process booted
```

`version` is the `@warlock.js/core` package version, lazily loaded once. `uptime` is `process.uptime() * 1000` — the Node-process uptime, not "the framework's uptime." If you spawn workers, each worker has its own.

`startedAt` is computed once at module load: `new Date(Date.now() - process.uptime() * 1000)`. Stable across the run.

The project's home page surfaces the version in a footer:

```tsx
<span>v{Application.version}</span>
```

## The `app` runtime accessor

`Application` exposes **static metadata** (env, paths, version). For **runtime infrastructure** — the live Fastify instance, socket.io server, router, database connection — use the separate `app` object exported from `@warlock.js/core`:

```ts
import { app } from "@warlock.js/core";

app.http        // Fastify instance (after http connector starts)
app.socket      // socket.io Server (after socket connector starts)
app.router      // the Router singleton
app.database    // Cascade's DataSource
```

Each property is a getter backed by the framework's DI container (`container.get("http.server")` etc.). The container is populated by connectors during their `boot()`/`start()` phase — read these accessors only after the relevant connector has run. Every getter is a thin `container.get(...)`, so before the connector boots it returns `undefined` (it does **not** throw); chaining off an `undefined` accessor is what blows up. `http` and `socket` are *late*-phase connectors — they boot **after** app code is imported, so these accessors are not populated at the top level of a module's `main.ts`. From inside controllers, services, use-cases, or any code that runs while a request is in flight, every accessor is safe.

Typical uses (note: from runtime code, after bootstrap — not at module-import time):

```ts title="src/app/chat/setup-chat-socket.ts — attach a socket.io namespace"
import { getSocketServer } from "@warlock.js/core";

export function setupChatSocket() {
  const io = getSocketServer();
  if (!io) return; // socket connector not booted yet

  io.of("/chat").on("connection", (socket) => {
    // …
  });
}
```

```ts title="raw DataSource for an out-of-Cascade query"
import { app } from "@warlock.js/core";

const result = await app.database.client.query("SELECT NOW()");
```

Reach for `app.*` as the escape hatch. If a typed framework primitive wraps the same thing (`router.get(...)` over `app.http.route(...)`, repository methods over raw `app.database` queries), prefer that — the typed surface keeps you on the rails the rest of the framework is designed around.

## Common patterns

### Environment-aware logging level

```ts title="src/config/log.ts"
import { Application, type LogConfigurations } from "@warlock.js/core";

const logConfig: LogConfigurations = {
  level: Application.isProduction ? "info" : "debug",
};

export default logConfig;
```

### Resolve a file relative to the app root

```ts
import { appPath } from "@warlock.js/core";
import { readFile } from "node:fs/promises";

const template = await readFile(appPath("mailers/templates/welcome.html"), "utf-8");
```

### Health endpoint

```ts title="src/app/system/controllers/health.controller.ts"
import { Application, type RequestHandler, type Response } from "@warlock.js/core";

export const healthController: RequestHandler = async (_request, response: Response) => {
  return response.success({
    status: "ok",
    environment: Application.environment,
    version: Application.version,
    uptimeMs: Application.uptime,
    startedAt: Application.startedAt,
  });
};
```

### Don't ship debug routes to production

```ts title="src/app/dev/routes.ts"
import { Application, router } from "@warlock.js/core";
import { dumpStateController } from "./controllers/dump-state.controller";

if (!Application.isProduction) {
  router.get("/__dev/state", dumpStateController);
}
```

(The framework's HMR diffs route registrations — wrapping `router.get()` in an `if` means the route exists or doesn't depending on env, which is fine for boot-time branches like this one.)

### CORS — relax in dev, lock down in prod

```ts title="src/config/http.ts"
cors: {
  origin: Application.isProduction
    ? ["https://app.example.com"]
    : "*",
}
```

## Gotchas

- **Don't cache `Application.isProduction` at module top-level.** Tests routinely flip `NODE_ENV` before importing modules; a cached snapshot survives the flip and produces stale behavior. Read the getter at use site.

  ```ts
  // ❌ cached at import time — wrong after env flips
  const isProd = Application.isProduction;

  export function logLevel() {
    return isProd ? "info" : "debug";
  }

  // ✅ read at use site
  export function logLevel() {
    return Application.isProduction ? "info" : "debug";
  }
  ```

- **`version` is `null` until the first `await`.** The version loader is async (it reads `package.json`). On a cold start before any framework code has run `getWarlockVersion()`, `Application.version` returns `null`. The framework does load it during bootstrap, so anywhere downstream of bootstrap is fine — controllers, services, connectors after `start()`. CLI commands without `preload.bootstrap` may see `null`.
- **`Application` is static, not a DI registration.** Don't try to inject it. There's nothing to inject — it's a class with only static members.
- **`app.*` accessors return `undefined` before their connector boots — they don't throw.** `app.socket` / `app.database` / `app.http` are populated by their respective connectors during boot; until then each getter returns `undefined` (a bare `container.get(...)`). Reading them earlier (eager module-load code, the top level of a `main.ts` for the late-phase `http`/`socket`, certain CLI commands without the right `preload.connectors`) hands you `undefined`, and chaining off it throws. Safe everywhere downstream of bootstrap.

## See also

- [`resolve-path/SKILL.md`](../resolve-path/SKILL.md) — path helpers (`appPath`, `configPath`, `uploadsPath`, …) and the `paths.*` aggregate.
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — `src/config/*.ts` and `warlock.config.ts`.
- [`add-connector/SKILL.md`](../add-connector/SKILL.md) — using `Application.runtimeStrategy` inside a connector's `start()`.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, where paths point to.


## use-localization  `@warlock.js/core/use-localization/SKILL.md`

---
name: use-localization
description: 'Multi-locale translations via `groupedTranslations` (declare keys), `t()` / `request.t()` / `request.trans()` (look up), `request.getLocaleCode()` (detect locale from headers/query), `getLocalized` (pick the right value from a localized-array column). Triggers: `groupedTranslations`, `t`, `request.t`, `request.trans`, `request.transFrom`, `request.getLocaleCode`, `request.setLocaleCode`, `getLocalized`; "add a translation key", "resolve a localized error message", "detect request locale", "pick the right per-locale column value"; typical import `import { t, getLocalized } from "@warlock.js/core"`. Skip: resource output — `@warlock.js/core/define-resource/SKILL.md`; module scaffold — `@warlock.js/core/create-module/SKILL.md`; competing libs `i18next`, `react-intl`, raw `@mongez/localization`.'
---

# Warlock — translate keys + pick localized values

Two related but distinct jobs:

1. **Translation keys** — string identifiers like `"products.notFound"` mapped to per-locale strings (`en`, `ar`, ...). Used for error messages, response copy, anything that needs to render differently per locale.
2. **Localized columns** — a model column that stores `[{ localeCode: "en", value: "Hello" }, { localeCode: "ar", value: "مرحبا" }]`. Used for content that varies by locale (product names, article titles).

Both pivot on **the request's locale** — auto-detected from headers / query string, defaulting to a config value.

## The shape

```ts
// 1. Declare keys (auto-loaded from src/app/<module>/utils/locales.ts)
import { groupedTranslations } from "@mongez/localization";

groupedTranslations("products", {
  notFound: { en: "Product not found", ar: "المنتج غير موجود" },
  created:  { en: "Product created",  ar: "تم إنشاء المنتج" },
});

// 2. Look up in a controller / service
import { t } from "@warlock.js/core";

throw new ResourceNotFoundError(t("products.notFound"));
// or via the request:
return response.success({ message: request.t("products.created") });

// 3. Pick a value from a localized-array column
import { getLocalized } from "@warlock.js/core";

const name = getLocalized(product.get("name_translations"));
// → reads the current request's locale, returns the matching value
```

## Declaring translations — `groupedTranslations`

Every module owns its translation namespace under `src/app/<module>/utils/locales.ts`. The file is auto-loaded at boot — you don't import it from anywhere; the framework picks it up via the module loader.

```ts title="src/app/products/utils/locales.ts"
import { groupedTranslations } from "@mongez/localization";

groupedTranslations("products", {
  notFound:    { en: "Product not found",      ar: "المنتج غير موجود" },
  outOfStock:  { en: "Product out of stock",   ar: "المنتج غير متوفر" },
  created:     { en: "Product created",         ar: "تم إنشاء المنتج" },
  updated:     { en: "Product updated",         ar: "تم تحديث المنتج" },
  deleted:     { en: "Product deleted",         ar: "تم حذف المنتج" },
});
```

The first arg is the **group name** (matches the module's URL slug by convention). Lookup keys are dot-joined: `products.notFound`, `products.created`. Within a group, every key needs the same locale set — if you ship `en` for `notFound`, ship it for everything else too.

You can also import `groupedTranslations` from `@warlock.js/core` (it's re-exported). Both imports work; the project's seed file uses `@mongez/localization` directly. Either is fine.

### Placeholders

```ts
groupedTranslations("products", {
  outOfStock: {
    en: "Product :name is out of stock (current: :count)",
    ar: "المنتج :name غير متوفر (الكمية: :count)",
  },
});

// Lookup:
t("products.outOfStock", { name: "Pen", count: 0 });
// → "Product Pen is out of stock (current: 0)"
```

Placeholders use the `:name` syntax. The lookup helper substitutes them from the second argument.

## Looking up translations — `t()`, `request.t()`, `request.trans()`

Three calls, same job, slightly different reachability:

```ts
// 1. Top-level helper — works inside or outside a request.
//    Inside a request: uses request's locale.
//    Outside: uses the global default locale.
import { t } from "@warlock.js/core";

t("products.notFound");
t("products.outOfStock", { name: "Pen", count: 0 });

// 2. On the request object — explicit, scoped to that request.
request.t("products.notFound");

// 3. Alias for request.t — same behavior.
request.trans("products.notFound");
```

All three lookups go through `@mongez/localization`'s `trans()` under the hood, with the locale pulled from the request context (or the global default).

### Locale on a specific lookup

```ts
request.transFrom("ar", "products.notFound");
// → "المنتج غير موجود" regardless of request's actual locale
```

Use when you want a value in a specific locale — e.g. sending a notification to a user whose preferred locale differs from the current request's.

## Locale detection — `request.getLocaleCode()`

The framework reads the locale from the incoming request in this order:

1. **`translation-locale-code` header** (first priority).
2. **`locale` header**.
3. **`locale` query string param**.
4. **Default** — `config.key("app.localeCode")` falling back to `"en"`.

```ts
const locale = request.getLocaleCode();
// → "en" | "ar" | whatever the caller asked for
```

Configure the default:

```ts title="src/config/app.ts"
export default {
  localeCode: "en",                       // app-wide default
  // ...
};
```

### Setting the locale programmatically

```ts
request.setLocaleCode("ar");
```

After this, every `request.t(...)` and `getLocalized(...)` in the same request returns Arabic. Useful for user-preference overrides (e.g. "this user has set their language to ar in their profile — switch the request's locale on auth").

## Localized columns — `getLocalized`

When a column stores per-locale values as an array:

```ts
// Schema (Seal):
name_translations: v.array(
  v.object({
    localeCode: v.string(),  // "en", "ar", ...
    value: v.string(),
  })
),

// Stored row (DB):
{
  name_translations: [
    { localeCode: "en", value: "Hello World" },
    { localeCode: "ar", value: "مرحبا" },
  ],
}
```

Pick the right one for the current request:

```ts
import { getLocalized } from "@warlock.js/core";

const name = getLocalized(product.get("name_translations"));
// → "Hello World" if request locale is "en"
// → "مرحبا" if request locale is "ar"
```

### Signature

```ts
getLocalized(
  values: LocalizedObject[],
  localeCode?: string,
  key = "value",
): unknown;
```

- **`values`** — the localized-array column.
- **`localeCode`** *(optional)* — pin to a specific locale. Defaults to the current request's locale (reads via `useRequestStore()`).
- **`key`** *(default `"value"`)* — which property of the matched entry to return. Use a different key if your localized objects store the value under a different name.

```ts
const slug = getLocalized(product.get("slug_translations"), undefined, "value");
const tagline = getLocalized(product.get("name_translations"), "fr");  // force French
```

### Use inside a resource for clean per-locale responses

```ts title="src/app/products/resources/product.resource.ts"
import { defineResource, getLocalized } from "@warlock.js/core";

export const ProductResource = defineResource({
  schema: {
    id: "string",
    name: function (_value, resource) {
      return getLocalized(resource.get("name_translations"));
    },
    description: function (_value, resource) {
      return getLocalized(resource.get("description_translations"));
    },
  },
});
```

The `localized` cast (`name: "localized"`) handles the common case directly — reach for a `getLocalized` resolver only when the stored key differs from the output key or you need a fallback.

The wire response always returns the right locale string — no caller-side branching needed.

## Patterns

### Translated error in a service

```ts title="src/app/products/services/get-product.service.ts"
import { ResourceNotFoundError, t } from "@warlock.js/core";
import { productsRepository } from "../repositories/products.repository";

export async function getProductService(id: string) {
  const product = await productsRepository.find(id);
  if (!product) throw new ResourceNotFoundError(t("products.notFound"));
  return product;
}
```

The error's message is locale-aware — the `inject-request-context` middleware catches the error and the response carries the translated string.

### Translated response message in a controller

```ts
export const createProductController: GuardedRequestHandler<CreateProductSchema> = async (
  request,
  response,
) => {
  const product = await createProductService(request.validated());
  return response.success({
    message: request.t("products.created"),
    product,
  });
};
```

### Per-locale product names with a localized column

```ts
import { v } from "@warlock.js/seal";

export const productSchema = v.object({
  // ...
  name_translations: v.array(
    v.object({
      localeCode: v.string(),
      value: v.string(),
    }),
  ),
  // ...
});

// Create:
await Product.create({
  name_translations: [
    { localeCode: "en", value: "Pen" },
    { localeCode: "ar", value: "قلم" },
  ],
});

// Read (via a resource that uses getLocalized):
// → response includes "name": "Pen" for en, "قلم" for ar
```

### Caller overrides locale via query string

```bash
GET /products/42?locale=ar
```

`request.getLocaleCode()` returns `"ar"` — every `request.t(...)` and resource-output `getLocalized` returns Arabic. No app code changes required.

## Gotchas

- **Locales files are auto-loaded — don't import them.** `src/app/<module>/utils/locales.ts` is picked up by the module loader on boot. Importing it manually causes double-registration warnings.
- **Group names should match module URL slugs.** `groupedTranslations("products", ...)` for the `/products` module. Mismatch breaks the convention but doesn't throw.
- **Every key needs every locale.** If a key is missing for the active locale, the lookup falls back to the key string itself (`"products.notFound"` literally appears in the response). Always ship all locales together when adding a key.
- **`t()` outside a request uses the global default.** `Application.environment === "test"` or background jobs without a request context get the configured `app.localeCode`, not whatever the most recent request used.
- **`getLocalized` returns `undefined` if no entry matches.** Empty `name_translations` arrays, missing-locale arrays, or `undefined` columns all surface as `undefined`. Check at the read site or set a default in the resource: `getLocalized(values) ?? "n/a"`.
- **`request.getLocaleCode()` populates `_locale` on first read.** The second call returns the cached value. Calling `setLocaleCode` after the first read overrides cleanly.
- **The `t()` helper is exported from `@warlock.js/core`.** Don't import `trans` from `@mongez/localization` directly inside controllers/services — `t()` adds request-context awareness on top.
- **Placeholders are case-sensitive.** `:name` does NOT match `:Name`. The translation string must spell the placeholder exactly as you pass it.

## See also

- [`send-response/SKILL.md`](../send-response/SKILL.md) — error helpers that pair with translated messages (`response.notFound({ error: t("...") })`).
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — using `getLocalized` inside resource output for clean per-locale responses.
- [`create-module/SKILL.md`](../create-module/SKILL.md) — the `utils/locales.ts` file is part of the generated module scaffold.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — `utils/locales.ts` is auto-loaded; the suffix is mandatory.


## use-middleware  `@warlock.js/core/use-middleware/SKILL.md`

---
name: use-middleware
description: 'Attach built-in HTTP middleware to routes via the `middleware` namespace from `@warlock.js/core` — rateLimit, concurrencyLimit, maxBodySize, idempotency, maintenance, ipFilter, cache. Plus `X-Request-Id` correlation, wired automatically. Triggers: `middleware.rateLimit`, `middleware.concurrencyLimit`, `middleware.maxBodySize`, `middleware.idempotency`, `middleware.maintenance`, `middleware.ipFilter`, `middleware.cache`, `X-Request-Id`, `Idempotency-Key`; "add rate limiting", "dedupe writes by idempotency key", "cap concurrent requests", "block IPs", "cache a GET response"; typical import `import { middleware } from "@warlock.js/core"`. Skip: author custom middleware — `@warlock.js/core/write-middleware/SKILL.md`; cache singleton — `@warlock.js/cache/cache-basics/SKILL.md`; competing libs `@fastify/rate-limit` direct, `express-rate-limit`, `helmet`.'
---

# Warlock — use built-in middleware

`@warlock.js/core` ships seven HTTP middlewares behind a single namespace object: `middleware`. They cover the patterns most apps reach for — rate limit, concurrency cap, body size cap, idempotency, maintenance mode, IP filter, response cache. Plus the request-id echo, which isn't a middleware but lives in the same mental category.

```ts
import { middleware } from "@warlock.js/core";

middleware.rateLimit({ max: 5, duration: 60_000 });
middleware.idempotency();
middleware.maxBodySize("2mb");
// ... etc
```

For authoring your OWN middleware (custom guards, enrichment, etc.), see [`write-middleware`](../write-middleware/SKILL.md).

## The catalog

All seven are factories — call them to get a `Middleware`. All set `errorCode` on the response so clients can branch without parsing error text (see `HttpErrorCodes` from `@warlock.js/core`). Error messages are translation-driven — keys live under the `http` group in `src/app/shared/utils/locales.ts`.

| Factory | What it does | Status on reject |
|---|---|---|
| `middleware.rateLimit({ max, duration })` | Per-route/group cap on top of global `@fastify/rate-limit` | 429 + `Retry-After` |
| `middleware.concurrencyLimit(n)` | Cap in-flight requests; no queue, fast reject | 429 + `Retry-After: 1` |
| `middleware.maxBodySize("2mb")` | Per-route `Content-Length` cap | 413 |
| `middleware.idempotency()` | Dedupe writes by `Idempotency-Key` header; cached replay | 422 on conflict |
| `middleware.maintenance()` | Globally toggle 503 with allowlist bypass | 503 + `Retry-After` |
| `middleware.ipFilter({ allow, deny })` | Allowlist/denylist by client IP | 403 (fail-closed) |
| `middleware.cache(opts)` | Cache + replay successful JSON responses | n/a |

Each is documented inline (JSDoc + `@example`) — read the file under `@warlock.js/core/src/http/middleware/<name>.middleware.ts` for the full option surface.

## `rateLimit` vs `concurrencyLimit`

- **Rate limit** caps **requests per time window** — 5 logins/minute per IP. Use for abuse prevention, cost-control on cheap-but-spammable endpoints.
- **Concurrency limit** caps **in-flight requests at any instant** — 3 simultaneous report generations. Use for expensive endpoints where parallel calls would overload the worker (CPU-bound, large memory, slow third-party).

Both counters are **process-local**. With N replicas the effective cap is N × value. For globally-shared rate limits, configure `@fastify/rate-limit` with a Redis store via `http.rateLimit` instead.

```ts
import { middleware } from "@warlock.js/core";

router.post("/ai/summarize", summarizeController, {
  middleware: [
    middleware.rateLimit({ max: 60, duration: 60 * 60 * 1000 }), // 60/hr per user
    middleware.concurrencyLimit(5),                              // ≤5 in-flight at once
  ],
});
```

## `idempotency` — must run after auth

The cache key is `idem:{userType}:{userId|ip}:{key}` so user A can't replay user B's key. That requires `request.user` to be populated, so order it **after** `authMiddleware`:

```ts
import { authMiddleware } from "@warlock.js/auth";
import { middleware } from "@warlock.js/core";

router.post("/orders", createOrderController, {
  middleware: [authMiddleware("client"), middleware.idempotency()],
});
```

Lifecycle:

1. **Method not eligible** (GET / HEAD) → pass through.
2. **No `Idempotency-Key` header** → pass through (the primitive is opt-in by the client).
3. **Key + same body within TTL** (default 24h) → replays the cached response with `Idempotent-Replay: true`. Handler does NOT re-run.
4. **Key + different body** → 422 `IdempotencyKeyConflict`. Client bug — same key must mean same intent.
5. **Cache miss** → runs handler; on response sent, caches `{ status, body, bodyHash }` for TTL.

Server errors (5xx) are not cached — clients can retry past a 5xx. 4xx responses ARE cached (they're deterministic outcomes of the request).

**Client side, this only works if the client reuses the same key across retries.** Generate once at "intent to submit" time, persist it across the retry loop, drop it on confirmed success or final failure.

## `maxBodySize` vs the global `http.bodyLimit`

`http.bodyLimit` in config is read by Fastify at server-start and applies to every body. `middleware.maxBodySize()` is a per-route middleware on top — it checks `Content-Length` after route match and rejects with 413 before body parsing runs. Use both: global as a safety net, per-route for tight caps on small-payload endpoints.

```ts
// src/config/http.ts
export default { bodyLimit: 10 * 1024 * 1024 }; // 10MB globally

// src/app/comments/routes.ts
import { middleware } from "@warlock.js/core";

router.post("/comments", createCommentController, {
  middleware: [middleware.maxBodySize("8kb")], // comments shouldn't be larger
});
```

## `maintenance` is config-driven

Toggle via `http.maintenance.enabled` (and `http.maintenance.allowlist`, default `["/health"]`). Flipping the flag requires a process restart — there's no runtime hot-flip yet. Register at the app-level so every route is covered:

```ts
// src/config/http.ts
import { middleware } from "@warlock.js/core";

export default {
  maintenance: { enabled: env("MAINTENANCE_MODE") === "true" },
  middleware: {
    all: [middleware.maintenance({ allowlist: ["/health", "/admin/*"] })],
  },
};
```

## `ipFilter` — fail-closed

`deny` wins over `allow`. If the IP can't be read (empty / unparseable), the request is rejected with 403. Reads via `request.detectIp()` which honors `X-Real-IP` and `X-Forwarded-For` (Fastify starts with `trustProxy: true`).

```ts
import { middleware } from "@warlock.js/core";

// Group-wide: pass it through the group's `middleware` array (there is no
// `router.use()` — middleware attaches via group options or route options).
router.group(
  {
    prefix: "/admin",
    middleware: [middleware.ipFilter({ allow: ["10.0.0.0/8", "203.0.113.42"] })],
  },
  () => {
    router.get("/dashboard", dashboardController);
  },
);

// Per-route: pass it in the route's `middleware` array.
router.post("/webhooks/provider", webhookController, {
  middleware: [middleware.ipFilter({ allow: ["198.51.100.0/24"] })],
});
```

IPv4 CIDR matching only — IPv6 patterns are matched as exact strings.

## `cache` — response caching

Cache successful JSON responses by key, serve replays from cache until TTL expiry. Useful for expensive read endpoints (analytics dashboards, expensive aggregations).

```ts
import { middleware } from "@warlock.js/core";

router.get("/analytics/summary", summaryController, {
  middleware: [middleware.cache({ cacheKey: "analytics.summary", ttl: 300 })],
});
```

`cacheKey` can be a string OR a function `(request) => string | Promise<string>` for per-request keys. Excludes failures and omits `["user", "settings"]` from the cached body by default.

## Composed example

Tight cap on logins, concurrency + idempotency on AI calls:

```ts
import { authMiddleware } from "@warlock.js/auth";
import { middleware, router } from "@warlock.js/core";

router.post("/auth/login", loginController, {
  middleware: [
    middleware.rateLimit({ max: 5, duration: 60_000 }),
    middleware.maxBodySize("4kb"),
  ],
});

router.group({ middleware: [authMiddleware("client")] }, () => {
  router.post("/ai/summarize", summarizeController, {
    middleware: [
      middleware.rateLimit({ max: 60, duration: 60 * 60 * 1000 }),
      middleware.concurrencyLimit(5),
      middleware.idempotency({ ttl: 60 * 60 }),
    ],
  });
});
```

## Request ID correlation

Not a middleware — wired into `Request.setRequest()` and `createRequestStore()`. Nothing to register; it runs on every request automatically.

Every request gets a `request.id` (32-char random string by default). The framework:

1. **Inherits** an incoming `X-Request-Id` header if it's well-formed (printable ASCII, ≤128 chars) — so proxies / FE / edge can propagate their own correlation ID end-to-end.
2. **Echoes** `X-Request-Id: <request.id>` on every response — clients can show "request 7a3f… failed" in error toasts; support can grep logs by that single string.
3. **Stamps** `request.id` into every `request.log()` / `response.log()` line — so the per-request correlation is already in your log channel.

Configure via `http.requestId`:

```ts
// src/config/http.ts
import { ulid } from "ulid"; // if you prefer ULID over the default random string

export default {
  requestId: {
    header: "X-Request-Id",   // outbound + inbound header name
    generator: () => ulid(),  // optional override
    enabled: true,            // set false to disable both inherit + echo
  },
};
```

**Request ID is correlation, not idempotency.** A fresh ID is generated on every retry; same key, different ID. For write deduplication on retry, use `middleware.idempotency()`.

## Gotchas

- **Bare factory names are not exported.** Always reach for them via `middleware` (`middleware.rateLimit`, not `rateLimitMiddleware`). The internal `*Middleware`-suffixed names are an in-package code-organization detail.
- **Idempotency must run after auth.** The cache key includes `request.user` for scope-isolation. Putting it before auth silently falls back to IP-scope for every request.
- **In-process counters lose state on restart.** `middleware.rateLimit` and `middleware.concurrencyLimit` use module-scoped `Map`s. A redeploy resets every window/counter. For globally-shared limits, use `@fastify/rate-limit` with a Redis store.
- **Idempotency clients must reuse the key across retries.** If your client generates a new UUID on every attempt, idempotency is a no-op. Generate once at "intent" time.
- **`ipFilter` fail-closed.** Empty / unparseable IP = denied. Internal callers (Unix sockets, local processes) need explicit allowlisting.
- **Maintenance flag is config-driven, restart required.** No runtime hot-flip. Flip via env-var + redeploy.
- **Error messages come from translations.** All status-text messages are `t("http.X")` lookups. The project's `locales.ts` template ships the `http` group. If you fork the template and remove it, errors will fall back to the key name.

## See also

- [`write-middleware/SKILL.md`](../write-middleware/SKILL.md) — author your OWN middleware (custom guards, enrichment).
- [`register-route/SKILL.md`](../register-route/SKILL.md) — where middleware attaches: `router.group` and route-options.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — the response helpers used to short-circuit (`tooManyRequests`, `contentTooLarge`, `serviceUnavailable`, etc.).
- [`@warlock.js/cache/cache-basics/SKILL.md`](../../../cache/skills/cache-basics/SKILL.md) — the cache singleton (`@warlock.js/cache`) that backs `middleware.idempotency` and `middleware.cache`.


## use-model-transformers  `@warlock.js/core/use-model-transformers/SKILL.md`

---
name: use-model-transformers
description: 'Three schema-side helpers — `useHashedPassword()` (bcrypt on save) attaches via `.addTransformer(...)`; `useComputedSlug(field?, scope?)` (auto-slug from another field) and `useComputedModel(callback)` (arbitrary computed-on-save value) attach via `v.computed(...)`. Triggers: `useHashedPassword`, `useComputedSlug`, `useComputedModel`, `.addTransformer`, `v.computed`, `ComputedCallback`; "auto-hash a password field", "auto-slug from title on save", "derive a value at write time", "declarative model transformers"; typical import `import { useHashedPassword, useComputedSlug } from "@warlock.js/core"`. Skip: bcrypt setup details — `@warlock.js/core/hash-password/SKILL.md`; repository writes — `@warlock.js/core/use-repository/SKILL.md`; output filtering — `@warlock.js/core/define-resource/SKILL.md`; competing patterns: manual `await hashPassword(input)` in services, ORM lifecycle hooks.'
---

# Warlock — declare model transformers

Three helpers in `@warlock.js/core` plug into Seal schemas to compute or mutate a field at write time. They sit between "controllers + services" (the mutable-input layer) and "the database row" (the stored state) — so the value the DB sees is always derived, even if the caller didn't think about it.

| Helper | Job | Attaches via | Behavior |
| --- | --- | --- | --- |
| `useHashedPassword()` | bcrypt-hash a password field on save | `.addTransformer(useHashedPassword())` on the field | Hash on new row; hash on change; pass through on no-change |
| `useComputedSlug(field?, scope?)` | Derive a slug from another field | `v.computed(useComputedSlug("title"))` | Compute on every save where the source field is set |
| `useComputedModel(callback)` | Any custom derived value | `v.computed(useComputedModel(...))` | Runs your callback with `(data, model, context)` |

All three are imported from `@warlock.js/core` (despite living in `src/database/utils.ts` under the hood).

**Why the split:** `useHashedPassword()` returns a Seal `TransformerCallback` (built via cascade's `useModelTransformer`), so it mutates the field's own value through `.addTransformer(...)`. `useComputedSlug()` / `useComputedModel()` return a Seal `ComputedCallback` (signature `(data, context)`) — those are wired with the `v.computed(...)` field validator, **not** `.addTransformer(...)`. Passing a computed helper to `.addTransformer(...)` is wrong: the second argument shapes differ (`{ options, context }` vs bare `context`), so it misbehaves at runtime.

## The shape

```ts title="src/app/users/models/user/user.model.ts"
import { useHashedPassword } from "@warlock.js/core";
import { type Infer, v } from "@warlock.js/seal";

export const userSchema = v.object({
  email: v.email().unique("User"),
  password: v.string().requiredIfEmpty("id").addTransformer(useHashedPassword()),
});

export type UserSchema = Infer<typeof userSchema>;
```

```ts title="src/app/articles/models/article/article.model.ts"
import { useComputedSlug } from "@warlock.js/core";
import { type Infer, v } from "@warlock.js/seal";

export const articleSchema = v.object({
  title: v.string(),
  slug: v.computed(useComputedSlug("title")),
});

export type ArticleSchema = Infer<typeof articleSchema>;
```

Two attachments, two completely different behaviors — `useHashedPassword()` rides on `.addTransformer(...)` (it mutates the field), while `useComputedSlug()` rides on `v.computed(...)` (it derives the field).

## `useHashedPassword()` — bcrypt on save

```ts
password: v.string().addTransformer(useHashedPassword()),
```

What it does at save time:

| Situation                       | Behavior                                    |
| ------------------------------- | ------------------------------------------- |
| New row, password set           | Hash the value before insert.               |
| Existing row, password changed  | Re-hash the new value.                      |
| Existing row, password unchanged | Pass through (no re-hashing — stored hash preserved). |
| Empty / undefined value         | Pass through untouched.                     |

Calls `authService.hashPassword(String(value))` under the hood — same bcryptjs path as the standalone `hashPassword()` helper. See [`hash-password/SKILL.md`](../hash-password/SKILL.md) for full bcrypt setup (salt rounds, `yarn add bcryptjs`).

### Why declarative wins

Without the transformer, every code path that writes a password has to remember:

```ts
await User.create({
  email: input.email,
  password: await hashPassword(input.password),  // easy to forget
});
```

Forget the hash once — even in a one-off seed or admin tool — and you've stored plaintext. The transformer makes the wrong thing impossible: the field can only end up hashed, no matter who wrote it.

Pass plaintext to `User.create({ password: input.password })` and the transformer handles the rest.

## `useComputedSlug(field?, scope?)` — auto-slug from another field

```ts
slug: v.computed(useComputedSlug("title")),
```

- **`field`** *(default `"title"`)* — the source field name to slugify.
- **`scope`** — `"sibling"` *(default)* or `"global"`. `"sibling"` reads `data[field]` from the same object being saved; `"global"` reads `get(context.allValues, field)` (the root context — useful in nested schemas where the source field lives on a parent).

What it does:

| Situation                          | Behavior                                   |
| ---------------------------------- | ------------------------------------------ |
| Source field has a value           | Return `slugify(value)` — overwrite the slug. |
| Source field is empty / undefined  | Return `model.get(field)` — i.e. the model's existing value for the **source** field (e.g. the stored `title`), not the existing slug. |

So if a caller sets `title: "Hello World"` without touching `slug`, the saved row has `slug: "hello-world"`. If they explicitly set `slug: "custom-handle"` AND leave `title` populated, the computed field **still overwrites** with the slugified title — because the source value is set. (Note the empty-source fallback returns `model.get(field)` keyed by the *source* field name, not the slug — a quirk of the implementation; if `title` is also empty on the model you get whatever the source field holds, which may be `undefined`.)

To allow explicit slug overrides, branch in your service before calling save, or skip the transformer for that one save (model-level mutation).

```ts title="example service — letting the transformer slug"
await Article.create({
  title: "Hello World",
  // slug auto-derived → "hello-world"
});
```

```ts title="example service — manual slug, bypassing"
const article = new Article();
article.set("title", "Hello World");
article.set("slug", "my-custom-handle");
// The transformer still fires and overwrites — to truly bypass,
// store the slug on a separate non-transformed field instead.
await article.save();
```

The slug uses `@mongez/slug` under the hood — sensible defaults (lowercase, hyphenated, ASCII transliteration).

## `useComputedModel(callback)` — arbitrary derived values

The general-purpose computed-value helper. Use it when the value depends on the row in non-trivial ways:

```ts
import { useComputedModel } from "@warlock.js/core";

slug: v.computed(useComputedModel((data, model, context) => {
  // data    — the current schema-level input object
  // model   — the Model instance being saved
  // context — Seal's SchemaContext (allValues, rootContext, etc.)

  return `${data.category}-${slugify(data.title)}`;
})),
```

`useComputedSlug` is implemented on top of `useComputedModel` — they share the same signature. Reach for `useComputedSlug` for the common slug case; reach for `useComputedModel` when you need to combine fields, branch on env, or call out to a service.

### Signature

```ts
type ComputedCallbackModel = (
  data: any,         // the schema input being saved
  model: Model,      // the Cascade model instance
  context: SchemaContext,
) => any | Promise<any>;
```

The callback can be async — return a `Promise<T>` and the schema layer awaits it.

## Patterns

### Hash + computed slug on the same model

```ts
export const userSchema = v.object({
  email: v.email().unique("User"),
  password: v.string().requiredIfEmpty("id").addTransformer(useHashedPassword()),
  display_name: v.string(),
  handle: v.computed(useComputedSlug("display_name")),
});
```

The two attachment kinds coexist — `password` mutates via `.addTransformer(...)`, `handle` derives via `v.computed(...)`. Each fires at save time, independent of the other.

### Derived total from line items

```ts
total: v.computed(useComputedModel((data) => {
  const items = (data.items as Array<{ price: number; quantity: number }>) ?? [];
  return items.reduce((sum, it) => sum + it.price * it.quantity, 0);
})),
```

Whenever `items` is part of the save payload, `total` is overwritten with the computed sum. Callers literally can't set `total` to a wrong value.

### Conditional compute

```ts
status: v.computed(useComputedModel((data, model) => {
  // First save → "draft"
  if (!model.id) return "draft";
  // Subsequent saves → leave the existing status alone
  return model.get("status");
})),
```

Use the `model` argument to branch on "is this a new row or an update."

### Multi-locale slugs

```ts
slug_en: v.computed(useComputedSlug("title_en")),
slug_ar: v.computed(useComputedSlug("title_ar")),
```

One computed field per locale. Cleaner than juggling multi-locale logic in `useComputedModel`.

## When NOT to use a transformer

Transformers live at the **schema layer** — they fire on every `create()` / `save()`. They're not the right tool when:

- **The derived value depends on side effects** (an API call, external lookup, time-bounded data). Do that in a service; service code is testable and observable. A transformer that calls an external API on every save is hard to reason about.
- **You need conditional skipping by caller intent.** Transformers run unconditionally based on the schema. A service-side mutation lets the caller opt in / out per call.
- **The "compute" requires reading other rows.** Possible (await DB lookups), but it puts query latency on the save path. Often better to compute upstream and pass the value in.

Rule of thumb: transformers are for **pure, deterministic** transforms of the row's own fields. Hashing, slugifying, summing, normalizing — yes. Cross-row lookups, network calls, audit-side effects — no.

## Gotchas

- **All three are imported from `@warlock.js/core`.** They live in `src/database/utils.ts` internally, but the public surface is the core package. `.addTransformer(...)` and `v.computed(...)` are Seal's APIs, but these helpers are Warlock's — don't reach into `@warlock.js/cascade` or `@warlock.js/seal` for them.
- **Match the attachment to the helper.** `useHashedPassword()` → `.addTransformer(...)` (it's a `TransformerCallback`). `useComputedSlug()` / `useComputedModel()` → `v.computed(...)` (they're `ComputedCallback`s). Cross-wiring them is a runtime bug, not just a type error.
- **`useHashedPassword` only re-hashes on actual change.** It returns the value unchanged when `!isNew && !isChanged`, where `isChanged` comes from the model's dirty tracking (`model.isDirty(column)`). Save an already-stored hash and the field isn't marked dirty, so it isn't re-hashed.
- **`useComputedSlug` overwrites unconditionally when the source field is present.** Set both `title` and `slug` in one save — the slug derived from `title` wins. When the source is empty it falls back to `model.get(field)` keyed by the **source** field name (not the slug field). To allow user-customized slugs, keep them on a separate field that isn't computed.
- **Computed callbacks run through the save pipeline.** A slow callback (an external API call) blocks every save. Keep them fast and deterministic. They may be async — `v.computed`'s validator awaits the result.
- **`v.computed(...)` is the write-time computed field.** The value is computed during validation and persisted on the row. There is no separate `.compute()` method on a field — `useComputedSlug` / `useComputedModel` produce the `ComputedCallback` you hand to `v.computed(...)`.

## See also

- [`hash-password/SKILL.md`](../hash-password/SKILL.md) — the bcrypt setup that `useHashedPassword` calls under the hood; salt rounds, `yarn add bcryptjs`.
- [`use-repository/SKILL.md`](../use-repository/SKILL.md) — where `create` / `save` calls happen that trigger the transformers.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — filtering transformed fields (`password`) out of API responses.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — schema files live in `src/app/<module>/models/<entity>/<entity>.model.ts`.


## use-repository  `@warlock.js/core/use-repository/SKILL.md`

---
name: use-repository
description: 'Subclass `RepositoryManager` for data access — declare `source`, `filterBy`, `defaultOptions`, then call `list()`/`listCached()`/`find()`/`create()`/`update()`/`delete()` (and the active/cached/cursor variants). Triggers: `RepositoryManager`, `FilterRules`, `RepositoryOptions`, `.list`, `.listCached`, `.find`, `.findCached`, `.create`, `.update`, `.delete`, `simpleSelectColumns`; "create a repository", "filter rules for a list endpoint", "cursor vs page pagination", "cached vs uncached read"; typical import `import { RepositoryManager } from "@warlock.js/core"`. Skip: cache singleton — `@warlock.js/cache/cache-basics/SKILL.md`; use-case pipelines — `@warlock.js/core/write-use-case/SKILL.md`; wire mapping — `@warlock.js/core/define-resource/SKILL.md`; competing libs `typeorm` Repository, `prisma.client.<model>`, `@nestjs/typeorm`.'
---

# Warlock — use a repository

A repository is a thin wrapper around a Cascade model that centralizes filtering, pagination, and caching. You subclass `RepositoryManager<TModel, TFilter>`, declare `source`/`filterBy`/`defaultOptions`, export a singleton, and call its methods from services. Controllers never touch repositories directly.

## The shape

```ts title="src/app/faqs/repositories/faqs.repository.ts"
import type { FilterRules, RepositoryOptions } from "@warlock.js/core";
import { RepositoryManager } from "@warlock.js/core";
import { Faq } from "../models/faq";

type FaqListFilter = {
  ids?: string[];
  id?: string;
  organization_id?: string;
  project_id?: string;
  status?: string;
};

export type FaqListOptions = RepositoryOptions & FaqListFilter;

class FaqsRepository extends RepositoryManager<Faq, FaqListOptions> {
  public source = Faq;

  public simpleSelectColumns: string[] = ["id"];

  public filterBy: FilterRules = {
    id: "=",
    ids: ["in", "id"],
    organization_id: "=",
    project_id: "=",
    status: "=",
  };

  public defaultOptions: RepositoryOptions = {
    orderBy: { id: "desc" },
  };
}

export const faqsRepository = new FaqsRepository();
```

Five lines do the heavy lifting:

1. **`source = Faq`** — Cascade model the repo wraps; auto-instantiates a `CascadeAdapter`.
2. **`simpleSelectColumns`** — projection used when callers pass `simpleSelect: true`.
3. **`filterBy`** — filter-key → operator map; powers the auto-applied WHERE clauses.
4. **`defaultOptions`** — applied to every call (`orderBy`, default `limit`, etc.).
5. **`new FaqsRepository()`** singleton — import this everywhere; never instantiate again.

The class is intentionally private — only the singleton escapes the module. Scaffold with `yarn warlock generate.repository <module>/<entity>`.

## The `filterBy` rules

A `FilterRules` map. Keys are filter input names; values are operators or `[operator, column]` tuples (when the input name differs from the column):

```ts
public filterBy: FilterRules = {
  // input "id" → WHERE id = ?
  id: "=",

  // input "ids" → WHERE id IN (?)
  ids: ["in", "id"],

  // input "name" → WHERE name LIKE %?%
  name: "like",

  // input "price_min" → WHERE price >= ?
  price_min: ["int>=", "price"],

  // input "createdBetween" → WHERE created_at BETWEEN ? AND ?
  createdBetween: ["dateBetween", "created_at"],

  // custom — full control
  search: (value, query, ctx) => {
    query.where((qb) => {
      qb.where("name", "like", value).orWhere("description", "like", value);
    });
  },
};
```

Operators (from `@warlock.js/core/src/repositories/contracts/types.ts`, `FilterOperator`):

- SQL-style: `=`, `!=`, `<>`, `>`, `>=`, `<`, `<=`, `like`, `not like`, `in`, `not in`, `between`, `not between`
- Typed coercion: `int`, `int>`, `int>=`, `int<`, `int<=`, `inInt`, `!int`, `integer`, `float`, `double`, `inFloat`, `number`, `inNumber`, `bool`, `boolean`, `null`, `notNull`, `!null`
- Dates: `date`, `date>`, `date>=`, `date<`, `date<=`, `dateBetween`, `inDate`, `dateTime`, `dateTime>`, `dateTime>=`, `dateTime<`, `dateTime<=`, `dateTimeBetween`, `inDateTime`
- Special: `location`, `scope`, `with`, `joinWith`, `similarTo`

For custom logic, pass a function: `(value, query, context) => void` — mutate `query` directly with the query-builder API.

## Method surface

From `@warlock.js/core/src/repositories/repository.manager.ts`. Every method has an `…Active` counterpart that adds the active-record filter (`isActive = true`), and most have a `…Cached` counterpart that reads/writes through the cache layer.

### Finding

```ts
await repo.find(id);                       // by primary key (or model instance)
await repo.findBy(column, value);
await repo.first(options?);                // first matching `options`
await repo.firstId(options?);              // → id only
await repo.exists(options?);               // → boolean
await repo.idExists(id);                   // → boolean
await repo.last(options?);                 // orderBy id desc

await repo.findCached(id);                 // alias of getCached
await repo.getCached(id);
await repo.getCachedBy(column, value);
await repo.firstCached(options?);

await repo.findActive(id);
await repo.firstActive(options?);
await repo.firstActiveCached(options?);
```

### Listing

```ts
// Page-based (default)
const { data, pagination } = await repo.list({ page: 2, limit: 10, status: "active" });

// Cursor-based
const { data, pagination } = await repo.list({
  paginationMode: "cursor",
  limit: 20,
  cursor: lastSeenId,
  direction: "next",
});

await repo.all(options?);                  // unpaginated array
await repo.listActive(options?);
await repo.listCached(options?);           // page-based + cache
await repo.allCached(options?);
await repo.latest(options?);               // orderBy id desc + paginate
await repo.oldest(options?);
```

### Pagination shape

```ts
// page-based — repo.list({ page, limit })
type PaginationResult<T> = {
  data: T[];
  pagination: {
    limit: number;
    result: number; // count in this page
    page: number; // 1-indexed
    total: number; // total rows
    pages: number; // total pages
  };
};

// cursor-based — repo.list({ paginationMode: "cursor", cursor, limit })
type CursorPaginationResult<T> = {
  data: T[];
  pagination: {
    limit: number;
    result: number;
    hasMore: boolean;
    nextCursor?: string | number;
    prevCursor?: string | number;
  };
};
```

### CRUD

```ts
await repo.create(data); // → created model
await repo.update(id, data); // → updated model
await repo.delete(id); // → void

await repo.updateMany(filter, data); // → number affected
await repo.deleteMany(filter); // → number affected

await repo.findOrCreate(where, data);
await repo.updateOrCreate(where, data);
```

### Counting

```ts
await repo.count(options?);                // → number
await repo.countActive(options?);
await repo.countCached(options?);
```

### Chunking

```ts
await repo.chunk(500, async (rows, index) => {
  // process 500 at a time; return false to stop early
});

await repo.chunkActive(500, async (rows, index) => {});
```

### Cache control

```ts
await repo.clearCache(); // wipe all repo cache
await repo.clearCache({ id: 5 });
await repo.clearModelCache(model);
await repo.cacheModel(model); // manual cache write
```

## Cached vs uncached

`listCached` / `firstCached` / `getCached` etc. hit the cache layer (`@warlock.js/cache`) first; on miss, they execute and cache. Cache invalidation is automatic on `create`/`update`/`delete` events that the adapter listens to.

Use cached methods by default. Drop to uncached when:

- The data changes too fast for the cache TTL to be useful.
- You need transactional consistency with a write you just made (cache invalidation runs after the event fires; a read in the same tick may still hit stale).
- Diagnosing a cache-related bug.

```ts
// Default — go through cache
await faqsRepository.listCached({ organization_id, status: "published" });

// Diagnostic / write-then-read flow — bypass cache
await faqsRepository.list({ organization_id, status: "published" });
```

## Calling from a service

```ts title="src/app/faqs/services/list-faqs.service.ts"
import { faqsRepository, type FaqListOptions } from "../repositories/faqs.repository";

export async function listFaqsService(filters: FaqListOptions) {
  return faqsRepository.listCached(filters);
}
```

```ts title="src/app/faqs/controllers/list-faqs.controller.ts"
import type { RequestHandler, Response } from "@warlock.js/core";
import { listFaqsService } from "../services/list-faqs.service";

export const listFaqsController: RequestHandler = async (request, response: Response) => {
  const { data, pagination } = await listFaqsService({
    ...request.all(),
    organization_id: request.user.organizationId,
  });

  return response.success({ data, pagination });
};
```

## When to drop to `Model.query()`

Repositories are for the 90% case: filter, paginate, cache. For anything beyond — joins, aggregates, raw expressions, complex `OR` trees — drop to the model's query builder inside a service:

```ts title="src/app/orders/services/order-stats.service.ts"
import { Order } from "../models/order";

export async function orderStatsByMonth(organizationId: string) {
  return Order.query()
    .where("organization_id", organizationId)
    .groupBy(Order.query().raw("DATE_TRUNC('month', created_at)"))
    .select([
      Order.query().raw("DATE_TRUNC('month', created_at) as month"),
      Order.query().raw("COUNT(*) as count"),
      Order.query().raw("SUM(total) as revenue"),
    ])
    .get();
}
```

A repository method exposing `query()` is also fine for service-level shaping:

```ts
class OrdersRepository extends RepositoryManager<Order> {
  public source = Order;

  public async paidLastWeek(organizationId: string) {
    return this.newQuery()
      .where({ organization_id: organizationId, status: "paid" })
      .where("created_at", ">", new Date(Date.now() - 7 * 86_400_000))
      .get();
  }
}
```

`this.newQuery()` returns a fresh `QueryBuilderContract` for the underlying source — no cache, no filter rules, just SQL.

## Repository lifecycle hooks

Override in the subclass for cross-cutting behavior:

```ts
class ProductsRepository extends RepositoryManager<Product> {
  public source = Product;

  protected async onCreate(product: Product, data: any) {
    await searchIndex.add(product);
  }

  protected async onUpdate(product: Product, data: any) {
    await searchIndex.update(product);
  }

  protected async onDelete(id: string | number) {
    await searchIndex.remove(id);
  }
}
```

Available hooks: `beforeListing`, `onList`, `onCreating`, `onCreate`, `onUpdating`, `onUpdate`, `onSaving`, `onSave`, `onDeleting`, `onDelete`. They run inside the repository's adapter — no need to wire events manually.

## Common patterns

### Typed list filter

The second generic on `RepositoryManager` makes `options.<filter>` autocomplete from the caller side:

```ts
type ProductListFilter = {
  category_id?: string;
  price_min?: number;
  price_max?: number;
  in_stock?: boolean;
};

export type ProductListOptions = RepositoryOptions & ProductListFilter;

class ProductsRepository extends RepositoryManager<Product, ProductListOptions> {
  public source = Product;

  public filterBy: FilterRules = {
    category_id: "=",
    price_min: ["int>=", "price"],
    price_max: ["int<=", "price"],
    in_stock: ["bool", "is_in_stock"],
  };
}

// Caller side — typed
await productsRepository.list({ category_id, price_min: 10, in_stock: true });
```

### `simpleSelect` for lightweight payloads

```ts
class UsersRepository extends RepositoryManager<User> {
  public source = User;
  public simpleSelectColumns = ["id", "name", "email"];
}

await usersRepository.list({ simpleSelect: true });
// only id, name, email selected
```

### Custom filter function

```ts
public filterBy: FilterRules = {
  search: (value, query) => {
    query.where((qb) => {
      qb.where("name", "like", `%${value}%`)
        .orWhere("description", "like", `%${value}%`)
        .orWhere("sku", "=", value);
    });
  },
};
```

The third arg is a context object — `{ allValues, dateFormat, ... }` — for filters that compose with other inputs.

## Gotchas

- **One singleton per repository.** Module-export `new FaqsRepository()` at the bottom; everyone imports that. Event listeners are wired on construction — instantiating twice double-registers them.
- **Don't access protected members from outside.** `defaultOptions`, `filterBy`, `cacheDriver`, `name` are `protected` for a reason. Configure via subclass; never reach in.
- **`cursor` pagination owns its sort key.** If you pass `paginationMode: "cursor"` _and_ an `orderBy: { id: "asc" }` that conflicts with the cursor column, the framework warns and ignores your `orderBy`. Use cursor pagination's `direction` and `cursorColumn` options instead.
- **`listCached` only supports page-based pagination.** Cursor mode bypasses the cache.
- **`findCached` is an alias of `getCached`.** Both look up by `id` through the cache. There is no `findCachedBy` — use `getCachedBy(column, value)`.
- **`create`/`update`/`delete` clear cache on every write.** That's why `listCached` is usually safe. If you write outside the repository (raw query, bulk insert), the cache won't invalidate — call `repo.clearCache()` manually.
- **The repository owns `clearCache` per repo + per model.** It doesn't reach into other repositories. If a write in one module affects another's cache, invalidate explicitly from a service.
- **Adapter init is `setTimeout(0)` delayed.** Don't call repository methods synchronously inside the constructor — they'll fire before the adapter is ready.

## See also

- [`create-module/SKILL.md`](../create-module/SKILL.md) — `warlock generate.repository` and where the file lives.
- [`@warlock.js/cache/cache-basics/SKILL.md`](../../../cache/skills/cache-basics/SKILL.md) — the cache singleton behind `listCached` / `getCached`. See sibling skills (`pick-cache-driver`, `use-swr`) for related tasks.
- [`write-use-case/SKILL.md`](../write-use-case/SKILL.md) — calling repositories from a use-case handler.
- [`define-resource/SKILL.md`](../define-resource/SKILL.md) — mapping repository output to the wire format.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — services that consume repositories from the controller edge.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — controller → service → repository → model layering.


## validate-input  `@warlock.js/core/validate-input/SKILL.md`

---
name: validate-input
description: 'Author seal schemas, attach them to controllers via `controller.validation = { schema }`, infer types via `Infer<typeof schema>`, and layer DB-aware (`unique`/`exists`) and file validators on top. Triggers: `v.object`, `v.string`, `v.email`, `Infer`, `controller.validation`, `.unique`, `.exists`, `uniqueExceptCurrentId`, `request.validated`; "validate a request body", "attach a schema to a controller", "DB-aware unique rule", "infer schema types"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: schema authoring foundations — `@warlock.js/seal/seal-basics/SKILL.md`; controller wiring — `@warlock.js/core/create-controller/SKILL.md`; file rules deep-dive — `@warlock.js/core/upload-file/SKILL.md`; competing libs `zod`, `joi`, `yup`, `class-validator`.'
---

# Warlock — validate a request

Validation is a three-file pattern: a seal schema, a `Request<Schema>` type alias, and the controller that attaches the schema via a static property. If validation fails, the framework returns a 400 with an `errors` payload — your handler never runs.

## The shape

```ts title="src/app/products/schema/create-product.schema.ts"
import { v, type Infer } from "@warlock.js/seal";

export const createProductSchema = v.object({
  name: v.string().min(2).max(120),
  price: v.number().min(0),
  sku: v.string().unique("Product"),
});

export type CreateProductSchema = Infer<typeof createProductSchema>;
```

```ts title="src/app/products/controllers/create-product.controller.ts"
import type { Request, RequestHandler } from "@warlock.js/core";
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
import { createProductService } from "../services/create-product.service";

export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
  request,
  response,
) => {
  const product = await createProductService(request.validated());

  return response.successCreate({ product });
};

createProductController.validation = {
  schema: createProductSchema,
};
```

Two pieces, always:

1. **Schema file in `schema/`** — exports both the value (`createProductSchema`) and the inferred type (`CreateProductSchema`). One file, one source of truth.
2. **Controller typed as `RequestHandler<Request<TSchema>>`** (or `GuardedRequestHandler<TSchema>` for auth'd routes) — pulls `TSchema` from the schema file and `controller.validation = { schema }` registers it with the framework.

No separate `*.request.ts` alias file. `RequestHandler<Request<TSchema>>` types `request.validated()` directly off the schema's inferred type.

Scaffold with `yarn warlock generate.controller <module>/<action> --with-validation`. If the scaffolder emits a `requests/<action>.request.ts` file, delete it — the inline pattern is the convention.

## The `v.*` factory surface

From `@warlock.js/seal` — **always import from seal directly**. `@warlock.js/core` does not re-export `v`/`Infer`.

| Factory                            | Output type                  | Common chain                                     |
| ---------------------------------- | ---------------------------- | ------------------------------------------------ |
| `v.string(msg?)`                   | `string`                     | `.min(n)`, `.max(n)`, `.length(n)`, `.pattern(re)`, `.email()`, `.url()`, `.oneOf([...])`, `.trim()`, `.lowercase()`, `.uppercase()` |
| `v.email(msg?)`                    | `string`                     | (alias of `v.string().email()`)                  |
| `v.number(msg?)`                   | `number`                     | `.min(n)`, `.max(n)`, `.positive()`              |
| `v.numeric(msg?)`                  | `number` (accepts numeric strings) | same                                        |
| `v.int(msg?)`                      | `number` (integer)           | same                                             |
| `v.float(msg?)`                    | `number`                     | same                                             |
| `v.boolean(msg?)`                  | `boolean`                    | —                                                |
| `v.date(msg?)`                     | `Date`                       | —                                                |
| `v.array(inner, msg?)`             | `T[]`                        | `.minLength(n)`, `.maxLength(n)`                 |
| `v.object(shape, msg?)`            | `{ ... }`                    | —                                                |
| `v.record(value, msg?)`            | `Record<string, T>`          | —                                                |
| `v.tuple([a, b, c], msg?)`         | `[A, B, C]`                  | —                                                |
| `v.union([a, b], msg?)`            | `A | B`                      | —                                                |
| `v.discriminatedUnion(key, [...])` | `A | B` (tagged)             | each branch must be `v.object({ key: v.literal(...) })` |
| `v.enum([...] | EnumObj, msg?)`    | literal union                | —                                                |
| `v.literal(...values)`             | exact literals               | —                                                |
| `v.instanceof(Ctor, msg?)`         | `Ctor` instances             | —                                                |
| `v.lazy(() => schema)`             | recursive/forward refs       | —                                                |
| `v.file(msg?)`                     | `UploadedFile`               | `.image()`, `.accept(exts)`, `.mimeType(types)`, `.minSize(n)`, `.maxSize(n)`, `.minWidth(n)`, `.maxWidth(n)` — see [`upload-file`](../upload-file/SKILL.md) |
| `v.computed(callback)`             | derived value                | —                                                |
| `v.managed(callback)`              | framework-injected value     | —                                                |

Universal modifiers from `BaseValidator`: `.optional()`, `.nullable()`, `.default(value)`, `.catch(value)`. Note: **there is no `v.url(...)`** at factory level — use `v.string().url(...)`.

## Inferring the type

```ts
import { v, type Infer } from "@warlock.js/seal";

const updateProductSchema = v.object({
  name: v.string().min(2).optional(),
  price: v.number().min(0).optional(),
});

// Input type — what the caller may send (optional fields are `?`)
type UpdateProductInput = Infer<typeof updateProductSchema>;
// → { name?: string; price?: number }

// Output type — what comes out after validation (defaults applied)
type UpdateProductOutput = Infer.Output<typeof updateProductSchema>;
```

The bare `Infer<T>` is the input shape (what the client sends). `Infer.Output<T>` is what's available after validation and transformation. For most service signatures, `Infer<typeof schema>` is what you want.

## Database-aware rules

The DB validators come from two plugins: base `unique`/`exists` (registered by `@warlock.js/cascade`) and request-aware `…ExceptCurrentId`/`…ExceptCurrentUser` (registered by `@warlock.js/core`). All four chain onto scalar validators:

```ts
import { v } from "@warlock.js/seal";
import { Product } from "../models/product";
import { Category } from "../../categories/models/category";

export const createProductSchema = v.object({
  // Reject if any Product row has the same SKU
  sku: v.string().unique(Product),

  // Reject if no Category row exists with this id
  category_id: v.string().exists(Category, { column: "id" }),
});

export const updateProductSchema = v.object({
  // Reject if any *other* Product row has the same SKU (skip the current one)
  sku: v.string().uniqueExceptCurrentId(Product),
});
```

The signatures (from `@warlock.js/cascade/src/validation/plugins/database-rules-plugin.ts`):

```ts
.unique(Model: ChildModel | string, options?: {
  column?: string;        // defaults to the field key
  except?: string;        // sibling input key whose value to !=
  query?: ({ query, value, allValues }) => Promise<void>;
  errorMessage?: string;
})

.exists(Model: ChildModel | string, options?: {
  column?: string;
  query?: ({ query, value, allValues }) => Promise<void>;
  errorMessage?: string;
})
```

`uniqueExceptCurrentId` reads `request.input("id")` automatically — designed for `PATCH /resource/:id` endpoints where the current row should be allowed to "match itself."

For an ad-hoc query that the basic options can't express, pass a `query` callback that mutates the query builder before `first()`:

```ts
v.string().unique(Product, {
  column: "sku",
  query: async ({ query, allValues }) => {
    query.where("organization_id", allValues.organization_id);
  },
});
```

## File rules

`v.file()` returns a `FileValidator` (from `@warlock.js/core/src/validation/validators/file-validator.ts`). Chain size, mime, and image rules:

```ts
import { v } from "@warlock.js/seal";

const uploadAvatarSchema = v.object({
  avatar: v
    .file()
    .image()
    .maxSize({ unit: "MB", size: 5 })
    .mimeType(["image/jpeg", "image/png", "image/webp"]),
});
```

Full file chain: `.image()`, `.accept(extensions)`, `.mimeType(types)`, `.pdf()`, `.excel()`, `.word()`, `.minSize(n)`, `.maxSize(n)`, `.minWidth(px)`, `.maxWidth(px)`, `.minHeight(px)`, `.maxHeight(px)`. See [`upload-file`](../upload-file/SKILL.md) for the full upload flow.

## What the framework sends on failure

The framework calls `response.failedSchema(result)` which sends `400` with the shape configured under `validation.response` (defaults shown):

```jsonc
// 400 Bad Request
{
  "errors": [
    { "input": "email", "error": "The email must be a valid email" },
    { "input": "password", "error": "The password must be at least 6 characters" },
  ]
}
```

The key names (`errors`, `input`, `error`) come from `config.get("validation.response")` — change them globally if your wire format differs.

## Scoping what's validated

By default the validator merges `request.body + request.query` (everything but route params) and validates the merged object. To narrow:

```ts
createProductController.validation = {
  schema: createProductSchema,
  validating: ["body"], // skip query string
};
```

Allowed values: `"body"`, `"query"`, `"params"`, `"headers"`. Useful when query-string filters share keys with body fields and you only want body-side rules.

## Ad-hoc validation (outside a controller)

For background jobs, CLI commands, or anywhere outside the HTTP path, call `validateAll` directly (or `v.validate(schema, data)` for a raw seal result):

```ts
import { v } from "@warlock.js/seal";

const result = await v.validate(createProductSchema, untrustedInput);

if (!result.isValid) {
  // result.errors is the same shape the controller pipeline emits
  throw new Error("invalid input: " + JSON.stringify(result.errors));
}

const clean = result.data; // typed via Infer.Output<>
```

`v.validate(schema, data)` returns `{ isValid, data, errors }` — same shape used inside the framework.

## Common patterns

### Login schema

```ts title="src/app/auth/schema/login.schema.ts"
import { v, type Infer } from "@warlock.js/seal";

export const loginSchema = v.object({
  email: v.email(),
  password: v.string(),
});

export type LoginSchema = Infer<typeof loginSchema>;
```

### Optional fields with defaults

```ts
const filterSchema = v.object({
  page: v.int().min(1).default(1),
  limit: v.int().min(1).max(100).default(15),
  status: v.string().oneOf(["active", "archived"]).optional(),
});
```

`Infer<typeof filterSchema>` makes `page`, `limit`, `status` all optional from the caller's view. `Infer.Output<typeof filterSchema>` makes `page` and `limit` required (default applied) but keeps `status` optional.

### Discriminated union

```ts
const emailNotification = v.object({
  type: v.literal("email"),
  to: v.email(),
});

const smsNotification = v.object({
  type: v.literal("sms"),
  phone: v.string(),
});

export const notificationSchema = v.discriminatedUnion("type", [
  emailNotification,
  smsNotification,
]);

type Notification = Infer<typeof notificationSchema>;
// → { type: "email"; to: string } | { type: "sms"; phone: string }
```

### Selective validation per route group

```ts
createProductController.validation = {
  schema: createProductSchema,
};

updateProductController.validation = {
  schema: updateProductSchema,
  validating: ["body"], // params (id) handled by the controller
};
```

## Gotchas

- **`v.url(...)` does not exist on the factory.** Use `v.string().url(...)`. Same goes for any other rule that's not in the table above — check `BaseValidator` / `StringValidator` first.
- **`.optional()` and `.required()` are not symmetric.** `.optional()` brands the field as absent-allowed. `.required()` clears the optional flag *and* replaces any prior required rule — read seal's `BaseValidator` source if you need conditional required.
- **`uniqueExceptCurrentId` reads `request.input("id")`.** It only works inside an HTTP context — useless in CLI/job code.
- **`request.validated()` is empty until the framework runs validation.** If you forgot the `controller.validation = { schema }` line, `validated()` returns `{}` (or the input from `setValidatedData` if you set it manually). Always pair the schema with the assignment.
- **Import `v`/`Infer` from `@warlock.js/seal`.** Core does not re-export them — `@warlock.js/seal` is the canonical and only home.
- **DB rules trigger queries on every validation pass.** For high-throughput endpoints, prefer enforcing uniqueness at the DB layer (`UNIQUE` index) and catch the conflict in the controller.

## See also

- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — how the schema attaches and how `request.validated()` reads.
- [`upload-file/SKILL.md`](../upload-file/SKILL.md) — `v.file()` and how it interacts with multipart uploads.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — the 400 helper used internally by `failedSchema`.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — `Request<Schema>` type-alias convention.


## warlock-conventions  `@warlock.js/core/warlock-conventions/SKILL.md`

---
name: warlock-conventions
description: 'Framework-wide invariants for projects built on @warlock.js/core — module layout, canonical imports, layered flow, file naming, and the non-negotiable rules every other warlock skill assumes. Triggers: `src/app/<module>`, `routes.ts`, `main.ts`, `Request<TSchema>`, `RequestHandler`, `GuardedRequestHandler`, `app/<module>/...`; "where do files go in this project", "canonical Warlock imports", "module layout rules", "controller-service-repository layering"; typical import `import { router, type RequestHandler } from "@warlock.js/core"`. Skip: scaffold a new module — `@warlock.js/core/create-module/SKILL.md`; route shape — `@warlock.js/core/register-route/SKILL.md`; controller shape — `@warlock.js/core/create-controller/SKILL.md`; competing patterns: `express` ad-hoc layouts, `@nestjs/common` decorator-driven structure.'
---

# Warlock — framework-wide conventions

This skill is the foundation. Every other warlock skill (`register-route`, `create-controller`, `send-response`, …) assumes you know these rules. Don't repeat them in task skills — link here instead.

## Always-true facts

1. **Every feature lives in `src/app/<module>/`.** No exceptions. A module is a folder with the standard subfolders (`controllers/`, `services/`, `models/`, `repositories/`, `resources/`, `schema/`, `routes.ts`, `main.ts`). Generate with `warlock generate.module <name>`.
2. **Several files are auto-loaded — never `import` them.** The dev-server file watcher and the production builder categorize these as "special" and load them on boot.
   - `src/app/<module>/routes.ts` — route declarations
   - `src/app/<module>/main.ts` — per-module one-time setup
   - `src/app/main.ts` — **project-level** one-time setup (where `connectorsManager.register(...)`, global hooks, etc. live)
   - `src/app/<module>/events/*.ts(x)` — **any** `.ts(x)` file inside the `events/` folder (the `*.event.ts` suffix is convention, not a framework requirement)
   - `src/app/<module>/utils/locales.ts` — module translations via `groupedTranslations(...)`
   - `src/config/*.ts(x)` — subsystem config files
3. **Canonical imports — each package owns its surface.** `@warlock.js/core` does **NOT** re-export `v`/`Infer` or Cascade types. Import from the home package:
   - `v`, `Infer` → `@warlock.js/seal`
   - `Model`, `RegisterModel`, `Migration`, `onceConnected` → `@warlock.js/cascade`
   - `router`, `RequestHandler`, `Request`, `Response`, `useCase`, `RepositoryManager`, `Resource`, `defineResource`, `Restful`, `defineConfig`, `config`, `env`, `Application`, `BaseConnector`, `connectorsManager`, `storage`, `Mail`, `sendMail`, `hashPassword`, `verifyPassword`, `encrypt`, `decrypt`, `hmacHash`, `measure`, `retry`, `t`, `command`, `CLICommand` → `@warlock.js/core`
   - `cache` → `@warlock.js/cache`
   - `log` → `@warlock.js/logger`
   - `authMiddleware`, `authService` → `@warlock.js/auth`
   - `ai`, `OpenAISDK`, etc. → `@warlock.js/ai*`
4. **Layered flow is one-way.** `routes.ts → controllers → services → repositories → models`. Resources cut across as the wire mapper. Never invert — repositories don't call services, services don't call controllers.
5. **Controllers are thin.** Pull input from `request`, call a service or use-case, return via `response.<helper>()`. No business logic. No transactions. No external API calls.
6. **Resources are output-only.** Map model fields to wire fields and nothing else. Reconciliation, hydration, and computed side-effects belong in services or model accessors — never in resources.
7. **No per-action `*.request.ts` files.** Schema files export **both** the value and the inferred type from one place; controllers consume both directly and type themselves as `RequestHandler<Request<TSchema>>` (or `GuardedRequestHandler<TSchema>` for auth'd routes). Source pattern:

   ```ts title="src/app/<module>/schema/create-<thing>.schema.ts"
   import { v, type Infer } from "@warlock.js/seal";

   export const createThingSchema = v.object({ /* … */ });
   export type CreateThingSchema = Infer<typeof createThingSchema>;
   ```

   ```ts title="src/app/<module>/controllers/create-<thing>.controller.ts"
   import { type GuardedRequestHandler } from "app/auth/types/guarded-request.type";
   import { type CreateThingSchema, createThingSchema } from "../schema/create-thing.schema";
   import { createThingService } from "../services/create-thing.service";

   export const createThingController: GuardedRequestHandler<CreateThingSchema> = async (request, response) => {
     const thing = await createThingService(request.validated());
     return response.success({ thing });
   };

   createThingController.validation = { schema: createThingSchema };
   ```
8. **Model getters beat `.get<T>("field")`.** Add a typed getter on the model rather than scattering `.get<string>("name")` casts across call sites.

## Module layout (the standard subfolders)

```
src/app/<module>/
  controllers/              thin request handlers
  services/                 stateless business logic
  models/<entity>/          Cascade model + migrations
  repositories/             RepositoryManager subclasses
  resources/                Resource subclasses (output mapping)
  schema/                   seal schemas (`<action>.schema.ts`) — value + type from one file
  events/                   any `*.ts(x)` file here is auto-loaded
  types/                    `.type.ts` files for module-internal types
  utils/                    module-private helpers (`utils/locales.ts` is auto-loaded)
  seeds/                    seed data for `warlock seed`
  routes.ts                 ← auto-loaded
  main.ts                   ← auto-loaded once, one-time setup
```

Some older modules still have a `validation/` folder instead of `schema/` — historical drift. The framework treats them as plain folders either way; the generator emits to `schema/`.

## File naming (project-wide)

| Suffix              | Role                                  | Example                             |
| ------------------- | ------------------------------------- | ----------------------------------- |
| `.controller.ts`    | HTTP handler                          | `create-product.controller.ts`      |
| `.service.ts`       | Stateless business logic              | `create-product.service.ts`         |
| `.usecase.ts`       | `useCase()` pipeline                  | `login.usecase.ts`                  |
| `.model.ts`         | Cascade model class                   | `product.model.ts`                  |
| `.repository.ts`    | `RepositoryManager` subclass          | `products.repository.ts`            |
| `.resource.ts`      | `Resource` subclass                   | `product.resource.ts`               |
| `.schema.ts`        | seal validation schema + its inferred `<Name>Schema` type from the same file | `create-product.schema.ts`          |
| `.type.ts`          | data shape (TypeScript `type`)        | `cart-state.type.ts`                |
| `.contract.ts`      | interface (TypeScript `interface`)    | `model.contract.ts`                 |
| `.event.ts`         | event listener registrations (any `.ts(x)` inside `events/` works; the suffix is convention) | `audit.event.ts`                    |
| `.migration.ts`     | Cascade migration                     | `2026_05_22_120000_product.migration.ts` |
| `.seed.ts`          | seed data                             | `products.seed.ts`                  |

Filenames are kebab-case. Class names are PascalCase. Function names are camelCase. Table/collection names are snake_case (plural).

## Path aliases

The scaffolded `tsconfig.json` defines:

```jsonc
{
  "paths": {
    "app/*": ["./src/app/*"]
  }
}
```

Use `app/<module>/...` when crossing module boundaries:

```ts
// ✅ from src/app/orders/services/place-order.service.ts
import { Product } from "app/products/models/product";

// ❌ deep relative path
import { Product } from "../../../products/models/product";
```

Within the same module, use relative paths (`./`, `../`).

## Decorators

Cascade uses `@RegisterModel()` for the model registry and `@BelongsTo` / `@HasMany` / `@MorphTo` for relations. The scaffolded `tsconfig.json` has `"experimentalDecorators": true`. If you ever see "model not registered" errors at runtime, check that flag first.

## See also

- [`register-route/SKILL.md`](../register-route/SKILL.md) — how to wire URLs to controllers.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — controller signature, validation, response shape.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — the full Response helper surface.


## wire-socket  `@warlock.js/core/wire-socket/SKILL.md`

---
name: wire-socket
description: 'Configure Socket.IO via `src/config/socket.ts`, reach the live server through `getSocketServer()` (or `app.socket` post-bootstrap), register `connection` handlers once the late-phase socket connector has booted, emit from controllers/services, use rooms and namespaces. Triggers: `app.socket`, `getSocketServer`, `SocketOptions`, `socket.io` `Server`, `socket.join`, `socket.to`, `io.of`, `io.use`; "add realtime chat", "emit socket events from a service", "use rooms and namespaces", "per-socket JWT auth". Skip: connector lifecycle — `@warlock.js/core/add-connector/SKILL.md`; app context accessors — `@warlock.js/core/use-app-context/SKILL.md`; competing libs `ws`, `socket.io` direct without Warlock connector, `uWebSockets.js`.'
---

# Warlock — wire a Socket.IO server

Warlock wraps `socket.io`'s `Server` in a connector. That connector is in the **late** lifecycle phase — it boots **after** your app code (every module's `main.ts`, `routes.ts`, `events.ts`) has already been imported. The order is: early-phase connectors (logger, mailer, database, cache, storage) → app code is imported → late-phase connectors (http, then socket) boot. The socket server lives in the framework's DI container once it boots — reach it through `app.socket` or `getSocketServer()`.

The practical consequence: **`app.socket` is not yet populated while a module's `main.ts` is being evaluated at import time.** Register connection handlers from code that runs *after* bootstrap completes — a controller, a service, a job — or guard with `getSocketServer()`. (Unlike the router, which collects routes into a standalone registry that the http connector scans on boot, the socket connector does not harvest listeners registered at import time — it just constructs the `Server`.)

## The shape

```ts title="src/config/socket.ts"
import type { SocketOptions } from "@warlock.js/core";

const socketOptions: SocketOptions = {
  options: {
    cors: { origin: "*" },
  },
};

export default socketOptions;
```

Config the server in `src/config/socket.ts`, then attach handlers once the server is live:

```ts title="src/app/chat/setup-chat-socket.ts — called after bootstrap, or guarded"
import { getSocketServer } from "@warlock.js/core";

export function setupChatSocket() {
  const io = getSocketServer();
  if (!io) return; // socket connector hasn't booted yet

  io.on("connection", (socket) => {
    socket.on("message", (payload) => {
      socket.broadcast.emit("message", payload);
    });
  });
}
```

The framework auto-loads each module's `main.ts` once at boot, but **before** the socket connector starts — so don't read `app.socket` at the top level of `main.ts`.

## Configuration — `src/config/socket.ts`

```ts
export type SocketOptions = {
  port?: number;     // standalone port (only if HTTP is disabled)
  options?: ServerOptions;  // forwarded as the 2nd arg to socket.io's `new Server(httpServer, options)`
};
```

Default: socket.io is mounted on the same HTTP server Warlock boots — no separate port. Set `port` only if you want a dedicated socket server (the connector then creates its own HTTP/HTTPS server and listens on that port).

The `options` block is forwarded verbatim as the second argument to socket.io's `new Server(server, options)`, so it accepts the Socket.IO `ServerOptions` — CORS, transports, pingInterval, path, etc. Common shape:

```ts
const socketOptions: SocketOptions = {
  options: {
    cors: { origin: "*" },
    transports: ["websocket", "polling"],
    pingInterval: 25000,
    pingTimeout: 60000,
  },
};
```

## Reaching the server — `app.socket` vs `getSocketServer()`

Two ways to get the `socket.io` `Server` instance:

```ts
// 1. Via the app runtime accessor — reads container.get("socket")
import { app } from "@warlock.js/core";

const io = app.socket;            // → Server, or undefined before the socket connector boots

// 2. Via the safe getter — explicit null when the connector hasn't started
import { getSocketServer } from "@warlock.js/core";

const io = getSocketServer();     // → Server | null
```

Both read the same DI container slot. `app.socket` is a getter that returns `container.get("socket")` — that's `undefined` (it does **not** throw) until the socket connector has booted. `getSocketServer()` checks `container.has("socket")` and returns `null` if absent. Prefer `getSocketServer()` plus a null-guard at any call site that *might* run before the connector boots (module-load code, `main.ts` top level, scripts that skip bootstrap). From a controller, a service, a job — anything downstream of a completed bootstrap — `app.socket` is populated and safe to read directly.

## Wiring `connection` handlers

The socket connector boots *after* app code is imported, and it does not collect listeners registered at `main.ts` import time. So register handlers from a function that runs once the server is live — call it from post-bootstrap code, or guard it:

```ts title="src/app/chat/setup-chat-socket.ts"
import { getSocketServer } from "@warlock.js/core";

export function setupChatSocket() {
  const io = getSocketServer();
  if (!io) return; // not booted yet — nothing to attach to

  io.on("connection", (socket) => {
    console.log("client connected", socket.id);

    socket.on("disconnect", () => {
      console.log("client disconnected", socket.id);
    });
  });
}
```

Reading `app.socket` (or `getSocketServer()`) at the top level of `main.ts` returns nothing useful — the server isn't constructed yet. Defer the read to runtime.

## Emitting from a controller or service

Once the server is up, anything with access to `app.socket` can emit:

```ts title="src/app/notifications/services/notify-user.service.ts"
import { app } from "@warlock.js/core";
import type { User } from "app/users/models/user";

export async function notifyUserService(user: User, payload: unknown) {
  app.socket.to(`user:${user.id}`).emit("notification", payload);
}
```

Then from a controller:

```ts
import type { GuardedRequestHandler } from "app/auth/types/guarded-request.type";
import { notifyUserService } from "../services/notify-user.service";

export const sendNotificationController: GuardedRequestHandler = async (request, response) => {
  await notifyUserService(request.user, request.input("payload"));
  return response.success({ delivered: true });
};
```

The HTTP request handles the write side; the socket emit handles the realtime fan-out. Same pattern works for messages, status changes, presence updates.

## Rooms

Rooms are socket.io's per-connection labels — emit to a room and every socket joined to it receives the event:

```ts
io.on("connection", (socket) => {
  socket.on("subscribe", async (channelId: string) => {
    socket.join(`channel:${channelId}`);
  });

  socket.on("unsubscribe", async (channelId: string) => {
    socket.leave(`channel:${channelId}`);
  });
});

// From anywhere with `io`:
io.to(`channel:42`).emit("message", { text: "…" });
```

Typical room-key conventions: `user:<id>`, `channel:<id>`, `org:<id>`. Join on connection (often after auth), leave on disconnect or explicit unsubscribe.

## Namespaces

For larger apps, split socket surfaces into namespaces — each is an isolated event channel. Reach the live server first, then carve the namespace off it:

```ts title="src/app/chat/setup-chat-socket.ts"
import { getSocketServer } from "@warlock.js/core";

export function setupChatSocket() {
  const io = getSocketServer();
  if (!io) return;

  const chat = io.of("/chat");

  chat.on("connection", (socket) => {
    socket.on("message", (payload) => {
      chat.emit("message", payload);
    });
  });
}
```

Client connects to the namespace explicitly: `io("https://your-host/chat")`. Use one namespace per feature when the event sets diverge (chat vs notifications vs presence vs admin).

## Per-socket auth

Hand the socket the JWT (or session token) at the handshake; verify in middleware before any `connection` handler runs. Register the middleware on the live server, same as handlers:

```ts
import { authService } from "@warlock.js/auth";
import { getSocketServer } from "@warlock.js/core";

export function setupAuthedSocket() {
  const io = getSocketServer();
  if (!io) return;

  io.use(async (socket, next) => {
    try {
      const token = socket.handshake.auth?.token as string | undefined;

      if (!token) {
        return next(new Error("missing-token"));
      }

      const user = await authService.verifyAccessToken(token);

      if (!user) {
        return next(new Error("invalid-token"));
      }

      (socket.data as { user: typeof user }).user = user;
      next();
    } catch (error) {
      next(error as Error);
    }
  });

  io.on("connection", (socket) => {
    const user = (socket.data as { user: User }).user;

    socket.join(`user:${user.id}`);
  });
}
```

Socket middleware runs once at handshake — if it errors, the connection is rejected and `connection` never fires. Verify exact auth API against `@warlock.js/auth` source; the example above uses the typical token-verification flow.

## Common patterns

### Broadcast a model update to a room

```ts title="src/app/messages/services/post-message.service.ts"
import { app } from "@warlock.js/core";
import { Message } from "../models/message";

export async function postMessageService(input: { channel_id: string; body: string; user: User }) {
  const message = await Message.create({
    channel_id: input.channel_id,
    body: input.body,
    author_id: input.user.id,
  });

  app.socket.to(`channel:${input.channel_id}`).emit("message:new", message.toJSON());

  return message;
}
```

### Typed event payloads

Socket.IO supports typed events via generic parameters on `Server`:

```ts
import type { Server } from "socket.io";

type ClientToServer = {
  subscribe: (channelId: string) => void;
  message: (payload: { channelId: string; body: string }) => void;
};

type ServerToClient = {
  "message:new": (message: { id: string; body: string }) => void;
};

const io = app.socket as Server<ClientToServer, ServerToClient>;
```

You're casting because the framework exposes a non-generic `Server`. Cast once at the call site and you get typed `emit` / `on` for that scope.

### Emit from outside a request

CLI commands, scheduled jobs, queue workers — anything running outside an HTTP request — still reaches `app.socket` the same way. As long as the socket connector booted, the accessor is live.

## Gotchas

- **`app.socket` is `undefined` before the connector starts — it does not throw.** Because socket is a *late*-phase connector, this includes the top level of `main.ts` (imported before late connectors boot). Reading `app.socket.on(...)` there throws a `TypeError` on `undefined`, not a helpful framework error. Safe in controllers, services, jobs, and any post-bootstrap code; use `getSocketServer()` + a null-guard everywhere else.
- **Register handlers from post-bootstrap code, not at `main.ts` import time.** The socket connector constructs the `Server` but doesn't harvest listeners you registered at import time (unlike routes, which the http connector scans on boot). Wire handlers from a setup function invoked after bootstrap, or guard the registration with `getSocketServer()`.
- **Re-registration can double-bind.** If a setup function runs more than once (HMR, repeated calls), `io.on("connection", ...)` stacks handlers — guard with an idempotency flag if you observe duplicates.
- **CORS matters.** Browsers fail Socket.IO connections silently if CORS rejects the upgrade request. Set `options.cors.origin` to the deployed frontend's origin in production.
- **Rooms are per-socket-instance.** A user logged in from two devices has two sockets. Joining `user:<id>` from both is what makes `app.socket.to("user:42").emit(...)` reach both devices.
- **`io.emit(...)` broadcasts to every connected socket.** Use `to(room).emit(...)` unless that's actually what you want.
- **Don't keep handlers stateful.** Socket events come in any order; treat each as idempotent or guard with a transaction. Stateful in-memory accumulators die with the process and don't survive horizontal scale.

## See also

- [`use-app-context/SKILL.md`](../use-app-context/SKILL.md) — `app.socket` runtime accessor + `Application` static metadata.
- [`add-connector/SKILL.md`](../add-connector/SKILL.md) — how connectors order their boot sequence around your modules.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — where socket-related code lives (a setup function for handler registration, `services/` for emit sites).


## write-cli-command  `@warlock.js/core/write-cli-command/SKILL.md`

---
name: write-cli-command
description: 'Author a custom `warlock <my-cmd>` command via the `command()` factory — name, description, action, options, preload, then register in `warlock.config.ts > cli.commands` or drop in `src/app/<module>/commands/`. Triggers: `command`, `CLICommand`, `CLICommandPreload`, `CLICommandOption`, `preload`, `preAction`, `persistent`, `colors`; "write a custom warlock command", "one-off maintenance task", "ship a CLI from a package", "framework built-in commands"; typical import `import { command } from "@warlock.js/core"`. Skip: framework dev/build/start — `@warlock.js/core/run-app/SKILL.md`; warlock.config.ts wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs `commander`, `yargs`, `oclif`.'
---

# Warlock — write a CLI command

A Warlock CLI command is a `CLICommand` instance produced by the `command()` factory. Three ways to surface it:

1. **Project commands** — drop a `<name>.command.ts` file under `src/app/<module>/commands/` with the `CLICommand` as the default export. Auto-discovered.
2. **Plugin commands** — exposed as a factory function (`registerXCommand()`) from a package, registered via `warlock.config.ts > cli.commands: [...]`.
3. **Framework commands** — `migrate`, `seed`, `dev`, `build`, `generate.*`, `storage.put`, `drop.tables`, etc. Built into core. You don't write these; you copy their shape.

## The shape

```ts title="src/app/users/commands/promote-admin.command.ts"
import { command } from "@warlock.js/core";

export default command({
  name: "users.promote",
  description: "Promote a user to admin by email",
  alias: "up",
  preload: {
    env: true,
    config: ["database"],
    connectors: ["database", "logger"],
  },
  options: [
    {
      text: "--email, -e",
      description: "User email address",
      required: true,
    },
  ],
  action: async ({ options }) => {
    // …business work using framework services
    console.log(`Promoting ${options.email}…`);
  },
});
```

Run it: `yarn warlock users.promote --email=hasan@example.com` (or `yarn warlock up -e hasan@example.com`).

## `CLICommandOptions` — the factory input

| Field         | Type                          | Required | Notes                                                                                          |
| ------------- | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `name`        | `string`                      | yes      | Dot notation OK (`db.seed`, `jwt.generate`). May include positional placeholders (`name <arg>`). |
| `description` | `string`                      |          | Shown in `warlock --help` and `warlock <cmd> --help`.                                          |
| `alias`       | `string`                      |          | Short name (`m` for `migrate`).                                                                |
| `action`      | `(data) => void \| Promise`   | yes      | Runs after preloaders. `data` is `{ args, options }`.                                          |
| `preAction`   | `(data) => void \| Promise`   |          | Runs **before** preloaders — banner, input validation.                                         |
| `preload`     | `CLICommandPreload`           |          | What to load before `action` runs. See below.                                                  |
| `persistent`  | `boolean`                     |          | `true` for long-running commands (dev server). Skips the auto-exit.                            |
| `options`     | `CLICommandOption[]`          |          | Flag definitions. See below.                                                                   |

## Options — flag shape

Each entry in the `options` array:

```ts
{
  text: "--fresh, -f",            // "--key", "-k", "--key, -k", or "-k, --key"
  description: "Drop tables first",
  type: "boolean",                // "string" (default) | "boolean" | "number"
  defaultValue: false,            // applied if flag missing
  required: false,                // 1 missing required → command refuses to run
}
```

The parser auto-extracts `name` (long form, camelCased) and `alias` (short form) from `text`. Inside `action`, read via `options.fresh` — kebab-case becomes camelCase (`--no-cache` → `options.noCache`).

`--help`/`-h` are reserved — the framework intercepts them to print per-command help.

## Preload — lazy-loaded subsystems

Commands run with a minimal world by default. Opt in to what you need so the command stays fast:

```ts
preload: {
  env: true,                              // load .env
  config: ["database", "log"],            // load these src/config/*.ts files (or `true` for all)
  bootstrap: true,                        // full bootstrap (env + app + prestart hooks)
  connectors: ["database", "cache"],      // start these connectors (or `true` for all early-phase)
  prestart: true,                         // run src/app/prestart.ts after config
  warlockConfig: true,                    // load warlock.config.ts
  runtimeStrategy: "production",          // force-set
  environemnt: "production",              // force-set (note: original typo preserved)
}
```

Connector names: `"logger"`, `"mailer"`, `"http"`, `"database"`, `"cache"`, `"storage"`, `"communicator"` (herald), `"socket"`. Pass `connectors: true` to start every `Early`-phase connector — `http` and `socket` are `Late` phase and stay off unless you list them explicitly.

Picking the right preload matters: `migrate` only needs database + logger, but `seed` runs full `bootstrap: true` because seeds touch app models. Inspect `@warlock.js/core/src/cli/commands/*.ts` for canonical pairings.

## Inside `action` — `CommandActionData`

```ts
action: async ({ args, options }) => {
  // args: positional, e.g. `warlock storage.put ./uploads backups/` → args = ["./uploads", "backups/"]
  // options: flags, e.g. `--driver=r2 --concurrency=5` → { driver: "r2", concurrency: "5" }
};
```

For positional capture in `name`, declare slots: `name: "storage.put <localPath> [destination]"` — `<>` required, `[]` optional. Storage-put uses this exact pattern.

## Registering external-package commands

The convention is to export a **factory function** that returns a fresh `CLICommand`. Why a factory and not the instance directly? It defers any side-effects (like loading config) to when the command is actually wired into the CLI, and it keeps each project free to skip commands it doesn't want.

```ts title="@warlock.js/auth/src/commands/jwt-secret-generator-command.ts"
import { command } from "@warlock.js/core";
import { generateJWTSecret } from "../services/generate-jwt-secret";

export function registerJWTSecretGeneratorCommand() {
  return command({
    name: "jwt.generate",
    description: "Generate JWT Secret key in .env file",
    action: generateJWTSecret,
  });
}
```

Wire it in the project's `warlock.config.ts`:

```ts title="warlock.config.ts"
import { registerJWTSecretGeneratorCommand } from "@warlock.js/auth";
import { defineConfig } from "@warlock.js/core";

export default defineConfig({
  cli: {
    commands: [registerJWTSecretGeneratorCommand()],
  },
});
```

## Output and exit codes

By default, the framework prints `Executing <name>…`, runs your `action`, then prints `Done in <ms>ms` and `process.exit(0)`. Throwing exits with `1` and prints the error. If `persistent: true`, the framework keeps the process alive (no auto-exit on success; errors are logged but don't crash).

For colored output, the canonical helper is `colors` from `@mongez/copper` (re-exported from `@warlock.js/core`):

```ts
import { colors } from "@warlock.js/core";

console.log(colors.green("✓") + " user promoted");
```

## Built-in framework commands

The framework ships a fixed set of commands you call but don't author. Knowing their flags up front saves a `--help` round-trip.

> For `warlock dev` / `warlock build` / `warlock start` — see [`run-app/SKILL.md`](../run-app/SKILL.md). Everything below covers the rest.

### Database + migrations

| Command  | Flags / args                                    | Preloads                       |
| -------- | ----------------------------------------------- | ------------------------------ |
| `warlock migrate` | `--list` (just list pending), `--fresh` / `-f` (drop tables first) | database, logger          |
| `warlock seed` | `--name <pattern>` (run seeds matching the pattern) | full bootstrap (env, configs, app modules) |
| `warlock create-database <name>` | bare positional `<name>`        | database                       |
| `warlock drop.tables` | `--force, -f` (skip confirmation prompt) | database, logger          |
| `warlock db.indexes` | builds DB indexes for every registered model    | database                  |

### Scaffolding

The `generate.*` family covers every module piece:

```
warlock generate.module <name>           (alias: gen.m)  — flags: --minimal, --force
warlock generate.controller <m>/<n>      (alias: gen.c)  — flags: --with-validation, --force
warlock generate.service <m>/<n>         (alias: gen.s)
warlock generate.model <m>/<n>           (alias: gen.md) — flags: --with-resource, --table <name>, --timestamps
warlock generate.repository <m>/<n>      (alias: gen.r)
warlock generate.resource <m>/<n>        (alias: gen.rs)
warlock generate.migration <model-path>  (alias: gen.mig)
warlock generate.typings
warlock generate                         (alias: g)      — interactive picker
```

`<m>/<n>` is `<module>/<name>` (e.g. `products/create-product`). See [`create-module/SKILL.md`](../create-module/SKILL.md) for the full scaffolding flow.

### Feature installation

`warlock add <features...>` installs framework-adjacent features through a curated registry — bundles npm dependencies + scaffold files + tsconfig patches + package.json scripts in one call. Featured names (from `add-command.action.ts`):

| Feature       | Installs                                                                                            |
| ------------- | --------------------------------------------------------------------------------------------------- |
| `react-email` | `react-email` + `@react-email/components` + `@react-email/render` + `@react-email/tailwind`; drops a `welcome-email.tsx` sample; patches `tsconfig.json` |
| `react`       | `react` + `react-dom` + types                                                                       |
| `image`       | `sharp` (for the `Image` class)                                                                     |
| `mail`        | `nodemailer` + types                                                                                |
| `ses`         | `@aws-sdk/client-sesv2`                                                                             |
| `mongodb`     | `mongodb` driver                                                                                    |
| `postgres`    | `pg`                                                                                                |
| `mysql`       | `mysql2`                                                                                            |
| `redis`       | `redis`                                                                                             |
| `s3`          | `@aws-sdk/client-s3` + `@aws-sdk/lib-storage` + `@aws-sdk/s3-request-presigner`                     |
| `scheduler`   | `@warlock.js/scheduler`                                                                             |
| `swagger`     | `@warlock.js/swagger`                                                                               |
| `postman`     | `@warlock.js/postman`                                                                               |
| `herald`      | `@warlock.js/herald` + `amqplib` + types; ejects `src/config/communicator.ts`                       |
| `test`        | `vitest` + `@mongez/vite` + coverage; drops `test-global-setup.ts` / `test-setup.ts` / `vite.config.ts` |

Run `warlock add --list` to see what's currently registered. Pass `--packageManager <yarn\|pnpm\|npm>` to override auto-detection (defaults to whichever lockfile is present).

### Misc

| Command  | Purpose                                                     |
| -------- | ----------------------------------------------------------- |
| `warlock storage.put <localPath> [destination]` | Upload a local file to the default storage disk |
| `warlock jwt.generate` | Generate a JWT secret in `.env` (from `@warlock.js/auth`)            |

## Common patterns

### Single-shot maintenance task

```ts title="src/app/orders/commands/recompute-totals.command.ts"
import { command } from "@warlock.js/core";
import { recomputeAllOrderTotals } from "../services/recompute-all-order-totals.service";

export default command({
  name: "orders.recompute",
  description: "Recompute totals for all orders",
  preload: {
    env: true,
    bootstrap: true,
    connectors: ["database", "logger"],
  },
  action: async () => {
    const count = await recomputeAllOrderTotals();
    console.log(`Recomputed ${count} orders`);
  },
});
```

### Stats report

```ts
export default command({
  name: "stats.report",
  description: "Print daily stats to stdout",
  preload: { env: true, config: ["database"], connectors: ["database"] },
  options: [
    { text: "--from, -f", description: "Start date (YYYY-MM-DD)" },
    { text: "--to, -t", description: "End date (YYYY-MM-DD)" },
  ],
  action: async ({ options }) => {
    const stats = await buildReport(options.from as string, options.to as string);
    console.table(stats);
  },
});
```

### Confirm-before-destroy

```ts
export default command({
  name: "users.purge",
  description: "Hard-delete soft-deleted users older than 90 days",
  options: [{ text: "--force, -f", description: "Skip confirmation", type: "boolean" }],
  preAction: async ({ options }) => {
    if (!options.force) {
      throw new Error("Refusing to run without --force");
    }
  },
  preload: { env: true, bootstrap: true, connectors: ["database", "logger"] },
  action: async () => {
    await purgeStaleSoftDeletedUsers();
  },
});
```

`preAction` runs *before* preloaders — cheap way to bail out without spinning up the database.

## Gotchas

- **Default-export the `CLICommand`** for project commands. The loader does `await import(...)` and reads `.default`.
- **Don't put logic at module top level.** The file gets imported during the commands scan (`warlock --warm-cache`). Anything outside `action` runs at scan time, possibly before any config or env is loaded.
- **Required options block execution.** If `required: true` and the user omits the flag, the framework prints `Missing required options:` and exits `1` before `action` runs.
- **Connectors are not free.** `connectors: true` boots the database, cache, storage, etc. For a print-version-and-exit command, leave `preload` undefined.
- **`name` field with positional slots** (`name: "storage.put <localPath>"`) — the *registered* name is still `storage.put` (the first whitespace-separated token), so look up + alias work normally. Slots are only documentation/help-output.
- **Aliases must be unique across plugin + framework + project.** The first registration wins; later collisions silently overwrite the map entry.

## See also

- [`run-app/SKILL.md`](../run-app/SKILL.md) — the operational commands (`dev` / `build` / `start`) with all flags and config knobs.
- [`configure-app/SKILL.md`](../configure-app/SKILL.md) — wiring `defineConfig` and `warlock.config.ts`.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — module layout, canonical imports.


## write-middleware  `@warlock.js/core/write-middleware/SKILL.md`

---
name: write-middleware
description: 'Author HTTP middleware for @warlock.js/core — the `(request, response)` signature, short-circuit by returning a response, enrich the request with extra fields, register per-route, per-group, or app-wide. Triggers: `Middleware`, `MiddlewareResponse`, `router.group`, `guarded`, `request.detectIp`, `authMiddleware`; "write a custom middleware", "short-circuit a request", "enrich the request with extra fields", "per-route vs per-group middleware"; typical import `import type { Middleware } from "@warlock.js/core"`. Skip: built-in middleware catalog — `@warlock.js/core/use-middleware/SKILL.md`; route attachment — `@warlock.js/core/register-route/SKILL.md`; response helpers — `@warlock.js/core/send-response/SKILL.md`; competing patterns: `express` `(req, res, next)` middleware, Fastify `preHandler` hooks.'
---

# Warlock — write a middleware

Middleware is a plain function that runs before the controller. Two outcomes: return nothing (or `undefined`) and the request continues; return a `Response` and the request is short-circuited there. No `next()` callback — the framework chains them automatically.

## The shape

```ts title="src/app/<module>/utils/<name>.middleware.ts"
import type { Middleware } from "@warlock.js/core";

export const requireApiKey: Middleware = (request, response) => {
  const key = request.header("X-API-Key");

  if (!key || key !== process.env.API_KEY) {
    return response.unauthorized({ error: "invalid api key" });
  }

  // return nothing → request continues to next middleware / controller
};
```

That's the contract: `(request: Request, response: Response) => Response | undefined | void`. Async is fine — return a `Promise<Response | undefined | void>`.

The real type, from `@warlock.js/core/src/router/types.ts`:

```ts
export type Middleware<MiddlewareRequest extends Request = Request> = {
  (request: MiddlewareRequest, response: Response): MiddlewareResponse;
};

export type MiddlewareResponse = ReturnedResponse | undefined | void;
```

## Short-circuit vs continue

The pattern is "return a response to stop, return nothing to continue":

```ts
import type { Middleware } from "@warlock.js/core";

export const requireFeatureFlag: Middleware = async (request, response) => {
  const flag = await loadFeatureFlag(request.input("organization_id"));

  if (!flag.enabled) {
    return response.forbidden({ error: "feature.disabled" });
  }

  // implicit `return undefined` → continue
};
```

If you short-circuit, the controller never runs. The response helper you pick (`forbidden`, `unauthorized`, `badRequest`, etc.) sets the status — see [`send-response`](../send-response/SKILL.md).

## Enriching the request

You can attach arbitrary fields to `request` from a middleware, and they survive into the controller. The cleanest pattern is to extend `Request` via module augmentation in a `.d.ts` and assign in the middleware:

```ts title="src/app/feature-flags/middleware/load-feature-flag.middleware.ts"
import type { Middleware } from "@warlock.js/core";
import type { FeatureFlag } from "../models/feature-flag";

declare module "@warlock.js/core" {
  interface Request {
    featureFlag?: FeatureFlag;
  }
}

export const loadFeatureFlag: Middleware = async (request) => {
  request.featureFlag = await FeatureFlag.findBy("organization_id", request.user.organizationId);
};
```

After this middleware runs, `request.featureFlag` is typed inside any downstream middleware or controller. The same pattern is how `@warlock.js/auth`'s `authMiddleware` attaches `request.user` and `request.decodedAccessToken`.

## Registration — three scopes

### Per-route

Pass middleware in the third arg's `middleware` array:

```ts
import { router } from "@warlock.js/core";

router.post("/sync", syncController, {
  middleware: [requireApiKey],
});
```

### Per-group (preferred for modules)

`router.group(options, callback)` applies middleware to every route registered inside the callback:

```ts
import { router } from "@warlock.js/core";
import { authMiddleware } from "@warlock.js/auth";

router.group(
  {
    prefix: "/admin",
    middleware: [authMiddleware("admin")],
  },
  () => {
    router.get("/dashboard", dashboardController);
    router.delete("/users/:id", removeUserController);
  },
);
```

Group middleware runs **before** per-route middleware. Stack multiple middlewares — they run in array order:

```ts
import { middleware } from "@warlock.js/core";
import { authMiddleware } from "@warlock.js/auth";

router.group(
  { middleware: [middleware.rateLimit({ max: 60, duration: 60_000 }), authMiddleware("user")] },
  () => {
    // rate-limit first, then auth, then controller
  },
);
```

`all` runs on every route; `only` / `except` scope by route path or named-route. Prefer per-group for anything domain-specific — app-wide is for true cross-cutting concerns (request logging, CORS, rate limit defaults).

## The `guarded()` helper

Most modules wrap a `router.group({ middleware: [authMiddleware("user")] }, …)` call in a project-level helper:

```ts title="src/app/shared/utils/router.ts"
import { authMiddleware } from "@warlock.js/auth";
import { router } from "@warlock.js/core";

export function guarded(callback: () => void) {
  router.group({ middleware: [authMiddleware("user")] }, callback);
}

export function guardedAdmin(callback: () => void) {
  router.group({ prefix: "/admin", middleware: [authMiddleware()] }, callback);
}

export function publicRoutes(callback: () => void) {
  router.group({ prefix: "/" }, callback);
}
```

Then every module's `routes.ts` reads cleanly:

```ts
import { guarded } from "app/shared/utils/router";

guarded(() => {
  router.post("/products", createProductController);
});
```

`authMiddleware(allowedUserType?)` accepts a user-type string or array. Without an arg it just verifies the token is present; with `"user"` / `"admin"` it also checks the decoded `userType` matches.

## Common patterns

### Combining auth with a custom check

```ts
import { authMiddleware } from "@warlock.js/auth";
import { router } from "@warlock.js/core";
import { requireFeatureFlag } from "./middleware/require-feature-flag";

router.group(
  {
    prefix: "/beta",
    middleware: [authMiddleware("user"), requireFeatureFlag],
  },
  () => {
    router.get("/canary", canaryController);
  },
);
```

### Conditional pass-through

```ts
import type { Middleware } from "@warlock.js/core";

export const optionalAuth: Middleware = async (request, response) => {
  if (!request.authorizationValue) {
    return; // anonymous — let it through
  }

  // token present → enforce it
  return authMiddleware("user")(request, response);
};
```

## Using built-in middleware

`@warlock.js/core` ships seven built-in middlewares (rate limit, concurrency cap, body cap, idempotency, maintenance, IP filter, response cache) under the `middleware` namespace from `@warlock.js/core`. They cover the patterns most apps need — see [`use-middleware`](../use-middleware/SKILL.md) for the catalog, per-primitive deep-dives, error semantics, and gotchas specific to using them.

`X-Request-Id` correlation is wired automatically (inherit + echo on every response) — the same skill covers that too. It's not a middleware; nothing to register.

## Gotchas

- **Don't `throw` for HTTP-shaped failures.** Throwing escalates to the framework's error handler and you lose the specific status. Use `return response.<helper>(...)`.
- **Group middleware runs before per-route middleware**, in array order. The full chain is `app.all → group → per-route → controller`. Mind the order if you stack auth + rate-limit + audit.
- **Middleware can be async.** Returning `Promise<undefined>` continues the chain. Returning `Promise<Response>` short-circuits. The framework awaits the result.
- **Don't mutate `request.payload` directly.** Use `request.setValidatedData(...)` or attach a new named field (`request.featureFlag = ...`). The internals expect `payload.all` shapes to come from the validator pipeline.
- **`Middleware` is generic over the request type.** For middleware that assumes a validated schema, narrow it: `const m: Middleware<CreateProductRequest> = (request) => { ... }`. But most middlewares run before validation, so the default `Middleware` is right.
- **No `next()` parameter.** Express-style `next()` doesn't apply here. The framework chains based on return value.
- **`request.baseRequest` / `response.baseResponse` are escape hatches, not API.** They expose the underlying Fastify primitives for cases the framework hasn't covered yet (streaming was the historical precedent). Prefer framework helpers first — `response.send()`, `response.header()`, `response.replay()`, `request.input()`, `request.detectIp()`, etc. If you find yourself reaching for `baseResponse` or `baseRequest` for non-streaming work, that's a missing helper — file an issue. The cache and idempotency middlewares both shipped with a quietly-broken FastifyReply-return bug because they bypassed the helper layer; the framework now guards `Response.send()` against double-send, but the right answer is "use the helper."
- **Behind any proxy, use `request.detectIp()` not `request.ip`.** `request.ip` is the immediate peer (likely your load balancer); `request.detectIp()` honors `X-Real-IP` / `X-Forwarded-For`. Either way, only trust the result as far as you trust the upstream chain — those headers are client-settable; verify the request came through your trusted edge before treating the value as authoritative.

## See also

- [`use-middleware/SKILL.md`](../use-middleware/SKILL.md) — the built-in middleware catalog (`rateLimit`, `idempotency`, `maxBodySize`, etc.) + request-id correlation.
- [`register-route/SKILL.md`](../register-route/SKILL.md) — where middleware attaches: `router.group` and route-options.
- [`create-controller/SKILL.md`](../create-controller/SKILL.md) — how the controller picks up `request.user`, `request.validated()`, etc., set by upstream middleware.
- [`send-response/SKILL.md`](../send-response/SKILL.md) — the response helpers used to short-circuit.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — the `guarded()` / `guardedAdmin()` / `publicRoutes()` convention.


## write-seeder  `@warlock.js/core/write-seeder/SKILL.md`

---
name: write-seeder
description: 'Author a seed file under `src/app/<module>/seeds/<name>.ts` using the `seeder()` factory — `name`, `dependsOn`, `once`, `order`, `batchSize`, `run()`. Auto-discovered by `warlock seed`; tracked in a `seeds` table. Triggers: `seeder`, `Seeder`, `SeedResult`, `SeedersManager`, `warlock seed`, `--fresh`, `--list`, `--path`; "seed default roles", "one-time data migration", "auto-discovered seeds", "order seeds by dependency"; typical import `import { seeder } from "@warlock.js/core"`. Skip: module folder layout — `@warlock.js/core/create-module/SKILL.md`; repository CRUD — `@warlock.js/core/use-repository/SKILL.md`; CLI flags — `@warlock.js/core/write-cli-command/SKILL.md`; competing patterns: hand-rolled `node scripts/seed.js`, `typeorm-seeding`.'
---

# Warlock — write a seeder

Seeds are how you push initial data into the database — default roles, currencies, lookup tables, sample records for staging. They sit beside the module they belong to (`src/app/<module>/seeds/<name>.ts`), get auto-discovered by `warlock seed`, and the framework tracks which seeds have run so `once: true` ones don't re-run on a clean DB after the first time.

## The shape

```ts title="src/app/roles/seeds/default-roles.seed.ts"
import { seeder } from "@warlock.js/core";
import { Role } from "../models/role";

export default seeder({
  name: "default-roles",
  description: "Insert admin / member / viewer roles",
  once: true,
  order: 1,
  async run() {
    let recordsCreated = 0;

    for (const slug of ["admin", "member", "viewer"]) {
      const existing = await Role.first({ slug });
      if (existing) continue;

      await Role.create({ slug, name: slug.toUpperCase() });
      recordsCreated++;
    }

    return { recordsCreated };
  },
});
```

Run them:

```bash
yarn warlock seed                            # discover + run all
yarn warlock seed --list                     # show registry, don't run
yarn warlock seed --path=src/app/roles/seeds/default-roles.seed.ts  # one file
yarn warlock seed --fresh                    # truncate every table first, then run all
```

`--fresh` truncates **every** table in the DB (`datasource.driver.truncateTable(table, { cascade: true })`), including the `seeds` tracking table. After `--fresh`, `once: true` seeds will run again.

## The `Seeder` type

```ts
type Seeder = {
  name: string;                                // required, used as the registry key
  enabled?: boolean;                            // default true — set false to skip
  description?: string;                         // shown in --list output
  dependsOn?: string[];                         // names of seeds that must run first
  once?: boolean;                               // skip if a row exists in the `seeds` table
  order?: number;                               // sort key — lower runs first
  batchSize?: number;                           // documented; not enforced by the manager
  run(): Promise<SeedResult | void>;            // your work
};

type SeedResult = {
  recordsCreated: number;                       // tracked in the seeds table
};
```

Returning a `SeedResult` is optional — the manager uses `recordsCreated` only to populate metadata in the `seeds` table. If you don't return anything, the seed still counts as run (the row is inserted with `recordsCreated: 0`).

## Auto-discovery

`warlock seed` walks `src/app/*/seeds/*.ts` and imports each file via the framework's module loader. Each file must **default-export** a `Seeder`:

```
src/app/
├── roles/
│   └── seeds/
│       ├── default-roles.seed.ts
│       └── role-permissions.seed.ts
└── currencies/
    └── seeds/
        └── default-currencies.seed.ts
```

A seed file that doesn't export a default `Seeder` throws at discovery time:
> `Seeder file <relative> does not export a default seeder.`

Filename convention is `<name>.seed.ts` but the loader doesn't enforce it — any `.ts` inside a `seeds/` folder is picked up. Sticking to the suffix keeps things grep-able.

## Tracking — the `seeds` table

The first `warlock seed` run creates a `seeds` table via `SeedsTableMigration`. Every successful seed run stores a row:

| Column                  | Meaning                                    |
| ----------------------- | ------------------------------------------ |
| `name`                  | Seeder's `name` (the lookup key)           |
| `createdAt`             | Wall time of the first run                 |
| `firstRunAt`            | Same as `createdAt`                        |
| `lastRunAt`             | Updated on every subsequent successful run |
| `runCount`              | Increments by 1 per run                    |
| `totalRecordsCreated`   | Sum of `recordsCreated` across all runs    |
| `lastRunRecordsCreated` | Last run's `recordsCreated`                |

Skip-on-`once`:

- Before each seed runs, the manager looks up `name` in the `seeds` table.
- If a row exists **and** `once: true`, the seed is skipped with `⏭️  Skipping <name> (already executed)`.
- If `once` is unset / `false`, the seed always runs (but still appends a row, incrementing `runCount`).

Use `once: true` for irreversible "starter" data (the framework's default `roles`, fixed enum tables) and leave it off for repeatable seeds (dev fixtures you re-run after rolling the DB).

## Ordering

```ts
seeder({
  name: "user-roles",
  order: 10,
  // ...
});

seeder({
  name: "default-admin-user",
  order: 20,         // runs AFTER user-roles (which created the roles it references)
  // ...
});
```

The manager sorts by `order` ascending — lower runs first. Seeds without an `order` get sorted to the end (`Number.MAX_SAFE_INTEGER`).

`dependsOn: ["user-roles"]` is on the `Seeder` type but **not yet resolved** by the manager (there's an explicit `TODO: Handle dependsOn resolution` in the source — topological sort isn't implemented). For now, rely on `order` to express dependencies. The `dependsOn` field stays available for forward-compat.

## Transactions

By default, each seed runs inside a transaction:

```ts
const result = withTransaction
  ? await transaction(async () => seeder.run())
  : await seeder.run();
```

If your `run()` throws, the transaction rolls back. The CLI takes `--transaction` to flip this — pass `--transaction=false` for large seeds where you want each insert committed individually (avoid the rollback-on-failure cost; accept partial state on error).

Within a single seed, multiple model `create()` / `insert()` calls share the same transaction — partial-on-error doesn't apply unless you opt out.

## Patterns

### Idempotent seed (safe to re-run without `once`)

```ts title="src/app/currencies/seeds/default-currencies.seed.ts"
import { seeder } from "@warlock.js/core";
import { Currency } from "../models/currency";

export default seeder({
  name: "default-currencies",
  description: "USD / EUR / SAR / EGP currencies",
  order: 5,
  async run() {
    const seeds = [
      { code: "USD", name: "US Dollar", decimals: 2 },
      { code: "EUR", name: "Euro", decimals: 2 },
      { code: "SAR", name: "Saudi Riyal", decimals: 2 },
      { code: "EGP", name: "Egyptian Pound", decimals: 2 },
    ];

    let recordsCreated = 0;
    for (const data of seeds) {
      const existing = await Currency.first({ code: data.code });
      if (existing) continue;
      await Currency.create(data);
      recordsCreated++;
    }

    return { recordsCreated };
  },
});
```

`first` + `continue` keeps the seed safe to run twice — the second run does nothing.

### Run-once admin user (depends on roles)

```ts title="src/app/users/seeds/default-admin.seed.ts"
import { seeder } from "@warlock.js/core";
import { Role } from "../../roles/models/role";
import { User } from "../models/user";

export default seeder({
  name: "default-admin",
  description: "Insert the seed admin user",
  once: true,                   // never re-run
  order: 20,                    // after roles (order: 10)
  async run() {
    const adminRole = await Role.first({ slug: "admin" });
    if (!adminRole) throw new Error("admin role missing — run user-roles seed first");

    await User.create({
      email: "admin@example.com",
      password: "change-me-immediately",
      role_id: adminRole.id,
    });

    return { recordsCreated: 1 };
  },
});
```

Combining `once: true` + `order` is the canonical "starter data" pattern.

### Bulk dev fixtures (no `once`, large batch)

```ts title="src/app/products/seeds/dev-products.seed.ts"
import { seeder } from "@warlock.js/core";
import { Product } from "../models/product";

export default seeder({
  name: "dev-products",
  description: "100 sample products for dev",
  enabled: process.env.NODE_ENV === "development",
  order: 100,
  async run() {
    let recordsCreated = 0;
    for (let i = 0; i < 100; i++) {
      await Product.create({
        name: `Sample ${i}`,
        price: Math.random() * 100,
      });
      recordsCreated++;
    }
    return { recordsCreated };
  },
});
```

`enabled: false` (or env-gated like above) keeps the seed off in production runs while leaving the file in the repo.

### Wire seeds explicitly (alternative to auto-discovery)

If you need to bypass auto-discovery — e.g. a shared-seeds package — register via `SeedersManager` directly:

```ts
import { SeedersManager } from "@warlock.js/core";
import { defaultRoles } from "./seeds/default-roles.seed";
import { defaultCurrencies } from "./seeds/default-currencies.seed";

const manager = new SeedersManager();
manager.register(defaultRoles, defaultCurrencies);
await manager.run();
```

Most projects don't need this — auto-discovery handles the common case.

## CLI flags

```
warlock seed                  # run all auto-discovered seeds
warlock seed --list           # show registry (name, order, enabled), don't run
warlock seed --path=<file>    # run one file by absolute or relative path
warlock seed --fresh          # truncate every table first, then run all
warlock seed --transaction    # (default true) — pass --transaction=false to skip wrapping
```

`--fresh` is **destructive** — it truncates all tables, including the `seeds` tracking table. Treat it as "reset the dev DB," not "run pending seeds."

## Gotchas

- **The seed file must default-export.** A named export gets the discovery error at boot. Wrap with `export default seeder({...})`.
- **`once: true` only works if the seed run succeeds.** A throw rolls back the transaction including the `seeds` table insert — the next run will retry.
- **`dependsOn` is documented but not enforced yet.** Use `order` until topological sort lands (`TODO` in source).
- **`batchSize` is documented on the type but unused by the manager.** It's a forward-compat field. If you need batch inserts, batch inside `run()` directly.
- **`--fresh` truncates `cascade: true`.** Foreign-key chains delete with their parents. If you have data outside the seeded scope that you want to keep, do NOT run `--fresh`.
- **Seeds aren't migrations.** Schema changes go in `migrations/`. Seeds populate data into a schema that already exists. Mixing the two (creating a table in a seed) confuses the lifecycle.
- **`enabled: false` skips the seed but it's still in the registry.** `warlock seed --list` shows it as disabled. Re-enable by flipping the flag, not by deleting the file.

## See also

- [`create-module/SKILL.md`](../create-module/SKILL.md) — the `seeds/` folder layout in a module, `generate.module` scaffolding.
- [`use-repository/SKILL.md`](../use-repository/SKILL.md) — the model `create` / `first` calls used inside seeds.
- [`write-cli-command/SKILL.md`](../write-cli-command/SKILL.md) — the `warlock seed` CLI flags and full preload shape.
- [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — `seeds/` is the canonical folder name (singular `seed/` is incorrect).


## write-use-case  `@warlock.js/core/write-use-case/SKILL.md`

---
name: write-use-case
description: 'Author `useCase()` pipelines for business logic — guards, schema, before/after middleware, retry, benchmark, broadcast, lifecycle callbacks; transport-agnostic and observable by default. Input is inferred from the `schema`. Triggers: `useCase`, `UseCaseContext`, `UseCaseResult`, `retry`, `benchmark`, `broadcast`, `description`, `globalUseCasesEvents`, `UseCaseBroadcastChannel`; "encapsulate a business operation", "share logic between HTTP and CLI", "add guards and lifecycle hooks", "broadcast a use case result", "transport-agnostic pipeline"; typical import `import { useCase } from "@warlock.js/core"`. Skip: thin handler shape — `@warlock.js/core/create-controller/SKILL.md`; schema details — `@warlock.js/core/validate-input/SKILL.md`; the standalone retry util — `@warlock.js/core/retry-operation/SKILL.md`; competing libs `@nestjs/cqrs`, `inversify`, hand-rolled service classes.'
---

# Warlock — write a use case

A use case is a named, observable, transport-agnostic unit of business logic. The factory `useCase({ ... })` returns a typed async function you call with the input. Under the hood it runs a fixed pipeline — guards, schema validation, before middleware, handler, after middleware — then emits lifecycle events and optionally broadcasts the result. Retry and benchmark wrap the **handler only**.

## The shape

```ts title="src/app/orders/use-cases/place-order.usecase.ts"
import { useCase } from "@warlock.js/core";
import { v } from "@warlock.js/seal";
import { placeOrderService } from "../services/place-order.service";

export const placeOrderUseCase = useCase({
  name: "orders.placeOrder",
  description: "Place a new order",
  schema: v.object({
    productId: v.string(),
    quantity: v.int().min(1),
    userId: v.string(),
  }),
  handler: async (data) => placeOrderService(data),
});
```

**Input is inferred from `schema`** — `data` above is typed `{ productId: string; quantity: number; userId: string }` with no manual generic. When there's **no schema**, give the types explicitly: `useCase<Output, Input>({ ... })`.

```ts
const order = await placeOrderUseCase({ productId, quantity, userId });
```

## Pipeline phases

From `@warlock.js/core/src/use-cases/use-case.ts`, the order is fixed:

```
onExecuting → guards → schema → before → handler → after → onCompleted → broadcast
                                            ↘  (on error)
                                                onError
                                  └ retry + benchmark wrap the handler only ┘
```

| Phase         | Signature                                                              | Notes                                                                            |
| ------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `onExecuting` | `(ctx: UseCaseOnExecutingContext) => void`                            | Fires first (once). `ctx` carries `id`, `name`, `data`, `schema`, `startedAt`.    |
| `guards`      | `(data: Readonly<Input>, ctx: Ctx) => void \| Promise<void>`          | Authorization / precondition checks. **Throw to abort.** Enrich `ctx`, don't mutate `data`. |
| `schema`      | `ObjectValidator` (seal `v.object(...)`)                              | Runs after guards. Failures throw `BadSchemaUseCaseError` and skip the handler.   |
| `before`      | `(data: Input, ctx) => Input \| Promise<Input>`                       | Runs after schema. Sequential — each return becomes the next's input.            |
| `handler`     | `(data: Input, ctx) => Promise<Output>`                               | The work. Receives the post-`before` payload. Retry + benchmark wrap **this**.   |
| `after`       | `(output: Output, ctx) => void \| Promise<void>`                      | Fire-and-forget side effects on success. Errors are logged, never re-thrown.     |
| `onCompleted` | `(result: UseCaseResult<Output>) => void`                            | Lifecycle event with the full `UseCaseResult` snapshot.                          |
| `onError`     | `(ctx: UseCaseErrorResult) => void`                                  | Lifecycle event with the error + execution metadata.                            |

`ctx` is a `UseCaseContext` shared across every phase. With the optional `Ctx` generic (`useCase<Output, Input, MyCtx>`) it's typed end-to-end; guards write `ctx.currentUser`, the handler reads it.

## The full options surface

```ts
import type { RetryOptions } from "@mongez/reinforcements";

type UseCase<Output, Input, Ctx = UseCaseContext> = {
  name: string;
  description?: string;
  handler: (data: Input, ctx: Ctx) => Promise<Output>;
  schema?: ObjectValidator;
  guards?: UseCaseGuard<Input, Ctx>[];
  before?: UseCaseBeforeMiddleware<Input, Ctx>[];
  after?: UseCaseAfterMiddleware<Output, Ctx>[];
  onExecuting?: (ctx: UseCaseOnExecutingContext) => void;
  onCompleted?: (result: UseCaseResult<Output>) => void;
  onError?: (ctx: UseCaseErrorResult) => void;
  retry?: RetryOptions;                 // from @mongez/reinforcements
  benchmark?: boolean | BenchmarkOptions;
  broadcast?: boolean | { event?: string; output?: (output, result) => unknown };
};
```

Defaults come from `config.get("use-cases")` (`src/config/use-cases.ts`); per-use-case options win. Resolution per option: **per-use-case ?? global ?? framework default**.

## Retry

`retry` is the [`@mongez/reinforcements`](mongez-reinforcements-async) `RetryOptions` — `attempts` (total, default 3), `delay`, `backoff` (`"linear" | "exponential"` or a fn), `maxDelay`, `jitter`, `shouldRetry`, `signal`. It wraps the **handler only** — guards, validation, and `before` run once and are never retried.

```ts
export const flakyApiCallUseCase = useCase({
  name: "external.callFlakyApi",
  retry: {
    attempts: 4,                         // initial try + 3 retries
    delay: 500,
    backoff: "exponential",
    shouldRetry: (error) => !(error instanceof ValidationError), // skip 4xx
  },
  handler: async (data) => callExternalApi(data),
});
```

Opt-in: with no `retry`, the handler runs exactly once. The result snapshot reports `retries: { attempts, delay, currentRetry }` where `currentRetry` is the actual number of retries performed (0 = succeeded first try).

## Benchmark

```ts
export const expensiveReportUseCase = useCase({
  name: "reports.generate",
  benchmark: { latencyRange: { excellent: 200, poor: 1000 } },
  handler: async (data) => generateReport(data),
});
```

`benchmark: true` uses the global config defaults; an object customizes thresholds/hooks; `false` disables. It measures the **handler only** (per attempt) — never the guard/validation prelude or retry backoff delays. Each completed run gets `benchmarkResult: { latency, state }` in the snapshot.

## Broadcast

Publish the result onto a message bus on success — declaratively, "as if you'd published it yourself". The use case declares **what**; the global config declares **how** (the channels).

```ts
// 1. Per use case — opt in (WHAT to broadcast)
export const createUserUseCase = useCase({
  name: "users.create",
  schema: createUserSchema,
  handler: async (data) => User.create(data),
  broadcast: true,                       // → channel "users.create", payload = output as-is
});

// Project / rename to avoid leaking sensitive fields:
export const signupUseCase = useCase({
  name: "auth.signup",
  schema: signupSchema,
  handler: async (data) => signupService(data),
  broadcast: {
    event: "auth.signup",                // custom channel name (default = use case name)
    output: (result) => ({ id: result.id, email: result.email }), // never the raw model
  },
});
```

```ts
// 2. Global config — the transport (HOW). src/config/use-cases.ts
import { heraldBroadcast } from "@warlock.js/herald";

export default {
  broadcast: {
    enabled: true,                       // global kill-switch
    channels: [heraldBroadcast({ broker: "default" })],
  },
} satisfies UseCaseConfigurations;
```

- **Success-only.** Failures don't broadcast.
- **Isolated.** Fan-out is `Promise.allSettled` + try/catch — a dead broker is logged, never breaks the use case.
- **No-op** when `broadcast.enabled` is `false`, no channels are registered, or the use case didn't opt in.
- **Envelope** — consumers receive `{ useCase, event, id, at, payload }`; the `id` is the execution id for tracing/idempotency under at-least-once delivery.
- **Payload safety** — `broadcast: true` sends the output as-is, which serializes a model's full `toJSON` (can leak secrets). For anything sensitive, use the `output` projector.

Channels are **global-only** — a use case picks its event name, never its transport. Adding a second sink (socket, webhook) is one entry in `channels`, zero use-case edits.

## Guards

Guards run before validation. Throw to abort.

```ts
import { ConflictError, ForbiddenError, useCase } from "@warlock.js/core";

const requireOwner = async (data: { orderId: string }, ctx) => {
  const order = await Order.find(data.orderId);

  if (!order) {
    throw new ConflictError("order.notFound");
  }

  if (order.userId !== ctx.currentUser?.id) {
    throw new ForbiddenError("order.forbidden");
  }

  ctx.order = order; // available to handler
};

export const cancelOrderUseCase = useCase({
  name: "orders.cancel",
  guards: [requireOwner],
  handler: async ({ orderId }, ctx) => {
    await ctx.order.update({ status: "cancelled" });
    return { orderId, status: "cancelled" };
  },
});
```

Populate `ctx` from a controller via runtime options:

```ts
await cancelOrderUseCase(
  { orderId: request.input("id") },
  { ctx: { currentUser: request.user } },
);
```

## Before / after middleware

`before` runs after schema validation and transforms the data; `after` is fire-and-forget on success (errors logged, never propagated):

```ts
const enrichWithPricing = async (data, ctx) => {
  const price = await Pricing.lookup(data.productId);
  return { ...data, unitPrice: price };
};

const sendConfirmationEmail = async (output, ctx) => {
  await emails.send(ctx.currentUser.email, "Order placed", { orderId: output.orderId });
};

export const placeOrderUseCase = useCase({
  name: "orders.placeOrder",
  schema: orderSchema,
  before: [enrichWithPricing],
  handler: async (data) => placeOrderService(data),
  after: [sendConfirmationEmail],
});
```

The caller still gets the handler's output even if an `after` middleware throws — side effects don't fail the operation.

## Lifecycle callbacks

`onExecuting`, `onCompleted`, `onError` run for every invocation — for observability:

```ts
useCase({
  name: "billing.charge",
  onExecuting: ({ id, data }) => log.info("billing", "executing", { id }),
  onCompleted: ({ id, benchmarkResult }) =>
    metrics.histogram("billing.latency", benchmarkResult?.latency ?? 0, { id }),
  onError: ({ id, error }) => Sentry.captureException(error, { extra: { id } }),
  handler: async (data) => chargeService(data),
});
```

Global listeners exist too — `globalUseCasesEvents.onExecuting(...)` / `onCompleted(...)` / `onError(...)` — for cross-cutting telemetry without per-use-case wiring. Each observer is isolated: a throwing or slow observer can't break or stall the pipeline.

## Global config

`src/config/use-cases.ts` (`UseCaseConfigurations`):

```ts
export default {
  benchmark: true,                       // benchmark every handler by default
  log: false,                            // per-step debug logging via @warlock.js/logger
  history: { enabled: true, ttl: 3600, maxEntries: 100 },
  broadcast: { enabled: false, channels: [] },
} satisfies UseCaseConfigurations;
```

## Runtime options

```ts
await placeOrderUseCase(input, {
  id: "order-tracking-123",              // override auto-generated execution id
  ctx: { currentUser: user },            // pre-populate context for guards
  onCompleted: (result) => log.debug("done", result.benchmarkResult),
  onError: ({ error }) => log.error("failed", error),
});
```

Per-invocation callbacks fire first, then use-case-level, then globals. The returned function also carries `$cleanup()` to unregister the use case and drop its history.

## Use case vs service vs controller

| Layer      | What it owns                                                              | When to use                                |
| ---------- | ------------------------------------------------------------------------ | ------------------------------------------ |
| Controller | Pull from `request`, return via `response.<helper>()`                     | Always — the HTTP edge.                    |
| Service    | One unit of work, plain async function, no observability magic           | Most CRUD; one or two model touches.       |
| Use case   | Guards/validation/handler/after + retry + benchmark + broadcast + events | Cross-cutting concerns, multi-service orchestration, anything called from multiple transports (HTTP + CLI + queue). |

Don't reach for `useCase` for a 5-line service — the ceremony outweighs the benefit.

## Gotchas

- **`name` must be unique.** The registry de-dupes by name; a duplicate in dev logs a warning and one wins. Use namespaced names: `"orders.placeOrder"`.
- **Input is inferred from `schema`.** With a schema you don't pass generics; without one, pass `useCase<Output, Input>(...)`.
- **Retry wraps the handler only.** Guards, validation, and `before` run once and are never retried — retrying a 4xx is impossible by design. (This changed from older versions that retried the whole pipeline.)
- **Benchmark measures the handler only.** Latency excludes the prelude and retry backoff.
- **`retry` is the reinforcements shape** — `attempts` is the **total** (not extra) count. `attempts: 3` = 3 calls. Defaults to 3 when `retry` is set.
- **`broadcast: true` sends the output as-is.** A model instance serializes its full `toJSON` — use the `output` projector for anything sensitive.
- **Broadcast needs channels.** `broadcast: true` does nothing unless `config.broadcast.channels` has at least one adapter and `enabled` isn't `false`.
- **`after` errors are swallowed.** Fire-and-forget by design. Roll back inside the handler if a side-effect failure must abort.
- **Schema runs after guards.** Guards see the raw input shape, not the validated one.

## See also

- [`@warlock.js/core/create-controller/SKILL.md`](@warlock.js/core/create-controller/SKILL.md) — the usual next step: invoke this use case from an HTTP controller.
- [`@warlock.js/herald/publish-message/SKILL.md`](@warlock.js/herald/publish-message/SKILL.md) — the message bus the `broadcast` adapter publishes to (cross-package).



