Skip to Content
Typed Apps & ExamplesDrizzle ORM & Code Generation

Drizzle ORM & Generator

@kalamdb/orm is the Drizzle package for KalamDB. Use it when you want generated tables, typed queries, or table-level live helpers. It runs on top of @kalamdb/client.

Install it with the client and Drizzle:

BASH
npm i @kalamdb/client @kalamdb/orm drizzle-orm

Driver quick start

Copy this after you have a generated schema.ts file.

TS
import { Auth, createClient } from '@kalamdb/client';import { kalamDriver } from '@kalamdb/orm';import { drizzle } from 'drizzle-orm/pg-proxy';import { messages } from './schema'; const client = createClient({  url: 'http://localhost:2900',  authProvider: async () => Auth.basic('admin', 'AdminPass123!'),}); const db = drizzle(kalamDriver(client));const recent = await db.select().from(messages).limit(20);

The driver uses KalamDB SQL over HTTP and normalizes temporal values for Drizzle columns. timestamp(..., { mode: 'date' }), date(..., { mode: 'date' }), and time(...) can read KalamDB’s numeric wire values without app-side parsing.

Pass a full KalamDBClient to kalamDriver() so FILE uploads can route to queryWithFiles() when needed.

Table helpers

Use kTable instead of raw pgTable so the schema retains KalamDB table type metadata.

TS
import { bytes, embedding, file, kTable } from '@kalamdb/orm';import { bigint, jsonb, text, timestamp, uuid } from 'drizzle-orm/pg-core'; export const docs = kTable.shared('app.docs', {  id: bigint('id', { mode: 'bigint' }).primaryKey(),  owner_id: uuid('owner_id').notNull(),  title: text('title').notNull(),  metadata: jsonb('metadata'),  attachment: file('attachment'),  raw_bytes: bytes('raw_bytes'),  doc_embedding: embedding('doc_embedding', 384),  created_at: timestamp('created_at', { mode: 'date' }).notNull(),});

Available table helpers:

HelperUse for
kTable.shared(name, columns, options?)WITH (TYPE = 'SHARED') tables
kTable.user(name, columns, options?)per-user isolated tables
kTable.stream(name, columns, options?)stream tables
kTable.system(name, columns, options?)system metadata tables
kSystemColumns([...])_seq, _deleted, and _commit_seq typed hidden columns

KalamDB-specific columns:

HelperRuntime value
file(name)`FileRef
bytes(name)`Uint8Array
embedding(name, dimensions)`number[]

Generate schema.ts

Generate a Drizzle schema from a running KalamDB server:

BASH
npx kalamdb-orm \  --url http://localhost:2900 \  --user admin \  --password AdminPass123! \  --namespace app \  --include-system-columns \  --out src/db/schema.ts

The generator reads SHOW TABLES, calls DESCRIBE <namespace.table> when column metadata is incomplete, and emits only the imports needed by the generated tables. It preserves primary keys, not-null constraints, system-column options, comments, table config constants such as chat_messagesConfig, and $inferSelect / $inferInsert aliases.

Options:

OptionDescription
--namespace <name>Limit output to one or more namespaces. Repeat it or pass comma-separated names.
--include-systemInclude system and dba tables.
--include-system-columns <mode>Add typed hidden columns to generated table definitions. Use all, _seq, or _deleted.
--bigint-mode <mode>Choose how generated BIGINT columns are emitted. Accepted values are string, bigint, or number; default is string.
--no-type-aliasesSkip generated $inferSelect and $inferInsert aliases.

BIGINT defaults to text() because KalamDB transports Int64 values as strings to preserve precision. Choose --bigint-mode bigint when you want Drizzle to coerce values to native bigint, or --bigint-mode number only when values fit safely in JavaScript numbers.

Keep schema.ts fresh during local development

Add a generator script to your app so the command stays short:

JSON
{  "scripts": {    "schema:gen": "kalamdb-orm --url http://localhost:2900 --user admin --password AdminPass123! --namespace app --out src/db/schema.ts"  }}

In a kalam.toml project, kalam dev watches schema.sql and reruns kalam schema gen when [dev].generate_types = true:

BASH
kalam dev

See Local Development. The legacy kalam --watch-schema server-polling flag is deprecated.

PostgreSQL types and Drizzle mapping

KalamDB schemas use PostgreSQL-oriented SQL types. That is why standard typed columns come from drizzle-orm/pg-core: text, integer, boolean, numeric, timestamp, and related builders describe the server columns. kTable adds KalamDB table metadata. Dart and Rust use the same server type model, with their own language representations.

The table below separates recommended SQL declarations from the current generator’s output. A generated JavaScript value does not change the server column’s SQL type. See PostgreSQL data types and compatibility limits.

KalamDB typeGenerated Drizzle helperRead behavior
BOOLEANboolean()boolean
INTinteger()number
SMALLINTsmallint()number
BIGINTtext() by defaultstring for Int64 precision
DOUBLE PRECISIONdoublePrecision()number
REALreal()number
TEXTtext()string
TIMESTAMPtimestamp(..., { mode: 'date' })Date when using date mode
DATEdate(..., { mode: 'date' })UTC-date Date
TIMEtime()HH:mm:ss[.fraction] string
JSONjsonb()JSON value
BYTEAbytes()Uint8Array
EMBEDDING(n)embedding(name, n)number[]
UUIDuuid()UUID string
NUMERIC(p,s) / DECIMAL(p,s)numeric()exact decimal string
FILEfile()`FileRef

