TypeScript backend framework. One function call bootstraps Postgres persistence, auto-CRUD routes, realtime subscriptions, background jobs, and authentication from your Model classes.
npm install @parcae/backend @parcae/modelimport { createApp } from "@parcae/backend";
const app = createApp({ models: "./models" });
await app.start();
// -> .parcae/ generated, tables created, CRUD routes live, WebSocket ready.env files are auto-loaded at startup:
# .env
DATABASE_URL=postgresql://localhost:5432/myappThe main entry point. Accepts model classes directly or a directory path for auto-discovery.
import { createApp } from "@parcae/backend";
import { User, Post } from "./models";
const app = createApp({
models: [User, Post], // or "./models" for auto-discovery
controllers: "./controllers", // optional — auto-import route files
hooks: "./hooks", // optional — auto-import hook files
jobs: "./jobs", // optional — auto-import job files
auth: {
// optional — omit to skip auth
providers: ["email"],
},
version: "v1", // API prefix (default: "v1")
root: process.cwd(), // project root (default: cwd)
});
await app.start({ port: 3000, dev: true });Controllers, hooks, and jobs self-register on import — just put files in the directory and they're auto-loaded (like Next.js pages).
- Parse and validate env config (Zod), auto-load
.env - Discover models (array or directory scan)
- Generate
.parcae/type metadata (RTTIST) - Connect Postgres (Knex, optional read replica)
- Connect Redis (PubSub + Queue, optional — falls back to in-process)
- Create
BackendAdapter, callModel.use() - Ensure tables exist (additive DDL migration)
- Create HTTP server (Polka) + WebSocket server (Socket.IO)
- Set up
QuerySubscriptionManagerfor realtime - Mount auth middleware + routes (if configured)
- Register auto-CRUD routes for scoped models
- Auto-discover and import controllers, hooks, jobs
- Start BullMQ workers + HTTP listener
interface ParcaeApp {
start(options?: { dev?: boolean; port?: number }): Promise<void>;
stop(): Promise<void>;
schemas: Map<string, SchemaDefinition>;
models: ModelConstructor[];
}Any model with a scope gets full REST endpoints automatically:
GET /v1/posts list (paginated, sortable, filterable)
GET /v1/posts/:id get one
POST /v1/posts create
PUT /v1/posts/:id update
DELETE /v1/posts/:id delete
PATCH /v1/posts/:id atomic JSON Patch (RFC 6902)
Scopes define per-operation access control. They receive the request context and return a query modifier, a data object, or null to deny.
class Post extends Model {
static type = "post" as const;
static scope = {
read: (ctx) => (qb) =>
qb.where("published", true).orWhere("user", ctx.user?.id),
create: (ctx) => (ctx.user ? { user: ctx.user.id } : null),
update: (ctx) => (qb) => qb.where("user", ctx.user.id),
delete: (ctx) => (qb) => qb.where("user", ctx.user.id),
};
user!: User;
title: string = "";
published: boolean = false;
}List endpoints support:
| Parameter | Example | Description |
|---|---|---|
limit |
?limit=25 |
Page size (max 100) |
page |
?page=2 |
Page number |
sort |
?sort=createdAt |
Sort column |
direction |
?direction=desc |
Sort direction |
where[field] |
?where[published]=true |
Field filter |
select |
?select=title,views |
Column selection |
Express-compatible function API. Middleware works the same way.
import { route } from "@parcae/backend";
route.get("/v1/health", (req, res) => {
res.end(JSON.stringify({ status: "ok" }));
});
route.post("/v1/upload", requireAuth, rateLimit(100), async (req, res) => {
// req.session available if auth is configured
});Methods: route.get, route.post, route.put, route.patch, route.delete, route.options, route.head, route.all
route.get("/health", handler, { priority: 0 }); // lower = registered firstConvenience functions for common response patterns:
import {
json,
ok,
error,
unauthorized,
notFound,
badRequest,
} from "@parcae/backend";
route.get("/v1/posts", async (req, res) => {
const posts = await Post.where({ published: true }).find();
ok(res, { posts });
});
route.get("/v1/posts/:id", async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) return notFound(res, "Post");
ok(res, post.toJSON());
});
route.post("/v1/admin/action", async (req, res) => {
if (!req.session?.user) return unauthorized(res);
if (!req.body.name) return badRequest(res, "name is required");
// ...
});| Helper | Status | Body |
|---|---|---|
json(res, status, body) |
any | raw JSON |
ok(res, result) |
200 | { result, success: true } |
error(res, status, message) |
any | { result: null, success: false, error } |
unauthorized(res) |
401 | { error: "Unauthorized" } |
notFound(res, what?) |
404 | { error: "{what} not found" } |
badRequest(res, message) |
400 | { error: message } |
Model lifecycle hooks. Run before or after persistence operations.
import { hook } from "@parcae/backend";
hook.after(Post, "save", async ({ model, lock, enqueue, user }) => {
const unlock = await lock(`index:${model.id}`);
try {
await model.refresh();
await enqueue("post:index", { postId: model.id });
} finally {
await unlock();
}
});
hook.before(Post, "create", ({ model }) => {
model.title = model.title.trim();
});save, create, update, patch, remove
save fires on both create and update. create and update fire on their respective operations only.
interface HookContext {
model: any;
action: HookAction;
data?: Record<string, any>;
user?: { id: string; [key: string]: any } | null;
lock(key, ttl?): Promise<() => Promise<void>>;
enqueue(name, data, opts?): Promise<boolean>;
}hook.after(Post, "patch", handler, {
async: true, // don't block the response (default: false)
priority: 200, // execution order — lower runs first (default: 100)
});Background job processing via BullMQ. Requires Redis.
import { job } from "@parcae/backend";
job("post:index", async ({ data, bullJob, attempt }) => {
const post = await Post.findById(data.postId);
if (!post) return { skipped: true };
// ... index in search engine ...
return { success: true };
});Jobs retry 3 times with exponential backoff (5s base).
You can enqueue jobs from anywhere — not just hook contexts:
import { enqueue } from "@parcae/backend";
await enqueue("post:index", { postId: post.id });
await enqueue(
"post:index",
{ postId: post.id },
{ jobId: `post:index:${post.id}` },
); // dedupedThe server-side ModelAdapter implementation. Handles Knex/Postgres persistence, hooks, pub/sub, and the overflow column pattern.
import { BackendAdapter } from "@parcae/backend";
const adapter = new BackendAdapter({
read: readDb, // Knex instance (read replica or same as write)
write: writeDb, // Knex instance
pubsub, // PubSub instance (optional)
logger, // Winston logger (optional)
});
Model.use(adapter);- Upsert —
INSERT ... ON CONFLICT MERGEfor save operations - Atomic JSON Patch — Generates
jsonb_set_lax,jsonb_insert,#-SQL for JSONB columns; directSETfor scalar columns - Overflow column — Declared schema properties get typed columns; everything else goes into a
dataJSONB column automatically - Additive migration —
ensureAllTables()creates tables/columns/indexes if missing. Never drops. - Read/write splitting — Separate Knex instances for read and write queries
- Hook execution — Runs registered before/after hooks during persistence operations
Redis-backed cross-process events. Falls back to in-process EventEmitter when Redis is unavailable.
import { PubSub } from "@parcae/backend";
const pubsub = new PubSub({ url: "redis://localhost:6379" });
await pubsub.building;
pubsub.emit("post:updated", { id: "abc" });
pubsub.on("post:updated", (data) => { ... });Includes distributed locking via Redlock:
const unlock = await pubsub.lock("resource:key", 10000);
try {
/* critical section */
} finally {
await unlock();
}import { lock } from "@parcae/backend";
const unlock = await lock("resource:abc", 120000);
try {
/* exclusive access */
} finally {
await unlock();
}BullMQ queue management. Falls back gracefully when Redis is unavailable.
import { QueueService, addJobIfNotExists } from "@parcae/backend";
const queue = new QueueService({ url: "redis://localhost:6379" });
await queue.building;
await addJobIfNotExists(queue.get(), "post:index", { postId: "abc" });Manages realtime query subscriptions for connected clients. When a model changes, affected queries are re-evaluated and surgical diff ops (add, remove, update) are pushed to subscribers.
Auth is pluggable via the AuthAdapter interface. The framework doesn't ship with any auth provider — install the one you need:
| Package | Provider | Users live... |
|---|---|---|
@parcae/auth-betterauth |
Better Auth | In your Postgres (same table as your User model) |
@parcae/auth-clerk |
Clerk | In Clerk's cloud (proxied to your User model) |
import { betterAuth } from "@parcae/auth-betterauth";
const app = createApp({
models: [User, Post],
auth: betterAuth({ providers: ["email", "google"] }),
});import { clerk } from "@parcae/auth-clerk";
const app = createApp({
models: [User, Post],
auth: clerk({
secretKey: process.env.CLERK_SECRET_KEY!,
publishableKey: process.env.CLERK_PUBLISHABLE_KEY!,
}),
});The User Model is always a real, managed Parcae Model. Auth adapters resolve identity and sync user data into it.
req.session.useravailable in route handlers and scopes- Socket.IO auth via
authenticateevent - Implement
AuthAdapterto bring your own provider
At startup, createApp() generates type metadata into .parcae/ (gitignored, like .next/):
- Runs RTTIST typegen to extract TypeScript type metadata
SchemaResolvermaps types to column definitions- Falls back to default-value inference if RTTIST is unavailable
- Caches resolved schemas to
.parcae/schema.json
Environment variables validated at startup via Zod. .env files are auto-loaded.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | -- | PostgreSQL connection string |
DATABASE_READ_URL |
No | -- | Read replica connection string |
REDIS_URL |
No | -- | Redis for PubSub + Queue |
PORT |
No | 3000 |
HTTP server port |
AUTH_SECRET |
No | -- | Session signing secret (required if auth enabled) |
TRUSTED_ORIGINS |
No | -- | Comma-separated CORS origins |
NODE_ENV |
No | development |
development / production / test |
SERVER |
No | true |
Run HTTP + WebSocket server |
DAEMON |
No | false |
Run background workers |
// App
import { createApp } from "@parcae/backend";
import type { ParcaeApp, AppConfig } from "@parcae/backend";
// Adapter
import { BackendAdapter, registerModelRoutes } from "@parcae/backend";
import type { BackendServices } from "@parcae/backend";
// Routing
import { route, Controller, hook, job } from "@parcae/backend";
import type {
RouteHandler,
Middleware,
RouteOptions,
RouteEntry,
HookContext,
HookOptions,
HookEntry,
JobHandler,
JobContext,
JobEntry,
} from "@parcae/backend";
// Response helpers
import {
json,
ok,
error,
unauthorized,
notFound,
badRequest,
} from "@parcae/backend";
// Services
import {
PubSub,
QueueService,
addJobIfNotExists,
QuerySubscriptionManager,
} from "@parcae/backend";
import { enqueue, lock, getQueue, getPubSub } from "@parcae/backend";
import type {
PubSubConfig,
QueueConfig,
EnqueueOptions,
} from "@parcae/backend";
// Auth (interface only — implementations in separate packages)
import type {
AuthAdapter,
AuthSession,
AuthSetupContext,
} from "@parcae/backend";
// Schema
import {
SchemaResolver,
generateSchemas,
loadCachedSchemas,
} from "@parcae/backend";
// Config
import { parseConfig, configSchema } from "@parcae/backend";
import type { Config } from "@parcae/backend";
// Registry utilities
import {
getRoutes,
clearRoutes,
getHooks,
getHooksFor,
clearHooks,
getJobs,
getJob,
clearJobs,
} from "@parcae/backend";
// Convenience re-export
import { Model } from "@parcae/backend";MIT