Functions
Define queries and mutations that run on Tether's infrastructure in a sandboxed JavaScript runtime. You don't need a server of your own.
Function types
Tether has two types of function:
- Query: reads data. Clients can subscribe to a query for realtime updates.
- Mutation: writes data and refreshes the subscriptions it affects.
Each function runs in a single transaction: a mutation's writes all commit when its handler returns, or none of them do if it throws.
Either type can be run on a timetable as a cron, and a mutation can schedule another function to run later.
Defining queries
Create a file in tether/functions/:
// tether/functions/todos.ts
import { query, z } from '../_generated/db';
export const list = query({
args: z.object({
completed: z.boolean().optional(),
}),
handler: async ({ args, db }) => {
return db.todos.findMany({
where: args.completed !== undefined ? { completed: args.completed } : undefined,
orderBy: { createdAt: 'desc' },
});
},
});
The handler receives a context object with args (validated arguments), db (database client), and ctx (auth context), among others. The db object routes all operations to Tether's SQLite storage, whilst ctx.auth provides the calling user's identity.
Defining mutations
Mutations modify data and automatically invalidate related subscriptions:
// tether/functions/todos.ts
import { mutation, z } from '../_generated/db';
export const create = mutation({
args: z.object({
title: z.string().min(1),
}),
handler: async ({ args, db }) => {
const todo = await db.todos.insert({
title: args.title,
completed: false,
});
return todo;
},
});
export const toggle = mutation({
args: z.object({
_id: z.string(),
}),
handler: async ({ args, db }) => {
const todo = await db.todos.findFirst({
where: { _id: args._id },
});
if (!todo) throw new TetherError('NOT_FOUND', 'Todo not found', { _id: args._id });
await db.todos.update({
where: { _id: args._id },
data: { completed: !todo.completed },
});
},
});
TetherError (from ../_generated/db) gives the caller a code to branch on. See Errors.
A mutation runs in one transaction, so the read and the write above can't be split by another mutation: no other write to the environment's database lands in between. If the handler throws after writing, every write it made is rolled back.
Access control
Control who can call your functions using the access property. This is especially useful when exposing functions to SPAs via publishable keys.
| Level | Description |
|---|---|
public |
Anyone with a valid publishable key can call this function. No authentication required. |
authenticated |
Requires a valid JWT from your configured auth provider. This is the default. |
internal |
Only callable with a secret API key. Publishable key requests are always rejected. |
An environment's auth provider is set by its issuer and, optionally, a JWKS URL. Both must be public https addresses. Tether fetches the signing keys with a 5 second timeout and no redirects, keeps them for an hour, and fetches again for an unknown key id at most once a minute. Keys are kept per issuer and JWKS URL, so another project naming the same issuer can never supply keys for yours.
import { query, mutation, z } from '../_generated/db';
// Anyone can read posts (no JWT needed)
export const list = query({
access: 'public',
handler: async ({ db }) => {
return db.posts.findMany({ orderBy: { createdAt: 'desc' } });
},
});
// Only authenticated users can create posts
export const create = mutation({
access: 'authenticated', // this is the default
args: z.object({ title: z.string().min(1) }),
handler: async ({ args, db, ctx }) => {
return db.posts.insert({
title: args.title,
authorId: ctx.auth.userId,
});
},
});
// Admin-only: only callable via secret API key
export const deleteAll = mutation({
access: 'internal',
handler: async ({ db }) => {
return db.posts.deleteAll();
},
});
When no access is specified, functions default to authenticated. Server-side requests using a secret API key (tthr_dev_ / tthr_prod_) bypass access checks entirely.
OAuth2 scoped access
When a third-party application accesses your project via OAuth2, permissions are enforced at both the function level and the project/environment level. The OAuth2 token must have the correct permission (query, mutate, or action) for the specific project and environment being accessed.
Function-level access rules still apply on top of OAuth2 scoping — a function marked as internal cannot be called via OAuth2 tokens, and authenticated functions require the token to carry a valid user identity.
Calling external services
Queries and mutations can call other services with fetch, using secrets from environment variables:
// tether/functions/orders.ts
import { mutation, z, TetherError } from '../_generated/db';
export const checkout = mutation({
args: z.object({ orderId: z.string(), amount: z.number() }),
handler: async ({ args, db, env }) => {
// Call out before the first database call: a mutation's transaction
// starts there and holds the database's write lock until it ends
const payment = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` },
body: new URLSearchParams({ amount: String(args.amount), currency: 'gbp' }),
}).then((r) => r.json());
if (payment.error) {
throw new TetherError('PAYMENT_FAILED', payment.error.message);
}
await db.orders.update({
where: { _id: args.orderId },
data: { status: 'paid', paymentId: payment.id },
});
return { paymentId: payment.id };
},
});
A rolled-back transaction can't undo a request that has already been sent, so make external calls you can't repeat safely after you've checked everything that could make the mutation throw, or move them into a scheduled mutation of their own.
Environment variables
Every function receives the environment's variables as the read-only env property of its context. They are stored encrypted in your project settings on the Tether dashboard.
import { mutation, z } from '../_generated/db';
export const notify = mutation({
args: z.object({ to: z.string() }),
handler: async ({ args, env }) => {
const sendgridKey = env.SENDGRID_API_KEY;
// Use it in your API calls
},
});
tthr generate types env from your variables, so a typo in a variable name is a type error.
Setting environment variables
Configure environment variables in your project's Settings tab on the dashboard. Values are encrypted at rest and can only be replaced, not viewed, so your secrets stay secure even if someone gains access to your dashboard.
For local development, you can use a .env file in your project root. When tthr dev deploys your functions to the development environment, these variables are included automatically.
Unreadable variables
Values are encrypted with the server's encryption key. If that key changes (or a value was saved under a different one), the stored value can no longer be decrypted. When this happens the variable is flagged Unreadable - re-save in the Settings tab, and functions see it as unset rather than receiving a stale or empty value. The server also logs a warning naming the project, environment and key (never the value) so the problem is visible. To fix it, open the variable and save its value again, which re-encrypts it with the current key.
Cron functions
Schedule functions to run automatically at specified intervals using cron expressions. Cron jobs are configured in the dashboard and trigger your functions via WebSocket.
// tether/functions/reports.ts
import { mutation, z } from '../_generated/db';
export const generateDaily = mutation({
args: z.object({
date: z.string().optional(),
}),
handler: async ({ args, db, log }) => {
const targetDate = args.date || new Date().toISOString().split('T')[0];
log.info('Generating daily report', { date: targetDate });
const stats = await db.orders.count({
where: { createdAt: { gte: targetDate } },
});
await db.reports.insert({
type: 'daily',
date: targetDate,
data: JSON.stringify({ orderCount: stats }),
});
log.info('Report generated successfully');
return { success: true, orderCount: stats };
},
});
Setting up cron jobs
Configure cron schedules in your project's Cron tab on the dashboard:
- Function name: The exported function to call (e.g.
reports.generateDaily) - Schedule: Standard cron expression (e.g.
0 9 * * *for 9am daily) - Timezone: The timezone for schedule evaluation
- Arguments: Optional JSON arguments to pass to the function
Each environment's crons run in that environment, on its own data. Cron functions execute on Tether's function runtime alongside your other functions. They have full access to the same handler context (db, ctx, log) and are deployed automatically when you run tthr deploy.
Crons are checked once a minute. To run something once at a particular time, or a few seconds from now, use the scheduler instead.
Handler context
Every function handler receives a context object with the following properties:
| Property | Description |
|---|---|
db |
Database client with typed access to all tables. Every call runs in the function's transaction. |
args |
Validated arguments (only when args schema is defined) |
ctx |
Execution context with authentication info |
auth |
auth.getUserIdentity() resolves to { subject, email, name } for the calling user, or null |
env |
The environment's variables, read-only |
storage |
File storage client |
scheduler |
Schedules functions to run later, in mutations only. See Scheduler. |
bridge |
Calls functions in bridged projects |
log |
Logger for sending structured logs to the dashboard |
Authentication context
Access the current user's identity via ctx.auth:
export const getProfile = query({
handler: async ({ ctx, db }) => {
if (!ctx.auth.userId) {
throw new Error('Not authenticated');
}
return db.users.findById(ctx.auth.userId);
},
});
Logging
Use the log object to send structured logs to the Tether dashboard:
export const processOrder = mutation({
args: z.object({ orderId: z.string() }),
handler: async ({ args, db, log }) => {
log.info('Processing order', { orderId: args.orderId });
try {
const order = await db.orders.findById(args.orderId);
log.debug('Order loaded', { total: order.total });
log.info('Order processed successfully');
return { success: true };
} catch (error) {
log.error('Failed to process order', { error: error.message });
throw error;
}
},
});
Available log levels: log.log(), log.info(), log.warn(), log.error(), log.debug()
console.* calls inside a handler are captured too, so existing console.log debugging shows up in the dashboard alongside log.* entries.
Each log line is grouped by execution ID and carries its own timestamp. In the dashboard's Logs tab every line also shows a +Nms offset, measured from the first line of the same execution, so you can see how far apart two log lines were without doing the arithmetic yourself.
Execution telemetry
Every function call is recorded server-side, on the runtime that executes it. You don't need to do anything in your client for this to work, and there is nothing a browser (ad-blocker, offline tab, closed page) can do to suppress it:
- Executions: each call is recorded with its function name, type, status (
successorerror), total duration, error message and a per-step timing breakdown (see below). This includes calls that never reached your handler, such as a function that isn't deployed, a publishable-key request that was denied, or a runtime timeout; those carry a zeroed breakdown. - Logs: every
log.*andconsole.*call made during the execution, grouped by execution ID.
Both are stored per environment and are visible in the dashboard's Logs and Overview pages, or via the GET /logs and GET /executions API endpoints. The SDKs do not send any telemetry from the client; the POST /logs endpoint remains available for developers who want to push additional log entries from their own server-side code.
Timing breakdown
Alongside the total duration, each execution records where its time went. There are two layers to read it with.
The additive phases run one after another and together make up almost all of the total duration:
queueMs: time spent waiting for a runtime share and a free isolate before the run started.compileMs: per-run setup, including building the environment's context and the function's own script.runMs: the handler itself, including any microtasks and timers it drives.
Inside runMs, the runtime also tallies the time the handler spent waiting on host work. These are an "of which" breakdown, not extra phases, so they are never added on top of the three above:
dbMs/dbCalls: database CRUD calls.bridgeMs/bridgeCalls: bridge calls into other projects.storageMs/storageCalls: storage (asset) operations.
The dashboard renders this as a stacked bar (queue, compile, run) with the host-call totals and their call counts beneath, both in the Logs tab's recent executions and in the function playground. The GET /executions response carries the same figures per execution under a timings object, plus a project-wide avgTimings average in its stats block. Operators also get one tracing::info! line per execution with the full breakdown.
Shared helpers
You can extract shared logic into helper files prefixed with _ (e.g. _helpers.ts). These files are not deployed as standalone functions — they're bundled into any function file that imports from them.
tether/functions/
_helpers.ts ← shared utilities (not deployed directly)
channel.ts ← imports from _helpers.ts
notifications.ts ← imports from _helpers.ts
// tether/functions/_helpers.ts
export function formatChannel(channel: any) {
return { ...channel, displayName: `#${channel.name}` };
}
// tether/functions/channel.ts
import { query } from "@tthr/server";
import { formatChannel } from "./_helpers";
export const getChannels = query({
handler: async ({ db }) => {
const channels = await db.channels.findMany();
return channels.map(formatChannel);
},
});
Helper code that's referenced by a function is shown in the dashboard as expandable artefacts beneath the function source.
Argument validation
Function arguments are validated using Zod schemas. Invalid arguments return a 400 error with validation details.
import { mutation, z } from '../_generated/db';
export const createUser = mutation({
args: z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().min(0).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
preferences: z.object({
newsletter: z.boolean().default(false),
theme: z.enum(['light', 'dark']).optional(),
}).optional(),
}),
handler: async ({ args, db }) => {
return db.users.insert(args);
},
});
Common Zod validators: .min(), .max(), .email(), .url(), .uuid(), .regex(), .optional(), .default(), .nullable()
Execution model
All Tether functions execute on Tether's infrastructure, in a V8 JavaScript engine embedded in the Tether server. You do not need to run your own server — Tether handles function execution, database access, and realtime subscription invalidation entirely.
When you run tthr deploy, your functions are bundled and pushed to Tether's servers, where they run sandboxed with direct access to your project's SQLite database. Each project and environment runs in its own JavaScript context, so globals are never shared with another project.
Available APIs
Besides the handler context, functions can use these standard web APIs:
| API | Notes |
|---|---|
fetch, Request, Response, Headers |
http and https only. Private, loopback and cloud metadata addresses are refused. Each call times out after 15 seconds (25 at most with AbortSignal.timeout), and response bodies are limited to 10 MB. Calls run one at a time. Redirects to another site drop the Authorization, Cookie and Proxy-Authorization headers. |
URL, URLSearchParams |
|
TextEncoder, TextDecoder |
UTF-8 |
atob, btoa |
|
crypto.randomUUID, crypto.getRandomValues |
|
crypto.subtle |
digest (SHA-1, SHA-256, SHA-384, SHA-512), importKey and exportKey (raw, and jwk for secret keys), generateKey, encrypt and decrypt (AES-CBC, AES-GCM with a 12-byte iv), sign and verify (HMAC) |
setTimeout, setInterval, clearTimeout, clearInterval, queueMicrotask |
Timers run until your handler settles; any still pending then are dropped. |
AbortController, AbortSignal |
|
performance.now, structuredClone |
structuredClone copies JSON-compatible values |
Node and Deno built-ins (process, Buffer, fs, Deno.*) are not available, and neither is WebAssembly.
Limits
| Limit | Value |
|---|---|
| Run time | 30 seconds |
| Memory | 64 MB of JavaScript heap. A function that runs out is stopped with an error. |
| Logs | 1,000 entries or 1 MB per execution. Single messages over 16 KB are truncated. |
| Bridge calls | Nested at most 3 deep |
| Running at once | Three quarters of the server's function workers per project, counting queued calls. Past that, a call waits up to 10 seconds for one of the project's own to finish, then fails with "too many functions running at once". Bridge calls have workers of their own at each depth, one per project at a time. |
| Crons | 50 per environment. A project runs at most 2 at once; its other due crons start at the next minute's check. |
| Scheduled jobs | 1,000 per mutation. A project runs at most 2 at once and the server 8; later jobs wait their turn. |
| Write lock | A mutation holds its environment's write lock from its first database call until it returns, so other mutations in that environment wait for it. Keep slow work (such as fetch) before the first database call. |
Development workflow
During development, tthr dev watches your tether/functions/ directory and automatically deploys changes to the development environment on each file save. This gives you a live feedback loop without needing to manually redeploy.
Generic CRUD vs custom functions
There are two ways to interact with your database:
- Generic CRUD operations — Use table.operation patterns like
posts.list,posts.get,posts.createdirectly via the Tether client. These are available when connecting with a secret API key. - Custom functions — Write business logic in
tether/functions/and call them via the SDK. These are deployed to Tether's function runtime and are the only way to interact with your database from SPA clients using a publishable key.
SPA and publishable key access
Single-page applications that connect using a publishable key can only call deployed functions — they cannot perform raw CRUD operations. This ensures that all data access from client-side applications goes through your defined functions, where you control validation, access rules, and business logic.
A publishable key only works from the origins in its environment's allowed domains (exact origins such as https://app.example.com, or *). Add your app's origin there before calling Tether from the browser. Browsers on those origins may call the query, mutation and file storage endpoints across origins, without cookies: authenticate with the Authorization header. Any other endpoint, and any origin not in an environment's allowed domains, gets no cross-origin access, and a call made anyway is refused with 403 Origin not allowed.
Function playground
You can run any deployed function from the dashboard without writing a client. Open the Functions tab, select a function and choose Run.
- Arguments: a JSON object passed to the handler as
args. It is prefilled from the function's args schema (strings as"", numbers as0, defaults where the schema sets one), so you only need to fill in the values. Reset to schema puts the template back. - Run as user: an optional user id. When set, the handler sees it as
ctx.auth.userId(the only claim provided issub). Leave it empty to run without a signed-in user, the same as a call made with a secret API key. - The function runs in the environment selected at the top of the project page, through the same runtime as calls from your app. The run shows up in the function's logs and execution history like any other call.
The result panel shows whether the run succeeded, the return value or error, the duration and its timing breakdown, every log entry the run produced, and the tables the function read and wrote. When a run writes to a table, live subscriptions on that table are told to refetch.
Queries run straight away. Mutations can change data, so the dashboard asks you to confirm before running them. The run is real: anything a mutation writes stays written, so take care in production.
The playground uses POST /api/v1/projects/{id}/env/{env}/functions/run with dashboard sign-in. It is not available to OAuth2 tokens or publishable keys.
Deployment history and rollback
Every tthr deploy is recorded in Settings > Deployments, separately for functions and schema. Each deployment also keeps a snapshot of what it made live:
- Functions: the code (bundle and TypeScript source), args schema and access level of every function in the environment after the deploy.
- Schema: the full schema that was deployed, including the SQL, table definitions and row-level security policies.
Snapshots are kept for the 50 most recent deployments in each environment. Deployments made before snapshots were introduced cannot be redeployed.
To roll back, choose Redeploy next to a deployment and confirm. Tether deploys that snapshot again and records it as a new deployment marked "Rolled back to" the version you picked, so the history stays complete and you can roll forward the same way.
- Rolling back functions restores every function to the code it had then and removes functions deployed since.
- Rolling back schema goes through the normal schema deploy: tables and columns added since then are removed, and any that hold data are soft-deleted so they can be recovered. Rows are not rolled back. To restore data as well, restore a backup.
Redeploying is also available at POST /api/v1/projects/{id}/env/{env}/deployments/{deploymentId}/redeploy.