@parcae/model
The core Model system for Parcae. Class properties are the schema. Direct property access via Proxy with change tracking, lazy-loading references, and a pluggable adapter pattern that runs the same code on frontend and backend.
Install
npm install @parcae/model
Define a Model
import { Model } from "@parcae/model";
class Post extends Model {
static type = "post" as const;
user!: User;
title: string = "";
body: PostBody = {};
tags: string[] = [];
published: boolean = false;
views: number = 0;
}
No decorators, no separate schema definition, no Zod. The class properties are the schema.
Property Access
The Model constructor returns a Proxy. Data properties read/write to an internal store with automatic change tracking.
const post = await Post.findById("abc");
post.title;
post.title = "Updated";
post.published;
await post.save();
References
Properties typed as another Model class become lazy-loading proxies. The $ prefix gives raw ID access.
post.user;
post.$user;
post.user = someUser;
post.$user = "user_abc123";
The reference proxy throws a Promise on first property access for React Suspense integration:
<Suspense fallback={<span>Loading...</span>}>
<span>{post.user.name}</span>
</Suspense>
Static Query Methods
All query methods go through the global adapter set via Model.use().
const post = await Post.findById("abc");
const published = await Post.where({ published: true })
.orderBy("createdAt", "desc")
.limit(10)
.find();
Post.whereIn("id", ["a", "b", "c"]);
Post.whereNot({ published: false });
Post.whereNotIn("status", ["draft", "archived"]);
Post.whereRaw("views > ?", 100);
Post.select("title", "views");
Post.count();
Post.basic(25, "createdAt", "desc", 0);
Instance Methods
const post = Post.create({ title: "New Post" });
post.id;
await post.save();
post.__debounceMs = 500;
post.title = "A";
post.title = "AB";
await post.save();
await post.patch([
{ op: "replace", path: "/title", value: "Patched" },
{ op: "add", path: "/body/blocks/-", value: { type: "text" } },
]);
await post.remove();
await post.refresh();
post.toJSON();
await post.sanitize(user);
Query Chain
The QueryChain<T> interface supports 40+ chainable methods:
Filtering: where, andWhere, orWhere, whereIn, whereNot, whereNotIn, whereNull, whereNotNull, whereBetween, whereRaw, orWhereRaw, orWhereIn, orWhereNull, whereExists
Ordering & Pagination: orderBy, orderByRaw, limit, offset
Selection & Grouping: select, distinct, distinctOn, groupBy, groupByRaw, having, havingRaw
Joins: join, innerJoin, leftJoin, rightJoin
Aggregates: sum, avg, min, max, increment, decrement
Terminal: find(), first(), count()
On the backend, each method directly mutates a Knex query. On the frontend, each method records a serializable QueryStep sent to the server for execution.
Adapter Pattern
The ModelAdapter interface decouples the Model from persistence:
interface ModelAdapter {
createStore(data): Record<string, any>;
save(model, changes): Promise<void>;
remove(model): Promise<void>;
findById(modelClass, id): Promise<T | null>;
query(modelClass): QueryChain<T>;
patch(model, ops): Promise<void>;
}
FrontendAdapter | Valtio proxy (reactive) | Transport RPC (Socket.IO / SSE) |
BackendAdapter | Plain object | Knex + PostgreSQL |
Set the adapter once at startup:
import { Model } from "@parcae/model";
Model.use(adapter);
FrontendAdapter
Included in this package. Wraps a Transport to handle client-side persistence.
import { FrontendAdapter } from "@parcae/model/adapters/client";
const adapter = new FrontendAdapter(transport);
Model.use(adapter);
The Transport interface is protocol-agnostic:
interface Transport {
get(path, data?): Promise<any>;
post(path, data?): Promise<any>;
put(path, data?): Promise<any>;
patch(path, data?): Promise<any>;
delete(path, data?): Promise<any>;
subscribe?(event, handler): () => void;
unsubscribe?(event, handler?): void;
send?(event, ...args): void;
readonly isConnected?: boolean;
readonly isLoading?: boolean;
on?(event, handler): void;
off?(event, handler?): void;
disconnect?(): void;
reconnect?(): Promise<void>;
}
Static Properties
type | string | Model identifier. Used for table naming and routing. |
path | string? | Custom API path. Defaults to /v1/{type}s. |
scope | ModelScope? | Row-level security rules. |
indexes | IndexDefinition[]? | Database index definitions. |
managed | boolean | false for externally managed tables (e.g. auth). Default: true. |
__schema | SchemaDefinition? | Resolved at startup by RTTIST. Maps properties to column types. |
Type Mapping
string | "string" | VARCHAR(2048) |
string (long) | "text" | TEXT |
number (int) | "integer" | INTEGER |
number (float) | "number" | DOUBLE PRECISION |
boolean | "boolean" | BOOLEAN |
Date | "datetime" | TIMESTAMP |
SomeModel | { kind: "ref" } | VARCHAR (foreign key) |
| object / array | "json" | JSONB |
Exports
import { Model, generateId } from "@parcae/model";
import { FrontendAdapter } from "@parcae/model/adapters/client";
import type { Transport } from "@parcae/model/adapters/client";
import type {
ModelAdapter,
ModelConstructor,
ChangeSet,
QueryChain,
QueryStep,
SchemaDefinition,
ColumnType,
PrimitiveColumnType,
IndexDefinition,
ModelScope,
ScopeContext,
ScopeResult,
ScopeFunction,
PatchOp,
} from "@parcae/model/adapters/types";
License
MIT