Skip to content
mauro-andrePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Weave

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.

How it fits together

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.

Why

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.

The model: two relationships, both foreign keys

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)

Requirements

  • 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=objectId gives you 24-hex ids instead.
  • Node 20+ — for your app and the CLI.

Quick start

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/weave

Open http://localhost:3000, log in, and create an API key from the API page.

2. Install the client:

npm install @mauroandre/weave-sdk

3. 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 push

5. 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.

Writing

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
});
  • owned children are replaced on write (the object is the source of truth): the existing children are deleted and re-inserted. So an owned child's id and createdAt change on every write — treat owned children as values, and never reference one by id from elsewhere.
  • reference is set via <field>Id (N:1) or <field>Ids (N:N) — Weave never writes the target entity. <field>Ids replaces 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.

Reading

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 expanded

The 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.

Filtering — the whole tree, compiled to indexed EXISTS

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, and some — 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. orderBy accepts 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.

How many rows

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).

Reading a subset — select

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 type

Anything 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.

Aggregation

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.

Migrations — weave push

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 server

Renaming 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.

weave gen — the server back into code

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 — and weave push runs gen at 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.

Scopes — access control as code

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.

Repo layout

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

Design references

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.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages