npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sidestep/core

v4.1.42

Published

Sidestep — your Xano backend as TypeScript. `sidestep deploy` ships your typed workspace (and an optional static frontend) to a live, auto-expiring ephemeral environment and prints its URL. init → deploy → URL.

Downloads

8,549

Readme

SideStep

Your whole backend, in TypeScript. Live on the internet in one command.

Write your app in TypeScript — the database, the APIs, even AI agents. Or let an AI write it for you. Then run one command and it's live on Xano's cloud, with its own URL. No servers, no setup, no config. That's it.

npx sidestep login                      # 1. sign in (opens your browser)
npx sidestep init my-app && cd my-app   # 2. scaffold your backend + frontend
npm run build                           # 3. build your frontend → frontend/dist
npx sidestep deploy ./xano/index.ts --static ./frontend/dist   # 4. deploy both → live URLs
→ Deploying ./xano/index.ts → new ephemeral "my-app"
✓ Ephemeral e4f2-9ab1 deployed
! New ephemeral URL:
    https://e4f2-9ab1.xano.io                                     ← backend, live
✓ Static host deployed
    https://my-app.xano.io                                        ← frontend, live
    Expires in 1h 0m

From an empty folder to a live full-stack app. init sets up your project — a TypeScript backend and a React frontend. npm run build builds your frontend, then deploy puts both online and hands you a live URL. Change your code — yourself or with an AI — and deploy again; your app updates in seconds. No servers to set up, nothing to configure, no glue code between your backend and your frontend.

Deploy it · The model · Quickstart · Type-safe frontend · Reference


Why SideStep

Xano gives you a genuinely scalable backend — Postgres, serverless functions, background tasks, realtime, MCP servers, AI agents — without you running a single server. SideStep gives you that backend as code you own:

  • 📦 TypeScript is the source of truth. Your whole workspace — tables, indexes, API endpoints, functions, triggers, tasks, middleware, AI toolsets — is typed TS in your repo. Version it, review it in PRs, diff it, roll it back. No more clicking through a dashboard and hoping prod matches staging.

  • 🚀 Deploy is built in. sidestep deploy compiles your code and ships it straight to a live Xano ephemeral environment over an authenticated connection, then prints its URL. No export/import dance, no upload script to maintain. Backend and static frontend in one command. Use ephemerals for QA and dev, then sidestep release the same workspace to your main Xano instance for production — same code, same command shape, promoted.

  • ⚡ Fast, safe iteration. Ephemerals are disposable (they auto-expire, ~1h by default), so you rebuild as often as you like — deploy figures out whether to refresh the one you're iterating on or spin up a fresh one, and calls out the URL when it changes. Deploys are identity-stable: re-running never duplicates objects, and with a committed xano.lock renames stay renames instead of delete-and-recreate.

  • 🧩 The types flow to your frontend. Import a query() def into your React/Angular app and get the endpoint path, HTTP verb, and a fully-typed request payload — with zero codegen. Rename a column and every consumer lights up red.

  • 🏗️ Highly scalable, zero ops. You write intent; Xano runs the infrastructure. Autoscaling compute, managed Postgres, edge-served static hosting. You never touch a Dockerfile.

  • 🤖 AI-first by design. A deterministic, fully-typed authoring surface — an agent (or you) emits well-typed TS that always compiles to a valid, importable workspace. Ships with machine-readable grounding: llms.txt is the lean, canonical tour an agent reads to author the SDK, with the exhaustive per-entry catalog — full field schemas, filter argument lists, engine mappings — a targeted lookup away in manifest.json. Agents learn the whole SDK without reading source.


Deploy it: backend + frontend, one command

SideStep's primary deploy target is an ephemeral environment: a named, disposable Xano workspace that spins up on demand, auto-expires (~1h by default), and is meant to be written to constantly while you build. sidestep deploy create-or-refreshes one and prints its URL — run it again and it refreshes the same environment; if it expired, a fresh one is minted and the new URL is called out. (Prefer a single throwaway singleton? --dest sandbox.) Most stacks make you deploy your API and your app through two separate pipelines. SideStep collapses that into one command — point --static at your built frontend and it archives and uploads it to the edge-served static host, right after the backend import, in the same run:

# Build once, then ship backend + frontend together. The deploy wires the
# backend URL into the frontend for you — no need to know it before building.
npm run build                                          # → frontend/dist
npx sidestep deploy ./xano/index.ts --static ./frontend/dist
→ Deploying ./xano/index.ts → new ephemeral "my-app"
✓ Ephemeral e4f2-9ab1 deployed
! New ephemeral URL:
    https://e4f2-9ab1.xano.io                                         ← backend, live
✓ Config injected into index.html: window.XANO_HOST                   ← backend URL, wired in
✓ Static host deployed
    https://my-app.xano.io                                            ← frontend, live
✓ Frontend is live                                                    ← edge confirmed serving THIS build
    Expires in 1h 0m

One authenticated call ships your database schema, your APIs, your functions and triggers, and your compiled web app. No separate frontend host to configure, no CI glue wiring the two together. Manage your environments with sidestep ephemeral <list|get|delete|export>.

Wiring the frontend to the backend. The deploy bakes the environment's backend URL into your build's index.html automatically, as a window.XANO_HOST global evaluated before your app bundle. So read it at runtime with a build-time fallback and you never have to know the URL ahead of time:

