Browse the docs
Reference
Migrating to fuast v2
Upgrade a capsule written for the original fuast API. What stays, what changes, before-and-after code, and a prompt to hand an agent.
fuast v2 brings the capsule API to full parity with Lakebed's database API v1 and adds type-checked deploys. Most capsules need small, mechanical changes. Apps you deployed on v1 are moved to v2 for you, with their data: see Deployed v1 apps. Move their code to the v2 API when you next work on them.
Deployed v1 apps
When a fuast platform upgrades to v2, it migrates every deployed v1 app on its own, once, in the background:
- It rebuilds the app from the source your last
fuast deployuploaded, on the v2 runtime. Your code is not rewritten: itsfuast/serverimports point at a generatedserver/fuast-v1-compat.tsthat keeps v1 behavior (insert()returns the row,ctx.auth.userIdis"guest"for signed-out callers and"system"in schedules,withIndex()on an undeclared index filters the table, every field is optional, endpoints may write and may return aResponse). - Apps created from the v1 dashboard template picker had no stored source. They are rebuilt from the v1 template they were created from.
- Every row keeps its
id,createdAtandupdatedAt. Fields your rows carry that the schema never declared, and values stored with a different type than declared, becomejson()fields so nothing is dropped. - Uploaded files get v2 storage keys (
private/v1_...). Rows that stored a v1 key or file URL are updated to the new key. - Sharing, members, environment variables (dashboard and
.env.fuast.server), schedules and workspace tables carry over. - Type errors never block the migration. They are recorded on the app's log.
An app that cannot be rebuilt (for example one deployed by an old CLI that uploaded no source, or code that fails to load on v2) stays listed with Needs redeploy and the reason, in the dashboard and in fuast list. Its data is kept. Deploy it from its folder with bunx @revnu/fuast deploy (add --no-typecheck until you have moved it to the v2 API): that first v2 deploy imports its v1 rows and files into every table of the new schema that is still empty and accepts them. Anything that does not fit is logged with fuast logs.
Nothing from v1 is deleted. The platform keeps a copy of the v1 platform database and of every app database in v1-backup/ in its data directory.
To move a migrated app to the v2 API, work in its folder (your copy never had the compat file, so its imports already say fuast/server), follow the steps below, then fuast check and fuast deploy. Fields that became optional or json() keep their stored values; declare them the way you want and redeploy.
Self-hosting: the migration runs at platform boot (set FUAST_V1_MIGRATION=off to skip it) and can be run by hand with bun scripts/migrate-v1.ts --dry-run (report only) or bun scripts/migrate-v1.ts (--retry retries apps marked Needs redeploy).
What stays the same
- The capsule shape:
server/index.ts,client/index.tsx,.env.fuast.server,fuast.json. - Handlers are async and database calls are awaited.
table(...).index(name, fields),withIndex(name, q => q.eq(...)),order(),collect(),take(),first(),get(),update(),delete().schedule(cron, fn),ctx.email.send,ctx.team.table(name),ctx.env,ctx.log(...).fuast/uicomponent names.- Stored rows. A redeploy keeps every row and backfills new indexes.
What changed
| Before | v2 |
|---|---|
insert(doc) returned the row |
insert(doc) returns the new id. Call get(id) if you need the row. |
text() field |
Alias of string(). Prefer string(). |
ctx.auth.userId always a string |
string | null. Use ctx.auth.requireIdentity() or requireSignedIn(). |
ctx.auth.isGuest to block guests |
ctx.auth.requireSignedIn(), or auth: { requireSignIn: true } app-wide. |
Owner fields declared string() |
Declare them userId() so guest data follows a sign-in. |
Schedules saw userId === "system" |
Schedules see userId === null and displayName === "system". |
useQuery<T>("name", ...) with hand-written types |
const client = createClient<typeof app>(), then client.useQuery("name", ...) with inferred types. |
useMutation<[A, B]>("name") |
client.useMutation("name"). |
uploadFile(file) |
client.storage.upload(file, { public? }). uploadFile still works and uploads privately. |
h from fuast/client, hooks from fuast/client |
jsx: react-jsx with jsxImportSource: preact. Import hooks from preact/hooks. |
Single view held in useState |
Router, Routes, Route, Link from fuast/client. |
Endpoints at /a/<slug>/e/<path> |
Endpoints at <app url><path>. /e/<path> still works. |
Endpoints returned Response from json() / httpText() |
json(), text(), empty(), redirect() return fuast response objects. Do not construct a global Response. |
ctx.log(...) only |
Also ctx.log.info/warn/error(message, data). |
| Deploys skipped type errors | Type errors block fuast deploy. Run fuast check. |
| Queries refreshed by polling | Queries are live over a WebSocket. |
ctx.storage.url/get/delete |
Also ctx.storage.put(bytes, { contentType, public? }). |
ctx.team.table(name).all/insert/remove |
Also update(id, patch). |
| (none) | actions, paginate(), count(), number().optional(), .default(), id("table"), json(), ctx.workspace.members(), WebMCP tool options. |
Before and after
Server, before:
import { boolean, capsule, mutation, query, string, table } from "fuast/server";
export default capsule({
schema: {
todos: table({ text: string(), done: boolean(), ownerId: string() }).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) =>
ctx.db.todos.withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)).order("desc").collect()
)
},
mutations: {
addTodo: mutation(async (ctx, text: string) => {
const row = await ctx.db.todos.insert({ text, done: false, ownerId: ctx.auth.userId });
return row.id;
})
}
});Server, v2:
import { boolean, capsule, mutation, query, string, table, userId } from "fuast/server";
export default capsule({
schema: {
todos: table({ text: string(), done: boolean().default(false), ownerId: userId() })
.index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos.withIndex("by_owner", (q) => q.eq("ownerId", userId)).order("desc").collect();
})
},
mutations: {
addTodo: mutation(async (ctx, text: string) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos.insert({ text, ownerId: userId }); // returns the id
})
}
});Client, before:
import { h, useMutation, useQuery, useState } from "fuast/client";
type Todo = { id: string; text: string; done: boolean };
export function App() {
const todos = useQuery<Todo[]>("todos");
const addTodo = useMutation<[string]>("addTodo");
// ...
return <ul>{(todos ?? []).map((t) => <li key={t.id}>{t.text}</li>)}</ul>;
}Client, v2:
import { createClient } from "fuast/client";
import { useState } from "preact/hooks";
import type app from "../server/index";
const client = createClient<typeof app>();
export function App() {
const todos = client.useQuery("todos");
const addTodo = client.useMutation("addTodo");
// ...
if (todos === undefined) return null;
if (todos.length === 0) return <p>No todos yet.</p>;
return (
<ul className="transition-opacity duration-200 starting:opacity-0">
{todos.map((t) => <li key={t.id}>{t.text}</li>)}
</ul>
);
}Data notes
- Changing a field from
string()touserId()keeps the stored values. It only marks the field for guest upgrade. - Changing
text()tostring()changes nothing stored. - Adding
.default(false)to an existing field is safe. Adding a new required field to a populated table is not: make it.optional()or give it a default. - Code that used the row returned by
insertmust now use the id, orawait ctx.db.t.get(id).
Regenerate fuast-env.d.ts
v2 types are in a new fuast-env.d.ts. Old copies describe the v1 API and produce wrong type errors. Replace it from the installed CLI:
bunx @revnu/fuast typesfuast types rewrites fuast-env.d.ts and writes tsconfig.json only if it is missing.
Then make tsconfig.json match:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["fuast-env.d.ts", "server", "client", "shared"]
}Agent migration prompt
Give this to an agent at the root of an existing fuast capsule:
Migrate this fuast capsule to the fuast v2 API. Keep its behavior, access
rules, handler names, routes, and stored data.
1. Read AGENTS.md, server/, client/, and shared/ before editing.
2. Run bunx @revnu/fuast types to replace fuast-env.d.ts, and make tsconfig.json
include it.
3. Server:
- Declare user reference fields with userId() instead of string().
- Replace text() with string().
- Replace every use of ctx.auth.userId with const { userId } =
ctx.auth.requireIdentity() (or requireSignedIn() where the old code
rejected guests with ctx.auth.isGuest).
- insert() now returns the id. Fix code that used the returned row.
- Keep every ownership check. Do not trust ids or emails from arguments.
- Await or return every database call. Every read uses withIndex or
by_creation.
4. Client:
- Import type app from "../server/index" and create one
createClient<typeof app>() at module scope. Replace useQuery<T>,
useMutation<Args> with client.useQuery / client.useMutation and delete
the hand-written row types.
- Import hooks from preact/hooks. Remove h imports.
- Replace uploadFile with client.storage.upload.
- Render nothing while a query is undefined, render an explicit empty
state for [], and wrap loaded content in
className="transition-opacity duration-200 starting:opacity-0". Remove
"Loading..." text and ?? [] before lists.
5. Run bunx @revnu/fuast check and fix every error. Run bunx @revnu/fuast dev, exercise the
app, and confirm rows with bunx @revnu/fuast db dump.
Report the files changed, fields changed to userId(), and check results.