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.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:
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 value instead:
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:
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" })usesTemporal.Instant;t.datetime({ as: "date" })usesDate. Input must be a valid ISO datetime with seconds and a UTC offset orZ; leap seconds are rejected. Output uses UTC (Z) and must have a 4-digit UTC year (0000-9999).Temporal.Instantpreserves nanoseconds during SDK conversion, but the Platform truncates datetimes to milliseconds.Dateitself has only millisecond precision. Withoutas, the existing behavior is unchanged: input is a string, and output acceptsstring | Date. Explicitas: "string"types the value asstring. - Time:
t.time({ as: "temporal" })usesTemporal.PlainTime;t.time({ as: "date" })uses aDateon1970-01-01in UTC. Input and output useHH:mmin the range00:00–23:59. ForDateoutput, 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.999becomes12:30. Use UTC getters/setters when changing aDatetime. Withoutas, or withas: "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:
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 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:
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 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.
// 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:
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 objectinvoker- 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:
{
"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 requestcaller- The user or machine user who called this resolver; unaffected byinvoker.nullfor anonymous calls.invoker- The principal running this function; equalscallerby default, or the machine user configured through the resolverinvokeroption.nullfor anonymous calls.env- Environment variables declared intailor.config.ts
Using Kysely for Database Access
If you're generating Kysely types with kyselyTypePlugin, 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,
deploysets it from the executors taking part in the same run:truewhile one of them uses this resolver withresolverExecutedTrigger, andfalseonce none does. Removing the last such trigger turns publishing back off on the nextdeploy - When explicitly set to
falsewhile an executor taking part in the same run uses this resolver,deployfails
Use cases:
Auto-detection (recommended): Don't set
publishEvents-deployenables it while an executor taking part in the same run needs it. An executor declared withdisabled: truenever runs, so it does not counttypescript// 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 an executor taking part in the same run uses it)
typescriptcreateResolver({ 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:
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.
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:
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:
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:
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.