Skip to content

Repository files navigation

PARCAE

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

npmlicensenodetypescript


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,typeRef}from"@parcae/model";classPostextendsModel{statictype="post"asconst;user!: Ref<User>;title: string="";published: boolean=false;views: number=0;}constapp=createApp({models: [Post]});awaitapp.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.

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.

SupabaseParcae
SchemaSQL migrations or dashboardTypeScript classes. That's it.
TypesGenerated from DB, always one step behindFlow from the class. Nothing to generate.
Business logicEdge Functions or Postgres triggersHooks, jobs, routes — same codebase, same types
RealtimePostgres CDC (row-level)Postgres triggers → targeted query diffs
AuthProprietary, tied to their infraPluggable — Better Auth, Clerk, or roll your own
Row-level securitySQL policies (hard to test)TypeScript scope functions (composable, testable)
Background jobsNot built inBullMQ with retries and backoff
Lock-inDeepZero. Postgres; Redis only for jobs/events/locks
// supabase: types are generated. schema lives in SQL. business logic is elsewhere.const{ data }=awaitsupabase.from("posts").select("*").eq("published",true);// parcae: the class IS the type IS the schema IS the API.constposts=awaitPost.where({published: true}).find();// posts is Post[]. always.

Getting started

npm install @parcae/backend @parcae/model

Define a model. Properties are columns.

// models/Post.tsimport{Model}from"@parcae/model";exportclassPostextendsModel{statictype="post"asconst;title: string="";published: boolean=false;}

Start the server.

// index.tsimport{createApp}from"@parcae/backend";constapp=createApp({models: "./models",migrations: "./migrations",});awaitapp.start();
ENSURE_SCHEMA=true DATABASE_URL=postgresql://localhost:5432/myapp node index.ts

Use 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

Packages

PackageDescription
@parcae/modelModel base class, query builder, adapter interface
@parcae/backendcreateApp, auto-CRUD, hooks, jobs, PubSub, queue, schema resolution
@parcae/sdkClient SDK — Socket.IO transport, session lifecycle, React hooks
@parcae/auth-betterauthBetter Auth adapter — self-hosted, same Postgres
@parcae/auth-clerkClerk adapter — external auth proxied to your User model
@parcae/i18nLingui-powered locale negotiation, backend middleware, React helper

Models

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,typeRef}from"@parcae/model";classPostextendsModel{statictype="post"asconst;user!: Ref<User>;// -> VARCHAR raw id; expanded explicitlytitle: string="";// -> VARCHARbody: PostBody={content: ""};// -> JSONBtags: string[]=[];// -> JSONBpublished: boolean=false;// -> BOOLEANviews: number=0;// -> DOUBLE PRECISION}

Direct property access. No .get(), no .data.title. Just post.title.

constpost=awaitPost.findById("abc");post.title;// "Hello" — typed as stringpost.user;// "user_k8f2m9x" — raw id until explicitly expandedpost.$user;// "user_k8f2m9x" — raw ID, no loadingpost.title="New";// change tracked automaticallyawaitpost.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

Scopes are row-level security in TypeScript. Any model with a scope gets auto-CRUD routes.

staticscope={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)=>{constcount=awaitPost.count();ok(res,{posts: count});});route.post("/v1/upload",requireAuth,async(req,res)=>{if(!req.session?.user)returnunauthorized(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 })=>{awaitenqueue("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 })=>{constpost=awaitPost.findById(data.postId);// index it somewherereturn{indexed: true};});
import{enqueue}from"@parcae/backend";awaitenqueue("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 tableimport{betterAuth}from"@parcae/auth-betterauth";constapp=createApp({models: [User,Post],auth: betterAuth({providers: ["email","google"]}),});
// external — Clerk users are proxied into your local User modelimport{clerk}from"@parcae/auth-clerk";constapp=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.

Client SDK

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";constclient=createClient({url: "http://localhost:3000",getToken: async()=>localStorage.getItem("token"),});constClientPost=client.bind(Post);constposts=awaitClientPost.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.

React

import{ParcaeProvider,useQuery}from"@parcae/sdk/react";functionApp(){return(<ParcaeProviderurl="http://localhost:3000"><PostList/></ParcaeProvider>);}functionPostList(){const{ items, loading }=useQuery(Post.where({published: true}).expand("user").orderBy("createdAt","desc"),);if(loading)return<p>Loading...</p>;returnitems.map((post)=>(<articlekey={post.id}><h2>{post.title}</h2><span>by {typeofpost.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.

Configuration

.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 + triggers

Schema and migrations

Schema 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:latest

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
i18n/ @parcae/i18n — Lingui integration helpers
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

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages