Skip to content

EdenCoder/parcae

Repository files navigation

PARCAE

Nona spins the thread. Decima measures it. Morta cuts it.
You write the class. Parcae does the rest.

npm license node typescript


TypeScript backend framework. Your class is the schema, the API, and the type system. One function call gives you Postgres, REST, realtime, auth, and a React SDK. No codegen, no dashboard, no vendor lock-in.

class Post extends Model {
  static type = "post" as const;
  user!: User;
  title: string = "";
  published: boolean = false;
  views: number = 0;
}

const app = createApp({ models: [Post] });
await app.start();

That's a running server. Tables exist. CRUD routes are live. WebSocket is ready.

The pitch (or: why not Supabase)

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) Query-level subs — re-evaluates and pushes 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. Swap anything.
// 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.

Getting started

npm install @parcae/backend @parcae/model

Define 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" });
await app.start();
DATABASE_URL=postgresql://localhost:5432/myapp node index.ts
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

Packages

Package Description
@parcae/model Model base class, Proxy system, query builder, adapter interface
@parcae/backend createApp, auto-CRUD, hooks, jobs, PubSub, queue, schema resolution
@parcae/sdk Client SDK — Socket.IO and SSE transports, React hooks
@parcae/auth-betterauth Better Auth adapter — self-hosted, same Postgres
@parcae/auth-clerk Clerk adapter — external auth proxied to your User model

Models

A class property with a default value becomes a Postgres column. A property typed as another Model becomes a lazy-loading reference. That's the whole system.

import { Model } from "@parcae/model";

class Post extends Model {
  static type = "post" as const;

  user!: User; // -> VARCHAR (foreign key, lazy-loads User)
  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 proxy — loads on access, works with Suspense
post.$user; // "user_k8f2m9x" — raw ID, no loading
post.title = "New"; // change tracked automatically

await post.save();

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

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.

Query builder

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.

Routes

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.

Hooks

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.

Jobs

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

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

req.session.user is available in every route handler and scope. Socket.IO authenticates via the authenticate event. Implement the AuthAdapter interface to bring whatever you want.

Client SDK

Two transports, same API. Socket.IO for bidirectional realtime, SSE for simpler infrastructure.

import { createClient } from "@parcae/sdk";

const client = createClient({ url: "http://localhost:3000" });
// or: createClient({ url: "...", transport: "sse" })

The client wires up Model.use() automatically — Post.where(...) just works on the frontend.

React

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 }).orderBy("createdAt", "desc"),
  );

  if (loading) return <p>Loading...</p>;

  return items.map((post) => (
    <article key={post.id}>
      <h2>{post.title}</h2>
      <Suspense fallback="...">
        <span>by {post.user.name}</span>
      </Suspense>
    </article>
  ));
}

useQuery is realtime. When something changes on the server, your query is re-evaluated and surgical diffs (add, remove, update) are pushed to the client. No polling, no refetching.

Other hooks: useApi, useSDK, useSetting, useConnectionStatus.

Configuration

.env files are auto-loaded. Everything is validated at startup with Zod.

DATABASE_URL=postgresql://localhost:5432/myapp  # required
DATABASE_READ_URL=postgresql://...              # read replica (optional)
REDIS_URL=redis://localhost:6379                # PubSub + Queue (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                              # run DDL migration on startup

Project structure

packages/
  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
examples/
  basic/              working example app

Requires Node >= 20 and pnpm.

pnpm install && pnpm build

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors