Core API
@gencow/core — createCrud, procedure, httpRoute, ownerRls, ctx, validator, cronJobs
The @gencow/core package provides backend primitives for Gencow — schema access control, CRUD auto-generation, typed procedures, HTTP routes, validators, and schedulers. Import procedure, createCrud, and httpRoute from gencow/runtime.ts in app code (not from @gencow/core directly).
How Gencow Works — Backend ↔ Frontend
New to Gencow? Understanding this 2-layer structure is the key to everything.
Backend (gencow/ folder) Frontend (src/ folder)
───────────────────────── ─────────────────────────
Runs on the server Runs in the browser
┌──────────────────────┐ ┌──────────────────────┐
│ gencow/schema.ts │ │ │
│ → pgTable + ownerRls│ │ useQuery(api.tasks. │
│ │ │ list) │
│ gencow/tasks.ts │ WebSocket │ → { data, total } │
│ → createCrud(tasks)│ ←──────→│ │
│ → procedure.query │ Auto- │ useMutation(api.tasks│
│ → procedure.mutation│ Sync │ .create) │
└──────────────────────┘ └──────────────────────┘- Backend defines your data model and API functions
- Frontend connects to those functions with
useQuery/useMutation - Data syncs automatically via WebSocket — no manual refetching needed
ownerRls()
Automatically injects PostgreSQL Row-Level Security policy into a pgTable so data is securely isolated by userId.
import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
import { ownerRls } from "@gencow/core";
// Apply RLS to the table — pass the userId column reference
export const tasks = pgTable("tasks", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
userId: text("user_id").notNull(),
}, (t) => ownerRls(t.userId));
// Public read — anyone can read, only owner can CUD
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
content: text("content").notNull(),
userId: text("user_id").notNull(),
}, (t) => ownerRls(t.userId, { read: "public" }));createCrud()
Factory function that auto-generates CRUD procedures (get, list, create, update, remove) for a given table. Built on procedure.query / procedure.mutation with authentication, realtime push, and RLS — zero boilerplate.
This is the recommended way to create APIs. Use
createCrud()first, and addprocedure.query/procedure.mutationonly when you need custom logic (joins, aggregations, external API calls).
import { createCrud } from "./runtime";
import { tasks } from "./schema";
// Automatically registers: tasks.list, tasks.get, tasks.create, tasks.update, tasks.remove
export const { get, list, create, update, remove } = createCrud(tasks);⚠️ Schema Requirements for createCrud()
Your table must have an id column to use createCrud():
// ✅ Works — has id column
export const tasks = pgTable("tasks", {
id: serial("id").primaryKey(), // serial (number) PK
title: text("title").notNull(),
});
// ✅ Also works — UUID string id
export const users = pgTable("users", {
id: text("id").primaryKey(), // text/UUID PK — auto-detected
name: text("name"),
});
// ❌ Error — no id column
export const settings = pgTable("settings", {
key: text("key").primaryKey(), // createCrud() requires "id", not "key"
value: text("value"),
});
// → [createCrud] Table "settings" must have an 'id' column.
// → Use manual procedure.query / procedure.mutation insteadRecommended table columns for createCrud():
| Column | Required? | Purpose |
|---|---|---|
id |
✅ Required | Primary key — serial or text (UUID auto-detected) |
userId |
Recommended | Auto-injected on create from authenticated user |
createdAt |
Recommended | Used for default sort order (desc) |
updatedAt |
Optional | Auto-set to new Date() on update |
Auto-Generated Endpoints
| Export | Kind | Key | Auth | Realtime |
|---|---|---|---|---|
list |
procedure.query |
{table}.list |
✅ | — |
get |
procedure.query |
{table}.get |
✅ | — |
create |
procedure.mutation |
{table}.create |
✅ | ✅ emits list |
update |
procedure.mutation |
{table}.update |
✅ | ✅ emits list + get |
remove |
procedure.mutation |
{table}.remove |
✅ | ✅ emits list |
list Response Format
createCrud().list returns an object with data and total count:
// Backend: list returns { data: T[], total: number }
// Frontend:
const result = useQuery(api.tasks.list);
// ✅ Access data array
result?.data.map(task => <li>{task.title}</li>)
// ✅ Access total count (for pagination)
result?.total // e.g. 42
// ❌ Common mistake — result is NOT an array
result?.map(...) // TypeError! Use result.data.map() insteadWith pagination:
const [page, setPage] = useState(1);
const limit = 20;
const result = useQuery(api.tasks.list, { page, limit });
const totalPages = Math.ceil((result?.total ?? 0) / limit);
return (
<div>
{result?.data.map(task => <TaskCard key={task.id} task={task} />)}
<div>Page {page} of {totalPages} ({result?.total ?? 0} items)</div>
<button onClick={() => setPage(p => p - 1)} disabled={page <= 1}>Prev</button>
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}>Next</button>
</div>
);list Built-in Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
number |
1 |
Page number |
limit |
number |
20 |
Items per page (max: 100) |
search |
string |
— | Full-text search (requires searchFields option) |
orderBy |
string |
createdAt |
Column name to sort by |
orderDir |
"asc" | "desc" |
"desc" |
Sort direction |
filters |
object |
— | Dynamic filters (requires allowedFilters option) |
Options
export const { list, get, create, update, remove } = createCrud(tasks, {
// Auth
allowAnonymous: true, // Skip auth (default: false — Secure by Default)
// Realtime
realtime: false, // Disable realtime push (default: true)
// Naming
prefix: "myTasks", // Override key prefix (default: table name)
// Search — enables list({ search: "keyword" })
searchFields: ["title", "description"],
// Filtering — enables list({ filters: { status: "active" } })
allowedFilters: ["status", "category"],
// Pagination
defaultLimit: 20, // Default page size (default: 20)
maxLimit: 100, // Max page size (default: 100)
// Soft delete — marks as deleted instead of removing
softDelete: { field: "deletedAt" },
// Lifecycle hooks
hooks: {
beforeCreate: (data) => ({ ...data, slug: slugify(data.title) }),
beforeUpdate: (data) => ({ ...data, updatedAt: new Date() }),
},
});allowedFilters Example
// Backend: enable filtering by status and category
export const { list } = createCrud(tasks, {
allowedFilters: ["status", "category"],
});
// Frontend: pass filters
const activeTasks = useQuery(api.tasks.list, {
filters: { status: "active" },
});
const techPosts = useQuery(api.tasks.list, {
filters: { status: "published", category: "tech" },
});
// ⚠️ Security: filters NOT in allowedFilters are silently ignored
// filters: { userId: "hacker" } → ignored (userId not in allowedFilters)
// ⚠️ Secure by Default: if allowedFilters is NOT set, ALL filters are ignoredAdvanced Filters (v3)
createCrud() supports complex filtering with comparison operators and logical groups (OR, AND):
Comparison Operators
// Simple equality (implicit eq — backward compatible)
useQuery(api.tasks.list, { filters: { status: "active" } });
// Explicit operator syntax
useQuery(api.tasks.list, { filters: { status: { op: "eq", value: "active" } } });
useQuery(api.tasks.list, { filters: { status: { op: "ne", value: "draft" } } });
useQuery(api.tasks.list, { filters: { priority: { op: "gte", value: 3 } } });
useQuery(api.tasks.list, { filters: { category: { op: "in", value: ["tech", "news"] } } });
useQuery(api.tasks.list, { filters: { title: { op: "ilike", value: "%meeting%" } } });| Operator | SQL Equivalent | Value Type |
|---|---|---|
eq |
= |
any |
ne |
!= |
any |
gt |
> |
number/date |
gte |
>= |
number/date |
lt |
< |
number/date |
lte |
<= |
number/date |
in |
IN (...) |
array |
nin |
NOT IN (...) |
array |
like |
LIKE |
string |
ilike |
ILIKE (case-insensitive) |
string |
Logical Groups (OR / AND)
// OR — match any condition
useQuery(api.tasks.list, {
filters: {
OR: [
{ status: "active" },
{ status: "review" },
],
},
});
// AND — match all conditions (explicit; multiple top-level keys are implicitly AND)
useQuery(api.tasks.list, {
filters: {
AND: [
{ status: "active" },
{ priority: { op: "gte", value: 3 } },
],
},
});
// Nested — OR within AND (max 5 levels deep)
useQuery(api.tasks.list, {
filters: {
AND: [
{ OR: [{ category: "tech" }, { category: "news" }] },
{ status: { op: "ne", value: "archived" } },
],
},
});Security Rules
allowedFiltersis required — if not set, all filters are silently ignored (Secure by Default)- Filters on columns not in
allowedFiltersare silently dropped (no error) allowedFiltersvalidation is applied recursively insideOR/ANDgroups- Filter depth is limited to 5 levels (DoS protection) — deeper nesting is ignored
- Invalid operators or value types are silently ignored (crash-free design)
Automatic Behaviors
- id type auto-detection:
serial→ number args,text/uuid→ string args - userId auto-injection: If the table has a
userIdcolumn,createautomatically sets it from the authenticated user - updatedAt auto-set: If the table has an
updatedAtcolumn,updateautomatically sets it tonew Date() - RLS filtering:
ctx.dbhas RLS context — queries only return the current user's data
When to Use createCrud() vs procedure
| Need | Use | Why |
|---|---|---|
| Basic list/get/create/update/delete | createCrud() ✅ |
Zero code, auth + realtime built-in |
| Search by text | createCrud({ searchFields }) ✅ |
Built-in ilike search |
| Filter by column values | createCrud({ allowedFilters }) ✅ |
Built-in dynamic WHERE |
| Pagination | createCrud() ✅ |
Built-in page/limit + total count |
| Join / relationship queries | procedure.query |
createCrud() doesn't support joins |
| Aggregations (SUM, AVG, COUNT) | procedure.query |
Custom SQL needed |
| Complex business logic | procedure.mutation |
Custom validation/workflow |
| External API calls | procedure.mutation |
Need custom async logic |
| Webhook handlers / custom REST | httpRoute |
Typed REST endpoints with Standard Schema v1 input/output, middleware |
procedure — typed read/write builders
The procedure API is the standard way to define custom endpoints. It uses Standard Schema validation, composable middleware, and a dedicated RPC wire format (including file uploads on mutations). Builders are grouped under a single namespace:
| Entry | Use for |
|---|---|
procedure.query |
Read procedures (POST /api/query) |
procedure.mutation |
Write procedures (POST /api/mutation, multipart when args include files) |
Bind once in gencow/runtime.ts (not from @gencow/core in feature modules) so context is typed for your Drizzle schema and session user:
// gencow/runtime.ts
import {
createCrudFactory,
createGencowHttpRouteBuilders,
createGencowProcedureBuilders,
} from "@gencow/core";
import type { GencowSchema } from "./generated/db-schema.gen";
export const procedure = createGencowProcedureBuilders<GencowSchema>();
export const httpRoute = createGencowHttpRouteBuilders<GencowSchema>();
export const createCrud = createCrudFactory(procedure);// gencow/tasks.ts
import { procedure } from "./runtime";
import { v } from "@gencow/core";
export const listMine = procedure.query
.name("tasks.listMine")
.handler(async ({ context }) => {
const user = context.auth.requireAuth();
return context.db.select().from(tasks).where(eq(tasks.userId, user.id));
});
export const createOne = procedure.mutation
.name("tasks.createOne")
.input(v.object({ title: v.string() }))
.handler(async ({ context, input }) => {
const user = context.auth.requireAuth();
return context.db.insert(tasks).values({ title: input.title, userId: user.id });
});Register defs on defineApi({ procedures: { listMine, createOne } }). Prefer createCrud(table) (from ./runtime) when you only need standard list/get/create/update/remove — it builds procedure.query / procedure.mutation for you. In new and migrated app code, register those generated operations under procedures; do not use the legacy-compatible crud bag.
Builder chain: .name() → optional .allowAnonymous() → .use() → .input() / .output() (Standard Schema) → .handler(). Middleware and validation ordering follow the same index model as httpRoute.
workflow()
Define a durable multi-step job that can survive sleep, restart, retries, and human approval waits.
import { workflow, deriveWorkflowStatus, v } from "@gencow/core";
export const runImport = workflow("imports.run", {
args: {
sourceId: v.string(),
requireApproval: v.optional(v.boolean()),
},
handler: async (wf, args) => {
const prepared = await wf.step("prepare", async () => {
return { sourceId: args.sourceId, startedAt: new Date().toISOString() };
});
if (args.requireApproval) {
await wf.waitForEvent("approval", "24h");
}
return wf.step("finalize", async () => ({
ok: true,
prepared,
}));
},
});
const label = deriveWorkflowStatus("pending", "wait:approval");
// "waiting"Runtime primitives
wf.step(name, fn)— memoized checkpointwf.parallel([...])— parallel branches with partial progress retentionwf.waitForEvent(name, timeout?)— durable external event waitwf.sleep(duration)— durable timer pause
Security and ownership
workflow()starts a durable run, but visibility still needs auth discipline- prefer
api.workflows.get/api.workflows.listfor inspection flows - if you query workflow rows manually, keep the endpoint authenticated and scope by
user_id - do not model
_gencow_*runtime tables in appschema.tsor app-authored migrations
httpRoute
Define custom HTTP endpoints (REST-style routes) with a fluent, typed builder.
Bind httpRoute once in gencow/runtime.ts via createGencowHttpRouteBuilders (same pattern as procedure), then import it from ./runtime in route modules. Register defs via defineApi({ httpRoutes }) — no global registry.
// gencow/runtime.ts
import type { GencowSchema } from "./generated/db-schema.gen";
import { createGencowHttpRouteBuilders } from "@gencow/core";
export const httpRoute = createGencowHttpRouteBuilders<GencowSchema>();
// gencow/http-routes.ts (or index.ts)
import { defineApi } from "@gencow/core";
import { httpRoute } from "./runtime";
import { z } from "zod";
// GET /api/health — public, no schema
const health = httpRoute.get
.path("/api/health")
.allowAnonymous()
.handler(async () => ({ body: { status: "ok" } }));
// POST /api/echo — typed input + output
const echo = httpRoute.post
.path("/api/echo")
.allowAnonymous()
.input(z.object({ message: z.string() }))
.output(z.object({ echoed: z.string(), length: z.number() }))
.handler(async ({ input }) => ({
body: { echoed: input.message, length: input.message.length },
}));
// GET /api/tasks/:id — path params, authenticated by default
const taskById = httpRoute.get
.path("/api/tasks/:id")
.handler(async ({ context, request }) => {
context.auth.requireAuth();
const id = Number(request.params.id);
if (!Number.isFinite(id)) return { status: 400, body: { error: "id must be a number" } };
const [row] = await context.db.select().from(tasks).where(eq(tasks.id, id));
return row ? { body: row } : { status: 404, body: { error: "not found" } };
});
export default defineApi({
httpRoutes: { health, echo, taskById },
});Builder Chain
| Step | Required | Description |
|---|---|---|
httpRoute.<verb> |
✅ | Entry point: get, post, put, delete, patch — method is fixed at the verb |
.path(string) |
✅ | URL path (Hono pattern, e.g. /api/tasks/:id) |
.allowAnonymous() |
optional | Skip auth gate (default: authenticated required, 401 otherwise) |
.use(middleware) |
optional | v1 middleware — composable onion model, can short-circuit |
.input(schema) |
optional | Standard Schema v1 validator (Zod, Valibot, ArkType, …). On failure: 400 |
.output(schema) |
optional | Standard Schema v1 validator for the response body. On failure: 500 |
.handler(fn) |
✅ | Terminal — returns an HttpActionDef |
Input Source by Method
The framework normalizes input to one shape before validation:
| Method | Input comes from | Notes |
|---|---|---|
GET |
request.query |
Query strings are always strings — no auto-coercion |
DELETE |
request.query |
Same as GET |
POST / PUT / PATCH |
parsed JSON body | Bad JSON → 400 Invalid JSON body |
No auto-coercion. If you want numbers/dates from a query string, do it in the schema (
z.coerce.number()) or inside the handler. Path params (:id) stay onrequest.params— they are not merged into.input().
Handler Signature
async ({ context, input, request }) => HttpRouteResult<TOutput>context—GencowCtx(db, auth, storage, …)input— typed from.input(schema);undefinedif no.input()was setrequest—HttpActionRequestwith raw access tomethod,headers,params,query,json(),formData(),arrayBuffer(),text()- Return shape:
{ status?, headers?, body? }. When.output(schema)is set, the schema validatesbody.
Middleware (.use)
const traced = httpRoute.post
.path("/api/traced")
.allowAnonymous()
.use(async ({ request, next }) => {
const start = Date.now();
const traceId = request.headers["x-trace-id"] ?? crypto.randomUUID();
const result = await next();
return {
...result,
headers: {
...(result.headers ?? {}),
"x-trace-id": traceId,
"x-duration-ms": String(Date.now() - start),
},
};
})
.input(z.object({ name: z.string() }))
.handler(async ({ input }) => ({ body: { greeting: `hello ${input.name}` } }));- Middlewares run in declared order, onion-style:
mw1.before → mw2.before → handler → mw2.after → mw1.after. - Calling
next({ context, input })overrides downstream values. - Skipping
next()short-circuits — handler never runs. - Ordering vs
.input()matters: middleware before.input()sees the raw parsed body; after sees the validated/transformed value. Validation runs at the chain position where you placed.input().
Input Validation and Middleware Ordering
.input(schema) is inserted into the middleware chain at the position where you called it. This affects both what each middleware sees and what happens when validation fails.
chain: use(mwA) → use(mwB) → input(schema) → use(mwC) → handler
│
└─ runs HERE during execution
happy path execution flow:
mwA.before → mwB.before → [validate input] → mwC.before → handler
↓
mwA.after ← mwB.after ← ───────────────── ← mwC.after ← ─┘
failure path (input schema rejects):
mwA.before → mwB.before → [validate input] ✗
│
↓
400 returned to client
(mwC, handler, mwA.after, mwB.after are SKIPPED)Why the short-circuit matters:
- A
traced-style middleware that adds response decoration (headers, timing) in its "after" half will not run on validation failure. The 4xx response is built by the framework, not by your middleware chain. If you need to log validation failures, do it from a middleware placed before.input(schema)and inspect the result ofawait next()— if the status is 400, validation failed. - A middleware placed after
.input(schema)can safely assumeinputis the validated/transformed value. - A middleware placed before
.input(schema)sees the raw parsed body (or query object for GET/DELETE). Use this position to mutate input before validation runs.
Same rule applies to output validation: when .output(schema) is set, validation runs before the response leaves the chain. Output validation is skipped on status >= 400 so handler-emitted error responses pass through unmodified. If you need a typed error escape hatch inside a route that has .output(schema), return { status: 4xx, body: ... as any } — the runtime accepts it, but the type checker will require a cast (or a union output schema) since the typed body is constrained to the success shape.
Registration
Register procedures via defineApi.procedures and routes via defineApi.httpRoutes — these bags are the single source of truth. defineApi itself is side-effect free; the server collects definitions from the loaded app module (no globalThis).
export default defineApi({
procedures: {
listMine,
createOne,
tasksList: tasksCrud.list,
tasksGet: tasksCrud.get,
tasksCreate: tasksCrud.create,
tasksUpdate: tasksCrud.update,
tasksRemove: tasksCrud.remove,
},
httpRoutes: { health, echo, taskById },
});The crud registration bag is kept for compatibility with older apps. Current templates and migrated apps should register every createCrud() operation explicitly under procedures.
Request Object (request)
| Property | Type | Description |
|---|---|---|
request.method |
string |
"GET", "POST", … |
request.path |
string |
URL path |
request.params |
Record<string, string> |
Hono path params (:id) |
request.query |
Record<string, string> |
URL query parameters |
request.headers |
Record<string, string> |
HTTP headers |
request.json<T>() |
Promise<T> |
Parse JSON body |
request.formData() |
Promise<FormData> |
Parse multipart/form-data |
request.arrayBuffer() |
Promise<ArrayBuffer> |
Raw bytes |
request.text() |
Promise<string> |
Raw text |
ctx — The Context Object
Every procedure handler receives context (often aliased as ctx):
handler: async (ctx, args) => {
// Database (RLS injected automatically)
ctx.db // Drizzle ORM instance (filtered)
ctx.unsafeDb // Drizzle ORM instance (unfiltered, admin only)
// Auth
ctx.auth.requireAuth() // Returns session (throws if not authed)
ctx.auth.getSession() // Returns session or null
// Storage
ctx.storage.store(file) // Store file → storageId
ctx.storage.getUrl(id) // Get serving URL
ctx.storage.delete(id) // Delete file
// Retrieval / RAG
ctx.search("rag_chunks", query, options) // Keyword search
ctx.vectorSearch("rag_chunks", options) // Vector search
ctx.hybridSearch("rag_chunks", query, options) // Keyword + vector fusion
ctx.grounding?.answer(input) // Grounded claims + citations
// Workflow-only document conversion
wf.services.document.convert(input) // Only inside workflow()
// Scheduler
ctx.scheduler.runAfter(delayMs, "mutation.name", args)
ctx.scheduler.runAt(timestamp, "mutation.name", args)
// AI (generated gencow/ai.ts)
// After: gencow add AI
import { ai } from "./ai";
ai.chat({ system, messages }) // → { text, usage, creditsCharged, model }
ai.stream({ system, messages }) // → AsyncIterable<string>
ai.embed(text) // → number[]
// Realtime (custom procedures only — createCrud() handles this automatically)
ctx.realtime.invalidate("queryKey") // Signal subscribers to refetch with their auth/args
ctx.realtime.refresh("queryKey") // Re-run server-safe query + push result
ctx.realtime.emit("queryKey", data) // Push data directly to public/opaque subscribers
// File Upload (in mutation with FormData)
// File is accessed via args, NOT ctx:
const file = args?.["file"] as File; // from FormData upload
await ctx.storage.store(file); // store to disk
}ctx.db — RLS Database
Drizzle ORM instance with automatic access control. RLS policies from ownerRls() are inherently applied at the PostgreSQL level since ctx.db has the active user session context automatically injected.
import { eq, and, desc } from "drizzle-orm";
import { tasks } from "./schema";
// SELECT — Postgres RLS automatically filters
const rows = await ctx.db.select().from(tasks); // only current user's tasks
// SELECT with additional WHERE — combined with RLS
const done = await ctx.db.select().from(tasks).where(eq(tasks.done, true));
// INSERT — passes through (no filter needed for new rows)
const [row] = await ctx.db.insert(tasks).values({ title: "..." }).returning();
// UPDATE — filter auto-applied (can only update own rows)
await ctx.db.update(tasks).set({ done: true }).where(eq(tasks.id, 1));
// DELETE — filter auto-applied
await ctx.db.delete(tasks).where(eq(tasks.id, 1));
// ❌ Blocked — raw SQL not allowed on scoped db
ctx.db.execute(sql`SELECT * FROM tasks`); // throws Errorctx.unsafeDb — Raw Database (Escape Hatch)
Direct Drizzle instance without access control. Use for admin operations, migrations, or cross-user queries.
// ⚠️ Only for intentional filter bypass — flagged in security audits
const allTasks = await ctx.unsafeDb.select().from(tasks);
ctx.unsafeDb.execute(sql`TRUNCATE tasks`); // raw SQL allowedWarning:
ctx.unsafeDbbypasses schema-level access control. Protected handlers are reported for review..allowAnonymous()procedures andhttpRoutehandlers are blocked when they usectx.unsafeDb,rawSql,SQL.unsafe, orclient.unsafewithout a completegencow-allow-unsafe-db reason: ... scope: ... owner: ... test: ...comment. Anonymous handlers cannot directly expose_system_*or_gencow_*tables.
ctx.auth — Authentication
// Require authentication (throws 401 if not logged in)
const session = ctx.auth.requireAuth();
// session.user.id — User ID
// session.user.email — User email
// session.user.name — User name
// Optional authentication
const session = ctx.auth.getSession();
// null if not authenticatedctx.storage — File Storage
// Store a file
const storageId = await ctx.storage.store(file, "optional-filename");
// Store from buffer
const storageId = await ctx.storage.storeBuffer(buffer, "name.pdf", "application/pdf");
// Get public URL
const url = ctx.storage.getUrl(storageId);
// → "/api/storage/<uuid>"
// Get metadata
const meta = await ctx.storage.getMeta(storageId);
// → { id, name, size, type, path }
// Delete (also removes all cached image transforms)
await ctx.storage.delete(storageId);Image Optimization (Automatic)
Images served through storage URLs are automatically optimized:
// Basic URL — Auto WebP applied if browser supports it
const url = ctx.storage.getUrl(storageId);
// → "/api/storage/<uuid>"
// Browser with WebP support → served as WebP (smaller)
// Browser without WebP → served as original
// Transform parameters (Pro/Scale plans only)
// Append query params to the URL:
const thumb = `${url}?w=200`; // 200px wide thumbnail
const hero = `${url}?w=1200&q=85&f=webp`; // hero image optimized| Parameter | Values | Plan |
|---|---|---|
w |
1–4096 (px) | Pro+ |
h |
1–4096 (px) | Pro+ |
f |
webp, avif, jpeg, png |
Pro+ |
q |
1–100 | Pro+ |
fit |
cover, contain, fill, inside |
Pro+ |
See Storage Guide for full details and tier limits.
ctx.search / ctx.vectorSearch / ctx.hybridSearch — Retrieval
Gencow exposes retrieval primitives on ctx. They are most commonly used with
canonical RAG chunks created by documents.ingest.*.
const keyword = await ctx.search("rag_chunks", "refund policy", {
fields: ["chunk_text", "lexical_text"],
scope: { corpus: "manuals", visibility: "shared" },
limit: 10,
});
const semantic = await ctx.vectorSearch("rag_chunks", {
vector: embedding,
vectorField: "embedding",
scope: { corpus: "manuals", visibility: "shared" },
limit: 10,
});
const hybrid = await ctx.hybridSearch("rag_chunks", "refund policy", {
fields: ["chunk_text", "lexical_text"],
vector: embedding,
vectorField: "embedding",
scope: { corpus: "manuals", visibility: "shared" },
tuning: {
keywordCandidateLimit: 40,
vectorCandidateLimit: 40,
fusion: { mode: "rrf" },
},
});Search options always require a canonical scope with corpus and
visibility. Private corpora should also pass ownerUserId from the authenticated
session.
ctx.grounding — Grounded RAG
ctx.grounding.answer() verifies answer claims against canonical rag_chunks and
returns citations that point back to rag_sources, rag_sections, and chunk ids.
const result = await ctx.grounding?.answer({
question: "What is the refund window?",
scope: { corpus: "manuals", visibility: "shared" },
claims: [
{
claimText: "Refunds are allowed within seven days.",
requiredTerms: ["refunds", "seven days"],
},
],
mode: "qa",
});
if (!result?.grounded) {
// Show an insufficient-evidence state instead of a fully grounded answer.
}Grounded RAG does not read the legacy rag_documents table generated by
gencow add RAG. Ingest files through documents.ingest.start when you need
claim-level grounding and citations.
wf.services.document.convert — Workflow-only Document Conversion
Document conversion can call external OCR/VLM providers, so it is only available
inside workflow() handlers through wf.services.document.convert().
For the full routing, provider, and Kordoc operations guide, see
Document Conversion.
import { workflow, v } from "@gencow/core";
export const convertManual = workflow("manuals.convert", {
args: {
storageId: v.string(),
filename: v.string(),
},
handler: async (wf, { storageId, filename }) => {
return wf.services.document.convert({
storageId,
filename,
mimeType: "application/pdf",
corpus: "manuals",
visibility: "shared",
mode: "force-ocr",
provider: "auto",
});
},
});Supported formats
document.convert() returns faithful Markdown when the platform has the matching
self-hosted provider configured.
| Format | Status | Default path |
|---|---|---|
| Supported | kordoc quality gate → OpenDataLoader hybrid/OCR |
|
| HWP/HWPX/HWPML | Supported | kordoc → OpenDataLoader when configured |
| DOCX | Supported | kordoc → OpenDataLoader when configured, with local DOCX fallback where available |
| XLSX | Supported | kordoc → OpenDataLoader when configured |
| TXT/Markdown/HTML/CSV | Supported | local text parser |
| PPTX | Not supported yet | planned separately |
| legacy DOC/PPT/XLS | Not supported | use modern Office formats |
Kordoc-backed formats require the platform to configure
document.providers.kordoc.url / GENCOW_DOCUMENT_KORDOC_URL to an internal
convert server. The platform process does not run the Kordoc package locally.
DEV/QC/PROD Kordoc convert servers should require an internal token; configure
document.providers.kordoc.token / GENCOW_DOCUMENT_KORDOC_TOKEN so the
platform sends it as X-Internal-Token.
Safe auto
provider: "auto" is cost-safe by default. It uses local/self-hosted providers
only and does not automatically call Gemini, OpenAI, OCR, or custom VLM providers.
PDF auto:
kordoc
-> OpenDataLoader
-> stop with DOCUMENT_PAID_FALLBACK_REQUIRED
HWP/HWPX/HWPML/DOCX/XLSX auto:
kordoc
-> OpenDataLoader
-> stop with DOCUMENT_PAID_FALLBACK_REQUIRED
TXT/Markdown/HTML/CSV auto:
local_textTo allow paid AI fallback, opt in explicitly and set a per-request budget:
await wf.services.document.convert({
storageId,
filename: "report.pdf",
mimeType: "application/pdf",
corpus: "manuals",
visibility: "shared",
provider: "auto",
paidFallback: true,
maxServiceCredits: 500,
});If paidFallback: true is set without maxServiceCredits, the platform applies
its default auto paid fallback cap. If the estimated cost exceeds the cap, the
request fails before any paid provider is called.
If neither Kordoc nor OpenDataLoader is configured for a supported document,
safe auto fails with DOCUMENT_PAID_FALLBACK_REQUIRED instead of silently using
paid AI providers.
Explicit provider routing
Use an explicit provider when you want that provider and do not want safe-auto selection:
await wf.services.document.convert({
storageId,
filename: "scan.pdf",
mimeType: "application/pdf",
corpus: "manuals",
visibility: "shared",
mode: "force-external",
provider: "openai",
maxServiceCredits: 500,
});Supported provider names are auto, local_text, kordoc, opendataloader,
openai, gemini, ocr, and custom_vlm.
Provider keys, OCR prompts, default models, Kordoc/OpenDataLoader URLs, and custom VLM endpoint settings are platform-owned configuration, not tenant app environment variables.
ctx.scheduler — Delayed Execution
// Run after delay (milliseconds)
await ctx.scheduler.runAfter(0, "emails.send", { to: "[email protected]" });
await ctx.scheduler.runAfter(60_000, "tasks.cleanup", {}); // 1 minute later
// Run at specific time
await ctx.scheduler.runAt(new Date("2026-03-27T09:00:00"), "reports.generate", {});⚠️ Cloud Warning:
scheduler.runAfter()andscheduler.runAt()are sleep-unsafe — if the app is idle and enters sleep mode, scheduled callbacks will be lost. For critical task chaining, usecronJobsor sequential calls from the frontend instead.
ctx.realtime — Push Updates to Clients
For custom write procedures (not createCrud()), use ctx.realtime to push data to WebSocket subscribers:
// invalidate() — clients refetch with their own auth and query args
// Best for private/RLS lists and general CRUD-style updates
ctx.realtime.invalidate("tasks.list");
// refresh() — re-runs the query handler on the server, pushes result
// Best for public/server-safe aggregate queries
ctx.realtime.refresh("dashboard.stats");
// emit() — pushes specific data directly (skip re-query)
// Best for public or opaque-token exact channels where you already have fresh data
ctx.realtime.emit("chat.getMessages", freshMessages);Note:
createCrud()write procedures handle realtime automatically — you only needctx.realtimein customprocedure.mutationhandlers.
| Method | When to Use |
|---|---|
invalidate(queryKey) |
Private/RLS updates — client refetches with auth/args |
refresh(queryKey) |
Public/server-safe aggregate mutations — reuses query logic |
emit(queryKey, data) |
Public or opaque-token exact channels — avoids re-running queries |
v — Validator
Built-in argument validation (similar to Zod but lighter):
import { v } from "@gencow/core";Available Validators
| Validator | Description | Example |
|---|---|---|
v.string() |
String | "hello" |
v.number() |
Number | 42 |
v.boolean() |
Boolean | true |
v.optional(v.X()) |
Optional field | undefined or value |
v.array(v.X()) |
Array | [1, 2, 3] |
v.object({...}) |
Nested object | { key: "value" } |
v.any() |
Any type | Dynamic data |
Usage in Args
{
args: {
title: v.string(),
count: v.number(),
active: v.boolean(),
tags: v.array(v.string()),
config: v.optional(v.object({
theme: v.string(),
limit: v.number(),
})),
},
}cronJobs()
Define scheduled tasks:
import { cronJobs } from "@gencow/core";
const crons = cronJobs();
crons.interval("name", { minutes: 30 }, "module.mutation");
crons.daily("name", { hour: 9 }, "module.mutation");
crons.weekly("name", { dayOfWeek: 1, hour: 10 }, "module.mutation");
crons.cron("name", "*/15 * * * *", "module.mutation");
// Or inline handler:
crons.interval("name", { minutes: 5 }, async (ctx) => { ... });
export default crons; // Required!defineAuth()
Configure authentication:
import { defineAuth } from "@gencow/core";
export default defineAuth({
emailAndPassword: { enabled: true },
// emailVerification: { ... },
});withRetry()
Retry failed operations with exponential backoff:
import { withRetry } from "@gencow/core";
const result = await withRetry(
() => fetch("https://api.example.com/data"),
{ maxAttempts: 3, initialBackoffMs: 1000 }
);Options
| Option | Default | Description |
|---|---|---|
maxAttempts |
3 |
Maximum retry attempts |
initialBackoffMs |
1000 |
Initial delay in ms |
maxBackoffMs |
30000 |
Maximum delay cap in ms |
base |
2 |
Backoff multiplier per retry (1s → 2s → 4s) |
shouldRetry |
all errors | (error, attempt) => boolean — filter retryable errors |
onRetry |
— | (error, attempt, delayMs) => void — logging callback |
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
[createCrud] Table "..." must have an 'id' column |
Table has no id column |
Add id: serial("id").primaryKey() or id: text("id").primaryKey() |
result.map is not a function |
createCrud list returns { data, total }, not array |
Use result.data.map() instead of result.map() |
useQuery returns undefined |
Data is loading | Add if (!result) return <Loading /> |
401 Unauthorized |
Auth required but not logged in | Use createCrud(table, { allowAnonymous: true }) or log in first |
Filters not working |
Filters not whitelisted | Add column names to allowedFilters: ["status"] option |
Tips & Patterns
⚠️ onConflictDoNothing() Requires unique()
onConflictDoNothing() only works when the table has a unique constraint on the conflict target. Without it, the clause is silently ignored — all rows are inserted.
// ❌ WRONG — no unique constraint, onConflictDoNothing is meaningless
export const articles = pgTable("articles", {
id: serial("id").primaryKey(),
url: text("url").notNull(),
});
await ctx.db.insert(articles)
.values({ url: "https://..." })
.onConflictDoNothing(); // ← silently does nothing useful!
// ✅ CORRECT — add unique() constraint on url
export const articles = pgTable("articles", {
id: serial("id").primaryKey(),
url: text("url").notNull().unique(), // ← unique constraint required
});
await ctx.db.insert(articles)
.values({ url: "https://..." })
.onConflictDoNothing({ target: articles.url }); // ← now works correctlyGet Latest 1 Record from createCrud().list
Use limit: 1 with orderBy to fetch the most recent record:
// Frontend — latest 1 record
const latest = useQuery(api.logs.list, { limit: 1, orderBy: "createdAt", orderDir: "desc" });
const latestLog = latest?.data[0]; // first (and only) item
// Frontend — latest with filter
const latestActive = useQuery(api.tasks.list, {
limit: 1,
filters: { status: "active" },
});