Use the Database
A server app has a database: ctx.db in every route handler and view loader. It runs SQL with ? placeholders on SQLite or Postgres, and it opens the first time something uses it, so an app that never touches it never creates one.
Query it
const notes = await ctx.db.query<{ id: string; body: string }>(
'SELECT id, body FROM notes WHERE owner_id = ? ORDER BY created_at DESC',
[ctx.user.id],
);
const note = await ctx.db.get('SELECT body FROM notes WHERE id = ?', [id]); // first row or undefined
const { changes } = await ctx.db.execute('DELETE FROM notes WHERE id = ?', [id]);
await ctx.db.transaction(async (tx) => {
await tx.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, from]);
await tx.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, to]);
});- A transaction commits when its function resolves and rolls back when it throws. One inside another is a savepoint.
- Dates are stored as ISO strings and booleans as
1and0, the same on both databases. - Jobs and scripts use the same database:
import { db } from '@webstir-io/webstir-backend/db'.
Choose the database
DATABASE_URL picks it:
DATABASE_URL |
Database |
|---|---|
| unset | SQLite at data/app.sqlite |
file:./data/app.sqlite |
SQLite, relative to the app |
postgres://user:password@host/database |
Postgres |
SQLite opens with WAL, foreign keys and a 5-second busy timeout. A transaction takes the write lock when it begins, so a script writing to the same file waits for it instead of failing. It suits most apps, in production too, on a disk that persists across deploys (see Docker Deployment).
Change the schema with migrations
webstir add-migration create-notes --workspace "$PWD"This writes src/backend/migrations/0001-create-notes.sql. Write the change in it:
CREATE TABLE notes (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
body TEXT NOT NULL,
created_at TEXT NOT NULL
);- Migrations apply when the server starts, in
watch,testand production, in file name order, each once, in a transaction with its record. - A migration that fails stops the server with its file and the database's error.
--tswrites a.tsmigration exportingup(db), for a change that needs code.- Migrations only go forward: fix a mistake with a new one.
- With sign-in, a migration can reference
users (id): Webstir creates its tables first.
webstir migrate --status --workspace "$PWD" # each migration, and when it was applied
webstir migrate --workspace "$PWD" # apply pending ones now, without starting the serverUse an ORM or query builder
ctx.db runs SQL. For Drizzle, Kysely or another tool, point it at the same DATABASE_URL; Webstir's migrations and yours can share the database.