# Resolver & Executor Patterns

## Resolver Patterns

All resolvers export a default `createResolver()` from `@tailor-platform/sdk`.

### Verify command input types

Before implementing a resolver, read the command's type definition:

```
node_modules/@tailor-platform/erp-kit/src/modules/<module>/command/<commandName>.ts
```

The command source is the ground truth. Do not invent fields that don't exist, and preserve the required/optional distinction.

### SDK type system

See [Resolver docs](https://raw.githubusercontent.com/tailor-platform/sdk/refs/heads/main/packages/sdk/docs/services/resolver.md) for the `t` namespace.

`t.array()` and `t.boolean()` do **not exist**. Never attempt to use them.

Use `t.uuid()` for fields that hold UUIDs (e.g. entity IDs, foreign keys). Do not use `t.string()` for UUID fields.

### Command-based mutation

The most common pattern. Run the command inside a transaction so a thrown error rolls back partial work, and chain a `.catch()` that applies the error policy from [Error handling](#error-handling):

```ts
import { createContext, DomainError } from "@tailor-platform/erp-kit/app";

body: async (context) => {
  const ctx = createContext(context);
  const db = getDB("main-db");
  const result = await db
    .transaction()
    .execute(async (trx) => {
      const cmdResult = await imCommands.createItem(trx, { ...context.input }, ctx);
      if (!cmdResult.ok) { /* switch on cmdResult.error.code — see Error handling */ }
      return { id: cmdResult.value.item.id, ... };
    })
    .catch((err: unknown) => {
      // rethrow DomainError, mask the rest — see Error handling
    });
  return result;
},
```

- `context.input.optionalField ?? undefined` — Convert null to undefined for command inputs
- `result.value.entity.nullableField ?? ""` — Handle nullable return values
- `createContext(context)` translates the resolver context for erp-kit commands. It only
  translates — an anonymous caller yields `actorId: null` rather than an invented id, and the
  command's own gate rejects that with `UNAUTHENTICATED`. Whether an anonymous caller reaches
  this resolver at all is decided by `permission` (or the namespace `defaultPermission`) in
  `tailor.config.ts`
- `getDB("main-db")` — Namespace must match `tailor.config.ts`
- Do not directly mutate module-owned tables via Kysely — always use module commands
- Throw domain errors **inside** the transaction and chain a `.catch()` — see [Error handling](#error-handling)

### Document update mutations (headerPatch + line collections)

Document update/amend commands take `{ id, headerPatch?, addLines?, updateLines?, removeLineIds? }` — three explicit line collections, each fully typed on its own:

```ts
input: {
  id: t.uuid(),
  headerPatch: t.object({ /* patchable header fields, all optional */ }, { optional: true }),
  addLines: t.object({ /* full line input */ }, { optional: true, array: true }),
  updateLines: t.object({
    lineId: t.uuid(),
    linePatch: t.object({ /* patchable line fields, all optional */ }),
  }, { optional: true, array: true }),
  removeLineIds: t.uuid({ optional: true, array: true }),
},
```

- Pass the input through to the command as-is.
- **Return `{ id }`, typed as the document entity** via `typeName` (reference the db type so the name can't drift):

```ts
import { purchaseOrder } from "@/db"; // the db.table() instance

body: async (context) => {
  const ctx = createContext(context);
  const db = getDB("main-db");
  const result = await db
    .transaction()
    .execute(async (trx) => {
      const cmdResult = await imCommands.updatePurchaseOrder(trx, { ...context.input }, ctx);
      if (!cmdResult.ok) { /* switch on cmdResult.error.code — see Error handling */ }
      return { id: cmdResult.value.purchaseOrder.id }; // key only
    })
    .catch((err: unknown) => {
      // rethrow DomainError, mask the rest — see Error handling
    });
  return result;
},
output: t.object({ id: t.uuid() }).typeName(purchaseOrder.name),
```

  `typeName` keys the result to the entity, which buys two things: the client selects header fields and the paginated `lines` straight off the mutation result (federation resolves them off the entity, same path as a query), and the `__typename`+`id` key lets a normalized GraphQL cache merge the result so existing views refresh.

### Error handling

Resolver specs document error codes the command can return. Add a `case` for every documented code and a `default` with `satisfies never` for exhaustiveness.

Throw errors **inside** the transaction callback so a domain failure rolls the transaction back — kysely commits on a normal return, so throwing after the transaction leaves partial work committed. To keep intentional, user-facing messages distinguishable from unexpected failures, throw them as a `DomainError` marker, then chain a `.catch()` on the transaction that:

- rethrows `DomainError` (deliberate, user-facing messages) unchanged,
- masks everything else behind a generic, action-specific message, keeping the raw error on `cause` for server-side logs. This is what stops connection drops, constraint violations, and other internal errors from leaking to the client.

`UNAUTHENTICATED` arrives the same way `INSUFFICIENT_PERMISSION` does — as a command error, from the command's own gate — so give it a `case` alongside it. A command needs an actor for its audit columns, so it refuses to run without one.

Every command case — including `INSUFFICIENT_PERMISSION` — throws a `DomainError`. Do **not** rethrow the raw command error for the permission case: its message embeds the actor id and internal permission scope (`Actor <id> lacks required permission: <scope>`), which the client shows verbatim, and nothing downstream reads the error `code`.

The `DomainError` marker is what lets a domain message survive the mask — several resolvers build messages from loop-local context inside the transaction (e.g. `` `Category ${categoryId} does not exist` ``), which an outer handler can't reconstruct. `DomainError` is exported from erp-kit — every module's generated errors extend it too — so import it rather than declaring a local class:

```ts
import { DomainError } from "@tailor-platform/erp-kit/app";
```

**Do this:**

```ts
body: async (context) => {
  const ctx = createContext(context);
  const db = getDB("main-db");
  const result = await db
    .transaction()
    .execute(async (trx) => {
      const cmdResult = await imCommands.confirmOrder(trx, { ...context.input }, ctx);
      if (!cmdResult.ok) {
        switch (cmdResult.error.code) {
          case "INSUFFICIENT_STOCK":
            throw new DomainError(`Stock insufficient for item ${context.input.itemId}`);
          case "ORDER_ALREADY_CONFIRMED":
            throw new DomainError("Cannot modify a confirmed order");
          case "INSUFFICIENT_PERMISSION":
            throw new DomainError("You do not have permission to perform this action");
          default:
            throw cmdResult.error satisfies never;
        }
      }
      return { id: cmdResult.value.order.id };
    })
    .catch((err: unknown) => {
      if (err instanceof DomainError) throw err;
      throw new Error("Failed to confirm the order", {
        cause: err,
      });
    });
  return result;
},
```

The `.catch()` handler always throws, so its inferred return type is `never` and `result` keeps the transaction's resolved type.

**Not this:**

```ts
// leaks the raw internal error to the client
if (!result.ok) throw result.error;

// domain switch OUTSIDE the transaction: the command returns { ok: false } without
// throwing, so execute() resolves normally and kysely COMMITS the partial work
const result = await db.transaction().execute((trx) => imCommands.confirmOrder(trx, ...));
if (!result.ok) throw new Error("...");
```
