Fold API

How to define a fold's server: fold(), the schema, queries, mutations, actions, endpoints, and the ctx every handler receives.

All server code imports from @aozumi/fold/server. The default export of server/index.ts must be a fold({...}) call.

A complete server

import { action, boolean, endpoint, fold, json, mutation, query, string, table, text, userId } from "@aozumi/fold/server";
import { cleanBody } from "../shared/notes";

// shared/notes.ts: export function cleanBody(value: string): string { return value.trim().slice(0, 500); }

export default fold({
  name: "notes",

  auth: { requireSignIn: false },

  schema: {
    notes: table({
      body: string(),
      pinned: boolean().default(false),
      color: string().optional(),
      ownerId: userId()
    }).index("by_owner", ["ownerId"])
  },

  queries: {
    myNotes: query(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.notes
        .withIndex("by_owner", (q) => q.eq("ownerId", userId))
        .order("desc")
        .take(100);
    })
  },

  mutations: {
    addNote: mutation(async (ctx, body: string) => {
      const { userId } = ctx.auth.requireIdentity();
      const clean = cleanBody(body);
      if (!clean) {
        throw new Error("Write something first.");
      }
      return ctx.db.notes.insert({ body: clean, ownerId: userId });
    }),
    removeNote: mutation(async (ctx, id: string) => {
      const { userId } = ctx.auth.requireIdentity();
      const note = await ctx.db.notes.get(id);
      if (!note || note.ownerId !== userId) {
        throw new Error("Note not found.");
      }
      await ctx.db.notes.delete(id);
    })
  },

  actions: {
    noteCount: action(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      const total = await ctx.db.notes.withIndex("by_owner", (q) => q.eq("ownerId", userId)).count();
      return { total };
    })
  },

  endpoints: {
    health: endpoint({ method: "GET", path: "/api/health" }, () => text("ok")),
    createNote: endpoint({ method: "POST", path: "/api/notes" }, async (ctx, req) => {
      const auth = ctx.auth.requireSignedIn();
      const payload = await req.json<{ body: string }>();
      const note = await ctx.db.notes.insert({ body: cleanBody(payload.body), ownerId: auth.userId });
      return json({ id: note.id }, { status: 201 });
    })
  }
});

fold(definition)

fold() accepts only these keys. Any other key throws Unknown fold() option.

KeyTypeMeaning
namestringApp title. The first deploy uses it to pick the subdomain. Defaults to "AOZUMI Fold".
faviconstringPath to an .svg or .ico file in the fold. Without it, favicon.svg or favicon.ico at the fold root is used. Max 64 KiB.
auth`{ requireSignIn?: boolean; onGuestUpgrade?: (ctx, upgrade) => void \Promise<void> }`See Auth.
schemaRecord<string, TableDefinition>Tables. See below.
queriesRecord<string, handler>Read-only, live handlers.
mutationsRecord<string, handler>Transactional writes.
actionsRecord<string, handler>One-shot, read-only calls with size and time caps.
endpointsRecord<string, EndpointDefinition>HTTP routes for webhooks and other clients.

Schema

schema: {
  lists: table({ title: string(), ownerId: userId() }).index("by_owner", ["ownerId"]),
  items: table({
    listId: id("lists"),
    text: string(),
    done: boolean().default(false),
    dueAt: number().optional()
  })
    .index("by_list", ["listId"])
    .index("by_list_due", ["listId", "dueAt"])
}

Field builders:

BuilderStores
string()A string. No NUL characters or unpaired surrogates.
number()A finite number. NaN and Infinity are rejected.
boolean()true or false.
id("table")The id string of a row in another table. The referenced table must exist in the schema.
userId()A Fold user ID. Use it for every owner field. When a guest signs in, these values move to the account.

Modifiers, chainable on any field:

Rules:

Only string, number, boolean, id, and userId exist. Store a timestamp as number() (epoch milliseconds). Store a list as rows in another table. See Database for queries.

Queries

query(async (ctx, args: { listId: string }) => {
  return ctx.db.items.withIndex("by_list", (q) => q.eq("listId", args.listId)).collect();
})

Mutations

mutation(async (ctx, listId: string, text: string) => {
  const { userId } = ctx.auth.requireIdentity();
  const list = await ctx.db.lists.get(listId);
  if (!list || list.ownerId !== userId) {
    throw new Error("List not found.");
  }
  return ctx.db.items.insert({ listId, text });
})

Actions

action(async (ctx, search: string) => {
  const matches = await ctx.db.items.withIndex("by_creation").order("desc").take(200);
  return matches.filter((item) => String(item.text).includes(search)).length;
})

Endpoints

endpoint({ method: "POST", path: "/api/items" }, async (ctx, req) => {
  const body = await req.json<{ listId: string; text: string }>();
  const item = await ctx.db.items.insert({ listId: body.listId, text: body.text });
  return json(item, { status: 201 });
})

endpoint(route, handler):

The request (EndpointRequest):

MemberType
method, path, urlstring
headersget(name), has(name), entries()
queryURLSearchParams
text()Promise<string>
json<T>()Promise<T>
bytes()Promise<Uint8Array>

Body readers can be called more than once. The request body may be up to 2 MiB.

Return a response with a helper:

HelperResponse
json(value, { status?, headers? })JSON body
text(value, { status?, headers? })Plain text body
empty({ status?, headers? })No body
redirect(url, { status?, headers? })Redirect to url

Endpoint callers authenticate with the same-origin guest cookie, or with a Google identity token in the X-Fold-Token header. Authorization is left for your app. See Auth.

ctx

Every handler gets ctx as its first argument:

FieldTypeNotes
ctx.authAuthContextThe caller. userId, isGuest, isSignedIn, displayName, and the guards requireIdentity() and requireSignedIn(). See Auth.
ctx.dbReadDatabase or WriteDatabaseOne table API per schema table. Writable only in mutations and writable endpoints.
ctx.env`Record<string, string \undefined>`Values from .env.fold.server in local dev. Empty in anonymous deploys.
ctx.log{ info, warn, error }Each takes (message: string, data?: unknown). Readable with logs --port in local dev. Hosted logs are coming soon.

Handler capabilities

QueryMutationActionEndpoint
Called fromuseQuery, usePaginatedQueryuseMutationuseActionAny HTTP client
DatabaseReadRead and write, one transactionReadRead (GET, HEAD), or read and write
Live updatesYesNoNoNo
Time limit, hosted10 s10 s5 s10 s
Counts as, hostedRequestRequest and mutationRequest and mutationRequest, plus mutation if writable
Outbound fetchLocal dev onlyLocal dev onlyLocal dev onlyLocal dev only
ctx.env valuesLocal dev onlyLocal dev onlyLocal dev onlyLocal dev only

Outbound fetch and server env need a claimed deploy. Claiming is coming soon, so today they work only in npx @aozumi/fold dev. An anonymous deploy rejects any server or shared file that mentions fetch.

Fold has no scheduler. To ingest data on a schedule, have an outside service call a writable endpoint. See Dashboard counts.

Errors

Throw an Error to fail a call. The message reaches the caller: useMutation and useAction promises reject with it, useQuery throws it during render (catch it with ErrorBoundary), and endpoints answer 500 with { "error": "<message>" }. Do not put secrets in error messages.