[]previous and next page

Use a database

The connection lives in one server module, and callers get functions, never the client.

For agentsUpdated

A Dusk app reaches its database from the server only: in a loader, an API route or a server function. The browser never holds a connection string. Four rules cover every client.

  1. A .server module owns the connection. A file whose name ends in .server.ts can never be bundled for the browser: the build fails with the import chain if a client module reaches it. The connection string is read there from process.env, and nowhere else.
  2. One client per process. A client opened per request exhausts the database's connections under load. Open it once at module load, and keep it on globalThis so the reloads of dusk dev do not open a second one.
  3. Callers get functions, not the client. Loaders and API routes call server functions. The browser's build replaces a server function with a stub, so the query code never ships.
  4. Migrations change the schema, run at deploy time by the library's own tool, not by the app on start.

Here it is with Drizzle on libSQL, the engine of Turso. The table is a schema, and $inferSelect gives the row type, so a query and the table cannot drift apart.

src/server/schema.tsts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const notes = sqliteTable("notes", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull().unique(),
  owner: text("owner").notNull(),
});

export type Note = typeof notes.$inferSelect;

The connection module reads DATABASE_URL, and DATABASE_AUTH_TOKEN for a remote database. Without them it opens an in-memory database.

src/server/db.server.tsts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";

function open() {
  const authToken = process.env["DATABASE_AUTH_TOKEN"];
  const client = createClient({
    url: process.env["DATABASE_URL"] ?? ":memory:",
    ...(authToken === undefined ? {} : { authToken }),
  });
  return { client, db: drizzle(client, { schema }) };
}

// One connection per process, also across the reloads of `dusk dev`.
const shared = globalThis as typeof globalThis & { __notesDb?: ReturnType<typeof open> };
const opened = (shared.__notesDb ??= open());

export const db = opened.db;

/**
 * Creates the table. A real app runs `drizzle-kit migrate` when it deploys and has no such code; this page keeps it here so
 * that the example is complete.
 */
export const ready: Promise<unknown> = opened.client.execute(
  "create table if not exists notes (id integer primary key autoincrement, title text not null unique, owner text not null)",
);

A server function is what the rest of the app calls:

src/server/notes.tsts
import { serverFn } from "@shadow-js/dusk/server";
import { db } from "./db.server";
import { notes } from "./schema";

export const listNotes = serverFn({ method: "GET" }, async () => db.select().from(notes));

The same shape works with any client. The databases guide runs it against Supabase too, with transactions, error mapping and what changes on edge runtimes, and shows a Prisma setup.

Next: sign users in.