Quickstart

This walks you from an empty directory to a running migration. Because the driver runs on embedded PGlite, you do not need to install or start a Postgres server to follow along.

PostgreSQL via PGlite (embedded) Node 18+ or Bun A terminal

Create a project

npm create schemic@latest

create schemic is interactive: pick the PostgreSQL driver when it asks, and it installs everything (including the embedded @electric-sql/pglite engine) and scaffolds your project. Run it in an empty directory for a new project, or inside an existing one — it merges the @schemic dependencies and a db script into your package.json without touching the rest of your setup. It scaffolds:

  • schemic.config.ts — a postgresConnection pointed at a local PGlite data dir
  • database/schema/tables.ts — a sample schema
  • database/seed/index.ts — the seed script
  • database/migrations/meta/_snapshot.json — migration state
  • .env.example — connection environment template

Read the generated schema

Open database/schema/tables.ts. This is the single source of truth; the DDL below is derived from it.

database/schema/tables.ts TypeScript
import { defineTable, s, sqlExpr } from "@schemic/postgres";

export const user = defineTable("user", {
  email: s.varchar(255).$unique(),
  name: s.text(),
  age: s.smallint().optional(),
  createdAt: s.timestamptz().$default(sqlExpr("now()")),
});

It generates this DDL:

Generated DDL PostgreSQL
CREATE TABLE "user" (
  "id" text PRIMARY KEY,
  "age" smallint,
  "createdAt" timestamp with time zone NOT NULL DEFAULT now(),
  "email" varchar(255) NOT NULL,
  "name" text NOT NULL
);
CREATE UNIQUE INDEX "user_email_key" ON "user" ("email");

A few things to notice, each of which you will use constantly:

  • You never declared id — Schemic adds an implicit "id" text PRIMARY KEY. Declare your own with .primaryKey(...) to replace it.
  • s.varchar(255) maps to a varchar(255) column, and .$unique() adds a CREATE UNIQUE INDEX.
  • .optional() makes age nullable; required fields emit NOT NULL.
  • .$default(sqlExpr("now()")) emits a database-side DEFAULT from a raw SQL expression.

Generate your first migration

schemic gen diffs your schema against the recorded snapshot and writes a migration for the difference.

shell
npx schemic gen initial
2 changes — 4 Fields, 1 Index.
 
-- schemic:up
CREATE TABLE "user" (
"id" text PRIMARY KEY,
"age" smallint,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"email" varchar(255) NOT NULL,
"name" text NOT NULL
);
CREATE UNIQUE INDEX "user_email_key" ON "user" ("email");
 
-- schemic:down
DROP INDEX IF EXISTS "user_email_key";
DROP TABLE IF EXISTS "user" CASCADE;
 
✓ 20260626114226_initial.sql (+2 up / 2 down)

The migration is plain, reviewable SQL with forward and rollback bodies. Open it before applying anything.

Apply it to the database

schemic migrate (alias up) applies every pending migration in order, against the embedded PGlite database from your config — no server to start.

shell
npx schemic migrate
↑ 20260626114226_initial
 
✓ Applied 1 migration.

Confirm what is applied with schemic status:

shell
npx schemic status
✓ applied 20260626114226_initial
 
1 migration, 0 pending.

Evolve the schema

Change the schema and Schemic writes the next migration for just the delta. Add a column:

database/schema/tables.ts TypeScript
export const user = defineTable("user", {
  email: s.varchar(255).$unique(),
  name: s.text(),
  age: s.smallint().optional(),
  bio: s.text().optional(),
  createdAt: s.timestamptz().$default(sqlExpr("now()")),
});
Shell
npx schemic gen add_bio
npx schemic migrate

The second migration contains only the new column (ALTER TABLE "user" ADD COLUMN "bio" text) — it does not re-create the table.

Read and write rows

Your table definition carries codecs that bridge app values and the Postgres wire format. encode builds the payload you write; decode validates a returned row and converts it to app values (so a timestamptz comes back as a Date). See Read & write rows for the full pattern against the PGlite client.

Where to go next