Skip to content

Relations

The README's Relations section is the surface tour: two helpers (rel.one, rel.many), three options (on, refs, onDelete), and a worked author-post example. This document goes deeper — every shape forge supports, every cascade action, how includes plan, how filters traverse relations, how nested writes order themselves, and six worked patterns lifted from real schemas.

Forge's relation surface is deliberately Prisma-shaped. If you've written Prisma schemas before, the mental model carries over. The differences live in two places: forge uses string keys rather than type references to point at related models (so cycles don't collapse the inference graph to any), and forge runs on six dialects — five SQL plus Mongo — so "foreign key" means something slightly different in each.

Contents


Four relation shapes

Every relation in forge is built from two primitives: rel.one (the side that owns the foreign key) and rel.many (the inverse, virtual, list side). The four classic shapes fall out of how you combine them.

One-to-many (a user has many posts)

ts
import { f, model, rel } from 'forge-orm';

const User = model('users', {
  id:   f.id(),
  name: f.string(),
}).relate(() => ({
  posts: rel.many('post', { on: 'author_id', refs: 'id' }),
}));

const Post = model('posts', {
  id:        f.id(),
  author_id: f.objectId(),
  title:     f.string(),
}).relate(() => ({
  author: rel.one('user', { on: 'author_id', refs: 'id' }),
}));

The Post.author side owns the relation — its row physically stores author_id. The User.posts side is virtual — nothing is stored on the user row; forge hydrates the list by querying posts with a matching author_id.

Many-to-one (a post belongs to one user)

The inverse direction of the same relation, no extra declaration needed. The owning side is whichever side stores the FK column, and rel.one on that side always wins.

Many-to-many (a post has many tags, a tag is on many posts)

Forge does not synthesise a hidden join table for you. Declare one explicitly and put rel.one on it pointing at both sides. See Join tables for many-to-many for the worked example.

One-to-one (a user has one profile)

ts
const User = model('users', {
  id: f.id(),
}).relate(() => ({
  profile: rel.one('profile', { on: 'id', refs: 'user_id' }),
  // or: profile: rel.many('profile', { on: 'id', refs: 'user_id' }),
  //     then take[0] in code — see notes below
}));

const Profile = model('profiles', {
  id:      f.id(),
  user_id: f.objectId().unique(),                // the unique is what makes it 1-1
  bio:     f.text(),
}).relate(() => ({
  user: rel.one('user', { on: 'user_id', refs: 'id', onDelete: 'Cascade' }),
}));

The user_id.unique() is what enforces "at most one profile per user." Without that unique, a misbehaving migration could insert two profiles pointing at the same user and the User.profile side would silently return whichever one the executor sees first.

If you declare User.profile as rel.one, forge will still issue a findMany under the hood and pull the first row — there's no separate "has-one" planner. Most teams declare the inverse as rel.many and read profile[0], which is the same physical query without the false promise of singleton semantics in the type.


The on and refs columns

on is the column on this model that holds the foreign key value. refs is the column on the target model that it points at.

ts
// On Post:
author: rel.one('user', { on: 'author_id', refs: 'id' });
//                              ^^^^^^^^^         ^^^^
//                              column on posts    column on users

The convention forge follows is straightforward: name the FK column <target>_id, point it at the target's id, and declare f.objectId() on the column. f.objectId() is the right type for a foreign key column on every dialect — it stores an ObjectId on Mongo and a plain text column on the five SQL dialects, matching the string id that f.id() produces.

If you opt into a different primary-key strategy via f.id({ type }), match the FK column to it:

ts
// Numeric autoincrement parent:
const Org = model('orgs', { id: f.id({ type: 'bigserial' }) });
const Member = model('members', {
  id:     f.id(),
  org_id: f.int(),                              // not f.objectId() — numeric FK
}).relate(() => ({
  org: rel.one('org', { on: 'org_id', refs: 'id' }),
}));

refs doesn't have to be id. Any column with a unique constraint can be the referenced side:

ts
const User = model('users', {
  id:    f.id(),
  email: f.string().unique(),                   // unique → safe to reference
});

const Invitation = model('invitations', {
  id:    f.id(),
  to:    f.string(),
}).relate(() => ({
  user: rel.one('user', { on: 'to', refs: 'email' }),
}));

The unique on email is load-bearing: without it, the relation could match multiple users for a single invitation and the include would error. On SQL dialects forge will emit a foreign-key constraint pointing at users(email), which requires the unique to exist before the FK can be added — forge push orders DDL accordingly.


Cascade rules

The onDelete option on the owning side controls what happens to the owning row when the row it points at is deleted. The four Prisma-shaped actions are all supported:

ActionEffect on this row when the target row is deleted
CascadeDelete this row too
SetNullSet the FK column to NULL on this row
RestrictBlock the delete if any owning row references it
NoActionDefer to the database default (default behaviour)

onDelete is only meaningful on rel.one — the side that stores the FK. Setting it on a rel.many is silently ignored.

Per-dialect emit

On the five SQL dialects, forge generates the FK constraint as part of CREATE TABLE / forge push and leaves enforcement to the database engine. The Mongo path has no FK concept; forge walks the relations application-side at delete time.

DialectEmit
PostgresFOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
MySQLFOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
SQLiteFOREIGN KEY(author_id) REFERENCES users(id) ON DELETE CASCADE
DuckDBFOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
MSSQLFOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
MongoNo emit. Walked at delete time by the cascade walker.

SQLite requires PRAGMA foreign_keys = ON for FKs to fire; forge sets that on every connection it opens.

Worked examples per action

ts
// Cascade — children die with the parent.
author: rel.one('user', { on: 'author_id', refs: 'id', onDelete: 'Cascade' });
await db.user.delete({ where: { id: 'u1' } });
// Every post with author_id = 'u1' is deleted in the same statement (SQL)
// or by the cascade walker (Mongo).

// SetNull — FK column must be nullable.
author_id: f.objectId().optional(),
author: rel.one('user', { on: 'author_id', refs: 'id', onDelete: 'SetNull' });
await db.user.delete({ where: { id: 'u1' } });
// Posts that used to point at u1 now have author_id = NULL.

// Restrict — hard guard against accidental orphans.
author: rel.one('user', { on: 'author_id', refs: 'id', onDelete: 'Restrict' });
await db.user.delete({ where: { id: 'u1' } });
// Throws on every SQL dialect if any post still points at u1.
// On Mongo, forge runs a precount and throws a matching error.

Declaring SetNull on a non-nullable FK is accepted at schema time, but the database rejects the delete at runtime (Postgres raises null value … violates not-null constraint). Make the FK column nullable when you pick SetNull.

NoAction is the default when onDelete is omitted. On SQL it emits ON DELETE NO ACTION — the deferred check fires at commit time, in practice behaving like Restrict. Mongo treats NoAction as "do nothing" and leaves orphans behind.


Inverse relations

The README mentions an inverse: true flag. In current forge, you don't set it by hand: rel.many always auto-sets inverse: true on the returned RelationInfo, and rel.one is always the owning side. The flag exists on the type so the cascade walker and the schema introspector can tell the two sides apart at runtime.

What this means practically:

  • Cascade walks only follow owning sides. The walker in src/adapters/mongo/cascade.ts skips any relation with inverse: true to avoid double-walking a one-to-many in both directions.
  • DDL is only emitted from the owning side. If both ends declared an rel.one, forge would try to emit two FK constraints and the second would fail. Declaring rel.many on the inverse side is what tells forge "don't emit DDL for me — I'm a virtual list."

The double-FK bug

If you forget which side owns the FK and declare rel.one on both ends, you end up with two FK columns and two constraints pointing at each other:

ts
// WRONG — both sides own the relation
const User = model('users', { id: f.id(), profile_id: f.objectId() })
  .relate(() => ({ profile: rel.one('profile', { on: 'profile_id', refs: 'id' }) }));

const Profile = model('profiles', { id: f.id(), user_id: f.objectId() })
  .relate(() => ({ user: rel.one('user', { on: 'user_id', refs: 'id' }) }));

forge push will create both users.profile_id and profiles.user_id, and the two FK constraints form a circular dependency that's painful to seed. The fix is to pick a side: typically the child (the row whose existence depends on the parent) owns the FK, and the parent declares the inverse with rel.many even when the cardinality is one-to-one. See One-to-one for the canonical shape.


Join tables for many-to-many

Forge does not synthesise a hidden join table the way classic ORMs do. A many-to-many is a first-class model with rel.one pointing at both sides, plus a composite unique to keep duplicate edges out.

Worked example — Post ↔ Tag

ts
const Post = model('posts', {
  id:    f.id(),
  title: f.string(),
}).relate(() => ({
  tags: rel.many('postTag', { on: 'id', refs: 'post_id' }),
}));

const Tag = model('tags', {
  id:   f.id(),
  name: f.string().unique(),
}).relate(() => ({
  posts: rel.many('postTag', { on: 'id', refs: 'tag_id' }),
}));

const PostTag = model('post_tags', {
  id:      f.id(),
  post_id: f.objectId(),
  tag_id:  f.objectId(),
})
  .index({ name: 'post_tag_unique', fields: ['post_id', 'tag_id'], unique: true })
  .relate(() => ({
    post: rel.one('post', { on: 'post_id', refs: 'id', onDelete: 'Cascade' }),
    tag:  rel.one('tag',  { on: 'tag_id',  refs: 'id', onDelete: 'Cascade' }),
  }));

The composite unique on (post_id, tag_id) is what enforces "at most one edge between this post and this tag." Without it, a retry that re-inserts the same edge will silently double-count tag membership.

Reading "posts and their tags"

To get the tag names off a post you traverse the join row in the include:

ts
const posts = await db.post.findMany({
  include: { tags: { include: { tag: true } } },
});
// posts[0].tags = [{ id, post_id, tag_id, tag: { id, name } }, ...]

If you'd rather not see the join row in the result shape, select the nested tag field and project it client-side:

ts
const rows = await db.post.findMany({
  select: { id: true, title: true, tags: { select: { tag: { select: { name: true } } } } },
});
const flat = rows.map(p => ({ ...p, tagNames: p.tags.map(t => t.tag.name) }));

Cascade choice on the join model

The example above declares onDelete: 'Cascade' on both sides of the join. That's almost always the right call: when a post is deleted, the edges it owned should die with it; same for tags. The alternative — SetNull — would orphan the join row, which has no useful meaning.


Self-referential relations

A model can point at itself. The canonical shape is a tree: each row optionally has a parent, and forge looks the children up by parent_id.

ts
const Category = model('categories', {
  id:        f.id(),
  parent_id: f.objectId().optional(),
  name:      f.string(),
}).relate(() => ({
  parent:   rel.one('category',  { on: 'parent_id', refs: 'id' }),
  children: rel.many('category', { on: 'parent_id', refs: 'id' }),
}));

Forge accepts 'self' as a synonym for the current schema key. Either form works — use whichever reads better in context.

Loading one level

include works the same as on any other relation:

ts
await db.category.findFirst({
  where:   { id: 'cat-root' },
  include: { children: true },
});

Loading the whole tree

Forge does not auto-recurse. Each include adds exactly one level of joins to the plan; nesting include: { children: { include: { children: { include: { children: true } } } } } works but is bounded to whatever depth you typed.

For an unbounded depth, two patterns work:

Pattern A — manual breadth-first loop. Pull one level at a time until you stop finding rows:

ts
async function loadTree(rootId: string) {
  const root = await db.category.findUnique({ where: { id: rootId } });
  if (!root) return null;
  const byParent = new Map<string, typeof root[]>();
  let frontier = [rootId];
  while (frontier.length) {
    const next = await db.category.findMany({ where: { parent_id: { in: frontier } } });
    if (!next.length) break;
    for (const row of next) {
      const list = byParent.get(row.parent_id!) ?? [];
      list.push(row);
      byParent.set(row.parent_id!, list);
    }
    frontier = next.map(r => r.id);
  }
  // attach children to each parent
  const attach = (n: any) => ({ ...n, children: (byParent.get(n.id) ?? []).map(attach) });
  return attach(root);
}

Two round trips on a shallow tree, log(N) on a deeper one. Use this when the depth varies per row and you don't want to over-include.

Pattern B — WITH RECURSIVE via $queryRaw. When you need the whole tree in one round trip and you're on a SQL dialect that supports CTEs (Postgres, SQLite, DuckDB, MSSQL, MySQL 8+):

ts
const rows = await db.$queryRaw<Array<{ id: string; parent_id: string; depth: number }>>`
  WITH RECURSIVE tree AS (
    SELECT id, parent_id, name, 0 AS depth
      FROM categories
     WHERE id = ${rootId}
    UNION ALL
    SELECT c.id, c.parent_id, c.name, t.depth + 1
      FROM categories c
      JOIN tree t ON c.parent_id = t.id
  )
  SELECT * FROM tree ORDER BY depth, id;
`;

See docs/RAW-SQL.md for the tagged-template rules and how parameters are bound. On Mongo, use $graphLookup via $runCommandRaw for the same shape.


Polymorphic relations (two patterns)

Forge has no native polymorphic relation. A single FK column can only reference a single target table; there's no equivalent to Rails' belongs_to :commentable, polymorphic: true. You build the same shape out of primitives.

Pattern A — Mongo discriminator with embedMany

When the polymorphic children belong to a single parent and never need to be queried independently, embed them with a discriminator field:

ts
const TextBlock  = () => embed('TextBlock',  { kind: f.string(), text: f.string() });
const ImageBlock = () => embed('ImageBlock', { kind: f.string(), url: f.string(), alt: f.string() });
const QuoteBlock = () => embed('QuoteBlock', { kind: f.string(), text: f.string(), source: f.string() });

const Page = model('pages', {
  id:     f.id(),
  blocks: f.embedMany('Block'),                  // union shape — discriminated on `kind`
});

The TS type is a union; you narrow on kind at the call site:

ts
const page = await db.page.findFirst({ where: { id: 'p1' } });
for (const block of page.blocks) {
  if (block.kind === 'image') {
    // block is narrowed to ImageBlock here
  }
}

Trade-offs. Cheap reads, no joins, the children move with the parent. But blocks aren't independently queryable ("find all pages with an image block of URL X" is awkward — see JSON path queries for the workable form on Mongo and Postgres) and you can't put a unique constraint on a discriminated field across all blocks.

Pattern B — SQL multi-FK with kind discriminator

When the polymorphic children need to be queried independently or live in their own tables, give the parent a nullable FK per possible target plus a discriminator:

ts
const Comment = model('comments', {
  id:           f.id(),
  body:         f.text(),
  target_kind:  f.string(),                     // 'post' | 'photo' | 'video'
  post_id:      f.objectId().optional(),
  photo_id:     f.objectId().optional(),
  video_id:     f.objectId().optional(),
})
  .index({ name: 'comment_target', fields: ['target_kind', 'post_id', 'photo_id', 'video_id'] })
  .relate(() => ({
    post:  rel.one('post',  { on: 'post_id',  refs: 'id', onDelete: 'Cascade' }),
    photo: rel.one('photo', { on: 'photo_id', refs: 'id', onDelete: 'Cascade' }),
    video: rel.one('video', { on: 'video_id', refs: 'id', onDelete: 'Cascade' }),
  }));

Reads:

ts
const c = await db.comment.findFirst({
  where:   { id: 'c1' },
  include: { post: true, photo: true, video: true },
});
const parent = c.post ?? c.photo ?? c.video;     // exactly one is non-null

Trade-offs. Each child kind is a real FK with real cascade semantics, and you can filter independently (db.comment.findMany({ where: { post: { is: { author_id: 'u1' } } } })). The cost is one nullable column per possible target plus an app-level invariant that exactly one is set per row. You can encode that invariant with a CHECK constraint on Postgres or a partial unique index on the populated column.

Rule of thumb. Pattern A when the children are content (blocks of a page, lines of an invoice) and the parent owns their lifecycle. Pattern B when the children are first-class records (comments, reactions, audit entries) and need their own indexes and policies.


Deep includes

include takes either true (load the relation, all columns) or an options object that can itself nest include:

ts
const user = await db.user.findFirst({
  where: { id: 'u1' },
  include: {
    posts: {
      include: {
        author: { include: { profile: true } },
        comments: { include: { author: true } },
      },
    },
  },
});

How forge plans the joins

On SQL dialects, each level of include is a separate query, not a single mega-join. The IR builder issues a parent query, then one query per included relation per parent batch, using IN (...) to fetch all matching children at once. This is the dataloader pattern — N+1 is collapsed to a constant number of round trips equal to the depth of the include tree.

A three-level include like the one above runs:

  1. Find users matching the where.
  2. Find posts where author_id IN (user.id, ...).
  3. Find authors where id IN (post.author_id, ...) (degenerate — it's the user we already have, so the planner short-circuits when the IDs match).
  4. Find profiles where user_id IN (author.id, ...).
  5. Find comments where post_id IN (post.id, ...).
  6. Find authors-of-comments where id IN (comment.author_id, ...).

Six queries, regardless of how many users matched the first one. On Mongo, forge uses $lookup for the immediate join and unwinds the result into the same nested shape.

N+1 risk

The dataloader plan is the cure for N+1 inside a single query. The classic N+1 — looping over results and issuing a query per row — is still possible if you don't include in the first place:

ts
// WRONG — one query per user
const users = await db.user.findMany();
for (const u of users) {
  const posts = await db.post.findMany({ where: { author_id: u.id } });
}

// RIGHT — one query for users + one query for all their posts
const users = await db.user.findMany({ include: { posts: true } });

include is always cheaper than the loop. The only time to reach for a manual JOIN via $queryRaw is when you need to filter the parent by an aggregate over the children in a single round trip (see Performance and the .compile escape hatch).

Bounded children per parent

You can cap the included children with take and order them with orderBy:

ts
await db.user.findMany({
  include: {
    posts: { orderBy: { created_at: 'desc' }, take: 5 },
  },
});

The cap is applied per parent, which compiles on SQL to a windowed subquery (ROW_NUMBER() OVER (PARTITION BY author_id ORDER BY ...)) and on Mongo to a $slice inside the $lookup pipeline.


select inside include

select and include are mutually exclusive at the top level, but you can nest select inside an include to project the included relation:

ts
const slim = await db.user.findFirst({
  where: { id: 'u1' },
  include: {
    posts: {
      select: { id: true, title: true },        // only id + title come back per post
    },
  },
});
// slim.posts: Array<{ id: string; title: string }>

The return type narrows to match: TypeScript sees only the fields you selected, the rest are gone from the inferred shape. This is the same inference machine that powers top-level select; the type infrastructure walks the select tree per include branch.

The reverse — include inside select — also works, because a top-level select is fully shape-controlled:

ts
const compact = await db.user.findFirst({
  select: {
    id:    true,
    email: true,
    posts: { select: { id: true, title: true } },
  },
});
// compact: { id; email; posts: Array<{ id; title }> }

See docs/TYPES.md for the type rules forge uses to flow selections through nested relations.


Filtering by a relation

where can traverse relations using four operators, mirroring Prisma's shape:

OperatorRelation kindMeaning
isrel.oneThe related row matches this where
isNotrel.oneThe related row exists but doesn't match, or doesn't exist
somerel.manyAt least one related row matches
everyrel.manyEvery related row matches (vacuously true if none)
nonerel.manyNo related row matches
ts
// Posts whose author's email contains 'acme':
await db.post.findMany({
  where: { author: { is: { email: { contains: 'acme' } } } },
});

// Users with at least one published post:
await db.user.findMany({
  where: { posts: { some: { status: 'PUBLISHED' } } },
});

// Users every one of whose posts is published:
await db.user.findMany({
  where: { posts: { every: { status: 'PUBLISHED' } } },
});

// Users with no draft posts:
await db.user.findMany({
  where: { posts: { none: { status: 'DRAFT' } } },
});

Per-dialect emit

On Postgres, MySQL, SQLite, DuckDB, and MSSQL, relation filters compile to EXISTS (or NOT EXISTS) subqueries. The every operator compiles to a double-negation — "no related row that doesn't match" — because that shape correctly handles the vacuous-true case (a user with zero posts satisfies every).

sql
-- some
WHERE EXISTS (SELECT 1 FROM posts p WHERE p.author_id = u.id AND p.status = 'PUBLISHED')
-- every
WHERE NOT EXISTS (SELECT 1 FROM posts p WHERE p.author_id = u.id AND p.status <> 'PUBLISHED')
-- none
WHERE NOT EXISTS (SELECT 1 FROM posts p WHERE p.author_id = u.id AND p.status = 'DRAFT')

On Mongo, the same operators compile to a $lookup followed by a $match on the lookup result's length or contents:

js
// some
{ $lookup: { from: 'posts', localField: '_id', foreignField: 'author_id', as: '_posts' } }
{ $match: { _posts: { $elemMatch: { status: 'PUBLISHED' } } } }

The every and none variants similarly use $not plus $elemMatch. Mongo executes the $lookup once per query, not per parent row.

isNot is not "is missing"

isNot matches rows whose related row exists but doesn't match — it does not match rows where the relation is absent. For "the related row is missing entirely," check the FK column directly:

ts
await db.post.findMany({ where: { author_id: null } });

This distinction matters when the FK column is nullable and you want either-or semantics.


Counting and aggregating relations

_count in include

The README touches on this. The full shape:

ts
const users = await db.user.findMany({
  include: {
    _count: { select: { posts: true, comments: true } },
  },
});
// users[0]._count: { posts: 5, comments: 12 }

Each entry in _count.select becomes a single column in the projection — on SQL a correlated subquery (SELECT COUNT(*) FROM posts WHERE posts.author_id = users.id) AS _count_posts, on Mongo a $lookup followed by $size.

You can also count with a filter:

ts
await db.user.findMany({
  include: {
    _count: {
      select: { posts: { where: { status: 'PUBLISHED' } } },
    },
  },
});
// users[0]._count.posts is now COUNT(*) WHERE status='PUBLISHED'

Filtering on _count

You can filter the parent by the count of its children:

ts
await db.user.findMany({
  where: { posts: { _count: { gt: 0 } } },              // users with ≥1 post
});

await db.user.findMany({
  where: { posts: { _count: { lt: 3 } } },              // users with fewer than 3 posts
});

On SQL this compiles to a HAVING clause if the planner already introduced a GROUP BY, or to a correlated subquery in the WHERE otherwise. On Mongo it routes through $lookup + $expr.

Aggregating across a relation

For sums, averages, min, and max across a relation, use aggregate on the child model with a parent filter, then merge the results client-side — there is no single forge call that says "sum the orders of every user." The reason is dialect parity: a correlated-aggregate subquery would need different rewrites for each dialect and the Mongo path would route through $group, all of which is easier to express directly than to hide:

ts
const totals = await db.order.groupBy({
  by:     ['user_id'],
  _sum:   { total: true },
  _count: { _all: true },
});

See docs/QUERIES.md for the full groupBy surface.


Writing through relations

create, update, and upsert all accept relation operations alongside the row's own fields. The supported verbs:

VerbOne sideMany sideMeaning
createyesyesInsert a new related row
createManyyesInsert a list of related rows
connectyesyesAttach an existing row by its identifier
connectOrCreateyesyesFind by where, otherwise create
disconnectyesyesClear the FK (one) or unlink specific rows (many)
setyesReplace the entire list — disconnect everything, then connect
updateyesyesUpdate the related row(s)
updateManyyesUpdate many related rows by where
upsertyesyesUpdate if found, otherwise create
deleteyesyesDelete the related row(s)
deleteManyyesDelete many related rows by where

Worked example — nested create

ts
const user = await db.user.create({
  data: {
    email: 'a@x.co',
    name:  'Alice',
    posts: {
      create: [
        { title: 'Hello',  body: 'first post' },
        { title: 'Second', body: 'another'    },
      ],
    },
  },
  include: { posts: true },
});
// user.posts: 2 freshly-created rows with author_id set to the new user's id

Parent-first ordering

Forge always inserts the parent first, captures the parent's generated id, and then writes the children with that id substituted into the FK column. This means:

  • You don't write author_id on the nested child — forge fills it in.
  • The parent must succeed before any child runs. If the child insert fails, the rule that applies depends on whether you're in a transaction.

Transaction guarantee on SQL, best-effort on Mongo

On the five SQL dialects, every nested write runs inside a single transaction by default. A nested create that fails halfway rolls back the parent and any siblings created so far. The behaviour matches db.$transaction(async tx => { ... }) — see docs/QUERIES.md for the surface.

On Mongo, forge doesn't open a multi-document transaction unless the deployment is a replica set or sharded cluster. Without transaction support the nested writes are best-effort: if the second child fails after the first one was inserted, the first child stays inserted. The cascade walker has no compensating "uncreate" pass.

If you need atomicity on Mongo, open a session and pass txn through the call:

ts
await db.$transaction(async (tx) => {
  await tx.user.create({
    data: {
      email: 'a@x.co',
      posts: { create: [{ title: 'A' }, { title: 'B' }] },
    },
  });
});

This is a no-op on standalone Mongo; on a replica set it pins a session and the nested writes are committed together or not at all.

connect, disconnect, set, connectOrCreate

ts
// Attach an existing post:
await db.user.update({ where: { id: 'u1' }, data: { posts: { connect: { id: 'p1' } } } });

// Detach a post (clear its author_id):
await db.user.update({ where: { id: 'u1' }, data: { posts: { disconnect: { id: 'p1' } } } });

// Replace the entire list — anything not in the new set is disconnected:
await db.user.update({ where: { id: 'u1' }, data: { posts: { set: [{ id: 'p1' }, { id: 'p2' }] } } });

// Tagging by name — create the tag if it doesn't already exist:
await db.post.update({
  where: { id: 'p1' },
  data: { tags: { connectOrCreate: [
    { where: { name: 'forge' }, create: { name: 'forge' } },
    { where: { name: 'orm'   }, create: { name: 'orm'   } },
  ]}},
});

set is the bluntest verb — it compiles to "diff current vs. new, disconnect removed, connect added," in a transaction on SQL and sequentially on Mongo. Prefer connect+disconnect when the caller already knows what changed; reach for set only when the client owns the full membership list.

connectOrCreate runs the where lookup first; only misses fall through to create. On SQL it's a SELECT + conditional INSERT ordered to avoid double-creating under concurrent writes (the atomic upsert path covered in the README).


Six worked patterns

The examples so far have been small. These six are real shapes lifted from production schemas, each picked because it tests a different corner of the relation surface.

(a) Blog with users, posts, tags, and comments

The canonical CRUD shape: one-to-many for authorship, many-to-many for tagging, self-referential for threaded comments. Post and PostTag follow the join-table shape from Join tables for many-to-many; Comment.parent/Comment.replies is the self-referential shape from Self-referential relations.

ts
const User = model('users', { id: f.id(), email: f.string().unique(), name: f.string() })
  .relate(() => ({
    posts:    rel.many('post',    { on: 'author_id', refs: 'id' }),
    comments: rel.many('comment', { on: 'author_id', refs: 'id' }),
  }));

const Post = model('posts', {
  id: f.id(), author_id: f.objectId(), title: f.string(), body: f.text(),
  status: f.enum(['DRAFT', 'PUBLISHED']).default('DRAFT'),
}).relate(() => ({
  author:   rel.one('user',     { on: 'author_id', refs: 'id', onDelete: 'Cascade' }),
  comments: rel.many('comment', { on: 'post_id',   refs: 'id' }),
  tags:     rel.many('postTag', { on: 'id',        refs: 'post_id' }),
}));

const Comment = model('comments', {
  id: f.id(), post_id: f.objectId(), author_id: f.objectId(),
  parent_id: f.objectId().optional(), body: f.text(),
}).relate(() => ({
  post:    rel.one('post',     { on: 'post_id',   refs: 'id', onDelete: 'Cascade' }),
  author:  rel.one('user',     { on: 'author_id', refs: 'id', onDelete: 'Cascade' }),
  parent:  rel.one('comment',  { on: 'parent_id', refs: 'id' }),
  replies: rel.many('comment', { on: 'parent_id', refs: 'id' }),
}));

"Show me this post with its author, top-level comments, and replies" in one include tree:

ts
await db.post.findFirst({
  where: { id: 'p1' },
  include: {
    author:   { select: { id: true, name: true } },
    tags:     { include: { tag: true } },
    comments: {
      where:   { parent_id: null },
      orderBy: { created_at: 'asc' },
      include: { author: true, replies: { include: { author: true } } },
    },
  },
});

(b) Org → users → roles via a permissions join

The "user has many roles in many orgs" shape — three-way join with a payload (invited_at). The unique on (org_id, user_id, role_id) prevents duplicate grants.

ts
const Membership = model('memberships', {
  id: f.id(), org_id: f.objectId(), user_id: f.objectId(), role_id: f.objectId(),
  invited_at: f.dateTime().default('now'),
})
  .index({ name: 'mem_org_user_role', fields: ['org_id', 'user_id', 'role_id'], unique: true })
  .relate(() => ({
    org:  rel.one('org',  { on: 'org_id',  refs: 'id', onDelete: 'Cascade' }),
    user: rel.one('user', { on: 'user_id', refs: 'id', onDelete: 'Cascade' }),
    role: rel.one('role', { on: 'role_id', refs: 'id', onDelete: 'Restrict' }),
  }));
// Org, User, and Role each declare `memberships: rel.many('membership', ...)`
// on the matching id.

onDelete: 'Restrict' on the role side is deliberate: deleting a role that's still assigned to anyone should be a hard error, not a silent mass-revocation. Org and user deletes cascade through their memberships; role deletes block until every membership using them is gone.

(c) BOM — recursive product composition

A bill of materials is a tree where products are composed of other products: self-referential many-to-many with a quantity payload.

ts
const Bom = model('bom', {
  id: f.id(), parent_id: f.objectId(), child_id: f.objectId(),
  quantity: f.decimal({ scale: 4 }),
})
  .index({ name: 'bom_unique', fields: ['parent_id', 'child_id'], unique: true })
  .relate(() => ({
    parent: rel.one('product', { on: 'parent_id', refs: 'id', onDelete: 'Cascade' }),
    child:  rel.one('product', { on: 'child_id',  refs: 'id', onDelete: 'Restrict' }),
  }));
// Product declares: components: rel.many('bom', { on: 'id', refs: 'parent_id' })
//                   partOf:     rel.many('bom', { on: 'id', refs: 'child_id' })

Restrict on the child side stops you deleting a product still used as a component elsewhere. The full recursive expansion ("what raw materials does this product depend on, at what quantity?") routes through the WITH RECURSIVE pattern from Self-referential relations.

(d) Discriminated content blocks (polymorphic)

A CMS where a page is composed of typed blocks. Pattern A (Mongo discriminator with embedMany) from the polymorphic section, made concrete:

ts
const PageBlock = () => embed('PageBlock', {
  kind:     f.string(),                       // 'text' | 'image' | 'embed' | 'quote'
  position: f.int(),
  text:     f.string().optional(),
  url:      f.string().optional(),
  alt:      f.string().optional(),
  caption:  f.string().optional(),
  source:   f.string().optional(),
});

const Page = model('pages', {
  id:     f.id(),
  slug:   f.string().unique(),
  title:  f.string(),
  blocks: f.embedMany(PageBlock).default([]),
});

Writes are a single document:

ts
await db.page.create({
  data: {
    slug:  'about',
    title: 'About us',
    blocks: [
      { kind: 'text',  position: 0, text: 'Welcome.' },
      { kind: 'image', position: 1, url: '/hero.jpg', alt: 'team' },
      { kind: 'quote', position: 2, text: 'We build', source: 'Bret' },
    ],
  },
});

Reads come back fully formed; narrowing on kind at render time is the client's job. On Postgres / SQLite / DuckDB, embedMany stores as JSON and the same path operator from JSON path queries gives you partial read access without unpacking the whole array.

(e) Marketplace — one user, many orders, many items

Two layers of one-to-many with cascades chosen per layer: deleting a user SetNulls the buyer (audit-safe orphan), deleting an order Cascades its line items (no independent meaning).

ts
const Order = model('orders', {
  id: f.id(), buyer_id: f.objectId().optional(),
  total: f.decimal({ scale: 2 }),
  status: f.enum(['PLACED', 'PAID', 'SHIPPED', 'CANCELLED']),
  deleted_at: f.dateTime().optional(),
})
  .softDelete()
  .relate(() => ({
    buyer: rel.one('user', { on: 'buyer_id', refs: 'id', onDelete: 'SetNull' }),
    items: rel.many('orderItem', { on: 'id', refs: 'order_id' }),
  }));

const OrderItem = model('order_items', {
  id: f.id(), order_id: f.objectId(),
  sku: f.string(), quantity: f.int(), unit_price: f.decimal({ scale: 2 }),
}).relate(() => ({
  order: rel.one('order', { on: 'order_id', refs: 'id', onDelete: 'Cascade' }),
}));

.softDelete() on Order means db.order.delete flips deleted_at rather than removing the row; the cascade to items only fires on db.order.deleteHard. See soft delete.

(f) Multi-tenant SaaS scoped by orgId

Every tenant-scoped row carries an org_id FK and every query filters by it. Forge has no native tenant-scope hook; the pattern is small enough to wrap as a helper.

ts
const User = model('users', { id: f.id(), org_id: f.objectId(), email: f.string() })
  .index({ name: 'user_email_per_org', fields: ['org_id', 'email'], unique: true })
  .relate(() => ({
    org: rel.one('org', { on: 'org_id', refs: 'id', onDelete: 'Cascade' }),
  }));

function scoped(orgId: string) {
  return {
    user: {
      findMany:  (q: any = {}) => db.user.findMany({ ...q, where: { ...q.where, org_id: orgId } }),
      findFirst: (q: any = {}) => db.user.findFirst({ ...q, where: { ...q.where, org_id: orgId } }),
      create:    (q: any)      => db.user.create({ ...q, data: { ...q.data, org_id: orgId } }),
      // ...same for update, delete, upsert
    },
  };
}

const scopedDb = scoped('org-1');
await scopedDb.user.findMany();                  // implicitly WHERE org_id = 'org-1'

The unique on (org_id, email) is what makes "email is unique per tenant" enforceable at the database — without it the scoping is purely advisory. Switch the cascade to Restrict if you want accidental tenant deletion to be a hard error rather than a mass cascade.


Cascade gotchas

Mongo has no native FK enforcement

Every other section in this document treats onDelete as if it's enforced by the database. On Mongo, it isn't. The cascade walker in src/adapters/mongo/cascade.ts runs application-side every time you call db.x.delete or db.x.deleteMany. The walk:

  1. Inspects the schema for every relation pointing at the model you're deleting.
  2. Skips inverse-side relations (the virtual rel.many list) — only owning sides drive a cascade.
  3. For each owning relation with onDelete: 'Cascade', recursively deletes the matching child rows.
  4. For each with onDelete: 'SetNull', updates the children's FK to null.
  5. Carries a visited set across the recursion to break cycles (self-referential relations with Cascade would otherwise spin).

The walk is best-effort: if the second cascade level fails after the first one succeeded, the partial state is left behind. Wrapping the delete in db.$transaction only helps on replica sets / sharded clusters; standalone Mongo has no rollback.

Restrict is the safe default for cross-tenant references

If a row crosses a tenant boundary — a billing record pointing at a plan, a webhook pointing at an org — pick Restrict rather than Cascade. A misclick that deletes a row the rest of the system depends on shouldn't cascade silently; making the delete fail forces the caller to deal with the references.

db.$transaction survival on SQL

On Postgres, MySQL, SQLite, DuckDB, and MSSQL, the cascade is part of the same statement that deletes the parent — the entire DELETE runs atomically by definition. Wrapping the call in db.$transaction doesn't change that; it only matters when you're issuing multiple deletes and want all-or-nothing across them.

Schema-driven cascade vs. raw SQL

db.$executeRaw does not run the cascade walker. If you delete rows directly with raw SQL on Mongo, the children stay. On SQL the database's own FK enforcement still fires (because it's set up via forge push), so a raw delete on Postgres still cascades — but raw deletes on Mongo need their own application-side cleanup.

Soft-delete interaction

softDelete() on a model rewires the wrapper so db.x.delete only flips the deleted_at column. The cascade walker doesn't fire on a soft delete; children stay live with a parent they can no longer find via the default query path. If you want a soft delete to soft-delete the children too, either chain it manually or use db.x.deleteHard (which does fire the cascade and runs the FK constraints).


Performance and the .compile escape hatch

When include is the right tool

For "fetch a row and its dependents," include is almost always the correct call. The dataloader plan keeps the round-trip count proportional to the depth of the include, not the breadth of the result set. A page that loads 50 users with their posts and tags is six queries — the same as a page that loads 5 users with their posts and tags.

When to drop to a manual JOIN

The case that include doesn't handle in one round trip is filtering the parent by a join'd aggregate when you also want the join'd rows in the result. For example: "users who have a published post in the last 7 days, ranked by total post count, with their three most recent posts."

That single query — filter by a computed property of the relation, order by an aggregate over the relation, and project a windowed slice of the relation — needs SQL. Run the parent query with $queryRaw:

ts
const rows = await db.$queryRaw<Array<{ user_id: string; post_count: number }>>`
  SELECT u.id AS user_id, COUNT(p.id) AS post_count
    FROM users u
    JOIN posts p ON p.author_id = u.id
   WHERE p.status = 'PUBLISHED' AND p.created_at > NOW() - INTERVAL '7 days'
   GROUP BY u.id
   ORDER BY post_count DESC
   LIMIT 20;
`;

const posts = await db.post.findMany({
  where:   { author_id: { in: rows.map(r => r.user_id) }, status: 'PUBLISHED' },
  orderBy: { created_at: 'desc' },
  take:    60,                                  // 3 per user × 20 users — coarse cap
});

The two-query split keeps the type information forge gives you on the second query while letting the first query express something the IR can't yet plan. See docs/RAW-SQL.md for the parameter binding rules.

The .compile escape hatch

Every method has a .compile variant that returns the SQL (or Mongo pipeline) it would have run, without executing it. You can hand the result to $queryRaw or $runCommandRaw to splice it into a bigger statement:

ts
const sub = db.post.compileSelect({
  where:   { status: 'PUBLISHED' },
  select:  { id: true, author_id: true, created_at: true },
});

const rows = await db.$queryRaw`
  SELECT u.name, p.title
    FROM users u
    JOIN (${sub.sql}) p ON p.author_id = u.id
   WHERE u.active = true;
`;

See the README's .compile section for the full surface — every read and write verb has a .compile counterpart, the IR builder runs but the executor is bypassed, and the returned { sql, params } is portable across the connection's dialect.

Detecting N+1 in development

Forge emits a query event for every executed statement (see docs/QUERIES.md for the event surface). The simplest N+1 detector is a counter:

ts
let count = 0;
db.$on('query', () => count++);

await handleRequest();
if (count > 10) console.warn(`high query count: ${count}`);

A handful of queries per request is normal. Hundreds usually means a loop is running a query per row — the fix is almost always to lift the loop into a single include or a single groupBy.

MIT licensed. Built from the repository's own markdown — README.md, CHANGELOG.md and docs/.