Schema changes
There are no migration files. Nothing generates one, nothing commits one, nothing replays one — the files under db/ are the only description of the schema you write, and a plan is computed live against whichever database is in front of you, as it is at that moment. That leaves exactly two rails and two verbs to learn: plan shows what would change and apply changes it, against the stack running on your machine; and palbase push carries the declaration to a linked project, applying the schema and activating the code in one request. This page is about what those two rails can and cannot express.
The workflow at a glance
# 1. Edit the declaration — e.g. add a `priority` column to todos
vim db/public.ts
# 2. Regenerate the types, so Database.public.* matches what you wrote
palbase build
# 3. See what it would take, against the stack in front of you
palbase db plan
palbase db apply
# 4. Read what shipping would do — code, runtime and schema in one place.
# The measurement is machine state, kept outside your checkout at
# ~/.palbase/checkouts/<hash>/plan.json — push reads it from there and
# will not run without it.
palbase stop
palbase plan
# 5. Ship it. The push carries that plan; it diffs db/public.ts against the
# project's own database and applies the change in the same request that
# activates the code.
palbase push
Step 3 is about the database in front of you and nothing else. Step 4 does its own diff, against a different database, and does not read anything step 3 wrote. There is no artifact travelling between them.
Where a plan comes from
A plan is not a text diff of two files, and there is no shadow or throwaway database involved. The stack evaluates db/public.ts with the same runtime that serves your controllers, introspects its own database through the system catalogs, and compares the two. That is why a plan covers type changes, policies, constraints and indexes, and why it sees a column somebody added by hand.
It is also why a plan is always about the database as it is right now. palbase db apply does not replay a plan you ran earlier: it computes its own, at the moment you run it, and applies that. The two commands share one function, so what db plan prints and what db apply does cannot drift apart by being written twice.
The local stack: plan and apply
palbase db plan, db apply and db query act only on the stack running on this machine — the one palbase start brings up. There is no cloud db command, and a checkout linked to a cloud project is refused by name:
`palbase db` works on the stack running on this machine, and there is not one.
palbase start bring one up here, then try the change against it
palbase push send db/public.ts, the code and the config to the linked project
Naming only the first way out would send somebody who meant production to a local database, let them watch it succeed, and let them believe it.
An unchanged schema says so, and says nothing else:
✓ the database matches db/public.ts
and db apply with nothing to do says ✓ the database already matches db/public.ts rather than inventing a change.
What a plan looks like
One line per change, in the stack's own words:
rename column todos.notes → remarks (keeps its data)
create table drafts
add column todos.priority text
enable RLS todos
add policy todos_owner on todos
change policy todos_owner on todos
drop policy todos_public on todos
add constraint todos_title_len on todos
create index todos_user_idx on todos
palbase db plan --detailed-exitcode exits 0 when the database is in sync and 2 when the plan would change something — 2 rather than 1 because a deliberate exit code cannot be impersonated by a subprocess failure.
Changes that take data away
A drop is named individually, with the rows it would cost counted from the live database, under a heading naming the flag that would run it:
add column todos.priority text
these DESTROY data and are not applied without --approve:
drop column todos.notes (168 rows, 168 non-null)
⚠ drop todos.notes — 168 value(s) in 168 row(s)
the ⚠ changes take data away — `palbase db apply --approve` runs them too
The split follows what a change actually costs, not what it is called. A drop of a column holding no values, or of a table holding no rows, destroys nothing and is applied without --approve — listed under its own heading so the tool does not warn about something it is about to do anyway:
these remove something that holds no values, and ARE applied:
drop column todos.draft_note (168 rows, 0 non-null)
Read the counts rather than the symbol: 0 non-null is the half of the plan that runs regardless.
db apply never prompts. It reads db/public.ts, posts it, and prints what the stack did, so it behaves the same in a script as in a terminal. What stands in for a prompt is a refusal — the plan itself, with its counts, printed before the command stops:
refused: this would take data away — run it again with --approve if that is what you mean
Nothing was applied and no transaction was opened. --approve is the flag that runs it. There is no --yes anywhere on this rail, and no separate production mode: the local stack has one behaviour, and a project's schema does not move through db at all.
An apply reports what it did, which reads differently from a plan for exactly that reason:
renamed todos.notes → remarks (kept its data)
created table drafts
added column todos.priority text
enabled row-level security on todos
added policy todos_owner on todos
DROPPED column todos.draft_note (168 rows, 0 non-null)
✓ applied
The project: palbase push
palbase push is the only thing that changes a project's schema. It posts the checkout to that project's own management API in one request, which applies the schema and activates the code together — two calls would leave a window in which the code is new and the tables are not.
▸ todoapp/main
schema:
added column todos.priority text
live: 37 endpoint(s), 57788ca062dc
The schema: block appears only when db/public.ts differs from the project's database, and it reports what the apply did. An unchanged schema costs nothing.
palbase plan shows both halves — code and schema — against the linked project and writes nothing.
When a push refuses
Nothing is swapped by a refused push: the previous release keeps serving.
A schema change that takes data away comes back itemised with row counts and needs the same word the local rail uses:
this push would remove data:
drop column todos.notes (1284 rows, 903 non-null)
drop table drafts (17 rows)
repeat with --approve when that is what you mean
--approve is one flag covering every dangerous thing a push can do — a data-removing schema change, and replacing a secret the target already holds — because a person facing a refusal should not have to work out which of several flags this particular one wanted.
Refusals the stack decided print their own sentence verbatim, and every one of them leaves the live release untouched:
| Code | What it means |
|---|---|
tests_failed | the tests failed against the new release, so it was discarded |
tests_timed_out | the test run did not finish |
schema_incompatible | the schema change and the running release cannot both be true |
candidate_failed | the new release did not come up |
test_identities_unavailable | the test identities the run needs were not there |
A push at a local target is refused outright, because that stack already serves your directory and a push there would activate a version nothing loads:
this checkout is pointed at the stack running on this machine, which already
serves this directory — a push here would activate a version nothing loads.
palbase stop point it back at the project, then push
palbase db apply if it was the schema you wanted applied here
Renaming a column
A comparison shown two names for one column cannot know they are the same column — from its side they are not — so it would drop one and add the other, and the data would go with the drop. Say what you mean:
import { defineTable, text, uuid } from "@palbase/backend";
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
// was: notes
remarks: text().nullable().renamedFrom("notes"),
},
});
rename column todos.notes → remarks (keeps its data)
Renames run first, before anything is compared: the ALTER TABLE … RENAME COLUMN is executed, the database is read again, and the diff computed against that fresh reading sees one name on both sides and emits nothing further about the column. Nothing is copied and nothing is lost. The column the rename produces is deliberately not also listed as an addition — a plan whose lines do not all happen teaches the reader to stop reading them, which is exactly the habit that makes a destructive line stop registering.
Once applied the annotation is inert, so delete it whenever you next touch the file, or leave it. Renaming two columns into each other is not supported: if both names are live they are treated as two ordinary columns rather than guessed at.
What the rail will not change for you
The comparison works by name: tables and columns added or dropped, RLS state, policies, constraints, indexes and foreign keys. A property change on a column that already exists is not applied. A change on this list is not slow to arrive — it is not coming, and the way through is a rename-and-backfill or a raw() object with a name of its own.
| You change | What happens |
|---|---|
| a column's type | not applied; the plan lists it under these are NOT applied by the declarative rail: |
| a column's nullability | not applied, and not shown in the plan |
| a column's default | not applied |
| an index's columns, same name | not applied — indexes are matched by name only |
removing an index, unique, check or raw object | not applied; those are one-way |
a check expression, same name | applied as DROP CONSTRAINT + ADD CONSTRAINT |
| a policy — added, edited or removed | applied; a removed policy is dropped, an edited one recreated |
| removing an extension | not applied; extensions are added only |
A type change at least announces itself:
these are NOT applied by the declarative rail:
todos.count — type mismatch: db/public.ts declares text but the database has integer
write an explicit migration (auto-migrate does not ALTER column types)
Warning: A nullability change is the one that announces nothing. Switching an existing column between
.notNull()and.nullable()produces no plan line, no warning and no error, and the nextpalbase db planwill report that the database matchesdb/public.tswhen it does not. Do it as a rename instead: add the column you want under a new name, backfill it, then drop the old one.
The hint that type change prints — "write an explicit migration" — is a stale sentence from an older rail. There is no migration to write. The move is the same rename-and-backfill.
Starting over: palbase start --reset
There is no db reset. The database db plan and db apply act on is the disposable one inside the stack on this machine, and throwing it away is the stack's job:
palbase start --reset
--reset takes the containers down with their volumes, so the database comes back empty, and the boot re-applies every Palbase module's own schema — auth, storage, flags and the rest. Your tables are not in that set. Nothing applies db/public.ts at boot, so run palbase db apply afterwards to build your schema back from the declaration. Because it plans against a database it has just emptied, that rebuild is purely additive.
palbase stop is not a reset — the database survives it.
A cloud project has no reset verb at all. Its schema moves only by palbase push, where a change that removes data needs an explicit --approve, so there is no single command that drops a project's tables.
Things the typed schema cannot say
Use raw() for DDL the DSL has no words for — EXCLUDE constraints, views, range types, and PL/pgSQL functions you call yourself:
import { defineTable, raw, text, uuid } from "@palbase/backend";
const bookings = defineTable("bookings", {
columns: {
id: uuid().primaryKey().defaultRandom(),
room: text().notNull(),
slot: text().notNull(),
},
raw: [
raw(
"bookings_no_room_clash",
"ALTER TABLE bookings ADD CONSTRAINT bookings_no_room_clash EXCLUDE USING gist (room WITH =, slot WITH =)",
),
],
});
Most of what this list used to hold is typed today — a partial or expression index is index(n).where(…), a partial UNIQUE is index(n).unique().where(…), a composite FOREIGN KEY is foreignKeys, a trigger that refuses a write is guards, a conditional FK is freeze, a one-off data fix is backfills, and dropping a constraint on purpose is dropConstraints. Reach for those first: they are diffed, validated against the live database before the push, and their mistakes answer you where you write them.
raw(name, up) takes two arguments. There is no down: one used to be accepted and fed a generated down-migration that nothing ever ran, so an author who wrote one believed they had a teardown they did not have. Drop what a raw() created by declaring the drop as its own raw(), or with dropConstraints.
A raw() object is tracked by name, not by comparing its body, so a changed body needs a new name or an explicit drop. Two raw() objects sharing a name are refused where you declare them, rather than failing later inside a statement you did not write. And the body now passes an AST authority gate: statements that change who can do what — roles, ownership, grants, search_path — are refused before any DDL runs.
A column type the DSL does not have is a real limit rather than an inconvenience with a workaround. raw() attaches to a declared table, so a table needing a tstzrange, an array or a bytea column has nowhere to be declared — and a table the rail cannot see is a table the plan would propose to drop. If you need one, say so: the typed column set is what gets extended.
Verbs that no longer exist
Readers arriving from an older tutorial are usually looking for one of these:
| Gone | What replaced it |
|---|---|
palbase db diff | nothing generates a migration file; the plan is computed live |
palbase db check | the question has no meaning once the schema is declarative |
palbase db types | palbase build writes palbase-env.d.ts and everything else derived |
palbase db reset | palbase start --reset |
db/migrations/*.sql | there is no such directory, and nothing reads one |
a git push deploy | palbase push — there is no repository-driven rail on this cloud |
The pre-push git hook is version 3 and runs palbase build only; v3 dropped the old palbase db check half for the same reason the command went.
Related
- Schema —
defineSchema, the column builders, and what each field can express - Database — the typed surface your schema powers
- Row-Level Security — why RLS is on by default, and how a policy change is applied
- Database — the three
palbase dbverbs in full, includingdb query - Deploying —
palbase push, what travels, and the rest of the refusals - Deploying — the platform's side of the same rail
Platform migrations
The tables above are yours. Alongside them, in the same database, live the
schemas the platform owns — auth, storage, palnotify and the rest. Those
belong to @palbase/backend, and they change when you move to a newer version
of it.
You do not run those migrations. When your checkout requires a newer
@palbase/backend than the project is running, palbase plan shows you which
platform schemas will move:
runtime
36.0.2 → 37.0.2 (migrations run against the live project before the new version takes over;
the switch is a restart of seconds and requests wait)
auth: expand 13→14, contract 0→1
and palbase push runs them — while your project is still serving, and before
the new version takes over. Your tables are not touched: a platform migration
only ever changes platform schemas.
Nothing here asks anything of you. If one of these migrations cannot run, the upgrade stops before it begins, your project keeps serving exactly as it was, and the report that comes back is addressed to us.