Palbase
Sign inGet started

CLI

Database

palbase db has three verbs — plan, apply and query — and every one of them acts only on the stack running on this machine, the one palbase start brings up. It is not a cloud command wearing a safety belt: there is no cloud db command, and a checkout linked to a cloud project is refused — with the same fixed message an unlinked directory gets, which names neither the project nor the link. There are no migration files anywhere in the product — the files under db/ are the declaration, a plan is computed live against the database as it is at that moment, and what carries a schema change to a project is palbase push, which applies the schema and activates the code in one request.

Quick example

# 1. Edit the declaration — e.g. add a `priority` column to todos
vim db/public.ts

# 2. See what it would take, against the stack in front of you
palbase db plan

# 3. Do it, here
palbase db apply

# 4. Ship it. `push` refuses while this checkout points at the local stack,
#    so stop it first; the push then diffs db/public.ts against the project's
#    own database and applies the change in the same request that activates
#    the code.
palbase stop
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.

It acts on the local stack, or it refuses

Every verb here resolves the checkout's target the way every other target-relative command does — the machine-local record palbase start writes and palbase stop removes, then the committed palbase/project.json — and stops if what it finds is anything but a local stack. A checkout linked to a cloud project and a directory with no stack up at all get the same refusal, and it names both ways out:

