Overview
Data at rest — SQL, KV, files, and search indexes through one typed fx.store handle.
Store is how your backend holds data at rest. Declare a facet once (store.sql, store.kv, store.files, store.index), then read and write through fx.store(decl) inside Flows.
For developers persisting domain data on okengine — one handle shape, drivers swapped by environment.
The one rule
World access goes through fx.store(decl). Drivers are protocol-named in oke.config.ts
(postgres, redis, s3 — never vendors). Application code never imports a driver client
directly.
Four facets, one handle
fx.store(db)…sqlselectRelational tables
store.sql(name, opts)Single-table session — ops cycle through one row
Domain tables, relations, constraints
kvsetKey-value space
store.kv(name)set → TTL drains → key expires
Cache, sessions, rate limits
filesputBlob bucket
store.files(name)put into the bucket, get the bytes back
Uploads, exports, attachments, image variants
indexsearchSearch index
store.index(name, opts)search fans out — hits ranked by score
Vector (dims) or full-text
Smallest Example
Declare a SQL store
import { store, field } from "okengine";
export const notes = store.schema.table("notes", {
id: field.id().primaryKey(),
title: field.text().notNull(),
createdAt: field.timestamp().notNull().now(),
});
export const db = store.sql("app", { schema: { notes } });Read and write in a Flow
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, notes } from "@/schema";
export const create = on(
http.post({
in: z.object({ title: z.string().min(1) }),
}),
flow({
do: async ({ title }, fx) => {
const id = fx.id();
await fx.store(db).insert(notes).values({ id, title });
return fx.json.create({ id, title });
},
}),
);Call the endpoint
curl -X POST http://localhost:6530/notes \
-H "content-type: application/json" \
-d '{"title":"Ship notes"}'Response:
{
"data": { "id": "…", "title": "Ship notes" },
"error": null
}Effects are inferred
Every fx.store touch is recorded on the Flow as reads / writes (sql:app, kv:sessions,
files:uploads, …). That powers the Manifest, Console, cache invalidation, and least privilege —
no hand-written effect lists.
Progressive Patterns
Same fx.store(decl) from a row insert to cache, blobs, and hybrid search:
Declare tables with store.schema.table + field.*, then use the session handle:
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";
export const get = on(
http.get({
in: z.object({ id: z.string() }),
errors: { NotFound: z.object({ id: z.string() }) },
}),
flow({
do: async ({ id }, fx) => {
const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
if (!note) return fx.fail("NotFound", { id });
return note;
},
}),
);Prefer store.resource when you want five CRUD Flows in one factory.
Facet Reference
| Facet | Declare | fx.store handle | Resource ref | Default drivers (dev / test / prod) |
|---|---|---|---|---|
| SQL | store.sql(name, opts?) | select / insert / update / page / search / … | sql:name | postgres / pglite / postgres |
| KV | store.kv(name, opts?) | get · set · delete · list · ttlMs | kv:name | redis / memory / redis |
| Files | store.files(name, opts?) | put · get · delete · list · image · putImage | files:name | s3 / memory / s3 |
| Index | store.index(name, opts?) | vector or Meilisearch (by driverId) | index:name | pin explicitly — no three-env default |
Built-in hybrid search (fx.store(db).search) lives on the SQL handle — see Search. store.index is the optional external engine facet.
Per-environment drivers
Standard starters inherit DRIVER_DEFAULTS. Pin only when you diverge; vendor choice lives under images, not driver ids:
import { defineConfig } from "okengine/config";
export default defineConfig({
drivers: {
// omit store.* to use defaults — pin only overrides
},
images: {
store: {
sql: "postgres:18-alpine",
kv: "redis:8-alpine",
files: "rustfs/rustfs:1.0.0-rc.5",
},
},
});| Facet | Protocol ids | Best for |
|---|---|---|
sql | postgres · pglite · memory | Domain tables, RLS, live queries, BM25 |
kv | redis · memory (+ durable SQL via oke_kv) | Sessions, caches, short-lived locks |
files | s3 · fs · memory | Uploads, exports, image variants |
index | memory · pgvector · meilisearch | Hosted FTS / ANN outside the primary table |
The Capabilities of Store
SQL
Schema tables, field tags, store.resource CRUD, RLS, and list grammar.
KV
Namespaced get/set with duration TTL — redis honors it; memory ignores it.
Files
Object buckets, putImage variants, and chainable Bun.Image transforms.
Search
Built-in BM25 ± LSH on SQL columns, plus optional store.index engines.
Troubleshooting
Boot needs a driver for that facet. Check drivers.store.* (or rely on DRIVER_DEFAULTS) and
that Compose / env URLs match the protocol.
fx.store received a declaration that was never registered — import the module that calls
store.sql / store.kv / … before the Flow runs (starters load @/core).
The app has not booted a store runtime. Run through oke / oke dev so drivers bind; do not call
fx.store outside a Flow invoke.
Cause: domain table not found — migrations have not been applied. Run oke db push (dev) or
oke db migrate against that environment.
Learn more
- SQL —
store.schema.table,store.resource, RLS - HTTP · Resources — mount CRUD + live
- fx —
fx.store,fx.json.* - Configuration —
drivers.store.* - Errors — OKE1110 and friends