# Migrating to fuast v2

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](#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 deploy` uploaded, on the v2 runtime. Your code is not rewritten: its `fuast/server` imports point at a generated `server/fuast-v1-compat.ts` that keeps v1 behavior (`insert()` returns the row, `ctx.auth.userId` is `"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 a `Response`).
- 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`, `createdAt` and `updatedAt`. Fields your rows carry that the schema never declared, and values stored with a different type than declared, become `json()` 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/ui` component 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:

```ts
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:

```ts
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:

```tsx
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:

```tsx
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()` to `userId()` keeps the stored values. It only marks the field for guest upgrade.
- Changing `text()` to `string()` 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 `insert` must now use the id, or `await 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:

```sh
bunx @revnu/fuast types
```

`fuast types` rewrites `fuast-env.d.ts` and writes `tsconfig.json` only if it is missing.

Then make `tsconfig.json` match:

```json
{
  "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:

```text
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.
```
