Skip to content

kahveciderin/covara

Repository files navigation

Covara

Covara

📚 Documentation  ·  Quick Start  ·  Tutorial

Your Drizzle schema is already a backend. Covara turns it into a complete, production-ready API — REST endpoints, real-time subscriptions, auth, file uploads, billing, email, and background jobs — with a type-safe, offline-first TypeScript client on the other end. Built on Hono, it runs standalone on Node or at the edge on Cloudflare Workers.

The Goal

Every product backend is the same 80%: CRUD endpoints, filtering, pagination, auth, sessions, password reset, file uploads, webhooks for payments, transactional email, a job queue, and a client that talks to all of it. You rewrite this plumbing for every project, and the pieces never quite fit together — your realtime layer doesn't know about your auth scopes, your client types drift from your API, your offline cache fights your subscriptions.

Covara's goal is to make that 80% one coherent system derived from a single source of truth: your Drizzle schema.

  • Define a table → get a full REST API with filtering, pagination, aggregations, batch ops, and OpenAPI docs.
  • Add an auth scope → it's enforced everywhere: queries, mutations, subscriptions, search.
  • Mutate data anywhere — generated endpoint, custom route, RPC — and every subscribed client updates in real time.
  • Use the client → full TypeScript inference, optimistic updates, offline queue, automatic reconnect. In React, React Native, or plain TS.

The remaining 20% — your business logic — goes in lifecycle hooks, RPC procedures, and ordinary Hono routes, with the framework's tracking and typing intact.

// server: a table becomes an API
const app = createCovara({ cors: true })
  .resource("/todos", todosTable, {
    id: todosTable.id,
    db,
    auth: { update: async (user) => rsql`userId==${user.id}` },
  });

// client: the API becomes live UI
function TodoList() {
  const { items, mutate } = useLiveList<Todo>("/api/todos", { orderBy: "position" });
  return items.map((todo) => (
    <Todo key={todo.id} {...todo} onDelete={() => mutate.delete(todo.id)} />
  ));
  // creates/updates/deletes apply optimistically, sync offline,
  // and stream to every other connected client over SSE
}

Features

