Skip to content
Tikab's Toolkit

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

Loading diagram...

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

TablePurpose
user, session, account, verificationBetter Auth's tables (its default names)
mailboxevery email the mailer records — the outbox
audit_logthe change history (scope without FK — history outlives its subjects)
app_configruntime 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 migrations

Review 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:generate running 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.