Elements

Store

Data at rest — SQL tables, KV cache, file blobs, and vector search, declared once and swapped per environment by driver.

Store is where your app's data at rest lives. It covers four kinds of storage — relational tables, key-value cache, file blobs, and vector search — behind one declaration style. Your flow code never changes between SQLite on your laptop and Postgres in production; only the driver does.

The one rule

Drivers are named after protocols, not vendors (postgres, redis, s3 — never neon or minio). Vendor choice lives in the images map of oke.config.ts.

Quick start

Declare a table

In src/schema.decl.ts, describe your tables with plain field builders — no ORM syntax:

src/schema.decl.ts
import { store, field, id, now } from "okengine";

export const notes = store.schema.table("notes", {
  id: field.text().primaryKey().defaultFn(id),
  title: field.text().notNull(),
  body: field.text().notNull(),
  createdAt: field.integer().notNull().defaultFn(now),
});

Declare the store

Bind the schema to a SQL store in src/core.ts:

src/core.ts
import { store } from "okengine";
import { notes } from "./schema.decl";

export const db = store.sql("notes", { schema: { notes } });

Push the schema

oke dev runs this automatically on save; or run it by hand:

oke db push   # dev — applies the schema to your local database

Read and write in a Flow

All data access goes through fx.store(db) — a typed session, one table at a time:

src/flows/notes/create.ts
export const createNote = on(
  http.post("/notes"),
  flow({
    in: z.object({ title: z.string(), body: z.string() }),
    out: z.object({ id: z.string() }),
    do: async (input, fx) => {
      const id = fx.id();
      await fx
        .store(db)
        .insert(notes)
        .values({ id, ...input, createdAt: Date.now() });
      return { id };
    },
  }),
);

The four facets

Pick the facet that matches the physics of your data. Each declaration is one line; the runtime handle shows what flows can do with it.

FacetDeclaresBest forfx.store(…) handle
store.sql(name, opts)a SQL databasedomain tables, relations, constraintsselect · insert · update · delete · findById
store.kv(name)a key-value spacecache, sessions, rate limitsget · set(key, value, ttl?) · delete · list
store.files(name)a blob bucketuploads, exports, attachmentsput · get · delete · list(prefix?)
store.index(name, opts)a vector indexsemantic search / RAG (dims)upsert · search(vector, topK?) · delete
export const cache = store.kv("sessions");
export const uploads = store.files("attachments");
export const embeddings = store.index("docs", { dims: 1536 });

CRUD without boilerplate — store.resource

Five conventional endpoints (list, create, get, update, remove) expand from one declaration. Each is an ordinary Flow underneath — same contracts, same fx:

const notesR = store.resource(db, notes, {
  in: NewNote, // create/update input schema
  out: Note, // response schema
  update: NewNote.partial(),
  list: {
    cursor: [notes.createdAt, notes.id], // keyset pagination columns
    direction: "desc",
    search: [notes.title], // ?search= / ?q=
    filter: "all", // ?col=op.value — "all" | Column[] | "none"
    order: "all", // ?order=col.desc
  },
  unit: "notes",
});

const mounted = on(http.resource("/notes", notesR.all()));

The list endpoint's URL is the whole query language:

ParamMeaningExample
?cursor= / ?offset= / ?limit=paginate (keyset when cursor columns are set)?limit=20&cursor=eyJ…
?search= (?q=)substring match over search columns?q=invoice
?col=op.valuefilter — ops eq ne gt gte lt lte like ilike in is?title=like.%draft%
?or=(…) / ?and=(…)grouped boolean filters?or=(a.eq.1,b.eq.2)
?order=sort?order=createdAt.desc
?select=project columns?select=id,title

Responses follow the Stripe-style envelope: { data, meta: { nextCursor, hasNextPage }, error }. create answers 201, remove answers 204, and a missing row is a typed NotFound — never a crash.

Querying by hand

When store.resource is too conventional, fx.store(db) is the full single-table session:

// select — chain where / orderBy / limit / offset in any order
const latest = await fx
  .store(db)
  .select()
  .from(notes)
  .where(like(notes.title, `%${input.q}%`))
  .orderBy(desc(notes.createdAt))
  .limit(20);