Core API

  • Automatic REST API - Full CRUD endpoints from your Drizzle schema
  • Real-time Subscriptions - SSE with changelog-based updates, sequence numbers, and seamless reconnection — all live subscriptions share one multiplexed connection by default (invisible, with automatic fallback), so many live hooks don't exhaust the browser's connection cap
  • Relations & Joins - belongsTo, hasOne, hasMany, manyToMany with efficient batch loading, optional foreign-key auto-discovery (autoRelations), and scope-enforced includes (a relation never reveals rows the user couldn't read directly)
  • RSQL Filtering - Comprehensive query language (30+ operators) plus custom operators
  • Cursor Pagination - Keyset pagination with multi-field ordering
  • Aggregations - Group by, count, sum, avg, min, max, with HAVING filtering on aggregate output — available as a one-shot query or a live subscription (useLiveAggregate) that recomputes on every change
  • Nested Write-Through - Create belongsTo parents and hasMany/hasOne children in one atomic POST
  • Soft Delete - Mark rows deleted instead of removing them; reads hide them unless ?withDeleted=true
  • Batch Operations - Bulk create, update, delete with limits, plus bulk upsert (POST /batch/upsert)
  • Writable Enforcement - fields.writable is an enforced allowlist (mass-assignment protection); strictInput rejects unknown fields
  • Computed Fields - Virtual computed fields added to every response and subscription event
  • Optimistic Locking - ETags, If-Match preconditions with compare-and-swap, auto-incrementing version fields
  • Full-Text Search - Built-in SQLite FTS5 / Postgres tsvector / OpenSearch / in-memory adapters, with an opt-in transactional outbox for at-least-once index convergence
  • RPC Procedures & Lifecycle Hooks - Custom Zod-validated endpoints and before/after hooks on every operation
  • Mutation Tracking - Wrap your Drizzle db so custom routes feed subscriptions and cache invalidation automatically

Runs Everywhere

  • Standalone Node - startServer(app) via covara/node
  • Cloudflare Workers - export default app, D1/Postgres, nodejs_compat
  • Durable Object KV - Cross-isolate subscriptions, rate limits, and sessions on Workers without Redis
  • SQLite & PostgreSQL - libsql, better-sqlite3, D1, postgres-js, Neon, PGlite via Drizzle

Authentication & Security

  • OIDC Provider - Built-in OpenID Connect server with PKCE, token revocation (RFC 7009) and introspection (RFC 7662)
  • OIDC Hardening - Component-wise redirect-URI validation, PKCE-required public clients (plain rejected), federated id_token verification, endpoint rate limiting, KV-backed stores by default
  • Dynamic Client Registration & Consent Revocation - Opt-in POST /register, plus POST /consent/revoke and consent TTL
  • Federated Login - Google, Microsoft, Okta, Auth0, Keycloak, custom (OIDC); plus backends.passport to use any Passport.js OAuth2 strategy (GitHub, Discord, …) as an upstream under your own OIDC provider
  • Social Login - Drop in any Passport.js OAuth2 strategy (GitHub, Discord, Google, …) via fromPassport; runs on Node and Workers, with one-call loginWithSocial / signInWith on the client
  • JWT Auth - JWT bearer adapter on the server, JWTClient + useJWTAuth hook on the client with pluggable token storage (localStorage, memory, AsyncStorage)
  • Session Auth - Auth.js, Passport.js, and session adapters with session rotation on login
  • Multi-Factor Auth - Opt-in TOTP MFA with backup codes
  • Magic Links - Opt-in passwordless email login
  • API Keys - Standalone helpers to create, verify, rotate, and revoke hashed API keys
  • Password Hashing & Policy - Built-in scrypt hashPassword/verifyPassword/needsRehash (Workers-safe) plus an enforceable password policy
  • Account Security - Opt-in CSRF protection, login throttling, email verification, password reset
  • Security Headers - HSTS, X-Frame-Options, MIME-sniffing protection, and more, auto-mounted by createCovara; opt-in CSP (so it never blocks your frontend)
  • Authorization Scopes - Row-level security with RSQL expressions, enforced across reads, writes, subscriptions, and search
  • Field-level Read Masking - fields.readable allowlist strips non-readable columns from every response and subscription event (cannot be bypassed via ?select=)
  • Abuse Protection - A cost-weighted token-bucket budget with proof of work as the overflow valve via abuseProtection({ budget, pow }): within budget, requests are served and debited; over budget, the server issues a stateless, request-bound, one-time-use 428 challenge that the client library solves and retries transparently (solving pays the overdraft). Difficulty is programmatically controllable via a trust hook; an endpoint can also always require PoW. Disable PoW for a hard 429 instead. KV-backed with in-memory fallback; works alongside the conventional rateLimit. See docs
  • CAPTCHA (beta) - Turnstile / hCaptcha / reCAPTCHA / custom providers as a per-endpoint gate (signup/login/…) or an alternative budget-overflow valve. The plain client surfaces a typed challenge; the React <CovaraCaptcha/> component renders the provider widget and retries transparently.

File Storage

  • Storage Adapters - Local disk, S3, Cloudflare R2 (native binding or S3-compat), and in-memory behind one StorageAdapter interface
  • File Resources - First-class resources with an upload/download layer: app.fileResource(...) chains like any resource and inherits the full CRUD/hooks/procedures/relations/subscriptions/scopes surface, plus MIME/size validation, per-user keys, and storage cleanup on delete
  • Zero-config local serving - createCovara auto-serves a local adapter's baseUrl; admin data explorer gets a per-row Download action
  • Presigned URLs - Optional direct-to-bucket uploads/downloads with configurable expiry
  • React Hooks - useFileUpload (with progress), useFile, useFiles; getDownloadUrl() for React Native

Background Processing

  • Task Queue - Distributed background jobs with Redis, Durable Objects, or in-memory backends
  • Cloudflare Queues - Producer/consumer adapter for running tasks on Workers without a poller
  • Retry Strategies - Exponential, linear, or fixed backoff
  • Scheduling - Delayed execution, cron expressions, recurring tasks with a missed-occurrence catchup policy
  • Progress & Result TTL - ctx.reportProgress, heartbeats, and resultTtlMs result expiry
  • Idempotency & Concurrency - Per-task idempotency keys and enforced maxConcurrency
  • Graceful Drain - worker.drain() / worker.stop({ drain: true })
  • Dead Letter Queue - Failed task management with replay lineage and an onDlqEnqueue alerting hook

Email

  • Unified Adapters - covara/email with Resend and Cloudflare Email Service adapters behind one EmailAdapter interface
  • Template Builder - Fluent createEmail().heading().button().code().build() rendering responsive, escaped HTML + a plaintext fallback, with theming
  • Batch Sending - sendEmailBatch for bulk delivery

Billing

  • One Interface, Four Providers - covara/billing over Stripe, Lemon Squeezy, Paddle, and Polar.sh (fetch-based, no SDK deps, Workers-safe)
  • Plans, One-Time & Usage - Define subscription/one-time/usage plans by key; checkout, subscription management, reportUsage, hosted portal
  • Credits Ledger - KV-backed atomic grant/consume/balance/history
  • Webhooks - Per-provider signature verification, idempotent delivery dedupe, and automatic credit granting on payment.succeeded
  • Router & Client - createBillingRouter plus client.billing.* and useCredits/useSubscription/useCheckout hooks

Client Library

  • Type-safe Client - Full TypeScript inference, select projections that narrow return types, typed filter builder, generated types from your API
  • React Hooks - useLiveList, useInfiniteList, useLiveAggregate, useMutation, useSearch, useAuth, useJWTAuth, useFileUpload/useFile/useFiles, useCredits/useSubscription/useCheckout, usePublicEnv, plus query invalidation and prefetch
  • React Native Support - No DOM assumptions: pluggable TokenStorage (AsyncStorage-compatible), environment-aware transport and offline backends, getDownloadUrl() for native file handling
  • Resilient Transport - Per-request AbortSignal + timeout, automatic 401 refresh-and-retry, SSE reconnect with jitter
  • Offline Support - Optimistic updates, mutation queue, field-level merge, multi-tab coherence, IndexedDB backend
  • Auth Strategies - OIDC (PKCE flow, token refresh), JWT, bearer, API key, or cookie sessions — selected per client or auto-detected
  • HMR-safe - getOrCreateClient for development

Server-rendered htmx (Beta)

  • One JSX page → full app - app.page(path, Component) server-renders a page whose <Live> regions auto-generate the htmx endpoints (list/create/update/delete/subscribe). Covara generates wiring, not UI components
  • Live by default - each region streams server-rendered fragments over SSE (reusing the existing subscription engine); rows update in place as anyone mutates the resource
  • Optimistic & offline - new rows insert once via the live SSE (no duplicate/phantom), deletes are optimistic, and mutations queue offline and replay on reconnect
  • No client framework - vendored htmx core + a tiny runtime, served and injected automatically
  • Beta - newer than the JSON API + TypeScript client; the API may still change

Environment Variables

  • Type-safe Configuration - Define and validate env vars with Zod via createEnv / envVariable
  • Public and Private Vars - PUBLIC_-prefixed or explicitly-marked vars served to clients via usePublicEnv (with ETag)
  • Client Access - Typed fetchPublicEnv / createEnvClient and a usePublicEnv React hook

Developer Experience

  • Project Scaffolding - npx covara create my-app (Node/Workers templates, SQLite/Postgres), optional live React SPA with --frontend react, plus covara generate resource|migration
  • covara dev Loop - Dev watcher: streams schema changes to the DB (additive auto-applied, destructive gated), regenerates the typed client, runs the server — no manual push. Plus covara db connection profiles (local/remote/Turso/Postgres), push/migrate/studio, and data/import/export/run/types
  • Deploy-Ready Output - Generated Dockerfile, docker-compose, complete wrangler.toml, GitHub Actions CI, .env.example
  • Framework Migrations - covara/db ships canonical internal-table schemas, an idempotent autoMigrate/migrateInternal, a generic seeder, and pool-sizing helpers
  • Shapeable Internal Tables - bring your own auth tables (custom names + column remapping) with defineInternalSchema; every internal/system table and its required columns is documented
  • Pluggable Observability Storage - the admin audit log, request/error logs, and metrics persist to KV (or your own adapter) when configured, in-memory otherwise
  • App Factory - createCovara() wires errors, auth, security headers, health, OpenAPI, admin UI
  • Graceful Shutdown - SIGTERM/SIGINT draining with /readyz 503 and clean SSE close
  • Admin UI - Built-in dashboard at /__covara/ui
  • OpenAPI Generation - Auto-generated specs from resources (filters, procedures, subscriptions, ETags)
  • Structured Logging - Pluggable JSON logger with COVARA_LOG_LEVEL and traceparent propagation
  • Middleware - Observability, versioning, idempotency, rate limiting
  • RFC 7807 Errors - Problem+JSON everywhere, even without custom error handling

Quick Start

Scaffold a project

npx covara create my-app                          # Node + SQLite
npx covara create my-app --frontend react         # + a live React SPA on the same server
npx covara create my-app --db postgres            # Node + PostgreSQL
npx covara create my-app --template cloudflare    # Cloudflare Workers + D1

Or add to an existing app

npm install covara hono drizzle-orm zod @libsql/client

Define your schema:

import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const usersTable = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull(),
  role: text("role").default("user"),
});

