Database
Drizzle ORM against Postgres. The block exports the client (db) and a schema split
into two fragments — that split is what makes the Toolkit consumable by a new
project without dragging the example's domain along.
The two fragments
The composition point is tiny — live code:
/**
* The example app's composed schema: the reusable foundation (auth + mailbox)
* plus the app's own domain tables. A NEW app keeps the foundation import and
* swaps `./app` for its own fragment — then owns its migrations from there
* (`drizzle-kit generate` reads whatever this module exports).
*/
export * from "./foundation";
export * from "./app";The dependency rule: the foundation never references app tables. App tables
import user and friends from the foundation, never the other way around. A new
project keeps foundation.ts and replaces app.ts with its own entities — see
Create your own project.
What the foundation contains
| Table | Purpose |
|---|---|
user, session, account, verification | Better Auth's tables (its default names) |
mailbox | every email the mailer records — the outbox |
audit_log | the change history (scope without FK — history outlives its subjects) |
app_config | runtime settings for the config block |
The user table, included live — note isSiteAdmin (Django's is_staff) and
deactivatedAt (Django's is_active, inverted):
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
isSiteAdmin: boolean("is_site_admin").notNull().default(false),
deactivatedAt: timestamp("deactivated_at"),
// Free-form per-user key/value store. Used by `user_info` tasks to persist
// collected field values (keyed by `task:<id>`); other future profile data
// can live here too.
metadata: jsonb("metadata"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});Migrations
Same mental model as makemigrations / migrate, with a readable artifact:
cd example
bun run db:generate # diff the schema → writes plain SQL into packages/db/drizzle/
bun run db:migrate # apply pending migrationsReview the generated SQL like any other code. Two practical notes:
- Plain-text enum columns (e.g. the mailbox
kind) are code-only changes — adding a value needs no migration. db:generaterunning clean ("No schema changes") is the proof a refactor didn't drift the schema — we use that as a gate after schema file moves.
Querying
Both styles are available; the relational API reads like the ORM you know:
const row = await db.query.user.findFirst({ where: eq(user.email, email) });
const rows = await db
.select({ id: project.id, name: project.name })
.from(project)
.where(eq(project.workspaceId, ws.id))
.orderBy(asc(project.name));Types are inferred end to end — row is User | undefined, no casts. Raw SQL via
the sql template exists as the escape hatch and is the one place to review for
injection the way you would review raw() in Django.
Geodata (PostGIS)
The GeoDjango replacement is the same database with the PostGIS extension — no
extra packages, no separate GIS layer. Locally the compose file builds a
postgres-with-PostGIS image; on Azure the extension is allowlisted through Bicep
(azure.extensions = POSTGIS) and created by a migration.
A geometry column is a first-class Drizzle column, and the spatial queries are
sql fragments against the GIST index:
export const place = pgTable(
"place",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
name: text("name").notNull(),
location: geometry("location", { type: "point", mode: "xy", srid: 4326 }).notNull(),
createdById: text("created_by_id").references(() => user.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [index("place_location_gist").using("gist", t.location)],
);export const sandboxNearbyPlaces = createServerFn({ method: "POST" })
.inputValidator(
z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
radiusKm: z.number().positive().max(20_000),
}).parse,
)
.handler(async ({ data }): Promise<NearbyPlace[]> => {
await requireSandboxAccess();
const target = sql`ST_SetSRID(ST_MakePoint(${data.lng}, ${data.lat}), 4326)`;
// ::geography makes ST_DWithin take meters (and use the GIST index);
// ST_DistanceSphere returns meters on plain geometry.
return db
.select({
id: place.id,
name: place.name,
lat: sql<number>`ST_Y(${place.location})`,
lng: sql<number>`ST_X(${place.location})`,
distanceM: sql<number>`ST_DistanceSphere(${place.location}, ${target})`,
})
.from(place)
.where(
sql`ST_DWithin(${place.location}::geography, ${target}::geography, ${data.radiusKm * 1000})`,
)
.orderBy(sql`ST_DistanceSphere(${place.location}, ${target})`)
.limit(50);
});ST_DWithin over a ::geography cast filters in meters using the index;
ST_AsGeoJSON produces the rest_framework_gis response shape. Try it on
/sandbox/gis — the block spec e2e/sandbox/gis.spec.ts proves the radius
search keeps Stockholm and drops Kiruna.