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:
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:
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 databaseRead and write in a Flow
All data access goes through fx.store(db) — a typed session, one table at a time:
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.
| Facet | Declares | Best for | fx.store(…) handle |
|---|---|---|---|
store.sql(name, opts) | a SQL database | domain tables, relations, constraints | select · insert · update · delete · findById |
store.kv(name) | a key-value space | cache, sessions, rate limits | get · set(key, value, ttl?) · delete · list |
store.files(name) | a blob bucket | uploads, exports, attachments | put · get · delete · list(prefix?) |
store.index(name, opts) | a vector index | semantic 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:
| Param | Meaning | Example |
|---|---|---|
?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.value | filter — 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 API | Meaning |
|---|---|
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
| Command | When |
|---|---|
oke db push | Dev — apply directly to the live local DB (no migration files) |
oke db generate | Write versioned SQL under drizzle/ for review |
oke db migrate | Apply 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:
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" },
},
},| Facet | Local | Docker / prod | Runs as |
|---|---|---|---|
sql | sqlite | postgres | file on disk → container + named volume |
kv | memory | redis | in-process → container |
files | fs | s3 | project folder → RustFS container |
index | memory | pgvector | in-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
Schema DDL never runs automatically outside local dev. Run oke db push against the target database for a dev-style sync, or oke db generate + oke db migrate for reviewed, versioned migrations.
fx.store is one table per call by design. Read each table separately and compose in the flow (or extract a shared flow and fx.call it). Every table then appears explicitly in the Manifest's effect graph — which is what powers cache invalidation and PII masking.
You are on offset paging. Switch list to keyset by setting cursor columns with a stable order (e.g. [createdAt, id]) — the page boundary becomes a row predicate, not a row count.
count: "exact" runs COUNT(*) per page. Set count: "none" in the list options to skip it, or use keyset mode where totals are rarely needed.
Learn more
- Flow — the
fx.storesession insidedo - Console · Store — browse data, cache keys, PII masking
- CLI Reference —
oke db push·generate·migrate