PARCAE
Nona spins the thread. Decima measures it. Morta cuts it.
You write the class. Parcae does the rest.
TypeScript backend framework. Your class is the schema, the API, and the type system. One function call gives you Postgres-native persistence and realtime, REST, auth, and a React SDK. No codegen, no dashboard, no vendor lock-in.
import { Model, type Ref } from "@parcae/model";
class Post extends Model {
static type = "post" as const;
user!: Ref<User>;
title: string = "";
published: boolean = false;
views: number = 0;
}
const app = createApp({ models: [Post] });
await app.start();With DATABASE_URL configured and the schema applied, that's a running server. CRUD routes are live. WebSocket queries update from committed Postgres changes.
Supabase is a platform. You write SQL, generate types, deploy edge functions, configure RLS policies, and hope the dashboard doesn't drift from your code. When you need a complex join, a multi-table transaction, or a background job — you're reaching outside the platform.
Parcae is the opposite. Everything is TypeScript. The class is the schema. The scope is the access rule. The hook is the side effect. It runs in your process, lives in your repo, and you debug it with a breakpoint.
| Supabase | Parcae | |
|---|---|---|
| Schema | SQL migrations or dashboard | TypeScript classes. That's it. |
| Types | Generated from DB, always one step behind | Flow from the class. Nothing to generate. |
| Business logic | Edge Functions or Postgres triggers | Hooks, jobs, routes — same codebase, same types |
| Realtime | Postgres CDC (row-level) | Postgres triggers → targeted query diffs |
| Auth | Proprietary, tied to their infra | Pluggable — Better Auth, Clerk, or roll your own |
| Row-level security | SQL policies (hard to test) | TypeScript scope functions (composable, testable) |
| Background jobs | Not built in | BullMQ with retries and backoff |
| Lock-in | Deep | Zero. Postgres; Redis only for jobs/events/locks |
// supabase: types are generated. schema lives in SQL. business logic is elsewhere.
const { data } = await supabase.from("posts").select("*").eq("published", true);
// parcae: the class IS the type IS the schema IS the API.
const posts = await Post.where({ published: true }).find();
// posts is Post[]. always.npm install @parcae/backend @parcae/modelDefine a model. Properties are columns.
// models/Post.ts
import { Model } from "@parcae/model";
export class Post extends Model {
static type = "post" as const;
title: string = "";
published: boolean = false;
}Start the server.
// index.ts
import { createApp } from "@parcae/backend";
const app = createApp({
models: "./models",
migrations: "./migrations",
});
await app.start();ENSURE_SCHEMA=true DATABASE_URL=postgresql://localhost:5432/myapp node index.tsUse ENSURE_SCHEMA=true for the migration/schema step. Normal API boots should omit it: they run no database DDL and verify that the required realtime triggers are already installed before listening.
09:41:02 INF Found 1 model(s): post
09:41:02 INF Resolved schemas for: post (cached)
09:41:02 INF Database connected
09:41:02 INF Registered 5 auto-CRUD route(s)
09:41:02 OK Ready on port 3000 — 1 models, 6 routes, 0 hooks, 0 jobs
You now have:
GET /v1/posts paginated list
GET /v1/posts/:id single record
POST /v1/posts create
PUT /v1/posts/:id update
DELETE /v1/posts/:id delete
PATCH /v1/posts/:id atomic JSON Patch (RFC 6902)
GET /v1/health status, uptime, model count
| Package | Description |
|---|---|
@parcae/model |
Model base class, query builder, adapter interface |
@parcae/backend |
createApp, auto-CRUD, hooks, jobs, PubSub, queue, schema resolution |
@parcae/sdk |
Client SDK — Socket.IO transport, session lifecycle, React hooks |
@parcae/auth-betterauth |
Better Auth adapter — self-hosted, same Postgres |
@parcae/auth-clerk |
Clerk adapter — external auth proxied to your User model |
@parcae/i18n |
Lingui-powered locale negotiation, backend middleware, React helper |
A class property with a default value becomes a Postgres column. A Ref<Model> property becomes a raw-id reference that can be expanded explicitly. That's the whole system.
import { Model, type Ref } from "@parcae/model";
class Post extends Model {
static type = "post" as const;
user!: Ref<User>; // -> VARCHAR raw id; expanded explicitly
title: string = ""; // -> VARCHAR
body: PostBody = { content: "" }; // -> JSONB
tags: string[] = []; // -> JSONB
published: boolean = false; // -> BOOLEAN
views: number = 0; // -> DOUBLE PRECISION
}Direct property access. No .get(), no .data.title. Just post.title.
const post = await Post.findById("abc");
post.title; // "Hello" — typed as string
post.user; // "user_k8f2m9x" — raw id until explicitly expanded
post.$user; // "user_k8f2m9x" — raw ID, no loading
post.title = "New"; // change tracked automatically
await post.save();Existing-row save() locks the row and applies only the changes made since the model's last server snapshot, so unrelated concurrent JSONB edits survive. Incompatible structural edits to the same JSON array fail with 409 instead of silently overwriting data.
Properties not in the schema spill into an overflow data JSONB column. You can throw anything on a model and it persists — declared properties just get their own typed columns.
Scopes are row-level security in TypeScript. Any model with a scope gets auto-CRUD routes.
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),
};Return null to deny. Return an object to inject defaults. Return a function to modify the query. These are real query builder callbacks — you can do OR clauses, subqueries, joins, whatever Knex supports.
Post.where({ published: true }).orderBy("createdAt", "desc").limit(10).find();
Post.where("views", ">", 100).first();
Post.whereIn("id", ["a", "b", "c"]).find();
Post.count();40+ chainable methods. On the backend they map to Knex. On the frontend they serialize and execute server-side.
Express-compatible function API with middleware support.
import { route, ok, unauthorized } from "@parcae/backend";
route.get("/v1/stats", async (req, res) => {
const count = await Post.count();
ok(res, { posts: count });
});
route.post("/v1/upload", requireAuth, async (req, res) => {
if (!req.session?.user) return unauthorized(res);
// ...
});Drop files in a controllers/ directory and they self-register on import. Like Next.js pages — just put them there.
Model lifecycle hooks. Before or after save, create, update, patch, remove.
import { hook } from "@parcae/backend";
hook.after(Post, "save", async ({ model, enqueue }) => {
await enqueue("post:index", { postId: model.id });
});
hook.before(Post, "create", ({ model }) => {
model.title = model.title.trim();
});Hook context gives you model, lock (distributed), enqueue (background jobs), and user.
BullMQ. 3 retries, exponential backoff. Requires Redis.
import { job } from "@parcae/backend";
job("post:index", async ({ data }) => {
const post = await Post.findById(data.postId);
// index it somewhere
return { indexed: true };
});import { enqueue } from "@parcae/backend";
await enqueue("post:index", { postId: post.id });Auth is a pluggable adapter. The framework itself has no opinion about your auth provider — it just needs to know who's making the request.
Your User model is always a real, managed Parcae model. Auth adapters resolve identity and sync user data into it. No managed = false, no hollow facades.
// self-hosted — Better Auth writes directly into your users table
import { betterAuth } from "@parcae/auth-betterauth";
const app = createApp({
models: [User, Post],
auth: betterAuth({ providers: ["email", "google"] }),
});// external — Clerk users are proxied into your local User model
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!,
authorizedParties: ["https://app.example.com"],
}),
});req.session.user is available in every route handler and scope. On every Socket.IO connection, the client sends one hello with its current bearer token; the server resolves it and acknowledges the user ID before RPC calls proceed. Implement the AuthAdapter interface to bring whatever you want.
The SDK uses Socket.IO for RPC and realtime updates. getToken is required and runs before the initial hello and again on reconnect; return null for an anonymous client.
import { createClient } from "@parcae/sdk";
const client = createClient({
url: "http://localhost:3000",
getToken: async () => localStorage.getItem("token"),
});
const ClientPost = client.bind(Post);
const posts = await ClientPost.where({ published: true }).find();createClient() owns an independent transport, session machine, connection machine, and frontend adapter. The first client can install the process's one-time default model adapter when none exists, but code with multiple clients or server and client contexts must use client.bind(ModelClass). Binding returns an adapter-bound constructor without mutating the original model or another client. Call client.dispose() when that client is permanently retired; temporary wire loss only changes connection state, and reconnect performs a fresh hello followed by query resync without signing the session out.
import { ParcaeProvider, useQuery } from "@parcae/sdk/react";
function App() {
return (
<ParcaeProvider url="http://localhost:3000">
<PostList />
</ParcaeProvider>
);
}
function PostList() {
const { items, loading } = useQuery(
Post.where({ published: true })
.expand("user")
.orderBy("createdAt", "desc"),
);
if (loading) return <p>Loading...</p>;
return items.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<span>by {typeof post.user === "string" ? "Unknown" : post.user?.name}</span>
</article>
));
}useQuery is realtime. Row triggers publish compact LISTEN/NOTIFY messages after commit; every API process refreshes the affected cache from the primary and pushes surgical diffs (add, remove, update) to its clients. Safe updates fetch only the changed row or expansion, while membership/order changes and reconnect gaps fall back to a full scoped query. Redis is not in the model-change path.
Other hooks: useApi, useSDK, useSetting, useConnectionStatus.
.env files are auto-loaded. Everything is validated at startup with Zod.
DATABASE_URL=postgresql://localhost:5432/myapp # required
DATABASE_READ_URL=postgresql://... # ordinary read replica (optional)
REDIS_URL=redis://localhost:6379 # queues, app events, locks (optional)
PORT=3000 # default: 3000
AUTH_SECRET=... # required if auth enabled
BACKEND_URL=https://api.myapp.com # for auth callbacks (optional)
FRONTEND_URL=https://myapp.com # (optional)
ENSURE_SCHEMA=true # migrations + additive schema + triggersSchema mutation is explicit. With ENSURE_SCHEMA=true, startup runs registered migrations, applies the additive model schema, and installs versioned row triggers for realtime. Without it, an API process performs read-only trigger verification and fails with migration guidance if the database is not ready.
Registered migrations run lexicographically before the additive schema pass and are tracked in parcae_migrations. Parcae also records a SHA-256 checksum and metadata in parcae_migration_meta.
Never edit or delete an applied migration file. Editing causes MigrationChecksumError; deleting leaves the Knex ledger pointing at a missing file and blocks later migrations. Revert the file and write a new compensating migration instead. PARCAE_ALLOW_CHECKSUM_DRIFT=true and --allow-checksum-drift are emergency audit-visible bypasses, not normal workflow.
The CLI manages registered migration files only. Automatic model columns, indexes, and realtime triggers still require an app schema step with ENSURE_SCHEMA=true.
npx parcae migrate:make add-post-slug
npx parcae migrate:list
npx parcae migrate:latestpackages/
model/ @parcae/model — the Model class
backend/ @parcae/backend — the server
sdk/ @parcae/sdk — the client
auth-betterauth/ @parcae/auth-betterauth
auth-clerk/ @parcae/auth-clerk
i18n/ @parcae/i18n — Lingui integration helpers
examples/
basic/ working example app
Requires Node >= 20 and pnpm.
pnpm install && pnpm buildMIT