Create your API:

import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import { createCovara } from "covara";
import { startServer } from "covara/node";
import { usersTable } from "./schema";

const client = createClient({ url: "file:./data.db" });
const db = drizzle(client);

const app = createCovara({ cors: true })
  .resource("/users", usersTable, { id: usersTable.id, db });

await startServer(app, { port: 3000 });

Health endpoints (/healthz, /readyz), OpenAPI (/__covara/openapi.json), the admin UI (/__covara/ui), and RFC 7807 error handling are wired automatically. The path is optional — .resource(usersTable, config) mounts at the table name.

Cloudflare Workers

import { drizzle } from "drizzle-orm/d1";
import { createCovara, CovaraApp } from "covara";
import { usersTable } from "./schema";

let app: CovaraApp | undefined;

export default {
  fetch(request: Request, env: { DB: D1Database }, ctx: ExecutionContext) {
    app ??= createCovara().resource("/users", usersTable, {
      id: usersTable.id,
      db: drizzle(env.DB),
    });
    return app.fetch(request, env, ctx);
  },
};
# wrangler.toml
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-app"

Workers bill CPU time, not wall-clock time — long-lived idle SSE subscriptions cost almost nothing, since heartbeats and event pushes use negligible CPU.

For production Workers deployments, bind the bundled CovaraKVDurableObject as Covara's KV store so subscriptions, rate limits, and sessions are shared across isolates — see the Durable Object KV guide. Projects scaffolded with covara create --template cloudflare have it wired up already.