const HOST = (typeof window !== "undefined" && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;

window.XANO_HOST is the sandbox tenant URL your deployed APIs answer at (the same value sidestep sandbox details prints as baseUrl); it is not sidestep profile me, which prints your account's instance origin. Because injection happens at deploy time, a prebuilt frontend/dist retargets any sandbox with no rebuild — ideal for headless agents. Add your own public config (base URLs, publishable keys) with --static-env KEY=VALUE (repeatable), exposed the same way as window.<KEY>. A static host serves these files verbatim to the browser, so everything injected is public — never put secrets here; those belong in backend env, read server-side via env(name).

Verifying the injection: the served index.html writes the global in bracket notationwindow["XANO_HOST"]="…"; — so grep for the bare token XANO_HOST, not the exact string window.XANO_HOST (the dot form is valid to read the global in your app, but it's not what the file contains, so an exact-string grep for it wrongly reads as "not injected"). Note it can also be served from cache for up to an hour after a deploy — fetch once with a cache-buster (curl -s "$URL/?nocache=$(date +%s)" | grep XANO_HOST) rather than retrying the bare URL.

Two targets, so the dev loop and the production step stay distinct:

| Command | Where it goes | |---|---| | sidestep deploy | A disposable ephemeral environment (default) — create-or-refreshed each run, auto-expiring, with its own URL. --dest sandbox targets your throwaway singleton instead. | | sidestep release | Your main Xano instance workspace — the production target. (Coming soon; prints a notice for now until record-preserving import lands, so a release never wipes production data.) |

Every deploy is a full replace of the disposable environment — always fresh, no merge mode, no flags to get wrong. Deploys are authenticated over OAuth — sign in once, and the CLI refreshes tokens automatically. The target instance comes from your token (never a stray flag), and the CLI prints what it's about to do before it touches anything.

CI & agents run fully headless from two env vars — no browser needed:

XANO_REFRESH_TOKEN=… XANO_CLIENT_ID=… npx sidestep deploy --bundle ws.json

⚠️ A deploy is a full replace of the target environment, including its table records, before importing. The blast radius is your own disposable ephemeral/sandbox — but anything you only ever created by hand in it (or any data it accumulated) is gone. That's exactly why production has a separate release path.


Already have a Xano workspace? Pull it into TypeScript

codegen runs the loop the other way: it reads a workspace and writes it back out as readable SideStep source — real s.db.query(...), f.email(), typed defs — not a JSON dump. And not a loose pile of files either: you get the same runnable project sidestep init scaffolds, with the pulled workspace filling xano/. So a pull deploys:

sidestep workspace codegen my-app   # your real workspace (the one your login is scoped to)
cd my-app
npm run build
npm run xano:deploy                 # → a live ephemeral URL

The other three sources are the same command with a different origin:

sidestep sandbox codegen my-app          # your sandbox
sidestep ephemeral codegen pr-42 my-app  # a named ephemeral (tenant first, path second)
sidestep codegen ws.json my-app          # a bundle already on disk — offline, no login

Inside, xano/ is shaped the way the workspace is: one directory per kind, with each object under its parent — queries under the API group that owns them, triggers under what they fire on. Each table gets its own table/<name>.ts, settings sit in xano/workspace.ts, _shared.ts holds anything else referenced from more than one file, and xano/README.md lists anything that did not translate cleanly. Object identities (guid) are preserved, so cross-references stay intact. A statement this SDK does not model yet round-trips verbatim rather than breaking the pull.

Pulled objects are authored the same way you would write them by hand — table({...}), query({...}), defineFunction({...}) — so the generated tree keeps its types. A pulled table's columns still check on fieldName/output/sortBy, InferInput<typeof q> still resolves a pulled query's payload, and a pulled agent still types s.ai.agent.run.

A pull states what the source workspace actually holds and leaves out what the SDK would put back anyway. A table's primary(id) / created_at / gin(xdo) indexes are the engine's standard set, so only the indexes someone created are listed. A trigger comes back through the factory that built it (tableTrigger, realtimeTrigger, …) rather than a bare satisfies TriggerDef, which keeps its typed stack handle; the two realtime types that bind a def handle are the exception, since a stored trigger carries two guids with no way to know they agree. And two objects that reference each other — a pair of tables joined both ways, two functions that call each other — can't both be declared first, so the second reference is a {name, guid} const hoisted to the top of the file (const OrdersRef = {…}) instead of an import that would close a cycle. Only the guid is ever read, so it binds exactly.

xano/README.md also lists objects that were already empty in the source — an endpoint someone created and never filled in pulls as a def with no stack, which looks identical to a decode that gave up. The report is what tells the two apart.

A few options exist only so a pull can be faithful, and reading them in generated code is the only time you should see them: table: null / fn: null (a statement whose target was deleted or never bound), merge / hidden on a field, paging: { enabled } on a query, and c.blank(tag) (the editor's unconfigured value box — not a zero or an empty collection; the engine reads "" and "0" differently, so tidying one into the other changes what the workspace stores). They describe what the source workspace actually stored — a pulled table: null is a defect to fix upstream, not a shape to copy — and each carries that warning at the call site. A blank binding also reports, because a statement wired to a table or function that no longer exists is worth seeing even though it round-trips exactly.

Then it checks its own work: the project it just wrote is loaded, exported, and diffed against the workspace it came from. A mismatch names the object and fails the command (--no-verify opts out). So "it compiled" and "it means the same thing" are separate claims, and you get both.

Re-pulling is a real workflow: a second codegen into the same directory refreshes xano/ and leaves the rest of the project — your package.json, your frontend/ — exactly as you left it. No --force needed, because the tree carries a marker saying it was machine-written.

⚠️ xano/ is a scratch surface, and there is no sidestep workspace deploy. Regenerating rewrites it (a directory that isn't a previous pull still needs --force), it carries schema only — no table rows — and deploying it is a full replace of the target. Pull from your real workspace, edit, and deploy to a disposable ephemeral or sandbox. Workspace env var values ride inline in xano/workspace.ts (that is what a deploy sends), so treat a pulled tree as secret-bearing before you commit it.


The model: TypeScript in, real infrastructure out

You author declarative def-objects, register them on one Xano instance, and SideStep compiles the whole thing into Xano's importable bundle.

import { workspace, table, query, apiGroup, f, s, ref, c, expr, col } from "@sidestep/core";

// A database table — `id` + `created_at` auto-inject, so declare only your own columns.
const user = table({
  name: "user",
  auth: true,
  schema: {
    email: f.email({ required: true, methods: ["trim", "lower"] }),
    name:  f.text(),
  },
});

const post = table({
  name: "post",
  schema: {
    title:     f.text({ required: true }),
    body:      f.text(),
    published: f.bool({ default: false }),
    author:    f.tableRef(user),          // a real foreign key, type-checked
  },
});

// A public API group + endpoint. This query def is also the contract your frontend imports.
const blog = apiGroup({ name: "blog", canonical: "blog" });

const listPosts = query({
  verb: "GET",
  apiGroup: blog,
  name: "list_posts",
  stack: [
    s.db.query({ table: post, where: expr(col("published"), "=", c.bool(true)), as: "rows" }),
  ],
  response: ref("rows"),
});

export default workspace("blog")
  .registerApiGroups([blog])
  .registerTables([user, post])
  .registerQueries([listPosts]);

Tab-complete s. to discover the entire statement catalog — s.db.*, s.math.*, s.array.*, s.text.*, s.storage.*, s.api.*, s.cloud.*, control flow, AI agent runs, and more. All 215 engine statement surfaces are authorable — every field name matches the Xano engine, and the output is proven byte-for-byte against the engine's own golden fixtures.

Seed data

Give a table seed rows and they ship into the database on deploy — so a fresh environment comes up with lookup tables, demo content, or fixtures already in place, not empty:

const product = table({
  name: "product",
  schema: {
    sku:   f.text({ required: true }),
    name:  f.text({ required: true }),
    price: f.decimal(),
    tags:  f.text({ array: true }),
  },
  // Rows are validated against the column types before deploy. A column without
  // `required: true` may be omitted (the engine applies its default). Omit `id` and
  // rows are keyed for you — 1..N for an int PK, a stable uuid for a uuid PK (or
  // set `id` on every row); a bad value
  // or unknown column is a loud error, never a silent drop.
  seed: [
    { sku: "SKU-001", name: "Aeron Chair",   price: 1395, tags: ["furniture", "ergonomic"] },
    { sku: "SKU-002", name: "Standing Desk", price: 599,  tags: ["furniture"] },
  ],
});

Pinning an id this way is a seed property, not a general bulk-insert one: the runtime statement s.db.bulk.add drops id from every row unless you pass allowIdField: true, assigning the next sequence value instead.

Deploy is a full replace, so re-deploying re-seeds cleanly — no duplicate rows. Seed data travels only in the deploy package (resolved at deploy time); it never enters the compiled workspace bundle.

For data in a file, use seedFile:

seed: seedFile("./products.seed.json", import.meta.url),

The path resolves against the file that declares the table, and it is read with node:fs at deploy time. A thunk (seed: () => import("./products.seed.json")) also works and is the right shape for computed seeds — but be aware it does not keep seed values out of a frontend build: the import() lives in your module, so a bundler emits the JSON as a served chunk, and any frontend that imports a def whose module graph reaches that table ships the seed to the browser. seedFile stores a path string, which a bundler has nothing to follow.

Either way, keep secrets out of seed — it is throwaway fixture data for disposable environments. As a backstop, sidestep deploy --static refuses to publish a frontend build containing seed values from columns your schema marks access: "internal" or sensitive (pass --allow-seed-in-static if the data is deliberately public).

Typing is unaffected by the form you choose — the table's row type and column names stay inferred.


60-second quickstart

The fastest start is sidestep init, which scaffolds the whole project — a Vite

  • React frontend under frontend/ (styled with Tailwind v4 and shadcn/ui), a sidestep backend under xano/, and the xano:export/xano:deploy scripts already wired:
npx sidestep init my-app           # scaffold; prompts to set up AI instructions
cd my-app
npm run dev                        # run the frontend right away

init flags: --name <name> (default: the folder name), --ai <claude|codex|cursor|none> (repeatable; writes CLAUDE.md/AGENTS.md/Cursor rules — none by default), --force (scaffold into a non-empty folder), --no-install (skip npm install). The starter backend is empty but already compiles and deploys — grow it from the walkthrough in xano/EXAMPLE.md.

To point npm run dev at a real backend, copy .env.example to .env.local — both live at the project root, next to vite.config.ts — and set VITE_XANO_HOST to a deployed URL. Deployed builds don't need it: sidestep deploy --static injects the backend URL as window.XANO_HOST, which takes precedence.

The frontend ships Button and Card in frontend/src/components/ui/, plus a pre-configured components.json, so npx shadcn@latest add dialog form input works immediately — no shadcn init step. shadcn components are copied into your repo rather than installed, so you own and edit them directly. Rebrand by editing the color tokens at the top of frontend/src/index.css (Tailwind v4 keeps the theme in CSS — there's no tailwind.config.js).

Prefer to wire it by hand? The same loop, from scratch:

# 1. Install
npm install @sidestep/core
npm i -D tsx                       # lets the CLI run your .ts entry directly

# 2. Write your workspace in TypeScript
#    xano/index.ts  →  export default workspace("my-app")...

# 3. Sign in once (OAuth — no API keys to copy around)
npx sidestep login                 # opens your browser; you pick the instance + workspace

# 4. Deploy to a live ephemeral environment — this is the dev loop
npx sidestep deploy ./xano/index.ts                     # → prints your ephemeral URL

# 5. Ship a built frontend alongside it (both land on the same ephemeral).
#    --static injects the backend URL for you, so no pre-build wiring is needed:
npm run build                                           # → ./dist
npx sidestep deploy ./xano/index.ts --static ./dist

That's the whole loop: install → write TypeScript → login → deploy → URL. No dashboards, no manual imports, no upload scripts. Deploy again to refresh the same environment.

The entry must be an ES module (SideStep defs are ESM-only). On Node ≥ 22.6 a .ts entry loads natively; install tsx for older Node or multi-file workspaces. Set "type": "module" in the nearest package.json if you hit a "must be ES modules" error.

Verifying a def outside a bundler. Inside a bundler (Vite/webpack) importing a query def to read getPath()/verb just works. To spot-check from Node, run a real file with tsx <file.ts> from inside the project root — not tsx -e "import …" (its CJS-preparse mis-resolves the package exports map → ERR_PACKAGE_PATH_NOT_EXPORTED) and not bare node file.ts (chokes on the .js-specifier intra-workspace imports the CLI's own loader resolves). Intra-workspace imports use .js specifiers (../tables/links.js) under moduleResolution: bundler, not extensionless.


The payoff: a type-safe frontend, for free

Because your API is a typed def, the code that calls it can reuse that def instead of re-typing URLs and request bodies. Import the query() into your frontend:

import { listPosts } from "../xano/index.js";          // the same def you deployed
import { post } from "../xano/tables.js";
import type { InferRow } from "@sidestep/core";

const BASE = "https://x8ki-letl.n7.xano.io";           // your instance host

type Post = InferRow<typeof post>;                     // { id: number; created_at: number; title: string; … }

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch(BASE + listPosts.getPath(), { method: listPosts.verb });
  return res.json();                                   // typed end to end
}
  • listPosts.getPath() → the endpoint path, resolved from your code (or the frozen xano.lock). No hardcoded strings.

  • listPosts.verb → the HTTP method, straight from the def.

  • sidestep paths ./xano/index.ts (alias routes) → list every endpoint's verb and resolved api:<canonical>/<name> path from the CLI, without writing a script — handy for wiring a client or curling a live env.

  • InferInput<typeof someQuery> → the request-payload type, derived from a query's input map at compile time. Required inputs are required keys; enums become literal unions; nested objects and lists carry through. No codegen, always in sync.

  • query.toSearchParams(input) → the GET transport counterpart to InferInput: serialize an input map into a URLSearchParams (scalars stringify, arrays repeat the key, null/undefined are dropped) instead of hand-building ?id=….

  • Endpoint names hold A-Z a-z 0-9 _ - / and {} — nothing else, capped at 200 chars. A . is the trap: Xano does not reject name: "export.zip", it stores the endpoint with an empty name, so it deploys clean and then 404s Unable to locate request. on every request. query() throws instead. Name it export_zip or export/zip and set the file extension in the response headers. Same rule for realtimeChannel and tool names; realtimeMessage is narrower still (no / or {}).

  • URL path params → name the endpoint with {param} segments and declare an input per segment. getPath({ params }) fills them, with the keys typed from the name itself:

    const getPost = query({
      name: "blog/{slug}/review/{review_id}",       // segments chain; no wildcards
      verb: "GET",
      apiGroup: api,
      input: { slug: input.text(), review_id: input.int(), verbose: input.bool() },
      stack: [s.db.get({ table: post, fieldName: "slug", fieldValue: inp("slug"), as: "row" })],
      response: ref("row"),
    });
    
    getPost.getPath({ params: { slug: "hello", review_id: 7 } });
    // → "/api:<canonical>/blog/hello/review/7"
    getPost.toSearchParams({ verbose: true });      // → "verbose=true" (path params dropped)

    Every {param} must have a matching input or query() throws — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. Inputs that aren't in the path (verbose) stay ordinary query-string params. Never interpolate the path by hand: a value containing / would silently address a different endpoint, which getPath refuses. realtimeChannel() paths work identically.

  • InferRow<typeof post> → the table's row type. Rename or retype a column and every consumer breaks at compile time — exactly where you want it.

  • InferResponse<typeof someQuery> → the endpoint's response type, closing the round trip. It auto-derives the common shapes with no codegen: an object-literal response yields those keys, and a query that returns a variable filled by a db op derives that op's result — the full row for db.add/db.edit/db.patch/db.add_or_edit (→ Row — each binds the full written row rather than null, so it stays non-nullable; a genuine miss throws instead of yielding null — NotFound/404 for edit/patch, a unique-constraint error for add, while add_or_edit upserts and never misses), Row | null for db.get (it binds null on a miss rather than throwing — handle the not-found path), a row list for db.query/db.bulk.patch (→ Row[]), a boolean for db.has, a number count for db.bulk.delete, and a get/query output: [...] selection narrows to a Pick (still | null for get). A dotted entry selects sub-keys of an object column (output: ["id", "meta.url"], on a statement or an addon); the narrowing keys off the path's root, since an object column's sub-keys aren't declared in the schema. Where the shape isn't statically knowable — a value reshaped by a filter/lambda, built by control flow, or from an op the engine itself leaves untyped (db.del, db.bulk.add/bulk.update, raw direct_query) — it resolves to unknown; declare responseShape to close it.

import { listPosts, getPost } from "../xano/index.js";
import type { InferResponse } from "@sidestep/core";

type Posts = InferResponse<typeof listPosts>;   // Post[]      — derived from the db.query it returns
type Post  = InferResponse<typeof getPost>;      // Post | null — a db.get misses to null

For a computed or multi-key object response, author it as a record of valuesresponse: { success: c.bool(true), id: inp("id") }not c.obj({ ... }). c.obj builds a constant, so a tagged value nested inside it would serialize as internal representation the engine can't decode (a runtime 500); nesting one is now a compile error that points you at the record form (issue #42). A nested plain object in a record response (response: { user: { id: ref("u"), age: 3 } }) is auto-wrapped for you — no manual obj({ ... }) — and raw literals in a call/agent input map coerce too (s.function.run({ fn, input: { max_age_days: 3 } }) — no c.int(3)).

When a response is filtered, computed, or otherwise opaque to the static walk, declare it once on the query and every caller derives from that single source of truth:

const getPost = query({
  verb: "GET", apiGroup: blog, name: "get_post",
  input: { id: input.int({ required: true }) },
  stack: [s.db.query({ table: post, where: expr(col("id"), "=", inp("id")), as: "rows" })],
  // A filtered response is opaque to the static walk, so derivation is `unknown`.
  response: withFilters(ref("rows"), fl.first()),
  responseShape: null as InferRow<typeof post> | null,   // declare the real shape once
});
type MaybePost = InferResponse<typeof getPost>;           // InferRow<typeof post> | null

(A plain response: ref("row") off a s.db.get needs no responseShape — it already derives InferRow<typeof post> | null, since db.get misses to null.)

This mirrors how the Xano engine itself derives an endpoint's response schema (a static walk of the stack), so what you get in the type is what the endpoint actually returns — and it degrades to unknown in exactly the cases the engine can't resolve either.

A GET endpoint carries its inputs in the query string rather than a JSON body:

import { getSnippet } from "../xano/index.js";
import { query, type InferInput } from "@sidestep/core";

const BASE = "https://your-instance.xano.io";

async function fetchSnippet(id: number) {
  const params = { id } satisfies InferInput<typeof getSnippet>;   // { id: number }
  const res = await fetch(`${BASE}${getSnippet.getPath()}?${query.toSearchParams(params)}`);
  return res.json();
}

The @sidestep/core entry has zero Node dependencies, so importing your workspace graph into a browser bundle just works. The node:fs-backed emitters live in the separate @sidestep/core/node entry a frontend never pulls in.

Bundle size & tree-shaking. @sidestep/core is sideEffects: false, so a bundler drops the SDK exports your frontend doesn't use. But importing a query def for its getPath() also pulls whatever its stack builds — the s.*/c.* factory calls run at module load to construct the def, so they can't be tree-shaken out. Types are free (InferInput/ InferRow erase to nothing — use import type). That cost is a floor, not a function of how lean the def is: on a trivial Vite app, one def imported for one getPath() measured 37 kB minified against 784 B for a hand-written path string, and splitting modules reduces the incremental cost of further defs but never the floor.

Generate a route manifest instead. It keeps the derived-not-hardcoded contract at almost no bundle cost:

sidestep routes ./xano/index.ts --emit xano/routes.gen.ts

The emitted file is plain data plus one interpolator and imports nothing at all — the same app builds to 1.7 kB. Route names and their {param} keys are still checked at compile time, so a backend rename is a compile error rather than a 404:

import { routePath, ROUTES } from "../xano/routes.gen";

fetch(BASE + routePath("blog/{slug}", { slug }), { method: ROUTES["blog/{slug}"].verb });

Realtime is in the same file when the workspace has any: socketUrl(server, baseUrl) for the websocket URL and channelPath(channel, params) for the path a frame's channel field takes, both keyed and {param}-checked exactly like the routes. socketUrl is the equivalent of realtimeServer().getUrl() down to the tenant rule — a base URL that names a tenant (https://host/tenant/ab-cd, what deploy injects as window.XANO_HOST) is rewritten to the socket's own wss://host/ws/ab-cd:<canonical> form, which is the one address a frontend has no way to reconstruct:

import { socketUrl, channelPath } from "../xano/routes.gen";

const ws = new WebSocket(socketUrl("chat", window.XANO_HOST), token);
ws.send(JSON.stringify({ action: "join", channel: channelPath("rooms/{room_id}", { room_id }) }));

Add --strict in CI to fail when the committed manifest is out of date. A hand-typed ROUTES table is the option that gives up both the bundle saving and the rename safety.


Reference

Where the exhaustive reference lives. Every kind, statement, filter, and field type is typed, so your editor's autocomplete is the fastest lookup — tab-complete s., f., c., fl., input.. For the written catalog, the package ships two machine-readable files that are generated from the SDK's own sources and can never drift from it: llms.txt (the canonical tour — every signature, plus the engine behavior each one depends on) and manifest.json (per-entry detail: full field schemas with engine defaults, filter argument lists, stored-name mappings). Both are readable by people too.

What follows is the part neither of those replaces: the shape of a project, and the behavior that will bite you.

Lay objects out however you like and register them explicitly — there's no folder auto-discovery magic (deliberately):

xano/
├── function/     get_user.ts         export const getUser = defineFunction({...})
├── table/        table.ts            export const user = table({...})
│   └── trigger/  on_insert.ts        export const onInsert = tableTrigger({...})
├── query/        public.ts           export const publicApi = apiGroup({...})
│                 public/posts_GET.ts export const posts = query({...})
├── agent/        assistant.ts        export const assistant = agent({...})
├── realtime_server/ chat.ts               export const chat = realtimeServer({...})
│                 chat/room.ts              export const room = realtimeChannel({...})
│                 chat/room/send.ts         export const send = realtimeMessage({...})
├── workspace.ts                      export const workspaceSettings = workspaceConfig({...})
└── index.ts      workspace("my-app").registerTables([...]).registerFunctions([...])…

Objects nest under whatever owns them. Anything with children — an API group, a realtime server, a channel — is a file named for itself sitting beside the folder holding its children, so chat.ts opens in a tab you can tell apart and a group with no queries needs no folder at all. Realtime is the deepest, being the only three-level hierarchy in a workspace — server, then channel, then message — and a trigger sits in a trigger/ folder at whichever level it fires on.

Paths are lower case throughout — an HTTP verb is the one exception, because it is the method rather than a word. Bindings keep the object's own casing, so a file name and the symbol it exports can differ.

That is the shape sidestep codegen writes, and its index.ts re-exports every object by name — import from the tree's root rather than from a file, since a file path moves when an object's parent or its _shared.ts placement changes. Hand-authored projects are free to use any other layout; only index.ts registering the objects matters.

workspace("my-app") is the natural entry point — sugar for new Xano().registerWorkspace({ name: "my-app" }), returning the same chainable registry. Authoring is declarative def-objects passed to factories; there is no callback/chaining builder. xano.export() returns the importable packageExport bundle, and sidestep export/deploy read the module's default export.

Every top-level Xano object is a registered kind with a factory and a Xano.register* method: defineFunction, table, query, apiGroup, tool, mcpServer, agent, task, workflowTest, middleware, addon, realtimeServer, realtimeChannel, realtimeMessage, microservice (its own section below), workspaceConfig, and the seven trigger factories below. Signatures and payload keys are in llms.txt; what follows is what the types don't tell you.

Triggers take a callback stack. stack: (t) => [...], not the plain array every other kind uses — because a trigger's inputs are implied by its type (fixed by Xano, not editable) and injected automatically. So triggers take no input field, and the typed handle t is the only way to read them (response: (t) => ... on response-bearing types). The seven types are tableTrigger, realtimeServerTrigger, realtimeChannelTrigger, mcpServerTrigger, agentTrigger, workspaceTrigger, and errorTrigger; they share one stored envelope discriminated by obj_type.

tableTrigger({
  name: "on-user-insert",
  table: users,
  actions: { insert: true },
  // Optional row filter, evaluated by the DATABASE before the stack runs — so it
  // names the SQL pseudo-tables with col(), NOT the t handle. Rejected with
  // `truncate`; insert cannot read OLD.*, delete cannot read NEW.*.
  search: cmp(col("NEW.email"), "!=", c.text("")),
  stack: (t) => [
    // t.new("email") is typed to the row; t.action is the op; t.old is null (insert-only).
    s.db.add({ table: auditLog, row: { email: t.new("email"), event: t.action } }),
  ],
});

A workflow test is an end-to-end test, and its datasource is the trap. workflowTest takes no input and no response — it calls other objects and asserts on what they bind. Leave datasource off: the default "" runs against an empty datasource. Naming one makes the engine clone that datasource before every run, so pointing a test at production-sized data is slow enough to fail the run outright. "live" warns at compile time; every other name is your call.

workflowTest({
  name: "signup_works",
  tags: ["smoke"],
  // datasource omitted on purpose — "" is an EMPTY datasource, not "no datasource".
  stack: [
    s.function.call({ fn: createUser, input: { email: "[email protected]" }, as: "created" }),
    s.expect.to_be_defined({ expr: ref("created") }),
    s.expect.to_equal({ expr: ref("created.status"), value: c.text("ok") }),
  ],
});

Realtime — the only three-level containment chain in the SDK: realtimeServer owns realtimeChannels, which own realtimeMessage handlers (a message is the realtime analogue of a query — its own typed payload and stack). Pass the handle, not a name: a channel path is unique only within its server. A channel's input types its path params (rooms/{room_id}); a message's input types the message payload. A server is off until enabled: true.

const chat = realtimeServer({ name: "chat", enabled: true });

const room = realtimeChannel({
  name: "rooms/{room_id}",           // `input` types the PATH params
  server: chat,
  input: { room_id: input.int() },
  publish: { who: "authenticated" },
  conversation: { enabled: true, limit: 50 },   // client-visible transcript
});

realtimeMessage({
  name: "send",                      // `input` types the message PAYLOAD
  channel: room,                     // the handle carries the server too
  input: { body: input.text({ required: true }) },
  deliverTo: "channel",              // or "sender" (request/response) / "others"
  stack: [s.debug.log({ value: inp("body") })],
});

The client side is derived too, the same way query().getPath() works — chat.getUrl(BASE) builds the socket URL (wss://…/ws/<canonical>, with a tenant base URL translated into the socket's /ws/<tenant>:<canonical> form) and room.getChannel({ room_id: 42 }) builds the path a client joins. Both throw rather than guess. In a browser bundle, reach for the generated manifest's socketUrl/channelPath instead — same addresses, same checks, without importing the defs (see The payoff: a type-safe frontend, for free).

Five traps account for most realtime bugs. The full wire protocol — every server frame, the presence roster shape, the at-least-once client contract — is in llms.txt.

  • An empty return denies, and so does a crash. connect and join are gates: return { allowed: true } or any truthy value to admit. A stack that falls through, or a gating trigger with no response, refuses everyone — and a raise refuses too, because the gate is seeded with a deny it keeps when the stack throws. Both failure modes lock the door, so the risk to plan for is a self-inflicted lockout, not a breach: guard every drill inside a gate with ref(path, { safe: true }), since db.get binds null on a miss. export() warns on the missing response; nothing can warn about the raise. Gating is opt-in — a server with no connect trigger admits everyone.
  • Only null drops a message. In a deliver trigger (per recipient) and in a message handler, false/0/"" all deliver the message unchanged, and a crash broadcasts the sender's original unvalidated payload. Return null to suppress. So a redaction check written as a boolean sends the very message it was meant to hide.
  • conversation: { enabled: true } alone stores nothing. limit defaults to 0, and 0 means retain none. Always pass a limit. What a handler broadcasts is the stored row, so broadcast everything a future joiner needs to render it.
  • An idle socket is reaped after ~10 minutes. A listen-only client (feed, dashboard, presence sidebar) must send { action: "ping" } or any frame periodically, or it silently drops and reconnects forever.
  • s.realtime.publish is the push direction, and it is fail-soft. It bypasses the channel's publish.who (authorization belongs in your stack), does not invoke the named message's handler, and swallows a missing or disabled server — a mis-targeted publish is silent. Pass the server handle and a filled-in path (room.getChannel({ room_id: 42 })), never the template.

The superseded realtime layer. Xano has had two realtime generations and they reuse the same words. realtimeTrigger(...) and s.api.realtime_event(...) belong to the old workspace-global layer; they are supported only so codegen can bring back a workspace that holds them, and they are named under ## Legacy in llms.txt rather than in its catalogs. Aiming s.api.realtime_event at a current-layer channel publishes into the void — use s.realtime.publish({ server, channel, data }), which names the owning server and so can resolve the channel.

MCP servers & agents — both persist under the toolset payload key, so an mcpServer and an agent sharing a name collide. A tool({...}) is its own kind, referenced by handle from either.

// Auth is PER-TOOL and works like a query's: name an auth table({ auth: true }).
mcpServer({ name: "books", tools: [{ tool: searchTool, auth: users }] });

const assistant = agent({
  name: "assistant",
  llm: { type: "xano-free", systemPrompt: "Be helpful.", prompt: "Answer the question." },
  tools: [{ tool: searchTool }],
});

// Agents have NO public endpoint — invoke them in-stack from any host with a stack.
query({
  name: "ask", verb: "POST", apiGroup: api,
  input: { question: input.text({ required: true }) },
  stack: [s.ai.agent.run({ agent: assistant, args: obj({ question: inp("question") }), as: "answer" })],
  response: { text: ref("answer.result") },
});
  • The run result is an envelope, not the completion. The model's text is at .resultref("answer") is the whole metadata object (finishReason, steps, …). Both are typed, so InferResponse reflects either.
  • llm is a provider-discriminated unionanthropic / openai / google-genai / xano-free (which needs no API key) — each with its provider's typed fields.
  • Structured output types the call site. Author output: { schema: { … } } on the agent with the input.* catalog and .result is typed from it wherever the handle is passed — no second witness. The type-only resultShape is only for overriding that, or for an agent referenced by bare name.
  • String settings are Twig-templated at run time. The args you pass to s.ai.agent.run become {{ $args }} (env vars are {{ $env.NAME }}), which is how an endpoint's inputs reach the prompt. Numeric and boolean fields are not templated. Build a dynamic arg with obj({...}), not c.obj.
  • mcpServer().getUrl(HOST) derives the Streamable-HTTP endpoint from the def, the same contract as query.getPath(). Agents expose only getCanonical().

Background execution. s.function.run and s.ai.agent.run take a runtime block ({ mode: "async-shared" }, or "async-dedicated" with cpu/memory/timeout/maxRetry) that moves the call off the request path. This is not a performance knob: Xano rewrites an async call to a statement that dispatches and continues, so it does not return the function's result — don't bind as expecting a value. Collect results later with s.await({ ids }).

A microservice is a container workload deployed alongside the workspace and called from a stack with s.microservice.request. Two mutually exclusive shapes chosen by kind: builtin declares containers (image/ports/resources/env/command/args) plus optional ingresses, and helm points at a chart and its values; passing both throws.

export const echo = microservice({
  name: "echo",
  deployment: {
    replicas: 2,
    containers: [{
      name: "echo",
      image: "ealen/echo-server:latest",
      ports: [{ servicePort: "8080", containerPort: "80" }],
      resources: { cpu: "50m", ram: "256Mi" },
    }],
  },
});

Call it by passing the def itself. port folds into the single "name:port" host string the engine reads, and is optional — a microservice exposing exactly one servicePort resolves to it, and one exposing several requires it. A port the microservice doesn't expose is a type error where the def's ports are known, and a build-time throw otherwise:

s.microservice.request({ as: "res", host: echo, path: "/health" });

Only host and path are required. method, params, headers, timeout, and follow_location default to the engine's own values (GET, {}, [], 10, true) and are always written — this statement's schema requires them, so they can't be left off the wire; you just don't have to type them.

host binds by name, not by guid, because that is how the engine resolves it — so renaming a microservice fixes every call site at once. A plain "name:port" string is also accepted and is the only way to reach an instance-level microservice, which isn't a workspace object; nothing checks that spelling, so prefer the def wherever there is one.

A container takes time to come up, so sidestep deploy waits for it: after the import it reads each microservice and reports whether it is ready, still starting, or failed, then lists them. A microservice that hasn't come up in time is a warning, not a failed deploy: the backend is already live and the container usually follows moments later. Skip the wait with --no-verify. The same report is available any time from sidestep ephemeral get <env>, sidestep sandbox details, and sidestep workspace details.

tenantDeploy: "manual" rows are reported but never waited on — nothing starts them for you. Reach for it when the row should exist without a workload behind it; examples/sandbox uses it so deploying the examples doesn't wait on containers.

This surface is early and expected to change. configs and volumes are typed but unconfirmed against a live engine. And two fields carry secrets into a pulled tree verbatim — chart.values and registryAuth.dockerconfigjson — because otherwise a pulled microservice could not be redeployed. Prefer leaving dockerconfigjson unset and supplying it out of band.

A middleware({...}) is reusable logic (input/stack/response + resultStrategy: "merge"|"replace" + exceptionPolicy). To run one, attach it with a host's middleware: { pre, post } field on query/apiGroup/defineFunction/task/tool (not triggers). Prefer a def handle over a bare name, the same rule as auth/apiGroup references; { middleware: mw, active: false } keeps an entry but disables it.

query({
  name: "get_user", verb: "GET", apiGroup: blog,
  middleware: { pre: [rateLimit], post: [audit] },
  stack: [/* ... */], response: ref("user"),
});
  • exceptionPolicy decides whether a guard is a guard. "silent" is the default and swallows the throw, so a rate limit or auth check authored without an explicit policy is not enforced. "rethrow" aborts the request and surfaces the authored error/status (a tripped s.redis.ratelimit → 429) while still running post; "critical" is the same but skips the post chain. That is the only difference.
  • Inheritance is override, not merge. Providing a phase overrides it; omitting a phase inherits the parent tier's chain, resolved at request time Query → API Group → Workspace. pre: middleware.clear() overrides a phase with nothing.
  • Setting workspaceConfig.middleware at all emits the whole map. Any host/phase you don't list is emitted empty, which clears that tier on deploy. Omit the field entirely to leave the workspace's existing middleware untouched. The same wholesale rule applies to datasources.
  • auth() is null on a public host, and a pre middleware runs after auth resolution. A rate limit keyed by auth("id") on a public endpoint collapses every caller into one bucket, silently. export() warns on direct attachment of an auth()-keyed middleware to a host where auth() may be null.
  • A resultStrategy: "replace" middleware attached post rewrites the response at runtime, which InferResponse can't see — declare responseShape on the endpoint.
  • workspaceConfig also carries realtime, documentation, and swagger, which are server-shaped and carried verbatim rather than authored. realtime there is the legacy workspace-level block, not the realtime primitives you author.

The canonical rate-limit middleware. Build the per-user key with the filter chain ("prefix" + auth("id") doesn't exist):

const writeRl = middleware({
  name: "write_rl",
  exceptionPolicy: "rethrow", // a tripped limit must abort (silent would let it through)
  stack: [
    s.redis.ratelimit({
      key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), // "rl:write:<id>"
      max: c.int(10), ttl: c.int(30), error: c.text("Too fast."),
    }),
  ],
});

query({ name: "create_post", verb: "POST", apiGroup: blog, auth: users, // authed ⇒ per-user
  middleware: { pre: [writeRl] }, stack: [/* ... */], response: ref("post") });

On a public endpoint key off the client IP instead — sys.remoteIp() — since auth("id") is null there. And note the shared-bucket rule: co-attaching one middleware object to N hosts means all N share the same key and therefore one counter, so max: 10 is a global budget across them. Vary the key (fold the host name into the prefix) for an independent limit per host.

Reading the request body in a pre middleware. It does receive the host's inputs, via s.util.get_all_input({ as: "payload" }) — but the result is wrapped as { type, vars }, so a body field lives at ref("payload.vars.<field>"). The un-nested path is the usual cause of an Unable to locate var 500.

Request history — the per-object execution trace behind Xano's debugger, authored as a single scalar history field: false off, true on at the default depth, a number = capture depth (statement executions recorded per record, not records retained), "all" = unlimited. Omitting it inherits; any value stops inheriting. Inheritance resolves object → container → workspace (a query from its API group, a tool from its toolset/agent, everything else straight from the workspace). Per-kind defaults when inheriting: query / task / tool capture on; function / trigger / middleware off. workspaceConfig.history is wholesale in the same way the middleware map is.

query({ name: "get_user", verb: "GET", history: 100 });   // capture, depth cap 100
apiGroup({ name: "blog", history: false });               // default for its queries
workspaceConfig({ history: { query: 100, trigger: "all" } });  // name inherited from workspace("…")

Workspace environment variables — author them as a name→value map on the workspace object; read them at request time with env("NAME"):

workspaceConfig({
  name: "my-app",
  env: {
    STRIPE_KEY: process.env.STRIPE_KEY!,          // sourced from the deploy environment
    APP_BASE_URL: "https://my-app.example.com",   // a plain config value
  },
});

Values are secrets. Prefer sourcing them from the deploy environment over committing literals, and don't commit a compiled bundle holding real ones. Deploying sets the vars you declare; omit the field to leave the workspace's existing env untouched.

f.* covers the full column catalog — scalars, f.timestamp, the four file resources, the six f.geo.* types, f.enum(values), f.vector(size), f.object(children). Foreign keys are f.tableRef(table), whose link resolves to the target table's guid at export. Any scalar becomes a list column with { array: true }, surfacing as string[] in InferRow. Tables take a named-map schema, filter methods carry args ("min:8"), and views[] encode through the shared comparison encoder.

  • A column default must stay within the BMP. A 4-byte character (codepoint > U+FFFF, e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline, so it is rejected at export rather than 500ing at deploy with Postgres 22021. Accents, , and most CJK are fine; otherwise put the value on an endpoint input, applied at runtime bind.
  • id and created_at auto-inject at the head of the schema unless system: false or you declare them (idType: "uuid" for a uuid key). Both are usable wherever a column name is expected and both appear in InferRow. The standard indexes — primary(id), btree(created_at desc), plus gin(xdo) when the table stores fields as JSON — auto-prepend, de-duped against your own. Declare yours as { type, fields: [{ name, op? }] }; "unique" is shorthand for "btree|unique".
  • use_xdo picks the storage mode — every field as JSON under the internal xdo column, or a real Postgres column per field. It is a workspace setting (default false) each table mirrors, overridable per table with table({ useXdo }), resolved at export() so the two can register in any order.

The stack of a function/query/tool is a list of statements, all reachable through one discoverable, typed namespace — s:

stack: [
  s.set_var("total", c.int(0)),
  s.math.add({ name: "total", value: c.int(5) }),
  s.array.find({ as: "hit", expr: ref("items"), if: expr(ref("$this"), "=", c.int(1)) }),
  s.conditional({ when: expr(ref("total"), ">", c.int(0)), then: [s.return(ref("total"))] }),
  s.function.run({ fn: getUser, as: "u", input: { id: ref("total") } }),
]

Tab-complete s. to explore. Each declarative statement takes one typed args object; control-flow specials (s.set_var, s.conditional, s.for, s.foreach, s.while, s.group, s.switch, s.try_catch, s.return, …) keep their authored signatures. Every statement also carries description and disabled — inline on the object-arg factories, a trailing options object on the positional specials. disabled: true is Xano's commented-out state: the step stays in the stack and the engine skips it.

Filter a statement's result as it binds. Any statement with an as also takes asFilters — the editor's return as <var> | upper — applied in order, from the same fl.* catalog as value filters:

s.security.create_uuid({ as: "token", asFilters: [fl.upper()] })
s.set_var("email", inp("raw"), { asFilters: [fl.trim(), fl.lower()] })

It saves a follow-up s.set_var for the common "bind it in a different shape" case. A statement that binds nothing does not offer the option.

The chain retypes the value. InferResponse folds each filter's declared result, so a filtered binding reports what it actually holds rather than unknown:

s.db.query({ table: users, as: "rows", asFilters: [fl.count()] })       // rows: number
s.db.query({ table: users, as: "rows", asFilters: [fl.reverse(), fl.first()] })  // rows: Row
withFilters(ref("rows"), fl.count())                                    // number

Filters the engine declares as returning anyget, set, transform, json_decode — fold to unknown, since no declaration could name their shape. Note this models a filter's OUTPUT, not its input: a filter applied to a value it cannot accept returns null at runtime rather than erroring, and still types as its declared result.

Fields with a fixed set of values take a bare literal. Where the engine accepts only certain spellings, the field's type is that set, so autocomplete offers them and a typo is a compile error rather than a runtime failure after deploy:

s.ai.external.mcp.tool.run({ url, tool, connection_type: "stream" }) // ✅ "sse" | "stream"
s.ai.external.mcp.tool.run({ url, tool, connection_type: "streaming" }) // ❌ compile error, and throws
s.ai.external.mcp.tool.run({ url, tool, connection_type: inp("mode") }) // ✅ resolved at runtime

"stream" and c.text("stream") encode identically — use whichever reads better. A value the SDK can't evaluate (an inp/ref, or anything with a filter chain) is never checked, so a computed field stays authorable.

The db family. Single-record reads and mutations match one field ({ fieldName, fieldValue }, defaulting to id) — there is no composite (a, b) form; for a two-column lookup use s.db.query with a where array. Writes take a partial row: {…}, and an s.db.edit writes only the columns you list, leaving every unmentioned column at its stored value. Only s.db.query takes a where, and its where/sort/paging/output are applied by the engine, not in your stack.

What each op binds decides your response type: s.db.get binds null on a miss (it does not throw — null-check it), s.db.add/edit/patch bind the full written row including auto-assigned id/created_at, s.db.del binds null, and edit/del throw NotFound (404) when nothing matches. InferResponse derives all of that automatically.

s.db.query mirrors the whole Xano query builder — returnType, bind joins, computed eval columns, aggregate groups, distinct, and the full operator set via cmp(left, op, right) with and(...)/or(...) for boolean groups. Signatures are in llms.txt; four behaviors are worth knowing here:

  • A join condition spells its two sides differently. The joined table's column takes its as alias; this query's own columns stay bare: bind: [{ table: users, as: "author", join: "left", where: expr(col("author_id"), "=", col("author.id")) }]. Qualifying your own column by the table's name (col("posts.author_id")) resolves only if the query also sets tableAlias — the alias the qualifier is matched against. Unqualified, the engine reads the operand as a text literal and fails at runtime with a parse error naming the other operand, so db.query rejects that spelling at export instead.

  • Paging changes the response shape. Supplying paging with metadata on (the default) returns a paging envelope{ items, curPage, nextPage, prevPage, offset, perPage, itemsReceived }, plus totals when totals: true — instead of a bare Row[], and InferResponse reflects that. Pass metadata: false to keep the bare array. Read nextPage (number | null) as the typed has-next signal.

  • Don't author mixed(...) conditions. Xano's editor allows a container whose terms don't all join the same way, so pulled workspaces contain it and it round-trips — but the stored form doesn't record the intended grouping, and the two places it can appear disagree: a branch folds terms strictly left to right (a OR b AND c = (a OR b) AND c) while a db.query filter inherits SQL's AND-before-OR precedence (a OR (b AND c)). Write and(or(a, b), c) or or(a, and(b, c)) — each says exactly one thing in every context.

  • An aggregate name is written bare ("status") and alias-qualified on emit; the engine rejects a bare column in an aggregate, and an already-dotted joined column passes through. The statement also declares the alias it qualified with, so the qualified name resolves — nothing to set by hand.

Addons enrich each returned row with related data, attached to the row-returning ops (query/get/add/edit/patch). An addon is a single table-bound db query rather than a statement stack: addon({ table, where, output, cardinality }), where where binds it to the parent row and cardinality shapes the graft ("single" object, the default "list", "count", "exists", "aggregate").

export const authorAddon = addon({
  name: "author",
  table: userTable,
  where: expr(col("id"), "=", inp("user_id")),   // bind to the parent row
  output: ["id", "name"],
  cardinality: "single",
  input: { user_id: input.int({ required: true }) },
});

s.db.query({
  table: post,
  addon: [{ addon: authorAddon, as: "_author", input: { user_id: out("author") } }],
  as: "rows",
});

Attaching a typed handle merges the graft onto the row shape in InferResponse with no cast; a bare-name reference grafts unknown. Author as relative to a row (_author) — when the query returns a paging envelope the items[] offset is added for you. If an alias shadows an existing column the build throws, because the engine would silently overwrite that column at runtime (Xano convention: prefix with _).

Valuesc.int/text/bool/decimal/null/obj/array, c.now(), ref(var), inp(input), col(name), the context refs auth(path?)/env(name)/setting(name)/sys.*(), and out(name) for a parent-row column in an addon input. withFilters(value, fl.a(), fl.b()) attaches the value pipeline from a typed catalog of filters generated from the engine's own sources.

  • c.obj/c.array take plain JSON literals only. A nested tagged value (inp/ref/auth/c.*) is a compile error. For a computed object — a response, or an api.request params — use a record of values ({ count: ref("count") }). For a dynamic object argument use obj({...}), which builds a checked expression.

  • An obj({...}) member may carry a filter chain. That matters most for the null-safe drill: db.get binds null on a miss, so obj({ city: ref("row.address.city", { safe: true }) }) is the normal shape — no per-member s.set_var to hoist it out. c.now(), env() and sys.*() are members too.

  • A bare scalar works in any fl.* argument. fl.get("a.b", 0) encodes identically to fl.get(c.text("a.b"), c.int(0)); strings, numbers and booleans are all wrapped for you. Objects and arrays still need c.obj/c.array.

  • Some filters require an argument their own docs call optional. Filter arguments are positional, and a short call is refused by the engine before the filter runs — so fl.csv_encode() and fl.number_format() are compile errors here rather than a failure on a deployed endpoint. Which filters those are is probed, not declared: fl.round() is also documented optional and genuinely works. Pass every argument the signature shows without a ?; the runtime guard names the count if you reach it from JavaScript.

    withFilters(ref("rows"), fl.csv_encode(",", '"', "\\")), // not fl.csv_encode()
  • fl.csv_encode writes no header — fl.csv_create is the one that does. They read as interchangeable and are not. csv_encode emits each row's values in that row's key order with no normalization across rows, so rows whose keys differ in order or count misalign columns silently; nested cells are JSON-encoded, false writes empty, and a piped array of scalars collapses to a single line. csv_create takes the column names as its piped value and the data as its rows argument.

  • Only fl.fsort({ type: "number" }) sorts numerically. Every other comparator — including a spelling the engine does not recognize — sorts as case-insensitive text, silently and with no error, so [2, 10, 1] comes back [1, 10, 2]. A lexicographic sort agrees with a numeric one whenever the values share a digit count, so this looks correct on small data and goes wrong on real data: a "top N by score/distance/recency" endpoint returns the right rows in the wrong order. The union rejects the two plausible wrong spellings ("decimal", "int") outright.

    withFilters(ref("rows"), fl.fsort({ path: "score", type: "number", asc: true })),
  • col() does not resolve to a stored value inside a db.edit row. To read-modify-write a column — incrementing a counter — db.get the row first and pipe its bound value through a filter. col() evaluates to null there, so fl.add(1) computes null + 1 and the engine aborts.

    s.db.get({ table, fieldValue: inp("id"), as: "current" }),
    s.db.edit({ table, fieldValue: inp("id"), row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } }),

    That pair is not atomic — concurrent writers can lose an increment, and no atomic increment statement exists. A genuinely safe counter needs the arithmetic in the database via s.db.direct_query, whi