Skip to content

Database

@delightstack/database is a type-safe data layer for Cloudflare Durable Objects with built-in full-text & vector search, runtime validation, automatic migrations, and a reactive Svelte 5 client.

  • Declarative schema — fluent TypeScript API; field types and validators inferred at compile time.
  • Full-text & vector search — a built-in engine (BM25, typo tolerance, facets, geo, vector/hybrid) indexed into SQLite rows; mark fields .searchable().
  • Automatic migrations — new columns are added when you change the schema; no migration files.
  • Dependency-free validation — every create()/update() is validated against the schema at runtime (no zod).
  • Transactions — batch operations atomically.
  • Incremental syncsync() returns only changes since a timestamp for client mirroring.
  • Reactive clientDatabaseClient gives reactive entity state, live search, IndexedDB caching, and optimistic updates, all off-thread in a SharedWorker.
Terminal window
pnpm add @delightstack/database
ImportUse
@delightstack/databaseDatabase (schema builder) + shared types (DatabaseStub, contract types)
@delightstack/database/workerDatabaseServer — the Durable Object class (Worker entry point only)
@delightstack/database/servercreateDatabaseHandle — SvelteKit CRUD + sync routes
@delightstack/database/clientDatabaseClient — reactive Svelte 5 client
// $lib/schema.ts — shared by the server worker and the browser client
import { Database } from '@delightstack/database';
// `id` (string primary key), `created_at`, and `updated_at` are auto-managed —
// tables don't declare them. Timestamps are epoch-millisecond numbers.
export const userTable = Database.table('user', (schema) => ({
name: schema.string().min(1).searchable(),
email: schema.string().email().unique(),
}));
export const postTable = Database.table('post', (schema) => ({
title: schema.string().searchable(),
body: schema.string().searchable(),
// Pass the table object itself — a typo'd reference is a compile error
author_id: schema.foreignKey({ type: 'string', table: userTable, column: 'id', on_delete: 'CASCADE' }),
tags: schema.array(schema.string()).searchable().optional(),
published: schema.boolean().default(false),
}));
export const tables = { user: userTable, post: postTable };

Root-level scalars become real SQLite columns; nested objects/arrays are serialized into a JSON catch-all column and transparently rehydrated on read.

Subclass DatabaseServer in your Worker entry point, passing the table record and a lazy WebSocket factory (return undefined to skip broadcasting), and register the class in wrangler.toml (see Architecture):

// server/src/index.ts (the Worker entry point)
import { DatabaseServer } from '@delightstack/database/worker';
import { tables } from '../../src/lib/schema';
export class OrgDatabaseServer extends DatabaseServer<typeof tables> {
constructor(ctx: DurableObjectState, env: Env) {
// The name this DO was created with (e.g. the org id) — use it to
// address the sibling WebSocket DO for the same org.
const room = DatabaseServer.instanceName(ctx);
super(tables, () => env.WS.get(env.WS.idFromName(room)), ctx, env);
}
}

Integrations like imageProcessing() and aiProcessing() register themselves with the base class’s alarm registry — no hand-written alarm() fan-out needed.

Durable Object stubs are opaque to TypeScript, so cast once at the boundary with DatabaseStub<typeof tables> — every call after that is fully typed:

app.d.ts
import type { DatabaseStub } from '@delightstack/database';
import type { tables } from '$lib/schema';
interface Locals {
db: DatabaseStub<typeof tables> | undefined;
}
// hooks.server.ts (or wherever you resolve the stub)
event.locals.db = penv.DB.get(penv.DB.idFromName(org_id)) as unknown as DatabaseStub<typeof tables>;
// Anywhere on the server — typed end to end:
await db.create('post', { title: 'Hello', body: '', author_id: 'u_1' });
const results = await db.list('post', { term: 'hello' }); // full-text search
const post = await db.get('post', post_id); // Database.Entity<typeof postTable>

For declarative CRUD routes, mount createDatabaseHandle() from @delightstack/database/server in your SvelteKit handle.

Create the client in your layout load and await db.init() before returning — every handle depends on it. Pass the load event’s fetch so SSR reads carry the request’s cookies:

+layout.ts
import { DatabaseClient } from '@delightstack/database/client';
import { tables } from '$lib/schema';
export const load = async ({ fetch }) => {
const db = new DatabaseClient({ tables, db_name: `app:${org_id}`, fetch });
await db.init(); // no-op on the server; required before any handle is read
return { db };
};
/** One alias for components that take a `db` prop */
export type AppDatabase = DatabaseClient<typeof tables>;
<script lang="ts">
const { data } = $props();
const posts = data.db.list('post'); // live reactive list
</script>
{#each posts.items as post (post.id)}
<article>{post.title}</article>
{/each}
  • Reading: db.get(type, id) (display), db.entity(type, id?) (editing/forms), db.list(type, query) (lists/search). Read handle properties in templates or $derived — the first read starts a live subscription.
  • SSR: await handle.load() in a +page.ts load; the component then re-derives the same cached handle (const person = $derived(db.entity('person', page.params.id))).
  • Props: type db props with the AppDatabase alias above instead of repeating DatabaseClient<typeof tables> in every component.

Every entity carries its table’s form wiring (entity.form.field / entity.form.schema). Hand the entity to the components Form and spread the field props — values, validation, saving, and submit state are all handled:

<script>
const post = $derived(db.entity('post', post_id)); // omit the id to create
</script>
<Form entity={post} onsaved={() => goto('/posts')}>
<Input {...post.form.field.title} />
<Button type="submit">Save</Button>
</Form>

See Working with Forms for the full pattern.