Using a plain Hono app

useResource returns a regular Hono router — compose it however you like:

import { Hono } from "hono";
import { useResource, errorHandler, notFoundHandler } from "covara";

const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.route("/api/users", useResource(usersTable, { id: usersTable.id, db }));

export default app;

Generated Endpoints

Method Path Description
GET /api/users List with filtering, pagination
GET /api/users/:id Get single resource
POST /api/users Create resource
PATCH /api/users/:id Update resource (partial)
PUT /api/users/:id Replace resource
DELETE /api/users/:id Delete resource
GET /api/users/count Count with filtering
GET /api/users/aggregate Aggregations
GET /api/users/aggregate/subscribe Live aggregation (SSE, recomputed on change)
GET /api/users/subscribe SSE subscription
GET /api/users/search Full-text search (when configured)
POST /api/users/batch Batch create
PATCH /api/users/batch Batch update
DELETE /api/users/batch Batch delete
POST /api/users/batch/upsert Bulk insert-or-update by primary key
POST /api/users/rpc/:name RPC procedures

Resource Configuration

Everything is opt-in per resource:

app.resource("/posts", postsTable, {
  id: postsTable.id,
  db,

  // Batch operation limits
  batch: { create: 100, update: 100, delete: 100 },

  // Pagination settings
  pagination: { defaultLimit: 20, maxLimit: 100 },

  // Rate limiting
  rateLimit: { windowMs: 60000, maxRequests: 100 },

  // Optimistic locking (ETag / If-Match)
  etag: { versionField: "version" },

  // Authorization scopes (row-level security via RSQL). Scopes can traverse a
  // declared relation as a join, e.g. rsql`organization.members.userId==${user.id}`
  auth: {
    public: { read: true },
    update: async (user) => rsql`authorId==${user.id}`,
    delete: async (user) => rsql`authorId==${user.id}`,
  },

  // Relations
  relations: {
    author: {
      resource: "users",
      schema: usersTable,
      type: "belongsTo",
      foreignKey: postsTable.authorId,
      references: usersTable.id,
    },
    comments: {
      resource: "comments",
      schema: commentsTable,
      type: "hasMany",
      foreignKey: commentsTable.postId,
      references: postsTable.id,
    },
  },

  // Lifecycle hooks
  hooks: {
    onBeforeCreate: async (ctx, data) => ({ ...data, createdAt: new Date() }),
  },

  // RPC procedures
  procedures: {
    publish: defineProcedure({
      input: z.object({ id: z.string() }),
      output: z.object({ success: z.boolean() }),
      handler: async (ctx, input) => {
        await db.update(postsTable).set({ published: true }).where(eq(postsTable.id, input.id));
        return { success: true };
      },
    }),
  },
});

