Schema changes
What happens to existing data when you change the schema and redeploy, and how to move an older fold to the async database API.
How a schema change is applied
Every deploy carries the full schema. When you redeploy, Fold checks every stored row against the new schema and rebuilds every index, in one step:
- If every row fits, the new version goes live.
- If any row does not fit, the deploy is rejected and the previous version stays live with its data unchanged. The CLI prints the reason:
The database could not switch to this schema: Missing value for tasks.priority
The local dev server applies the same rules when it rebuilds after a file change. If a change does not fit the local data, the rebuild fails, the old build keeps running, and the error appears in npx @aozumi/fold logs --port 3000. Restarting the dev server clears local data.
What is allowed
| Change | Result on existing rows |
|---|---|
| Add a table | Allowed. |
Add a field with .optional() | Allowed. Old rows have no value. |
Add a field with .default(value) | Allowed. Old rows read as value. Nothing is rewritten. |
| Add a required field (no default, not optional) | Rejected if the table has rows: Missing value for <table>.<field>. Allowed on an empty table. |
| Make an optional field required | Rejected if any row lacks a value, unless you also add .default(value). |
Change a field's kind, such as string() to number() | Rejected if any row holds a value of the old kind: Expected <table>.<field> to be a number. |
| Change or add a default | Allowed. Rows without a stored value read the new default. |
| Remove a field | Allowed. Values already stored are not erased. |
| Add, change, or remove an index | Allowed. Indexes are rebuilt from the rows. |
| Remove a table | Allowed, and every row in that table is deleted. This cannot be undone. |
| Rename a table | Same as removing the old table and adding a new one: the old rows are deleted. |
| Rename a field | Same as removing the old field and adding a new one: rows do not carry the value over. |
Also:
userId()values of guests who have since signed in are updated to the account's user ID during the switch.- Pagination cursors issued before the switch stop working:
Pagination cursor belongs to a stale schema.Clients usingusePaginatedQuerystart over. - Open pages pick up the new version after a redeploy.
Safe patterns
Add a required field to a table that has data:
- Add it with
.default(value), or with.optional()and fill it in. - Deploy.
- If you need a stored value on every row, write a mutation that updates rows a page at a time with
paginate(), and call it until it reports it is done. Each call can write at most 1,000 rows.
Rename a field without losing data:
- Add the new field as
.optional(). Keep the old one. - Deploy, then run a mutation that copies the old value into the new field.
- Switch reads and writes to the new field. Deploy.
- Remove the old field. Deploy.
Never remove or rename a table that holds data you want to keep.
Before deploying a schema change to a fold that has real users, check it against a copy of the change in local dev with some rows already inserted.
Older folds with synchronous database calls
Fold's database API is async and index-based. Code written against the older synchronous, scan-based calls must be updated. Anonymous builds reject the old calls in server code, and at runtime they throw.
| Old call | Replace with |
|---|---|
A database call used without await | await it, or return it. Every ctx.db method returns a Promise. |
where(...) (full scan) | Declare .index(name, fields) on the table and use withIndex(name, (q) => q.eq(field, value)). |
orderBy(...) (in-memory sort) | Use the index order and .order("asc") or .order("desc"). To sort by a field, put it in an index. |
limit(n) | .take(n), .first(), or .paginate(...). |
all() | .collect(), .take(n), .first(), or .paginate(...). |
Before and after, for a per-user list sorted newest first:
// Schema: add an index on the field you filter by.
todos: table({ text: string(), ownerId: userId() }).index("by_owner", ["ownerId"])
// Query
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
})
Prompt to give an agent for this migration:
Update this fold to the async, index-based Fold database API described at docs.aozumi.dev/fold/database/.
Await every ctx.db call. Replace where/orderBy/limit/all with declared indexes and withIndex, order, take, first, collect, or paginate.
Add an .index(...) for every field used as a filter. Use userId() for owner fields.
Then run `npx @aozumi/fold build --target anonymous` and `npx @aozumi/fold dev`, and fix every error.
Anonymous builds also reject any .all( call in server code, not only on ctx.db. Promise.all(...) is fine.