// findById, insert, update, delete
const one = await fx.store(db).findById(notes, input.id);
await fx.store(db).update(notes).set({ title: input.title }).where(eq(notes.id, input.id));
await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));

One table per call — no relational with:

fx.store is deliberately single-table: Drizzle's relational findMany({ with: … }) is not available through fx. Compose joins as separate single-table reads (or fx.call) so every table shows up explicitly in the Manifest's reads / writes — powering caching and PII masking.

Schema — declare once, generate per dialect

The recommended path: declare tables ORM-agnostically, then let oke db emit real Drizzle for the active dialect (sqliteTable locally, pgTable in docker/prod) into src/schema.generated.ts.

Field APIMeaning
field.text() / field.integer()v1 column primitives
.primaryKey() · .notNull() · .unique()constraints
.default(v) · .defaultFn(id | now)defaults
.pii() · .sensitive() · .retain("30d")privacy classification
.as("sql_name")override the automatic camelCase → snake_case
.references(() => col, { onDelete })foreign key

Foreign keys and relations

Declare FKs on fields, and relation metadata once per schema:

export const daily = store.schema.table("daily", {
  id: field.text().primaryKey(),
  code: field
    .text()
    .notNull()
    .references(() => links.code),
  day: field.text().notNull(),
  clicks: field.integer().notNull().default(0),
});

export const relations = store.schema.relations({ links, daily }, (r) => ({
  links: { daily: r.many.daily({ from: r.links.code, to: r.daily.code }) },
  daily: { link: r.one.links({ from: r.daily.code, to: r.links.code, optional: false }) },
}));

Many-to-many

A junction table with two foreign keys plus two one / many relations composes many-to-many. There is no separate API and no .through() — the junction is an ordinary table.

Syncing the schema

CommandWhen
oke db pushDev — apply directly to the live local DB (no migration files)
oke db generateWrite versioned SQL under drizzle/ for review
oke db migrateApply those files — human or CI, never at boot

oke dev (local) auto-pushes when the schema file changes — opt out with --no-db-push or db: { autoPush: false }. Docker/prod never auto-apply DDL; a missing table fails loudly as OKE1101, telling you to run oke db migrate.

Escape hatch

Hand-written Drizzle in src/schema.ts stays supported — if there is nothing to emit, the emit step is skipped and your file is used as-is. Plugins may contribute whole new tables; extending an app-owned table with plugin columns is not supported in v1.

Per-environment drivers

Same flow code, different backends — configured once in oke.config.ts:

oke.config.ts
drivers: {
  store: {
    sql:   { local: "sqlite", docker: "postgres", test: "memory", prod: "postgres" },
    kv:    { local: "memory", docker: "redis",    test: "memory", prod: "redis" },
    files: { local: "fs",     docker: "s3",       test: "memory", prod: "s3" },
  },
},
FacetLocalDocker / prodRuns as
sqlsqlitepostgresfile on disk → container + named volume
kvmemoryredisin-process → container
filesfss3project folder → RustFS container
indexmemorypgvectorin-process → pgvector image

Container images come from the images map — change the vendor by changing the pin, never the driver id.

Privacy built in

Columns tagged .pii() or .sensitive() are masked at the store boundary — flows, logs, and the Console see a mask, not the value. Revealing cleartext PII requires an explicit pii:reveal gate on the flow, so access is a permission, not a convention.

Examples — what follows from each choice

An admin table

list: { mode: "offset", count: "exact", limit: 20 },

Consequence: count: "exact" (the offset default) runs COUNT(*) to fill meta.total. On a huge table that count is the real cost — set count: "none" to return only meta.offset.

An infinite feed

list: { cursor: [notes.createdAt, notes.id], direction: "desc", limit: 20, maxLimit: 100 },

Consequence: keyset (cursor) paging is the default when cursor columns are set — pages stay stable while new rows are inserted, where offset pages would shift and show duplicates.

A public, restricted endpoint

list: { mode: "offset", filter: "none", limit: 20 },

Consequence: a request that filters on a forbidden column — ?secret=eq.x — fails with 422 and the exact message unknown list param "secret". Filterable columns are a whitelist, never an accident.

Troubleshooting

Learn more

Next

On this page