See the Resources reference for the full option reference (soft delete, computed fields, field allowlists, search, custom filter operators, and more).

Client Library

import { getOrCreateClient } from "covara/client";
import { useLiveList, useAuth } from "covara/client/react";

const client = getOrCreateClient({
  baseUrl: "https://api.myapp.com",
  credentials: "include",
  offline: true, // optimistic updates + mutation queue + persistence
});

function TodoApp() {
  const { user, isAuthenticated } = useAuth();
  const { items, status, mutate } = useLiveList<Todo>("/api/todos", {
    orderBy: "position",
  });

  return (
    <div>
      <p>Welcome, {user?.name}! ({status})</p>
      <ul>
        {items.map((todo) => (
          <li key={todo.id}>
            {todo.title}
            <button onClick={() => mutate.delete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
      <button onClick={() => mutate.create({ title: "New todo" })}>Add</button>
    </div>
  );
}

mutate.create/update/delete apply optimistically, queue while offline, reconcile on sync, and every other subscribed client sees the change over SSE.

Authentication options

The client supports OIDC (PKCE), JWT, bearer tokens, API keys, and cookie sessions. useAuth({ strategy }) selects one explicitly, or auto-detects.

JWT (works in React Native — bring your own token storage):

import { initJWTClient } from "covara/client/react";
import { useJWTAuth } from "covara/client/react";

initJWTClient({
  baseUrl: "https://api.myapp.com",
  // storage: AsyncStorage-backed TokenStorage for React Native;
  // defaults to localStorage in the browser
});

function LoginGate() {
  const { user, isAuthenticated, login, signup, logout } = useJWTAuth<User>();

  if (!isAuthenticated) {
    return <button onClick={() => login(email, password)}>Sign In</button>;
  }
  return <button onClick={logout}>Sign out, {user?.name}</button>;
}

OIDC (PKCE flow, token refresh, automatic 401 retry):

const client = getOrCreateClient({
  baseUrl: "https://api.myapp.com",
  auth: {
    issuer: "https://auth.myapp.com/oidc",
    clientId: "web-app",
    redirectUri: window.location.origin + "/callback",
  },
});

client.auth.login(); // redirects to the provider

Low-level API

const users = client.resource<User>("/users");

// CRUD operations
const allUsers = await users.list({ filter: 'role=="admin"', limit: 10 });
const user = await users.get("123");
const newUser = await users.create({ name: "Alice", email: "alice@example.com" });
await users.update("123", { name: "Alice Smith" });
await users.delete("123");

// Real-time subscriptions
const subscription = users.subscribe(
  { filter: 'role=="admin"' },
  {
    onAdded: (user) => console.log("New admin:", user),
    onChanged: (user) => console.log("Updated:", user),
    onRemoved: (id) => console.log("Removed:", id),
    onInvalidate: () => console.log("Out-of-band change, refetching"),
    onConnected: (seq) => console.log("Live from sequence", seq),
    onError: (err) => console.error(err),
  }
);

React Native

The client has no hard DOM dependencies: pass an AsyncStorage-backed TokenStorage for JWT auth, offline persistence picks an environment-appropriate backend, and the file hooks expose getDownloadUrl() for use with Linking instead of browser downloads.

Server-rendered htmx (Beta)

Beta. This is newer than the JSON API and TypeScript client and may still change between releases. The JSON API it builds on is stable.

Prefer hypermedia over a client framework? Register a single JSX page; <Live> is the only special element, and Covara generates the htmx endpoints, the live SSE stream, and the optimistic/offline client runtime for you.

// tsconfig: { "jsx": "react-jsx", "jsxImportSource": "hono/jsx" }
import { createCovara } from "covara/server";
import { Live } from "covara/htmx";
import { todos } from "./schema";

const app = createCovara()
  .resource("/todos", todos, { id: todos.id, db })
  .page("/todos", () => (
    <Live
      resource={todos}
      query={{ orderBy: "position" }}
      create={(c) => (
        <form {...c.create()}>
          <input name="title" />
          <button>Add</button>
        </form>
      )}
      container={(rows, c) => <ul {...c.container()}>{rows}</ul>}
      render={(t, c) => (
        <li {...c.row(t.id)}>
          {t.title}
          <button {...c.delete(t.id)}>Delete</button>
        </li>
      )}
    />
  ));

GET /todos is now a full server-rendered page. Creating a todo in one browser tab streams the new row into every other tab over SSE; updates and deletes patch rows in place. The helpers (c.create(), c.row(id), c.update(id), c.delete(id), c.container()) emit plain htmx attributes pointing at auto-generated endpoints under /__covara/live. See the htmx docs for optimistic/offline behavior, live aggregates, and scoping.

File Storage

Configure a storage backend once, then chain a file resource like any other. Local uploads are auto-served at baseUrl:

import { createCovara, initializeStorage } from "covara";

initializeStorage({
  type: "local", // or "s3" | "r2" | "memory"
  local: { basePath: "./uploads", baseUrl: "/uploads" },
});

const app = createCovara({ cors: true })
  .resource("/todos", todosTable, { id: todosTable.id, db })
  .fileResource("/files", filesTable, {
    db,
    id: filesTable.id,
    allowedMimeTypes: ["image/jpeg", "image/png"],
    maxFileSize: 5 * 1024 * 1024,
    auth: {
      read: async (user) => rsql`userId==${user?.id}`,
      delete: async (user) => rsql`userId==${user?.id}`,
    },
    usePresignedUrls: true, // direct-to-bucket on S3/R2
    // ...plus any resource option: hooks, relations, procedures, subscriptions
  });

Upload from React with progress tracking:

import { useFileUpload, useFiles } from "covara/client/react";

function Uploader() {
  const { upload, isUploading, progress } = useFileUpload({
    resourcePath: "/api/files",
    onSuccess: (file) => console.log("Uploaded", file.id),
  });

  return (
    <input
      type="file"
      disabled={isUploading}
      onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])}
    />
  );
}

See Storage.

Server-side Authentication

Standard auth routes

import { useAuth } from "covara/auth";

const { router, middleware } = useAuth({
  adapter: authAdapter, // JWT, Auth.js, Passport.js, or OIDC adapter
  login: { validateCredentials: async (email, password) => user },
  signup: { createUser: async ({ email, password, name }) => user },
});

app.route("/api/auth", router); // /me, /login, /signup, /logout
app.use("*", middleware);       // populates c.get("user")

Opt-in extras: TOTP MFA with backup codes, magic links, email verification, password reset, login throttling, CSRF protection, and API key management. See Authentication.

OIDC Provider

A complete OpenID Connect server, in your app:

import { createOIDCProvider } from "covara";

const { router, middleware } = createOIDCProvider({
  issuer: "https://auth.myapp.com",
  keys: { algorithm: "RS256" },
  tokens: {
    accessToken: { ttlSeconds: 3600 },
    refreshToken: { ttlSeconds: 30 * 24 * 3600, rotateOnUse: true },
  },
  clients: [{
    id: "web-app",
    name: "My Web App",
    redirectUris: ["https://myapp.com/callback"],
    grantTypes: ["authorization_code", "refresh_token"],
    tokenEndpointAuthMethod: "none", // public client, PKCE required
  }],
  backends: {
    emailPassword: {
      enabled: true,
      validateUser: async (email, password) => { /* ... */ },
      findUserById: async (id) => { /* ... */ },
    },
    federated: [
      oidcProviders.google({ clientId: "...", clientSecret: "..." }),
    ],
  },
});

app.route("/oidc", router);
app.use("/api/*", middleware);

The provider exposes discovery, JWKS, /authorize, /token, /userinfo, /logout, plus RFC 7009 revocation (/revoke) and RFC 7662 introspection (/introspect). Confidential client secrets may be stored hashed (scrypt$...) and are verified with the built-in hashPassword/verifyPassword helpers.

Background Tasks

Distributed task queue with retries and scheduling:

import { defineTask, initializeTasks, getTaskScheduler, getTaskRegistry, startTaskWorkers } from "covara/tasks";
import { createKV } from "covara/kv";

const kv = await createKV({ type: "redis", redis: { url: "redis://localhost" } });
initializeTasks(kv);

const sendEmailTask = defineTask({
  name: "send-email",
  input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }),
  retry: { maxAttempts: 3, backoff: "exponential" },
  handler: async (ctx, input) => {
    await sendEmail(input.to, input.subject, input.body);
  },
});

