Skip to main content
Server functions give your extension a backend. Define a schema, write typed functions, and the Vision runtime handles the rest — each installation gets its own isolated database at the edge.

Overview

There are three types of server functions:
TypeContextUse case
QueryRead-only databaseFetch data — leaderboards, player lists, settings
MutationRead/write databaseCreate, update, or delete rows — add a player, update a score
ActionRead/write database, fetch, secretsSide effects — call external APIs, send webhooks, process payments
All functions receive a typed context object and validated arguments. Declarative queries (built with queryRows) support real-time subscriptions — when data changes, connected clients update automatically. Handler-based queries (built with query()) work for on-demand fetching but do not support real-time subscriptions yet.

Getting Started

Scaffold with the CLI

When you run vision init, select “Include server functions” when prompted. This creates a server/ directory with starter files:
my-extension/
  src/
    admin.tsx
    layer.tsx
  server/
    schema.ts          # Database schema
    functions.ts       # Server functions
  vision.config.json
You can also add server functions to an existing project by creating the server/ directory manually and setting "server": true in your vision.config.json.

Imports

Server functions and schema utilities are imported from @1upvision/sdk/server:
import { defineSchema, defineTable, v } from "@1upvision/sdk/server";
import {
  queryRows,
  insertRow,
  patchRow,
  deleteRow,
  fetchAction,
  arg,
  identity,
  secret,
  filter,
} from "@1upvision/sdk/server";
import { query, mutation, action } from "@1upvision/sdk/server";

Schema

Define your database tables in server/schema.ts. Each table has typed fields and optional indexes.
import { defineSchema, defineTable, v } from "@1upvision/sdk/server";

export default defineSchema({
  players: defineTable({
    name: v.string(),
    score: v.number(),
    active: v.boolean(),
  }).index("by_score", ["score"]),

  matches: defineTable({
    playerIds: v.array(v.string()),
    winnerId: v.string().optional(),
    startedAt: v.number(),
  }),
});

Supported Field Types

ValidatorTypeDescription
v.string()stringText values
v.number()numberFinite numbers
v.boolean()booleanTrue or false
v.literal(value)Exact valueA specific string, number, or boolean
v.array(validator)T[]Array of a given type
v.object(shape){ ... }Nested object with typed fields
v.union(...validators)T1 | T2 | ...One of several types
v.nullable(validator)T | nullNullable variant of any type
v.any()unknownAny JSON value
Any validator can be made optional by chaining .optional():
defineTable({
  name: v.string(),
  bio: v.string().optional(), // string | undefined
  avatar: v.nullable(v.string()), // string | null
});

Indexes

Add indexes to tables for efficient querying. An index references one or more columns:
defineTable({
  userId: v.string(),
  score: v.number(),
})
  .index("by_user", ["userId"])
  .index("by_score", ["score"]);
Use indexes when querying to avoid full table scans.

Storage Scopes

By default, table data is scoped to the account (channel). You can change how data is partitioned by setting the storage scope:
ScopePartition KeyDescription
"instance"layerIdData is isolated per extension layer instance
"overlay"overlayIdData is shared across extension layers in the same overlay
"account"channelIdData is shared across all overlays for the same channel (default)
"global"extensionIdData is shared across ALL installs of this extension
Set the storage scope via the options object or the chainable .storage() method:
// Via options object
defineTable(
  {
    key: v.string(),
    value: v.string(),
  },
  { storage: "global" },
);

// Via chainable method
defineTable({
  key: v.string(),
  value: v.string(),
}).storage("overlay");

// Default is "account" — no need to specify
defineTable({
  userId: v.string(),
  score: v.number(),
});
When you change a table’s storage scope, existing data will not be automatically migrated. The runtime uses a new partition key, so data written under the previous scope will no longer be visible.

Declarative Functions

The simplest way to define server functions. Instead of writing handler logic, you describe what you want and the runtime executes it. Declarative queries also support real-time subscriptions — connected clients update automatically when data changes.
import {
  queryRows,
  insertRow,
  patchRow,
  deleteRow,
  arg,
  filter,
} from "@1upvision/sdk/server";

queryRows — Read from a table

// Get all players
export const getPlayers = queryRows("players");

