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 add procedure.query / procedure.mutation only 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 instead

Recommended 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() instead

With 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 ignored

Advanced 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

  • allowedFilters is required — if not set, all filters are silently ignored (Secure by Default)
  • Filters on columns not in allowedFilters are silently dropped (no error)
  • allowedFilters validation is applied recursively inside OR/AND groups
  • 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 userId column, create automatically sets it from the authenticated user
  • updatedAt auto-set: If the table has an updatedAt column, update automatically sets it to new Date()
  • RLS filtering: ctx.db has 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 checkpoint
  • wf.parallel([...]) — parallel branches with partial progress retention
  • wf.waitForEvent(name, timeout?) — durable external event wait
  • wf.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.list for inspection flows
  • if you query workflow rows manually, keep the endpoint authenticated and scope by user_id
  • do not model _gencow_* runtime tables in app schema.ts or 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 on request.params — they are not merged into .input().

Handler Signature

async ({ context, input, request }) => HttpRouteResult<TOutput>
  • contextGencowCtx (db, auth, storage, …)
  • input — typed from .input(schema); undefined if no .input() was set
  • requestHttpActionRequest with raw access to method, headers, params, query, json(), formData(), arrayBuffer(), text()
  • Return shape: { status?, headers?, body? }. When .output(schema) is set, the schema validates body.

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 of await next() — if the status is 400, validation failed.
  • A middleware placed after .input(schema) can safely assume input is 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 Error

ctx.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 allowed

Warning: ctx.unsafeDb bypasses schema-level access control. Protected handlers are reported for review. .allowAnonymous() procedures and httpRoute handlers are blocked when they use ctx.unsafeDb, rawSql, SQL.unsafe, or client.unsafe without a complete gencow-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 authenticated

ctx.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
PDF 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_text

To 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() and scheduler.runAt() are sleep-unsafe — if the app is idle and enters sleep mode, scheduled callbacks will be lost. For critical task chaining, use cronJobs or 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 need ctx.realtime in custom procedure.mutation handlers.

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 correctly

Get 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" },
});