getTaskRegistry().register(sendEmailTask);
await startTaskWorkers(kv, getTaskRegistry(), 3);

// Enqueue
await getTaskScheduler().enqueue(sendEmailTask, {
  to: "user@example.com",
  subject: "Welcome!",
  body: "Thanks for signing up.",
});

// Recurring
await getTaskScheduler().scheduleRecurring(dailyReportTask, {}, {
  cron: "0 6 * * *",
  timezone: "UTC",
});

On Workers, swap the poller for the Cloudflare Queues adapter. See Background tasks.

Email

import { setGlobalEmail, createResendAdapter, createEmail, sendEmail } from "covara/email";

setGlobalEmail(createResendAdapter({ apiKey: process.env.RESEND_API_KEY }));

const { html, text } = createEmail({ brandColor: "#4f46e5" })
  .heading("Verify your email")
  .text("Tap the button below to verify your account.")
  .button("Verify email", `https://acme.com/verify?token=${token}`)
  .divider()
  .code("123456")
  .build();

await sendEmail({ from: "noreply@acme.com", to: email, subject: "Verify your email", html, text });

The builder renders responsive, escaped HTML plus a plaintext fallback. A Cloudflare Email Service adapter is included for Workers. See Email.

Billing

One interface over Stripe, Lemon Squeezy, Paddle, and Polar.sh — fetch-based, no provider SDKs, Workers-safe:

