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.
Features
Section titled “Features”- 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 sync —
sync()returns only changes since a timestamp for client mirroring. - Reactive client —
DatabaseClientgives reactive entity state, live search, IndexedDB caching, and optimistic updates, all off-thread in a SharedWorker.
Install
Section titled “Install”pnpm add @delightstack/database| Import | Use |
|---|---|
@delightstack/database | Database (schema builder) + shared types (DatabaseStub, contract types) |
@delightstack/database/worker | DatabaseServer — the Durable Object class (Worker entry point only) |
@delightstack/database/server | createDatabaseHandle — SvelteKit CRUD + sync routes |
@delightstack/database/client | DatabaseClient — reactive Svelte 5 client |
1. Define your schema
Section titled “1. Define your schema”// $lib/schema.ts — shared by the server worker and the browser clientimport { 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.
2. Register the Durable Object
Section titled “2. Register the Durable Object”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.
3. Query from the server
Section titled “3. Query from the server”Durable Object stubs are opaque to TypeScript, so cast once at the boundary with
DatabaseStub<typeof tables> — every call after that is fully typed:
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 searchconst 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.
4. The reactive client
Section titled “4. The reactive client”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:
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}Client patterns
Section titled “Client patterns”- 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.tsload; the component then re-derives the same cached handle (const person = $derived(db.entity('person', page.params.id))). - Props: type
dbprops with theAppDatabasealias above instead of repeatingDatabaseClient<typeof tables>in every component.
5. Entity-backed forms
Section titled “5. Entity-backed forms”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.