Code-first object abstraction over PostgreSQL. Think in nested objects, never write SQL.
Weave lets you model your data as objects — nested as deep as you like — and translates that into a real relational schema (tables, foreign keys, indexes). It materializes the DDL for you, and weaves flat rows back into object trees on read. Underneath it's 100% relational: you get referential integrity, ACID transactions, and the Postgres optimizer for free — you just never see SQL.
Status: pre-release / alpha. The API may still change.
your app ──HTTP──▶ Weave server ──SQL──▶ Postgres
(weave-sdk) (container) (Weave owns this)
Weave is two things: a server you run as a container
(ghcr.io/mauro-andre/weave), and a typed client you install
(@mauroandre/weave-sdk). Your app declares entities as code, talks to the server over
HTTP, and thinks only in objects — HTTP, JSON and SQL all stay invisible. The server
owns the Postgres connection and the schema; your app never connects to a database and
never writes a migration.
The server also ships a dashboard: a visual entity designer, a data browser, API
keys, and scope editing. Code and GUI are isomorphic — both produce the same thing, and
weave gen mirrors the server back into .ts files you commit.
The object–relational impedance mismatch forces a bad trade:
- MongoDB — great object ergonomics, weak relational guarantees; embeds everything by default, which becomes consistency debt.
- ORMs (Prisma, TypeORM) — hide SQL behind heavy layers and their own schema languages.
- Query builders (Kysely, Drizzle) — excellent, but keep you glued to SQL and the row/table model.
Weave aims at the missing spot: Postgres' relational guarantees + Mongo's object ergonomics + zero SQL in application code.
Everything is relational. What differs between two relationships isn't where the data lives — it's who owns it and who controls its lifecycle.
owned (composition) |
reference (association) |
|
|---|---|---|
| Ownership | exclusive to the parent | independent / shareable |
| Lifecycle | dies with the parent (cascade) | lives on its own (no cascade) |
| Writes | managed by the parent | read-only from this side |
| Storage | dedicated, path-prefixed table | FK column / join table |
| Reads | automatic (part of the aggregate) | on demand (expand / select) |
- PostgreSQL 13+ — for the server. Ids are UUID v7 (time-ordered,
index-friendly), generated by Weave in TypeScript, so no native
uuidv7()is required (that's Postgres 18+ only). Migrating from MongoDB?WEAVE_ID_TYPE=objectIdgives you 24-hex ids instead. - Node 20+ — for your app and the CLI.
1. Run the server. Point it at a Postgres and a few secrets:
docker run -p 3000:3000 \
-e DATABASE_URL=postgres://user:pass@host:5432/db \
-e MASTER_USERNAME=master \
-e MASTER_PASSWORD=change-me \
-e SESSION_SECRET=a-long-random-string \
ghcr.io/mauro-andre/weaveOpen http://localhost:3000, log in, and create an API key from the API page.
2. Install the client:
npm install @mauroandre/weave-sdk3. Declare entities — one file per entity, default-exported:
// weave/entities/post.ts
import { defineEntity, text, int4, bool, array, owned, reference } from "@mauroandre/weave-sdk";
import author from "./author.js";
import tag from "./tag.js";
export default defineEntity("post", {
title: text().notNull(),
published: bool().notNull().default(false),
views: int4().notNull().default(0),
keywords: array(text()), // text[]
author: reference(author), // N:1 → author_id
tags: reference(array(tag)), // N:N → post_tags join table
comments: owned(array({ // 1:N → post_comments table (cascade)
body: text().notNull(),
approved: bool().notNull().default(false),
})),
});Fields are nullable by default — .notNull() makes one required.
A third argument declares composite unique constraints and indexes (each group is a
list of field names; a reference maps to its foreign key) — the natural/rollup key a
single-column .unique() can't express:
defineEntity("registryEntry",
{ slugName: text().notNull(), stack: reference(stack) },
{ unique: [["slugName", "stack"]] },
);4. Push the schema — the server creates the tables:
export WEAVE_URL=http://localhost:3000
export WEAVE_KEY=<your api key>
npx weave push5. Use the typed client:
import { createClient } from "@mauroandre/weave-sdk";
import author from "./weave/entities/author.js";
import post from "./weave/entities/post.js";
const weave = createClient({
url: process.env.WEAVE_URL!,
key: process.env.WEAVE_KEY!,
entities: { author, post },
});
const a = await weave.author.create({ name: "Ada", email: "ada@x.dev" });
const p = await weave.post.create({ title: "Why Weave", authorId: a.id });
p.views; // number (inferred)
p.createdAt; // Date (revived from JSON)InferEntity<typeof post> is inferred automatically — id, createdAt and
updatedAt are managed for you.
create shreds the object into rows and upserts the whole aggregate in one
transaction. You set references by id; the referenced entity is never touched.
const ts = await weave.tag.create({ label: "typescript" });
const p = await weave.post.create({
title: "Why Weave",
authorId: someAuthorId, // N:1 reference, by id
tagsIds: [ts.id], // N:N reference, by id set
comments: [{ body: "great!" }] // owned children, written in cascade
});ownedchildren are replaced on write (the object is the source of truth): the existing children are deleted and re-inserted. So an owned child'sidandcreatedAtchange on every write — treat owned children as values, and never reference one by id from elsewhere.referenceis set via<field>Id(N:1) or<field>Ids(N:N) — Weave never writes the target entity.<field>Idsreplaces the whole set, so to add a link you send the full list, not just the new id.
Verbs per entity: create · createMany · findOne · findMany · paginate ·
updateOne · updateMany · deleteOne · deleteMany · aggregate · accumulate.
Target rows with a bare where ({ id: "x" } ≡ { id: { eq: "x" } }); One takes the
first match, Many operates in bulk and returns { count }. An update is a merge —
fields you omit are preserved.
owned comes back automatically. reference comes only when you ask for it.
const posts = await weave.post.findMany(
{ published: true },
{ expand: { author: true, tags: true } }, // follow references
);
// posts[0].comments[] → owned, nested automatically
// posts[0].authorId → the FK is always there
// posts[0].author → the object, because you expanded it
// posts[0].tags[] → N:N targets, because expandedThe return type self-types by your expand (InferRead<E, X>) — a reference you
didn't expand doesn't exist on the type, so you never hand-write a result type and never
guess whether a field is populated.
await weave.post.findMany({
published: true,
views: { gte: 1000 }, // operators
keywords: { has: "typescript" }, // array column
author: { name: { ilike: "mauro%" } }, // reference target → EXISTS
tags: { some: { label: "postgres" } },// N:N quantifier → EXISTS
comments: { every: { approved: true } }, // owned quantifier
or: [ /* ... */ ], // and / or / not
}, { orderBy: { views: "desc" } });- Operators:
eq(bare value),ne,gt/gte/lt/lte,in/notIn,like/ilike(strings),isNull. - Array columns:
has,hasSome,hasEvery,isEmpty, andsome— any element matches scalar ops, e.g.{ scores: { some: { gte: 5 } } }. - Quantifiers (1:N owned, N:N reference):
some/every/none. - Logic:
and/or/not, nestable at any level. - Filtering by a reference's raw FK:
{ authorId: "..." }. - Managed fields: filter by
id,createdAt,updatedAt.orderByaccepts those plus nested paths ({ author: { name: "asc" } }).
This where/orderBy is a single, shared query language: the same object you build by
clicking in the GUI, write in the SDK, and store in a scope — from the click to the SQL.
findMany returns every matching row up to a 10 000 default, and tells you when it
had to cut. Raise or lower it with limit; for a paged UI use paginate:
await weave.post.findMany({ published: true }); // up to 10 000 (warns if more matched)
await weave.post.findMany({ published: true }, { limit: 50 });
const page = await weave.post.paginate({}, { page: 2, perPage: 20 });
// { docs, docsQuantity, pageQuantity, currentPage }findMany(where, { latestPer, orderBy }) returns the latest row per group
(DISTINCT ON).
A read hydrates everything you own. For a deep entity in a list, narrow it with
select — a whitelist that mirrors the tree, and prunes the object and the type:
const rows = await weave.post.findMany(where, {
select: { title: true, author: { name: true }, comments: { body: true } },
});
// rows[0]: { id; title; author: { id; name } | null; comments: { id; body }[] }
rows[0].views; // ❌ compile error — not selected, doesn't exist on the typeAnything you don't name isn't even joined. select also subsumes expand (it drives
references in the same key). It's response shaping, not access control — for that, see
Scopes.
aggregate rolls rows into numbers — pushed down to Postgres, returned as objects.
Accumulators (count/sum/avg/min/max/distinct/first/percentile/histogram)
go in select, alongside groupBy (fields, dot-paths or timeBucket), having,
orderBy, top-N and facets (many breakdowns in one pass). Expressions
(div/mul/add/sub) let you sort and filter by a derived rate server-side:
import { count, div } from "@mauroandre/weave-sdk";
const perRoute = await weave.appRequest.aggregate({
groupBy: ["route"],
select: {
errors: count({ where: { status: { gte: 500 } } }), // { where } → a filtered count
total: count(),
errorRate: div("errors", "total"),
},
orderBy: { errorRate: "desc" }, // the routes that fail most, proportionally
});For a historical tier, accumulate(key, ops) is a mergeable upsert — inc/max/min/
setOnInsert applied atomically in Postgres, no read-modify-write. Batch ingest is
createMany([...]) (one transaction). Full tour:
weavepg.dev/docs/aggregation.
Editing an entity file doesn't touch the database. Migrating is an explicit, gated step that diffs all your entities at once and applies them with the same risk buckets the GUI shows (🟢 auto · 🔴 confirm · 🟡 needs value · ⛔ blocked). The server is the source of truth.
weave push # entities (plan → apply) + scopes, then re-gen locally
weave push --confirm product.legacy # confirm a destructive drop
weave push --fill product.sku="N/A" # backfill a new required field
weave push --rename product.name=title # a rename: data preserved, not drop+add
weave push --no-gen # apply to the server but don't rewrite local files (CI)
weave gen # regenerate the whole weave/ folder from the serverRenaming a field in code is otherwise indistinguishable from drop+add — --rename tells
the server it's a rename (the field's stable id is preserved), so the data survives.
WEAVE_URL and WEAVE_KEY come from the environment; the config file holds only
structural decisions:
// weave.config.ts
import { defineConfig } from "@mauroandre/weave-sdk";
export default defineConfig({ dir: "app/weave" }); // optional — defaults to "weave"To converge the schema at boot instead of from a terminal, pushAll({ url, key, entities, scopes }) is the same project push as a plain function.
You can author entities and scopes in the GUI or in code, mixed. weave gen is a
one-way mirror: it fetches the server's current state and replaces weave/entities/
and weave/scopes/ — entity files (each field carrying its stable $id), scope files,
the re-export barrels, and a ready-to-use client. Everything is readable .ts, meant to
be committed.
weave/
entities/ post.ts · author.ts · index.ts
scopes/ storefront.ts · index.ts
index.ts # export const weave = createClient({ url: WEAVE_URL, key: WEAVE_KEY, entities })
Those two folders are rebuilt from scratch on every
gen— andweave pushrunsgenat the end unless you pass--no-gen. Never keep hand-written code in them.
Code-first round-trip: write a field id-less → weave push (the server mints the id) →
weave gen (rewrites the file with .$id(...)). From then on, renames are safe. The
generated client reads WEAVE_URL/WEAVE_KEY at runtime (no secret committed) and holds
the god-mode key, so it is server-side only.
A scope shapes access per entity: which verbs, which rows, which fields. You write it against the entity object; it's stored by stable field-id (rename-proof) and enforced on the server.
import { defineScope, scopeRule } from "@mauroandre/weave-sdk";
import product from "../entities/product.js";
export default defineScope("storefront", [
scopeRule(product, {
verbs: ["read"],
where: { company: { id: { eq: { param: "companyId" } } } }, // row filter, with a request param
fields: { exclude: ["cost"] }, // projection
}),
]);Param names are inferred from the rules, so narrowing a client requires them, typed:
const store = weave.as(storefront, { companyId: ctx.user.companyId });
await store.product.findMany(); // only the rows the scope allows, without `cost`
await store.product.create(/*…*/); // verb not granted → WeaveScopeError (403)The row filter governs writes too: a create or update whose resulting row would fall
outside it is rejected — a scope can never write into another tenant.
For a multi-tenant server, createScopedClient(weave) resolves the active scope from an
AsyncLocalStorage context, so services just call scopedWeave.* with no plumbing.
Outside a runAs it denies rather than falling back to god access.
The API key is the trust boundary. Without
weave.as, it is god-mode — a scope is a restriction the key-holder opts into, not a wall against it. Keep the key server-side, and derive scope params from the authenticated principal, never from request input.
Errors come back typed — WeaveScopeError (403), WeaveValidationError (400),
WeaveNotFoundError (404), WeaveAuthError (401) — never a raw SQL stack.
A monorepo. @mauroandre/weave-sdk is the only published package; core (pure
entity builders + IR + type inference) is bundled into it at publish time, and the
server ships as a container image, not a library.
packages/core/ entity builders · IR · type inference (zero runtime deps)
packages/sdk/ the published client + CLI
app/ the server: engine, HTTP API, dashboard
site/ weavepg.dev — docs
agent/ AGENTS.md — rules for AI agents building with Weave
Weave's architecture (object on top, relational underneath, a compiler in the
middle) is validated by Gel / EdgeDB. The "type as object" design and nested
reads are inspired by Drizzle pg-core; PG→TS mappings by Kanel / pg-types.
None are dependencies.
MIT