import { createBilling, createBillingRouter, createStripeAdapter } from "covara/billing";

const billing = createBilling({
  adapter: createStripeAdapter({ apiKey: env.STRIPE_SECRET_KEY }),
  webhookSecret: env.STRIPE_WEBHOOK_SECRET,
  plans: [
    { key: "pro_monthly", priceId: "price_123", type: "subscription", credits: 10_000 },
  ],
});

app.route("/api/billing", createBillingRouter(billing, {
  getAccount: (c) => c.get("user")?.id,
  getCustomerEmail: (c) => c.get("user")?.email,
}));
import { useCredits, useSubscription, useCheckout } from "covara/client/react";

function Account() {
  const { balance } = useCredits();
  const { activeSubscription } = useSubscription();
  const { redirectToCheckout, loading } = useCheckout();

  return (
    <div>
      <p>Credits: {balance}</p>
      <button onClick={() => redirectToCheckout({ plan: "pro_monthly" })} disabled={loading}>
        Upgrade
      </button>
    </div>
  );
}

Webhooks are signature-verified, deduplicated, and grant credits automatically on payment.succeeded. See Billing.

Mutation Tracking

Custom routes participate in the realtime system by wrapping your db once:

import { drizzle } from "drizzle-orm/libsql";
import { trackMutations, readJsonBody, requireUser } from "covara";
import * as schema from "./schema";

const baseDb = drizzle(/* config */);

export const db = trackMutations(baseDb, {
  todos: { table: schema.todosTable, id: schema.todosTable.id },
  users: { table: schema.usersTable, id: schema.usersTable.id },
});