Generator distinctions to review

The server distinguishes JSON from JSONB, and TIMESTAMP from TIMESTAMPTZ. The current ORM generator does not preserve all of those distinctions: its Json descriptor emits jsonb(), and the type-name parser has no TIMESTAMPTZ case, so that spelling falls back to Text. Do not use the generated file as proof of a lossless PostgreSQL schema round trip.

For a hand-authored schema, Drizzle exposes json() versus jsonb() and timestamp(name, { withTimezone: true, mode: 'date' }). Check the server declaration and generated output before using those columns. DATETIME may still appear in legacy descriptors, but new SQL should use the appropriate PostgreSQL timestamp declaration. The bytes() helper represents SQL BYTEA; it does not remove the server’s current PGWire binary-result limitation.

Upsert and returning

Drizzle returning() and onConflictDoUpdate() compile to KalamDB SQL and run through the same driver:

TS
import { sql } from 'drizzle-orm'; const inserted = await db  .insert(users)  .values({ id: 1, name: 'Nader', age: 3 })  .returning(); const updated = await db  .insert(users)  .values({ id: 1, name: 'Nader Updated', age: 5 })  .onConflictDoUpdate({    target: users.id,    set: { name: sql`excluded.name`, age: sql`excluded.age` },  })  .returning();

FILE uploads through Drizzle

You do not wrap every query. Normal select, insert, update, and delete calls go through db directly. Only FILE bytes need multipart SQL, and the driver handles that when upload values appear in .values() or .set():

TS
import { kalamFile } from '@kalamdb/orm';import { attachments } from './schema'; await db.insert(attachments).values({  id: 'att_1',  file_data: kalamFile('upload', selectedFile),}); await db  .insert(context_files)  .values({    path: 'notes/readme.md',    file_ref: kalamFile('upload', uploadFile),    updated_at: new Date(),  })  .onConflictDoUpdate({    target: context_files.path,    set: {      file_ref: kalamFile('upload', uploadFile),      updated_at: new Date(),    },  });

You can also pass a File or Blob directly to a generated file() column; the driver uses the filename (or upload for plain blobs) as the multipart field name.

kalamDriver() compiles the Drizzle builder, normalizes quoted identifiers and DEFAULT columns for KalamDB, replaces upload params with FILE("name"), and calls client.queryWithFiles() when bytes are present.

For raw SQL strings (no Drizzle builder), use queryWithFiles() from @kalamdb/orm or @kalamdb/client. Use compileQuery() when you need normalized SQL and params without executing.

See FILE Columns & Uploads for download URLs, FileRef, and progress callbacks.

Execute as a user

Agents and service workers can compile a Drizzle builder and run it through KalamDB’s EXECUTE AS USER path:

TS
import { executeAsUser } from '@kalamdb/orm'; await executeAsUser(  client,  db.insert(messages).values({    room: 'main',    role: 'assistant',    author: 'KalamDB Copilot',    sender_username: 'alice',    content: 'Done.',  }),  'alice',);

Only pass a user id that your service account is authorized to impersonate.

Live table helpers

Use ORM liveTable() for a typed live list outside React. In React, prefer useLiveQuery, which handles the component lifecycle. A normal db.select() runs once; it does not open a subscription.

Create the inbox table and copy or generate the typed messages schema as schema.ts. Then add:

TS
import type { KalamDBClient } from '@kalamdb/client';import { liveTable } from '@kalamdb/orm';import { asc, eq } from 'drizzle-orm';import { messages } from './schema'; type Message = typeof messages.$inferSelect; export async function watchRoom(  client: KalamDBClient,  room: string,  render: (rows: Message[]) => void,  reportError: (error: Error) => void,) {  try {    return await liveTable(client, messages, render, {      where: eq(messages.room, room),      orderBy: asc(messages.id),      limit: 100,      onError: (event) => reportError(new Error(`[${event.code}] ${event.message}`)),    });  } catch (error) {    // Startup failures reject; later subscription errors use onError above.    reportError(error instanceof Error ? error : new Error(String(error)));    throw error;  }}

Call await watchRoom(client, room, render, reportError) from your view/service startup and keep the returned stop function. Catch startup rejection at that boundary. Show loading before startup, replace the list in render, and show an empty state only after receiving an empty list. Keep an error visible if reportError runs; cached rows may be stale.

When the room changes, await stop() before opening the next room. Await it again on view disposal as appropriate; only the application owner disconnects the shared client. To verify the example, run the inbox write sequence with the same authenticated user.

where: eq(messages.room, room) compiles values through Drizzle; do not build predicates by interpolating user input into SQL. The ORM maps database column names to schema property names and decodes timestamp/date/time fields. Rows are inferred from messages.$inferSelect, so render row.body directly instead of calling a cell accessor.

orderBy and limit are local presentation controls. Unlike the direct client’s materializer limit, the ORM keeps the matching set behind the visible slice. Use a selective where for large tables; limit: 100 does not bound subscription memory. The underlying live SQL remains SELECT ... FROM ... WHERE ....

Use liveEvents() for protocol events. Use the recovery guidance before adding durable checkpoints or manual retry loops.

UI and example fit

Use this package in UI code or service code that wants typed tables. Worker code should import @kalamdb/consumer only when it needs topic consumption.

Copy This Project Shape

TEXT
src/  db/    schema.ts      # generated by kalamdb-orm    client.ts      # createClient() + drizzle(kalamDriver(client))  app/    messages.ts    # Drizzle queries against generated tables

Live query projection and generated row types

liveTable(client, table, callback, { orderBy, limit }) and compileLiveTableDescriptor(table, options) use the same projection behavior: Drizzle columns are mapped to their public property names, then ordered, then limited. Date columns sort chronologically. The transport retains the full matching set so deleting a visible row can reveal the next row. Narrow the SQL filter to bound memory; a presentation limit is not a retention bound.

A disposed LiveQueryController is terminal. Create another controller to open a new view. Callbacks from obsolete subscriptions cannot update its state or forward stale errors/checkpoints. Custom-key live subscriptions accumulate initial batches rather than replacing earlier batches.

The SQL-first generator preserves declared primary keys and emits array column builders. Its built-in File shape aliases the SDK’s PgCatalogFile, and generated FILE columns narrow their read type to that reference shape. For uploads through these narrowed generated columns, use a Drizzle SQL FILE("placeholder") expression with queryWithFiles; hand-authored file() builders retain their convenience upload-input union. This keeps generated read models free of Blob/upload markers.

Raw live SQL normalization preserves literal whitespace. Its v1 clause recognition still accepts a restricted SELECT grammar; for expressions outside that subset, provide explicit subscription SQL and projection via createLiveQueryDescriptor or use a Drizzle descriptor. This is not a general SQL parser.