// Get players with a filter and limit
export const getActivePlayers = queryRows("players", {
  filters: [filter("active", "eq", true)],
  limit: 50,
});

// Get players filtered by a function argument
export const getPlayersByTeam = queryRows("players", {
  filters: [filter("team", "eq", arg("team"))],
});
Call with useQuery:
const { data: players } = useQuery("getPlayers");
const { data: team } = useQuery("getPlayersByTeam", { team: "red" });
Options:
filters
FilterDefinition[]
Array of field/operator/value conditions. See Filter syntax below.
limit
number
Maximum rows to return.
offset
number
Skip rows (for pagination).
editorOnly
boolean
Restrict to channel editors/owner.

insertRow — Create a row

export const addPlayer = insertRow("players", {
  name: arg("name"),
  score: arg("score"),
  active: true,
});
Call with useMutation:
const { mutate: addPlayer } = useMutation("addPlayer");
addPlayer({ name: "Alice", score: 100 });

patchRow — Update a row

export const updateScore = patchRow("players", arg("id"), {
  score: arg("score"),
});
Call with useMutation:
const { mutate: updateScore } = useMutation("updateScore");
updateScore({ id: "abc123", score: 200 });

deleteRow — Delete a row

export const removePlayer = deleteRow("players", arg("id"));
Call with useMutation:
const { mutate: removePlayer } = useMutation("removePlayer");
removePlayer({ id: "abc123" });

fetchAction — Outbound HTTP request

Make external API calls from the server. Requires configuring an egress allowlist.
import { fetchAction, secret } from "@1upvision/sdk/server";

export const getWeather = fetchAction(
  {
    url: "https://api.weather.com/current",
    headers: { "X-Api-Key": secret("WEATHER_API_KEY") },
  },
  { response: "json" },
);
Call with useMutation:
const { mutateAsync: getWeather } = useMutation("getWeather");
const data = await getWeather();
Request config:
url
string | arg() | secret()
required
The URL to fetch. Can be a literal string, arg("paramName"), or built from a secret.
method
string
HTTP method. Defaults to "GET".
headers
Record<string, string | arg() | secret()>
Request headers.
body
string | arg()
Request body.
Options:
response
'json' | 'text' | 'none'
How to parse the response. Defaults to "json".

Value Expressions

Dynamic values that are resolved at runtime:
HelperDescriptionExample
arg("name")Reference a function argument passed by the callerarg("userId")
identity("key")Reference a field from the caller’s identityidentity("subject")
secret("KEY")Reference a stored secret (set in the dashboard)secret("API_KEY")
"literal"A literal value baked into the functiontrue, 0, "active"
Common identity keys: "subject", "channelId", "extensionId", "installationId", "overlayId", "layerId".

Filter Syntax

Use filter() to build query conditions for queryRows:
import { filter, arg, identity } from "@1upvision/sdk/server";

filter("userId", "eq", arg("userId")); // Field equals an argument
filter("owner", "eq", identity("subject")); // Field equals the caller
filter("score", "gt", 100); // Field greater than a literal
filter("status", "in", ["active", "pending"]); // Field is one of several values
Operators: "eq", "neq", "lt", "lte", "gt", "gte", "in"

Declarative vs Handler-based

DeclarativeHandler-based
SyntaxqueryRows(...), insertRow(...), etc.query({ handler }), mutation({ handler })
Real-time subscriptionsYesNot yet
Custom logicNo — values and filters onlyFull JavaScript in the handler
Use whenStandard CRUD and simple queriesComplex logic, conditional writes, multi-step operations
You can mix both styles in the same server/functions.ts file.

Egress Allowlist

fetchAction can only reach hosts on your extension’s allowlist. Configure it in vision.config.json:
{
  "server": true,
  "egress": {
    "allowHosts": ["api.weather.com", "*.example.com"],
    "allowPorts": [443]
  }
}
After changing the allowlist, rebuild with vision dev or vision deploy.

Complete Declarative Example

import {
  queryRows,
  insertRow,
  patchRow,
  deleteRow,
  arg,
  filter,
  identity,
} from "@1upvision/sdk/server";