app.post("/api/custom-action", async (c) => {
  const body = await readJsonBody(c) as { title: string };
  const user = requireUser(c);

  const [todo] = await db
    .insert(schema.todosTable)
    .values({ title: body.title, userId: user.id })
    .returning();
  // recorded in the changelog — subscribers are notified

  return c.json(todo);
});

Optional query caching with automatic invalidation:

const db = trackMutations(baseDb, tables, {
  cache: { enabled: true, ttl: 60000 },
});

For writers outside the tracked db — cron jobs, other services, manual edits — recordExternalMutation appends a changelog entry, invalidates the cache, and tells live subscribers to refetch. It's the portable alternative to database-specific CDC:

import { recordExternalMutation } from "covara";

await recordExternalMutation("todos", "update", { objectId: "todo-1" });

Typed Environment Variables

import { createEnv, envVariable, usePublicEnv } from "covara";
import { z } from "zod";

const env = createEnv({
  PUBLIC_API_URL: z.string().url(),                       // PUBLIC_ prefix → exposed to clients
  SECRET_KEY: envVariable(process.env.SECRET, z.string()), // explicit source
  PORT: z.string().default("3000").transform(Number),
});

app.route("/api/env", usePublicEnv(env)); // serves public vars (with ETag)

Clients read public vars with fetchPublicEnv/createEnvClient or the usePublicEnv React hook. See Environment variables.

Query Parameters

Parameter Example Description
filter age>=18;role=="admin" RSQL filter expression
select id,name,email Field projection
include author,comments(limit:5) Related data to load
cursor eyJpZCI6MTB9 Pagination cursor
limit 20 Page size
orderBy name:asc,age:desc Sort order
totalCount true Include total count
having count>=5;sum_amount>100 Filter aggregate groups (/aggregate only)
withDeleted true Include soft-deleted rows (when softDelete is configured)

Filter Syntax

# Comparison
name=="John"              # Equals
age>=18                   # Greater than or equal
status!="deleted"         # Not equals

# Logical operators
age>=18;role=="admin"     # AND (semicolon)
role=="admin",role=="mod" # OR (comma)
(age>=18;verified==true),role=="admin"  # Grouping

# String operations
name=icontains="john"     # Case-insensitive contains
email=iendswith="@company.com"
title=istartswith="how to"

# Set and range
role=in=("admin","mod")   # In list
age=between=[18,65]       # Range (inclusive)

# Null and empty
deletedAt=isnull=true     # Is null
bio=isempty=false         # Has non-empty value

# See https://kahveciderin.github.io/covara/core/filtering for all 30+ operators

The same expression filters database queries, subscription scopes, and auth scopes — parsed once, executed as SQL or in-memory as needed.

Error Handling

All errors follow RFC 7807 Problem Details format:

{
  "type": "/__covara/problems/not-found",
  "title": "Not found",
  "status": 404,
  "detail": "users with id '123' not found",
  "code": "NOT_FOUND",
  "resource": "users",
  "id": "123"
}

Error types include:

  • not-found (404) - Resource not found
  • validation-error (400) - Invalid input data
  • unauthorized (401) - Authentication required
  • forbidden (403) - Insufficient permissions
  • rate-limit-exceeded (429) - Too many requests
  • batch-limit-exceeded (400) - Batch size exceeded
  • filter-parse-error (400) - Invalid filter syntax
  • cursor-invalid (400) - Malformed pagination cursor
  • precondition-failed (412) - ETag mismatch

Errors extend Hono's HTTPException and self-render — resources mounted in any Hono app return proper problem+json without extra setup.

Testing

npm test                                           # all tests
npm test -- tests/integration/useResource.test.ts  # one file
npm test -- --coverage

Testing your own app needs no HTTP server:

const res = await app.request("/api/users", {
  method: "POST",
  body: JSON.stringify({ name: "Alice", email: "a@b.com" }),
  headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);

Documentation

📚 Full documentation: kahveciderin.github.io/covara

Highlights:

Requirements

  • Node.js 18+ or Cloudflare Workers (nodejs_compat)
  • TypeScript 5+
  • Drizzle ORM
  • Hono 4+

About

Your Drizzle schema is already a backend. Covara turns it into a complete, production-ready API — REST endpoints, real-time subscriptions, auth, file uploads, billing, email, and background jobs — with a type-safe, offline-first TypeScript client on the other end.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages