Skip to content

db

Your app’s relational database, queried through Drizzle ORM. For when the database is the right home (and how migrations work), see the Relational Data Store.

Unlike the other handles, db isn’t imported from @lodekit/sdk — your app builds its own typed handle in src/db.ts with createDb() and imports it from there. Works in the browser and in server functions.

Declare your tables with Drizzle’s SQLite builders, export them, and export a db built from them:

src/db.ts
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
import { createDb } from "@lodekit/sdk";
export const plants = sqliteTable("plants", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
species: text("species").notNull(),
icon: text("icon").notNull(),
personality: text("personality").notNull().default(""),
adoptedAt: integer("adopted_at").notNull(), // epoch ms
});
export const careRules = sqliteTable(
"care_rules",
{
plantId: integer("plant_id").notNull().references(() => plants.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // water | feed | mist
every: text("every").notNull(), // "3d" / "12h"
},
(t) => [primaryKey({ columns: [t.plantId, t.kind] })]
);
export const db = createDb({ plants, careRules });
export type Plant = typeof plants.$inferSelect;

The engine imports this module at worker boot and applies your schema to the app’s database before the app serves requests.

createDb<TSchema extends Record<string, unknown>>(schema: TSchema): SqliteRemoteDatabase<TSchema>
  • schema — an object of your exported Drizzle tables.

Returns a standard Drizzle database instance (Drizzle’s sqlite-proxy driver). Query building happens locally and is fully typed against your schema; execution travels over HTTP to the engine, which owns the SQLite file — app code never opens the database directly. Because the transport is plain HTTP, the same db works in the browser and in server functions.

createDb() only builds the query client — schema application happens separately, at worker boot, from the same src/db.ts file.

It’s standard Drizzle. The examples below come from the Greenhouse showcase app (careState and journal are two more of its tables, declared the same way as plants above). Select with a filter:

import { eq } from "drizzle-orm";
import { db, plants } from "../db";
const [plant] = await db.select().from(plants).where(eq(plants.id, plantId));

Upsert with onConflictDoUpdate:

await db.insert(careState)
.values({ plantId, kind, lastAt: now, dueAt, reminderRunId })
.onConflictDoUpdate({
target: [careState.plantId, careState.kind],
set: { lastAt: now, dueAt, dueSince: null, reminderRunId },
});

Insert:

await db.insert(journal).values({ plantId, at: now, kind, text: `${CARE_VERB[kind]} ${plant.name}.` });

Row types come from the schema, for free:

export type Plant = typeof plants.$inferSelect;

When you save src/db.ts, the engine diffs the declared schema against the database:

  • Additive changes apply automatically — CREATE TABLE, CREATE INDEX, ADD COLUMN.
  • Destructive changes are gated — instead of applying, the app reports what would be lost; the way forward is an expand-copy migration (new table, copy the data, retire the old one). See the Relational Data Store for the full migration story.
  • Reserved names — table names may not start with _ or sqlite_.

The query routes accept CRUD only — SELECT, INSERT, UPDATE, DELETE. Schema-altering or otherwise non-CRUD statements are rejected, as is any query referencing _- or sqlite_-prefixed tables. A rejected query surfaces as a rejected promise carrying the server’s message.

Query requests ride the scoped API, so its 100 kb request-body cap applies.