Core API
@gencow/core — createCrud, procedure, httpRoute, ownerRls, ctx, validator, cron
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 |
✅ | subscribes to list invalidation |
get |
procedure.query |
{table}.get |
✅ | subscribes by exact id |
create |
procedure.mutation |
{table}.create |
✅ | invalidates list |
update |
procedure.mutation |
{table}.update |
✅ | invalidates list + get |
remove |
procedure.mutation |
{table}.remove |
✅ | invalidates list + get |
list Response Format
createCrud().list returns an object with data and total count:
// Backend: list returns { data: T[], total: number }
// Frontend:
const { data: 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() insteadPassing an empty args object is also fine: useQuery(api.tasks.list, {}). Current runtimes normalize omitted optional object input to the same default list request.
With pagination:
const [page, setPage] = useState(1);
const limit = 20;
const { data: 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 list and exact get invalidation (default: enabled)
// 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() }),
},
});realtime accepts:
| Value | Effect |
|---|---|
| omitted | Enables broad list invalidation plus exact get invalidation. Public CRUD uses policy: "public"; authenticated CRUD uses policy: "authorized". |
false |
Disables generated list and exact get invalidation. |
{ listRealtime: false } |
Disables list fanout but keeps exact get({ id }) invalidation for update/remove. |
| scoped config | Enables list buckets by policy, resourceIdentity, and JSON scope. |
Scoped config shape:
createCrud(tasks, {
realtime: {
policy: "authorized", // or "public"
resourceIdentity: "tasks",
scope: (args) => ({ workspaceId: args?.filters?.workspaceId }),
scopeFromRow: ({ row, ctx }) => ({ workspaceId: row.workspaceId }),
authorize: async ({ ctx, args }) => {
const session = ctx.auth.requireAuth();
await assertWorkspaceMember(ctx.db, session.user.id, args?.filters?.workspaceId);
},
},
});authorize is required for policy: "authorized" and is not used for policy: "public". scope and scopeFromRow must return non-null plain objects containing only JSON-serializable values. create invalidates matching list buckets. update invalidates both old and new list buckets, then the exact get({ id }) subscription when generated. remove invalidates the matching list bucket and exact get({ id }). Exact generated get watchers keep the generated get auth/query contract instead of reusing CRUD list scope metadata.
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 API builders
The procedure API is the standard way to define custom query, mutation, and internal handlers. It uses Standard Schema validation, composable middleware, and a dedicated 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) |
procedure.internal |
Server-only procedures for cron/scheduler work; not exposed on user RPC |
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 });
});
export const cleanupOldTasks = procedure.internal
.name("tasks.cleanupOld")
.handler(async ({ context }) => {
await context.db.delete(tasks).where(eq(tasks.done, true));
});Register defs on defineApi({ procedures: { listMine, createOne, cleanupOldTasks } }). 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.
Use procedure.internal for scheduled jobs and other platform-invoked handlers.
Internal procedures run without a browser session and are only reachable through
trusted platform transports.
Workflow builder
Define a durable multi-step job that can survive sleep, restart, retries, and
human-approval waits. New application code uses the app-owned fluent builder,
then explicitly exports the resulting definition through defineApi({ workflows }).
// gencow/runtime.ts
import type { GencowSchema } from "./generated/db-schema.gen";
import { createGencowWorkflowBuilders } from "@gencow/core";
export const workflow = createGencowWorkflowBuilders<GencowSchema>();// gencow/imports.ts
import { v } from "@gencow/core";
import { workflow } from "./runtime";
export const runImport = workflow
.name("imports.run")
.input(v.object({
sourceId: v.string(),
requireApproval: v.optional(v.boolean()),
}))
.maxDuration("30m")
.retries(3)
.handler(async (wf, args) => {
const prepared = await wf.step("prepare", async () => {
return { sourceId: args.sourceId, startedAt: new Date().toISOString() };
});
if (args.requireApproval) {
const approval = await wf.waitForEvent<{ approved: boolean }>("approval", "24h");
if (approval.approved !== true) return { ok: false, reason: "declined" };
}
return wf.step("finalize", async () => ({ ok: true, prepared }));
});// gencow/index.ts
import { defineApi } from "@gencow/core";
import { runImport } from "./imports";
export default defineApi({ workflows: { runImport } });.name(...) is required before the terminal .handler(...). Before it, use
.input(...), .use(...), .allowAnonymous(),
.maxDuration(...), .lifecycleTimeout(...), .concurrency(...), and
.retries(...) as needed. WorkflowDuration accepts a positive number of
milliseconds or a duration string such as "30m".
| Method | Effect |
|---|---|
.name("imports.run") |
Set the public start name: api.imports.run |
.input(schema) |
Validate start input with a Standard Schema v1 validator |
.use(middleware) |
Add start admission middleware in declaration order |
.allowAnonymous() |
Allow starts without a signed-in user |
.maxDuration("10m") |
Limit active execution while a worker holds the run |
.lifecycleTimeout("48h") |
Set an overall deadline including durable pauses |
.concurrency(5) |
Limit concurrent execution for this workflow |
.retries(3) |
Configure recoverable execution retries |
.handler(fn) |
Finish the definition with its resumable handler |
After registration, run gencow codegen and start it through
useMutation(api.imports.run). Use the returned run ID with
useWorkflow(api.workflows.get, run.id) to observe the result. See the
Workflow Agents guide for a complete React example.
Note — definition versus execution: Calling the builder creates a definition; it does not start a run or register it globally. Register the definition in your existing
defineApi()export. Workflow start names follow.name(...), rather than theworkflowsbag key.
Advanced — timeout selection: Active execution, an individual wait, and the whole run have separate limits. Pair
.maxDuration("10m")withwf.waitForEvent("approval", "24h")for a bounded job that allows a day for approval; add.lifecycleTimeout("48h")to cap total elapsed lifetime.
Start middleware
.use(...) is start middleware, with the same context, input, and
single-use next({ context?, input? }) model as procedure middleware. It is
for admission-time authorization, tenancy, rate limits, tracing, and safe input
normalization. It never wraps the durable handler, a step, or a resume/retry.
export const runTenantImport = workflow
.name("imports.tenantRun")
.input(v.object({ sourceId: v.string() }))
.use(async ({ context, input, next }) => {
await assertTenantMayImport(context.auth, input.sourceId);
return next();
})
.concurrency(3)
.handler((wf, input) => wf.step("import", () => importSource(input.sourceId)));Input validation and middleware run in their declared order. Execution-wide policy belongs in the handler or a named durable step, because a resumed run may not have the original start request.
Note — validated middleware input: Place
.input(...)before.use(...)when middleware needs validated input.assertTenantMayImport()andimportSource()in this example are helpers you implement for your app's access rules and import logic.
Runtime primitives
wf.step(name, fn)— memoized checkpointwf.parallel([...])— run independent branches concurrently; use namedwf.step()checkpoints in each branchwf.waitForEvent(name, timeout?)— durable external event waitwf.sleep(duration)— durable timer pause
deriveWorkflowStatus("pending", "wait:approval") returns "waiting" for
UI display. A generated public start such as api.imports.run returns
WorkflowStartResult; its handler result is stored in the workflow state. The
framework-owned resume capability is internal and never emitted to client code.
Advanced — checkpoint safety: A resumed handler re-enters from the top, and completed named steps reuse their saved output. Keep step names stable, return compact JSON values, and use idempotency keys for external writes that could succeed before a checkpoint is saved. An approval signal's TypeScript type does not validate its payload at runtime; inspect or validate it before taking a sensitive action.
Security and ownership
- Workflow starts require auth unless
.allowAnonymous()is intentionally used. - 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.
Deprecated: legacy workflow()
The workflow(name, options) export from @gencow/core remains only for
existing apps during the migration window. It globally registers a legacy
mutation wire. Do not use it in new code, templates, or examples.
// Deprecated legacy API.
import { workflow, v } from "@gencow/core";
export const legacyRunImport = workflow("imports.legacyRun", {
args: { sourceId: v.string() },
handler: (wf, args) => wf.step("import", () => importSource(args.sourceId)),
});Migrate it to the fluent builder and add the resulting definition to
defineApi({ workflows: { ... } }). Convert args: { sourceId: v.string() }
to .input(v.object({ sourceId: v.string() })) and public: true to
.allowAnonymous(). Keep the logical name, replace the old constructor, and
rerun gencow codegen. The migration table
covers the other options.
Note — compatibility: Omitted legacy workflows remain on their legacy wire, even when
workflows: {}is present. Do not construct both a legacy and a fluent definition with the same name while migrating an app.
Realtime channel builder
Declare a typed, authorized realtime channel without a global registry. Bind the
pure builder in the app-owned runtime and register each completed declaration in
defineApi({ realtimeChannels }).
// gencow/runtime.ts
import type { GencowSchema } from "./generated/db-schema.gen";
import { createGencowRealtimeChannelBuilder } from "@gencow/core";
export const realtimeChannel = createGencowRealtimeChannelBuilder<GencowSchema>();// gencow/conversation-events.ts
import { v } from "@gencow/core";
import { realtimeChannel } from "./runtime";
export const conversationEvents = realtimeChannel
.name("conversation.events")
.input(v.object({ conversationId: v.string() }))
.event(v.object({
type: v.literal("message.created"),
message: v.object({ id: v.string(), body: v.string() }),
}))
.channelKey(({ conversationId }) => `conversation:${conversationId}`)
.authorize(async ({ context, input }) => {
const user = context.auth.requireAuth();
const membership = await requireConversationMember(context.db, user.id, input);
return { ttlSeconds: 300, epoch: membership.epoch };
});// gencow/index.ts
import { defineApi } from "@gencow/core";
import { conversationEvents } from "./conversation-events";
export default defineApi({ realtimeChannels: { conversationEvents } });.name(...) and the terminal .authorize(...) are required. .input(...)
validates browser subscription arguments; .event(...) validates and normalizes
one outbound event; .channelKey(...) creates the fanout key. The declaration
key in realtimeChannels: { conversationEvents } is the generated client
property (api.realtimeChannels.conversationEvents), while .name(...) is the
logical wire channel name.
| Method | Effect |
|---|---|
.name("conversation.events") |
Set the logical channel name; required |
.input(schema) |
Validate and type subscription/publisher scope |
.event(schema) |
Validate outbound events and type publisher/listener payloads |
.channelKey(fn) |
Derive a deterministic fanout key from parsed input |
.authorize(fn) |
Check subscriber access and finish the definition; required |
Without .channelKey(...), the name and normalized input determine the key.
Declare .input(...) and .event(...) when you want generated input and event
typing, then run gencow codegen after registration.
Authorize and publish
The authorizer receives { context, input } after input validation. Return
false to deny access, or { ttlSeconds?, epoch? } to grant access. It may
perform database access checks. channelKey() must instead be deterministic,
side-effect free, and inexpensive; it must not read the database, network,
clock, random values, or authorization state.
// Inside an authorized backend handler. Check writer permissions before this.
await ctx.realtime.channel(conversationEvents, { conversationId }).publish({
type: "message.created",
message: { id: message.id, body: message.body },
});
await ctx.realtime.channel(conversationEvents, { conversationId }).revoke({
epoch: nextMembershipEpoch,
reason: "membership_changed",
});Subscribe in a React component using the generated reference:
useRealtimeChannel(
{ channel: api.realtimeChannels.conversationEvents, args: { conversationId } },
(event) => console.log(event.message.body),
);Import useRealtimeChannel from @gencow/react and api from your generated
gencow/api.ts. The Realtime guide
shows complete server and component examples.
Note — authorization boundary:
.authorize()runs for grant issuance and refresh, not once per publish or recipient. Check a writer's permission in the mutation separately. All subscribers sharing a key receive the same event, so include every required workspace or resource boundary in the key.
Advanced — access epochs: Store an access version and return it as
epochfrom the authorizer. After a permission change, persist the new membership state, then callrevoke({ epoch: nextMembershipEpoch })to remove grants from other epochs. Callingrevoke()without an epoch removes all subscriptions for that key. Fresh grants must use the updated access checks.
Use the declaration object for new server code. It types input and publish data,
validates the event before fanout, and returns promises. Event data must be a
JSON wire DTO: use ISO-8601 strings instead of Date, and do not use Map,
bigint, undefined, functions, class instances, cyclic values, or non-finite
numbers. For example, declare createdAt: v.string() and publish
createdAt: new Date().toISOString().
Advanced — schema transformations: A transforming event validator receives its input type on
publish()and delivers normalized output to listeners. Generated event types fall back tounknownunless that validator exposes representable Standard JSON Schema v1 output metadata. Awaitpublish()so asynchronous validation finishes before the handler returns.
Deprecated: defineRealtimeChannels() and string publishers
defineRealtimeChannels() and ctx.realtime.channel("name", args) are legacy
compatibility APIs. The former writes to a global registry and the latter is
untyped. Do not use either in new code.
// Deprecated legacy API.
import { defineRealtimeChannels, v } from "@gencow/core";
defineRealtimeChannels({
"conversation.events": {
args: v.object({ conversationId: v.string() }),
authorize: (ctx) => {
ctx.auth.requireAuth();
return { ttlSeconds: 300 };
},
},
});
ctx.realtime.channel("conversation.events", { conversationId }).publish(event);Migrate to the builder, register the definition in defineApi({ realtimeChannels }),
publish with the definition, and subscribe with the generated client ref. An
example migration is args: v.object({ conversationId: v.string() }) to
.input(v.object({ conversationId: v.string() })), followed by an event schema
and .authorize(({ context, input }) => ...). The new authorizer receives an
options object instead of positional (ctx, args) arguments.
Note — compatibility: An empty
realtimeChannels: {}does not suppress legacy registrations. Replace a channel's old registry entry when registering its fluent declaration, preserve its intended key mapping, and rerun codegen. Other legacy channels may remain; duplicate logical names are rejected.
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 // System-plane Drizzle instance (audited admin/system only)
// Auth
ctx.auth.requireAuth() // Returns session (throws if not authed)
ctx.auth.getSession() // Returns session or null
// Storage
ctx.storage.store(file) // Store private file -> storageId
ctx.storage.createReadGrant(id) // Short-lived private browser URL
ctx.storage.getPublicUrl(id) // Bare URL for public objects
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 a workflow handler
// 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[]
ai.rerank({ query, documents, topK }) // → ranked candidates (Azure Cohere Fast in cloud)
ai.vision.extractText({ image, mediaType }) // → { text, markdown?, usage, creditsCharged, model }
ai.speech.transcribe({ storageId, model: "gpt-4o-mini-transcribe" }) // → async STT job
ai.speech.synthesize({ input, model: "gpt-4o-mini-tts", voice: "alloy" }) // → private audio
// 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.unsafeDbuses the audited system-plane database handle and is not for normal tenant/user-scoped app logic. 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 private file
const storageId = await ctx.storage.store(file, "optional-filename");
// Store from buffer
const storageId = await ctx.storage.storeBuffer(buffer, "name.pdf", "application/pdf");
// Create a private preview URL
const readGrant = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "inline",
});
// Store an intentionally public asset
const publicId = await ctx.storage.store(file, { visibility: "public" });
const publicUrl = ctx.storage.getPublicUrl(publicId);
// -> "/api/storage/<uuid>"
// Change visibility later
await ctx.storage.setVisibility(storageId, "public");
await ctx.storage.setVisibility(storageId, "private");
// Get metadata
const meta = await ctx.storage.getMeta(storageId);
// -> { id, name, size, type, visibility }
// Delete (also removes all cached image transforms)
await ctx.storage.delete(storageId);Image Optimization (Automatic)
Images served through storage URLs are automatically optimized:
// Public URL — Auto WebP applied if browser supports it
const url = ctx.storage.getPublicUrl(publicId);
// -> "/api/storage/<uuid>"
// Browser with WebP support → served as WebP (smaller)
// Browser without WebP → served as original
// Explicit transform parameters (Pro and Scale plans)
// 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 optimizedPrivate images use grant URLs instead:
const grant = await ctx.storage.createReadGrant(storageId);
const thumb = `${grant.url}&w=200`;| Parameter | Values | Plan |
|---|---|---|
w |
1–4096 (px) | Pro / Scale |
h |
1–4096 (px) | Pro / Scale |
f |
webp, avif, jpeg, png |
Pro / Scale |
q |
1–100 | Pro / Scale |
fit |
cover, contain, fill, inside |
Pro / Scale |
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 fluent workflow handlers through wf.services.document.convert().
For the full routing and provider guide, see
Document Conversion.
Do not use this API for raw PNG, JPEG, WebP, or GIF input. Use
`ai.vision.extractText()`
for image-to-text, or pass image content parts to ai.chat() for visual
questions and reasoning. mode: "force-ocr" only changes provider routing for
scanned content inside a supported document such as a PDF.
import { v } from "@gencow/core";
import { workflow } from "./runtime";
export const convertManual = workflow
.name("manuals.convert")
.input(v.object({
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 |
| PNG/JPEG/WebP/GIF | Not supported | use ai.vision.extractText() or image parts in ai.chat() |
| PPTX | Not supported yet | planned separately |
| legacy DOC/PPT/XLS | Not supported | use modern Office formats |
Provider routing and credentials are platform-owned configuration. Tenant app code can request a conversion mode or provider, but it should not manage document provider endpoints or tokens.
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, provider endpoints, and custom VLM settings are platform-owned configuration, not tenant app environment variables.
ctx.scheduler — Delayed Execution
Use ctx.scheduler for short delayed work and step-by-step task chaining. In
Gencow Cloud, this is a BaaS Platform DB-backed durable scheduler: scheduled
jobs are persisted in the Platform database, the Platform poller wakes the app
when the job is due, and the callback is retried with backoff if the runtime
crashes or the app is asleep.
// Run after delay (milliseconds)
await ctx.scheduler.runAfter(0, "emails.send", { to: "user@example.com" });
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 execution contract:
| Runtime | Durability | Notes |
|---|---|---|
| Gencow Cloud | Durable through Platform scheduled_jobs |
App sleep/crash does not discard the callback; execution can be delayed by poll interval and cold start |
| Local dev | In-memory best effort | Restarting the local process can lose pending timers |
Use the fluent workflow builder when you need a resumable state machine with named steps,
signals, and persisted progress. Use cron for recurring schedules. Use
ctx.scheduler.runAfter() when a completed mutation should enqueue the next
bounded internal step.
export const processNextBatch = procedure.internal
.name("import.processNextBatch")
.input(v.object({ importId: v.string(), cursor: v.optional(v.string()) }))
.handler(async ({ context: ctx, input }) => {
const batch = await loadBatch(input);
await writeRows(ctx.db, batch.rows);
if (batch.nextCursor) {
await ctx.scheduler.runAfter(0, "import.processNextBatch", {
importId: input.importId,
cursor: batch.nextCursor,
});
}
});Keep public starter procedures authenticated, validate ownership before enqueueing, and pass only small serializable arguments. Do not put secrets, provider keys, raw prompts, private storage grant URLs, or full database URLs in scheduled job arguments.
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(),
})),
},
}cron
Define scheduled tasks with procedure.internal handlers and register them on the same defineApi entry point as your procedures:
import { cron, defineApi } from "@gencow/core";
import { procedure } from "./runtime";
export const syncData = procedure.internal
.name("data.sync")
.handler(async ({ context }) => {
// server-only scheduled work
});
export const syncDataCron = cron.name("syncData").interval({ minutes: 30 }).handler(syncData);
export const morningSyncCron = cron.name("morningSync").daily({ hour: 9 }).handler(syncData);
export default defineApi({
procedures: { syncData },
crons: { syncDataCron, morningSyncCron },
});Cloud deploys register cron schedules with the platform scheduler. Legacy
gencow/crons.ts and cronJobs() definitions are still read during migration,
but new apps should prefer independent cron defs in
defineApi({ crons }).
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" },
});