`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 would send somebody who meant production to a local database, let them watch it succeed, and let them believe it.

Note: The second line of that message is stale in one word. A push carries code and schema; settings do not travel. Storage buckets, flag definitions, notification senders and auth settings are written straight to the stack by their own commands and take effect immediately — see Stack Settings.

Each verb prints its destination to stderr before it does anything, the way every remote verb does — commentary, not output, so a piped or redirected run still says where it went:

▸ http://127.0.0.1:54321 (local)

The address is whatever port palbase start allocated for this project's stack; only the edge container publishes one, and it is remembered so a restart lands on the same address.

No cloud login is involved. The stack holds its own key in ~/.palbase/stacks/<group>/, and the CLI reads it from there — which is also why palbase logout does not break these three verbs.

With no declaration to read:

no db/public.ts in this directory — `palbase db` reads the schema this project declares

How it reaches the database

It does not. The stack's Postgres publishes no port on your machine — the edge is the only container that does — so the CLI never opens a database connection. plan and apply post the files under db/ to the stack's own management API (/v1/management/schema/plan, /v1/management/schema/apply), and query posts JSON to /v1/management/sql. The stack evaluates the declaration with the same runtime that serves your controllers, introspects its own database, and answers.

palbase db plan

Shows what it would take to make the local database match db/public.ts. Applies nothing, writes nothing.

palbase db plan
  add column     todos.priority text

An unchanged schema says so, and says nothing else:

✓ the database matches db/public.ts

The plan is not a text diff of two files. The stack evaluates db/public.ts into a declaration, introspects the database it is running against as it is at that moment, and compares the two — which is why it covers type changes, policies, constraints and indexes, and why it sees a column somebody added by hand.

The change lines are the stack's own words, one per change, in a fixed set of shapes:

  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

Flags

FlagWhat it does
--detailed-exitcodeexit 0 when in sync and 2 when the plan would change something
# fail a job when the local schema has drifted from db/public.ts
palbase db plan --detailed-exitcode

2 rather than 1 is the convention planners already use, and it rides a deliberate-exit interface a subprocess failure cannot impersonate — so "the plan would change something" is never confused with "the plan crashed".

Changes that take data away

A drop is named individually, with the rows it would cost counted from the live database, under a heading that names the flag which would run it. The CLI then repeats each drop as a line of its own and closes with the sentence that matters:

  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

Each drop appears twice on purpose, from two different places: the stack's own plan lines carry the heading and the raw counts, and the CLI's summary is what the closing sentence refers to. Two lines, one drop.

A count is what turns "drop column" into a decision. The split follows what a change actually costs rather than what it is called:

  these remove something that holds no values, and ARE applied:
  drop column  todos.draft_note (168 rows, 0 non-null)

A drop of a column holding no values, or of a table holding no rows, is applied without --approve — it destroys nothing. It is still listed, and the CLI still marks it , so read the counts rather than the symbol: 0 non-null is the half of the plan that runs anyway.

Differences the declarative rail will not make itself are named rather than skipped, with the reason and, where there is one, a hint:

  these are NOT applied by the declarative rail:
  todos.count — type mismatch: db/public.ts declares text but the database has integer

Renames

A column that says where it came from is renamed, not dropped and re-added:

<!-- Bir KOLON bildirimi — tablonun `columns` nesnesinin içinde durur, tek başına bir modül değildir. Kapı bunu derleyemez; derleyebilseydi `text()` etiketli bir deyim olarak geçer ve hiçbir şey ölçmezdi. --> <!-- fragment -->
remarks: text().nullable().renamedFrom("notes"),
  rename column  todos.notes → remarks (keeps its data)

Renames are printed first and named as renames, and the column they create is not also listed as an addition. Without that, the plan would say "add column remarks" for a change that moves a column's data, and the reader would agree to something else entirely. .renamedFrom() is inert once applied, so it can stay in the declaration.

palbase db apply

Makes the local database match db/public.ts, in one transaction.

palbase db apply
  added column todos.priority text
✓ applied

Nothing to do says so rather than inventing a change:

✓ the database already matches db/public.ts

The summary reports what the apply did, not what a plan proposed. Its lines read differently from a plan's for that reason, and the destructive ones are shouted:

  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
  created index todos_user_idx on todos
  DROPPED column todos.draft_note (168 rows, 0 non-null)
✓ applied

There is no prompt. db apply reads db/public.ts, posts it, and prints what the stack did — it never reads stdin, so it behaves the same in a script as in a terminal. What stands in for a prompt is a refusal: a plan that would take data away comes back as a 409 carrying the plan itself, which the CLI prints before it stops.

  ⚠ drop todos.notes — 168 value(s) in 168 row(s)

the ⚠ changes take data away — `palbase db apply --approve` runs them too
refused: this would take data away — run it again with --approve if that is what you mean

The refusal is the plan, with the counts, rather than a sentence saying a count exists. Nothing was applied, and the transaction was never opened.

Flags

FlagWhat it does
--approvealso run the changes that take data away

--approve appends ?approve=true to the apply. It is the same word palbase push --approve uses against a project — the flag is --approve, and there is no --yes anywhere on this rail.

palbase db query

One read-only statement, and the rows it returns. No Docker needed — it goes through the stack.

palbase db query 'select id, title from todos limit 5'
id  title
1   buy milk
2   ship the thing

2 row(s), 3ms

It runs the statement inside a READ ONLY transaction, so anything that would write is refused by Postgres itself — including a write hidden in a CTE, a function or a DO block. That is a property of how the statement is executed, not a guess about what it says; deciding from the text whether a statement writes is a parser somebody eventually gets around. Change the schema by editing db/public.ts and running palbase db apply; ad-hoc DDL would put the database ahead of its declaration and the next push would refuse.

FlagDefaultMeaning
--limit <n>200how many rows to bring back

The CLI always sends a limit, so an unflagged db query brings back at most 200 rows whatever the statement's own limit says. The stack caps what you may ask for at 1000 and enforces that itself, because a limit only the client respects is not a limit. A result that hit the ceiling says so rather than letting you draw conclusions from what is missing:

200 row(s), 12ms — cut at the limit, there are more

Rows are printed as columns in order, not as objects: SQL permits two columns of the same name and an object would silently drop one. NULL prints as NULL, distinct from an empty string, and a structure keeps its JSON rather than being flattened into something no longer parseable.

A statement the database rejects comes back in the database's own words, with the status:

no statement in the request body (400)

A rewritten message would be less true and no kinder — "syntax error at or near FROM" locates the mistake, and "the query failed" does not.

Starting over: palbase start --reset

There is no db reset. The database a db verb acts on is the disposable one inside the stack on this machine, and throwing it away is the stack's job:

palbase start --reset
▸ removing the local database
▸ starting todoapp
▸ applying every module's schema

--reset takes the containers down with their volumes, so the database comes back empty. The boot then re-applies every Palbase module's own schema — auth, storage, flags and the rest — because the stack refuses to serve a database that has not been migrated.

Your tables are not in that set. Nothing applies db/public.ts at boot, and nothing applies it when you save: a save rebuilds your controllers, which is a different thing. After a reset, run palbase db apply to build your schema back from the declaration.

palbase stop is not a reset — the database survives it. It removes that machine-local record, which is what points every verb back at the linked project.

No db verb reaches a cloud project, and none of this resets one.

Opening a psql session

The local stack's Postgres has no port on your machine, so there is nothing for psql -h 127.0.0.1 or a GUI to connect to. Open the session inside the container:

docker exec -it palbase-<group>-postgres-1 psql -U palbase palbase

The user and the database are both palbase. <group> is the name palbase start prints beside ▸ starting — the linked project's group when there is one, and the directory's name otherwise. If you have forgotten it, ask docker:

docker ps --filter name=-postgres-1 --format '{{.Names}}'

Not docker compose exec: the stack's compose file lives under ~/.palbase/stacks/<group>/ as docker-compose.dev.yml, not in your project and not under a name compose discovers on its own — so docker compose run from your project answers no configuration file provided.

db query and the psql session are not the same tool. The psql session is a full session: it writes, and it is the one to reach for when you want to fix a row by hand.

How a schema change reaches a project

palbase push, and only palbase push. There are no migration files to generate, commit or run: the push sends the checkout, the project's stack diffs db/public.ts against its own live database, applies the result, and activates the code — one request, so the code never serves against a schema older than itself. An unchanged schema costs nothing.

▸ todoapp/main
schema:
  added column todos.priority text
live: 37 endpoint(s), 57788ca062dc

A schema change that would take data away comes back as a 409, itemised with row counts, and needs the same word:

this push would remove data:
  drop column  todos.notes (1284 rows, 903 non-null)
repeat with --approve when that is what you mean

palbase plan shows both halves — code and schema — against the linked project and writes nothing. See Deploying for the whole rail, and Schema changes for what the diff can and cannot express.

Verbs that are gone

The v1 db group had five more verbs. They were removed in the v2 cutover with no shims and no aliases, because a schema that is declared has no use for them:

GoneWhat replaced it
palbase db diffnothing generates a migration file; the plan is computed live
palbase db checkthe question has no meaning once the schema is declarative
palbase db typespalbase build writes palbase-env.d.ts, and produces everything derived
palbase db resetpalbase start --reset
cloud-targeting db verbspalbase push

palbase build prints the type file when it lands it:

✓ palbase-env.d.ts

or ✓ palbase-env.d.ts (unchanged). It is generated from db/*.ts and augments the SDK's Tables interface, which is what types Database.public.* with no import and no generic — so it belongs in the checkout and is not edited by hand.

Two shipped strings still name palbase db types, which does not exist. palbase doctor, when Node is missing, prints:

  ✗ node       not found on PATH — `palbase build` and `palbase db types` need Node.js

and the generated palbase-env.d.ts carries a header comment saying it is regenerated on every palbase db types.

Warning: Both of those are stale strings in the tooling, not instructions. The command that regenerates the type file is palbase build.

  • Running It Locallypalbase start and palbase stop, the four containers, and the stack every db verb acts on
  • Deployingpalbase push, and how a schema change reaches a project
  • Linking a Checkout — which project and environment a command acts on
  • Schema changes — how db/public.ts is diffed and applied
  • SchemadefineSchema, the column builders, and the extension allowlist
  • Row-Level Security — why RLS defaults to on, and what a table with no policies returns
  • Deploying — the platform's side of the same rail
  • Overview — the full command surface and the verbs that were removed