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.
| 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):
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):
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
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:
const user = db.type("User", {
name: db.string().unique(),
age: db.int(),
});
createResolver({
input: {
name: user.fields.name,
},
});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:
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()andobject()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 type 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 type's own enum/nested type names.
// Collision — "Item" + "status" auto-generates "ItemStatus",
// which collides with the TailorDB Item type'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:
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 validateddata- The entire input objectuser- The user 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:
{
"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 requestuser- The user who called this resolver; unaffected byauthInvokerinvoker- The principal running this function; equalsuserby default, or the machine user set byauthInvoker.nullfor anonymous calls.env- Environment variables declared intailor.config.ts
Using Kysely for Database Access
If you're generating Kysely types with a generator, you can use getDB to execute typed queries:
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:
// 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:
createResolver({
name: "processOrder",
operation: "mutation",
publishEvents: true,
// ...
});Behavior:
- When
publishEvents: true, resolver execution events are published - When not specified, it is automatically set to
trueif an executor uses this resolver withresolverExecutedTrigger - When explicitly set to
falsewhile an executor uses this resolver, an error is thrown duringtailor apply
Use cases:
Auto-detection (recommended): Don't set
publishEvents- the SDK automatically enables it when needed by executorstypescript// 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"), // ... });Manual enable: Enable event publishing for external consumers or debugging
typescriptcreateResolver({ name: "auditAction", operation: "mutation", publishEvents: true, // Enable even without executor triggers // ... });Explicit disable: Disable event publishing for a resolver that doesn't need it (error if executor uses it)
typescriptcreateResolver({ name: "internalHelper", operation: "query", publishEvents: false, // Explicitly disable // ... });
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:
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 inauth.userProfile.attributes(orauth.machineUserAttributesfor 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:
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:
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 omittingpermission, 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.
Authentication
Specify an authInvoker 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:
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" };
},
authInvoker: "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.
Deprecated:
auth.invoker("batch-processor")still works, but is deprecated. Importingauthinto runtime files pulls config-layer (Node-only) dependencies into the bundle.
Note: authInvoker controls the permissions for database operations and other platform actions. The user object passed to body still reflects the original caller, while invoker reflects the principal actually running the body.