# Resolver

Resolvers are custom GraphQL endpoints with business logic that execute on the Tailor Platform.

## Overview

Resolvers provide:

- Custom GraphQL queries and mutations
- Type-safe input/output schemas
- Access to TailorDB via Kysely query builder
- User context for authentication/authorization

## Comparison with Tailor Platform Pipeline Resolver

The SDK's Resolver is a simplified version of Tailor Platform's [Pipeline Resolver](https://docs.tailor.tech/guides/resolver).

| Pipeline Resolver                        | SDK Resolver                      |
| ---------------------------------------- | --------------------------------- |
| Multiple steps with different operations | Single `body` function            |
| Declarative step configuration           | Imperative TypeScript code        |
| Built-in TailorDB/GraphQL steps          | Direct database access via Kysely |
| CEL expressions for data transformation  | Native TypeScript transformations |

### Example Comparison

**Pipeline Resolver (Tailor Platform native):**

```yaml
steps:
  - name: getUser
    operation: tailordb.query
    params:
      type: User
      filter:
        email: { eq: "{{ input.email }}" }
  - name: updateAge
    operation: tailordb.mutation
    params:
      type: User
      id: "{{ steps.getUser.id }}"
      input:
        age: "{{ steps.getUser.age + 1 }}"
```

**Resolver (SDK):**

```typescript
createResolver({
  name: "incrementUserAge",
  operation: "mutation",
  input: { email: t.string() },
  body: async (context) => {
    const db = getDB("tailordb");
    const user = await db
      .selectFrom("User")
      .selectAll()
      .where("email", "=", context.input.email)
      .executeTakeFirstOrThrow();

    await db
      .updateTable("User")
      .set({ age: user.age + 1 })
      .where("id", "=", user.id)
      .execute();

    return { oldAge: user.age, newAge: user.age + 1 };
  },
  output: t.object({ oldAge: t.int(), newAge: t.int() }),
});
```

## Creating a Resolver

Define resolvers in files matching glob patterns specified in `tailor.config.ts`.

**Definition Rules:**

- **One resolver per file**: Each file must contain exactly one resolver definition
- **Export method**: Must use `export default`
- **Uniqueness**: Resolver names must be unique per namespace

```typescript
import { createResolver, t } from "@tailor-platform/sdk";

export default createResolver({
  name: "add",
  operation: "query",
  input: {
    left: t.int(),
    right: t.int(),
  },
  body: (context) => {
    return {
      result: context.input.left + context.input.right,
    };
  },
  output: t.object({
    result: t.int(),
  }),
});
```

## Input/Output Schemas

Define input/output schemas using methods of `t` object. Basic usage and supported field types are the same as TailorDB. TailorDB-specific options (e.g., index, relation) are not supported.

You can reuse fields defined with `db` object, but note that unsupported options will be ignored:

```typescript
const user = db.table("User", {
  name: db.string().unique(),
  age: db.int(),
});

createResolver({
  input: {
    name: user.fields.name,
  },
});
```

### Date and Time Values

`t.date()` uses `YYYY-MM-DD` strings by default. Use `t.date({ as: "date" })` to work with JavaScript `Date` values in the resolver body and input validators:

```typescript
createResolver({
  name: "nextDay",
  operation: "query",
  input: { day: t.date({ as: "date" }) },
  body: ({ input }) => {
    input.day.setUTCDate(input.day.getUTCDate() + 1);
    return input.day;
  },
  output: t.date({ as: "date" }),
});
```

GraphQL still accepts and returns `YYYY-MM-DD` strings. The SDK converts input to a `Date` at midnight UTC and formats output using its UTC year, month, and day. Use UTC getters and setters for date arithmetic; local getters and setters depend on the runtime's timezone. Any time component in the returned `Date` is discarded according to UTC, so `new Date("2026-09-07T00:00:00+09:00")` returns `"2026-09-06"`.

This option also works in nested objects and with `array: true` or `optional: true`. Input must be a valid calendar date, and output must be a valid `Date` with a 4-digit UTC year (0000-9999). Both deployed resolvers and `tailor function run` perform these conversions.

An executor subscribing to the resolver with `resolverExecutedTrigger` receives the event as JSON, so `result` holds the `YYYY-MM-DD` string rather than a `Date`.

Use `t.date({ as: "temporal" })` to work with the [`Temporal.PlainDate`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainDate) value instead:

```typescript
createResolver({
  name: "nextDay",
  operation: "query",
  input: { day: t.date({ as: "temporal" }) },
  body: ({ input }) => input.day.add({ days: 1 }),
  output: t.date({ as: "temporal" }),
});
```

`Temporal.PlainDate` has no time zone, so date arithmetic is unambiguous. It works the same as `as: "date"` otherwise: `array`/`optional`/nested objects are supported, input must be a valid calendar date, output must have a 4-digit year (0000-9999), and an executor receiving the event through `resolverExecutedTrigger` sees the `YYYY-MM-DD` string.

`t.datetime` and `t.time` also support both representations:

```typescript
createResolver({
  name: "reschedule",
  operation: "query",
  input: {
    at: t.datetime({ as: "temporal" }),
    time: t.time({ as: "temporal" }),
  },
  body: ({ input }) => ({
    at: input.at.add({ hours: 1 }),
    time: input.time.add({ minutes: 30 }),
  }),
  output: {
    at: t.datetime({ as: "temporal" }),
    time: t.time({ as: "temporal" }),
  },
});
```

- **Datetime**: `t.datetime({ as: "temporal" })` uses `Temporal.Instant`; `t.datetime({ as: "date" })` uses `Date`. Input must be a valid ISO datetime with seconds and a UTC offset or `Z`; leap seconds are rejected. Output uses UTC (`Z`) and must have a 4-digit UTC year (0000-9999). `Temporal.Instant` preserves nanoseconds during SDK conversion, but the Platform truncates datetimes to milliseconds. `Date` itself has only millisecond precision. Without `as`, the existing behavior is unchanged: input is a string, and output accepts `string | Date`. Explicit `as: "string"` types the value as `string`.
- **Time**: `t.time({ as: "temporal" })` uses `Temporal.PlainTime`; `t.time({ as: "date" })` uses a `Date` on `1970-01-01` in UTC. Input and output use `HH:mm` in the range `00:00`–`23:59`. For `Date` output, only the UTC hours and minutes are used; the date portion is ignored. Both representations truncate seconds and fractional seconds without rounding up: `12:30:59.999` becomes `12:30`. Use UTC getters/setters when changing a `Date` time. Without `as`, or with `as: "string"`, time values remain strings.

All three field types support `array`, `optional`, nested objects, and input validators with the selected representation. Both deployed resolvers and `tailor function run` convert input and output. Executors using `resolverExecutedTrigger` receive date, datetime, and time results as strings.

The SDK supplies Temporal types, so existing projects can use `as: "temporal"` without changing `compilerOptions.lib`. To construct values or name their types, import `Temporal` from the SDK:

```typescript
import { Temporal } from "@tailor-platform/sdk/runtime";

const day: Temporal.PlainDate = Temporal.PlainDate.from("2026-09-07");
const at: Temporal.Instant = Temporal.Instant.from("2026-09-07T12:30:00Z");
const time: Temporal.PlainTime = Temporal.PlainTime.from("12:30:59.999");
```

This import uses the runtime's Temporal implementation. Deployed resolvers and `tailor function run` use the Tailor Platform's native Temporal. The SDK's [`tailor-runtime` Vitest environment](../testing.md) installs a polyfill when Node.js does not provide Temporal; no Node.js flags or separate polyfill setup are needed. Other local runtimes must provide Temporal to construct or parse Temporal values. Importing the SDK and defining fields does not require it, so CLI configuration loading works on Node.js without Temporal. The SDK does not add ambient global Temporal types or include the polyfill in deployed functions.

### Custom Type Name (`typeName`)

Enum and nested object fields in input/output schemas generate protobuf type names automatically (e.g., `{ResolverName}{FieldName}`). Use `typeName()` to set a custom name:

```typescript
createResolver({
  name: "createOrder",
  operation: "mutation",
  input: {
    address: t
      .object({
        street: t.string(),
        city: t.string(),
        zip: t.string(),
      })
      .typeName("ShippingAddress"),
    status: t.enum(["pending", "confirmed", "shipped"]).typeName("OrderStatus"),
  },
  // ...
});
```

**Constraints:**

- Only available on `enum()` and `object()` fields — calling on scalar types is a compile error
- Cannot be called twice on the same field
- Can be chained with `description()`

This is useful when the same logical type appears in multiple resolvers or when you want a predictable, human-readable name in the generated GraphQL schema.

**Warning:** Do not set `typeName` to an existing TailorDB table name on an `object()` that contains enum or nested fields. Child fields without an explicit `typeName` auto-generate names using `{parentTypeName}{FieldName}`, which can collide with the TailorDB table's own enum/nested type names.

```typescript
// Collision — "Item" + "status" auto-generates "ItemStatus",
//   which collides with the TailorDB Item table's status enum
output: t
  .object({
    id: t.uuid(),
    status: t.enum(["ACTIVE", "INACTIVE"]),
  })
  .typeName("Item"),

// OK — use a distinct name that won't collide
output: t
  .object({
    id: t.uuid(),
    status: t.enum(["ACTIVE", "INACTIVE"]),
  })
  .typeName("DeactivateItemOutput"),

// OK — explicitly set typeName on child enum too
output: t
  .object({
    id: t.uuid(),
    status: t.enum(["ACTIVE", "INACTIVE"]).typeName("DeactivateItemStatus"),
  })
  .typeName("Item"),
```

## Input Validation

Add validation rules to input fields using the `validate` method:

```typescript
createResolver({
  name: "createUser",
  operation: "mutation",
  input: {
    email: t
      .string()
      .validate(
        ({ value }) => value.includes("@"),
        [({ value }) => value.length <= 255, "Email must be 255 characters or less"],
      ),
    age: t.int().validate(({ value }) => value >= 0 && value <= 150),
  },
  body: (context) => {
    // Input is validated before body executes
    return { email: context.input.email };
  },
  output: t.object({ email: t.string() }),
});
```

Validation functions receive:

- `value` - The field value being validated
- `data` - The entire input object
- `invoker` - The principal performing the operation

You can specify validation as:

- A function returning `boolean` (uses default error message)
- A tuple of `[function, errorMessage]` for custom error messages
- Multiple validators (pass multiple arguments to `validate`)

Validation runs automatically before the `body` function executes. When validation fails, individual errors are returned in the GraphQL `errors` array with field-level paths:

```json
{
  "errors": [
    {
      "message": "Value must be non-negative",
      "path": ["createUser", "age"]
    }
  ]
}
```

## Body Function

Define actual resolver logic in the `body` function. Function arguments include:

- `input` - Input data from GraphQL request
- `caller` - The user or machine user who called this resolver; unaffected by `invoker`. `null` for anonymous calls.
- `invoker` - The principal running this function; equals `caller` by default, or the machine user configured through the resolver `invoker` option. `null` for anonymous calls.
- `env` - Environment variables declared in `tailor.config.ts`

### Using Kysely for Database Access

If you're generating Kysely types with `kyselyTypePlugin`, you can use `getDB` to execute typed queries:

```typescript
import { getDB } from "../generated/tailordb";

createResolver({
  name: "getUser",
  operation: "query",
  input: {
    name: t.string(),
  },
  body: async (context) => {
    const db = getDB("tailordb");
    const result = await db
      .selectFrom("User")
      .select("id")
      .where("name", "=", context.input.name)
      .limit(1)
      .executeTakeFirstOrThrow();
    return {
      result: result.id,
    };
  },
  output: t.object({
    result: t.uuid(),
  }),
});
```

## Query vs Mutation

Use `operation: "query"` for read operations and `operation: "mutation"` for write operations:

```typescript
// Query - for reading data
createResolver({
  name: "getUsers",
  operation: "query",
  // ...
});

// Mutation - for creating, updating, or deleting data
createResolver({
  name: "createUser",
  operation: "mutation",
  // ...
});
```

## Event Publishing

Enable event publishing for a resolver to trigger executors on resolver execution:

```typescript
createResolver({
  name: "processOrder",
  operation: "mutation",
  publishEvents: true,
  // ...
});
```

**Behavior:**

- When `publishEvents: true`, resolver execution events are published
- When not specified, `deploy` sets it from the executors taking part in the same run: `true` while one of them uses this resolver with `resolverExecutedTrigger`, and `false` once none does. Removing the last such trigger turns publishing back off on the next `deploy`
- When explicitly set to `false` while an executor taking part in the same run uses this resolver, `deploy` fails

**Use cases:**

1. **Auto-detection (recommended)**: Don't set `publishEvents` - `deploy` enables it while an executor taking part in the same run needs it. An executor declared with `disabled: true` never runs, so it does not count

   ```typescript
   // publishEvents is automatically enabled because an executor uses this resolver
   export default createResolver({
     name: "processPayment",
     operation: "mutation",
     // publishEvents not set - auto-detected
     // ...
   });

   // In executor file:
   export default createExecutor({
     trigger: resolverExecutedTrigger("processPayment"),
     // ...
   });
   ```

2. **Manual enable**: Enable event publishing for external consumers or debugging

   ```typescript
   createResolver({
     name: "auditAction",
     operation: "mutation",
     publishEvents: true, // Enable even without executor triggers
     // ...
   });
   ```

3. **Explicit disable**: Disable event publishing for a resolver that doesn't need it (error if an executor taking part in the same run uses it)

   ```typescript
   createResolver({
     name: "internalHelper",
     operation: "query",
     publishEvents: false, // Explicitly disable
     // ...
   });
   ```

**Sharing a resolver across configs:** an executor in another config auto-enables publishing the same way, as long as both configs take part in the same `deploy` (`--config a,b`). `deploy` records that dependency, so deploying the owning config alone later asks for confirmation instead of silently turning publishing off — it fails outright in a non-interactive environment. Set `publishEvents: true` on the resolver to keep it on regardless of which configs take part.

## Permissions

### Access Requirement (`permission`)

By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `permission` to reject callers that don't match a condition, evaluated before `body` runs:

```typescript
import { createResolver, t } from "@tailor-platform/sdk";

export default createResolver({
  name: "getMyOrders",
  operation: "query",
  permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],
  output: t.object({ count: t.int() }),
  body: async (context) => {
    // context.user is guaranteed to be an authenticated caller here
    return { count: 0 };
  },
});
```

`permission` uses the same `conditions`/`permit` notation as TailorDB's `.permission()` — an array of policies, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons:

- `{ user: "_loggedIn" }` — whether the caller is authenticated
- `{ user: "id" }` — the caller's user ID
- `{ user: "someAttribute" }` — any string or boolean attribute enabled in `auth.userProfile.attributes` (or `auth.machineUserAttributes` for machine users); array attributes aren't supported, since conditions only compare against a single string/boolean value

Multiple conditions within the same policy's `conditions` array are combined with AND. `permit` is required, with no implicit default. At least one `permit: true` policy is required: `permission` is an allow-list, denied by default and granted only by a matching `permit: true` policy. This lets you express different eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check:

```typescript
permission: [
  { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true },
  { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true },
],
```

A `permit: false` policy always denies matching callers, even ones another policy would otherwise allow. Combine it with a `permit: true` policy to carve out an explicit exception, e.g. granting access broadly but rejecting one banned role:

```typescript
permission: [
  { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true },
  { conditions: [[{ user: "role" }, "=", "BANNED"]], permit: false },
],
```

A policy array made up of only `permit: false` policies is rejected: since none of its conditions apply to a caller presenting no user attributes at all, it wouldn't actually keep anyone out who's willing to drop their credentials, so it can't stand in for an allow-list.

Besides a policy array, `permission` also accepts:

- `"allowAnonymous"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `permission`, but records the decision so it isn't mistaken for an oversight.
- Omitted (default) — unchanged: anonymous callers can still reach the resolver.

This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access.

### Namespace-wide default (`defaultPermission`)

Declaring `permission` on every resolver is the only way to close a whole namespace, and one file that forgets it is enough to leave an opening. Declare `defaultPermission` on the resolver namespace in your config instead, and it applies to every resolver in that namespace:

```typescript
export default defineConfig({
  name: "my-app",
  resolver: {
    "main-resolver": {
      files: ["./src/resolver/*.ts"],
      defaultPermission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],
    },
  },
});
```

`defaultPermission` takes the same values as a resolver's own `permission`, including `"allowAnonymous"` — use that to record that a namespace is public by design rather than by oversight.

A resolver's own `permission` **replaces** the namespace default rather than adding to it, so a single resolver opts out of a namespace-wide requirement explicitly:

```typescript
export default createResolver({
  name: "healthCheck",
  operation: "query",
  permission: "allowAnonymous", // reachable even though the namespace requires a login
  output: t.string(),
  body: () => "ok",
});
```

When a namespace declares no `defaultPermission` and some of its resolvers declare no `permission` either, `generate` and `deploy` warn that those resolvers are reachable by anonymous callers. Declaring either one silences the warning.

## Authentication

Specify an `invoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config:

```typescript
import { createResolver, t } from "@tailor-platform/sdk";

export default createResolver({
  name: "adminQuery",
  operation: "query",
  output: t.object({ result: t.string() }),
  body: async () => {
    // Executes as "batch-processor" machine user
    return { result: "ok" };
  },
  invoker: "batch-processor",
});
```

The machine user name is looked up in the auth service configured on your app (`machineUsers` in `defineAuth`). The namespace is resolved automatically — no need to import `auth` from `tailor.config.ts` in resolver files.

**Note:** The `invoker` option controls the permissions for database operations and other platform actions. The `caller` object passed to `body` still reflects the original caller, while the `invoker` body field reflects the principal actually running the body.