export const getRules = queryRows("rules", {
  filters: [filter("channelId", "eq", identity("channelId"))],
});

export const addRule = insertRow(
  "rules",
  {
    text: arg("text"),
    revealed: false,
    channelId: identity("channelId"),
  },
  { editorOnly: true },
);

export const revealRule = patchRow(
  "rules",
  arg("id"),
  { revealed: true },
  { editorOnly: true },
);

export const removeRule = deleteRow("rules", arg("id"), {
  editorOnly: true,
});

Handler-based Functions

For complex logic that can’t be expressed declaratively — conditional writes, multi-step operations, or custom validation — use query(), mutation(), and action() with handler functions.

Queries

Queries read data from the database. They receive a QueryContext with a read-only db and auth API.
For simple reads, prefer declarative queryRows — it’s less code and supports real-time subscriptions. Use handler-based queries when you need custom logic.
import { query, v } from "@1upvision/sdk/server";

export const getPlayers = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("players");
  },
});

export const getPlayerById = query({
  args: {
    id: v.string(),
  },
  handler: async (ctx, args) => {
    return await ctx.db.get("players", args.id);
  },
});

Querying with Filters

export const getActivePlayers = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("players", {
      filters: [{ field: "active", op: "eq", value: true }],
      limit: 50,
    });
  },
});

Querying with Indexes

export const getTopScores = query({
  args: {
    limit: v.number(),
  },
  handler: async (ctx, args) => {
    return await ctx.db.query("players", {
      index: { name: "by_score", values: [] },
      limit: args.limit,
    });
  },
});

Database Reader API

MethodSignatureDescription
db.get(table: string, id: string) => Promise<object | null>Fetch a single row by ID
db.query(table: string, request?) => Promise<object[]>Query rows with optional filters, indexes, limit, and offset

Query Request Options

FieldTypeDescription
index{ name, values }Use a named index with optional key values
filtersQueryFilter[]Array of field/operator/value conditions
limitnumberMax rows to return
offsetnumberSkip rows (for pagination)

Filter Operators

eq, neq, lt, lte, gt, gte, in

Mutations

Mutations read and write data. They receive a MutationContext with the full db API. For simple inserts, updates, and deletes, prefer the declarative builders instead.
import { mutation, v } from "@1upvision/sdk/server";

export const addPlayer = mutation({
  args: {
    name: v.string(),
    score: v.number(),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert("players", {
      name: args.name,
      score: args.score,
      active: true,
    });
  },
});

export const updateScore = mutation({
  args: {
    id: v.string(),
    score: v.number(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch("players", args.id, { score: args.score });
  },
});

export const removePlayer = mutation({
  args: {
    id: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.delete("players", args.id);
  },
});

Database Writer API

Extends the reader API with write operations:
MethodSignatureDescription
db.insert(table: string, value: object) => Promise<{ id: string }>Insert a row and return its generated ID
db.patch(table: string, id: string, value: object) => Promise<void>Update specific fields on a row
db.delete(table: string, id: string) => Promise<void>Delete a row by ID

Actions

Actions have full database access plus the ability to make HTTP requests and read secrets. For simple HTTP requests without custom logic, you can use the declarative fetchAction instead. Use handler-based actions for complex flows — conditional logic, multiple API calls, or processing responses before storing data.
import { action, v } from "@1upvision/sdk/server";

export const notifyDiscord = action({
  args: {
    message: v.string(),
  },
  handler: async (ctx, args) => {
    const webhookUrl = await ctx.secrets.get("DISCORD_WEBHOOK_URL");
    if (!webhookUrl) throw new Error("Discord webhook not configured");

    await ctx.fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: args.message }),
    });

    return { sent: true };
  },
});

Action Context

Actions receive everything mutations get, plus:
PropertyTypeDescription
fetch(input: RequestInfo, init?: RequestInit) => Promise<Response>Restricted HTTP client (HTTPS only, no private IPs)
secrets{ get(name: string): Promise<string | undefined> }Read extension secrets set in the dashboard
ctx.fetch is sandboxed. It enforces HTTPS, blocks requests to private IP ranges and metadata endpoints, and only allows hosts on your extension’s egress allowlist.

Auth

Every server function context includes an auth API for identity and authorization:
export const getMyData = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      throw new Error("Not authenticated");
    }

    return await ctx.db.query("data", {
      filters: [{ field: "userId", op: "eq", value: identity.subject }],
    });
  },
});

Auth API

MethodSignatureDescription
auth.getUserIdentity() => Promise<UserIdentity | null>Returns the current user’s identity, or null if unauthenticated
auth.hasScope(scope: string) => booleanCheck if the caller has a specific scope

UserIdentity

PropertyTypeDescription
subjectstringUnique user identifier
issuerstring?Token issuer
audiencestring?Token audience

Scoping Functions

Restrict a function to callers with specific scopes:
export const adminReset = mutation({
  scope: "admin",
  args: {},
  handler: async (ctx) => {
    // Only callable by users with the "admin" scope
    const players = await ctx.db.query("players");
    for (const player of players) {
      await ctx.db.patch("players", player.id as string, { score: 0 });
    }
  },
});
You can require multiple scopes by passing an array:
export const dangerousAction = action({
  scope: ["admin", "superuser"],
  // ...
});

Editor-Only Functions

Restrict a function so only verified editors of the channel (users with an assigned role) and the channel owner can invoke it. All other callers receive a 403 Forbidden response.
export const resetScores = mutation({
  editorOnly: true,
  args: {},
  handler: async (ctx) => {
    const players = await ctx.db.query("players");
    for (const player of players) {
      await ctx.db.patch("players", player.id as string, { score: 0 });
    }
  },
});
This works with both handler-based and declarative functions:
export const clearLeaderboard = deleteRow("players", arg("playerId"), {
  editorOnly: true,
});
editorOnly is enforced at both the API layer and the edge runtime. Even if the caller has valid authentication, they must be a verified editor of the channel to invoke editor-only functions.
You can combine editorOnly with scope for additional restrictions:
export const dangerousReset = mutation({
  editorOnly: true,
  scope: "admin",
  handler: async (ctx) => {
    // Only editors with the "admin" scope can call this
  },
});

Argument Validation

All function arguments are validated at runtime using the same v validators used in schemas. Invalid arguments are rejected before the handler runs.
export const createMatch = mutation({
  args: {
    playerIds: v.array(v.string()),
    bestOf: v.number(),
    ranked: v.boolean().optional(),
  },
  handler: async (ctx, args) => {
    // args.playerIds is string[], args.bestOf is number
    // args.ranked is boolean | undefined
    return await ctx.db.insert("matches", {
      playerIds: args.playerIds,
      bestOf: args.bestOf,
      ranked: args.ranked ?? false,
      startedAt: Date.now(),
    });
  },
});

CLI Commands

vision dev

Watches your project, builds client and server bundles, pushes the schema, and uploads to the dev environment on every change.
vision dev

vision run

Execute a server function from the terminal during development:
# Run a query
vision run query:getPlayers

# Run a mutation with arguments
vision run mutation:addPlayer '{"name": "Alice", "score": 100}'

# Run an action
vision run action:notifyDiscord '{"message": "Hello from CLI!"}'

vision deploy

Build and deploy a production version:
vision deploy

Environment Variables (Secrets)

Secrets are managed in the extension dashboard under Settings > Environment. They are encrypted at rest and only available to action functions via ctx.secrets.get().
Secrets are server-only. They can only be accessed in action functions via ctx.secrets.get() and are never exposed to client-side code.

Managing Secrets

Use the Environment page in the extension settings sidebar to:
  • Add new secrets with uppercase keys (e.g., DISCORD_WEBHOOK_URL, API_TOKEN)
  • Update existing secret values
  • Delete secrets that are no longer needed
Keys must match the pattern ^[A-Z0-9_]+$ — uppercase letters, numbers, and underscores only.

How It Works

Each extension installation gets its own isolated database at the edge. Data partitioning is handled by the storage scope configured on each table. When a server function is invoked:
  1. The client request is authenticated and scoped to the extension and channel
  2. The function executes in an isolated runtime with access only to its own database
  3. Results are returned to the client
Mutations trigger real-time updates — any active query subscriptions are automatically re-evaluated and pushed to connected clients.