forge-orm
A small, Prisma-shaped data layer for MongoDB, PostgreSQL, MySQL, SQLite, DuckDB and SQL Server. You write your models once in plain TypeScript and the same query code runs against any of the six databases. There is no code generation step, no Rust query engine, and no framework to adopt — just readable TypeScript over the official drivers, organised one adapter per database.
npm install forge-orm
- npm: https://www.npmjs.com/package/forge-orm
- GitHub: https://github.com/johnsonfash/forge-orm
- License: MIT
import { createDb, f, model } from 'forge-orm';
const User = model('users', {
id: f.id(),
email: f.string().unique(),
name: f.string(),
});
const db = await createDb({ url: process.env.DATABASE_URL!, schema: { user: User } });
const alice = await db.user.create({ data: { email: 'a@x.co', name: 'Alice' } }); // no id needed
const users = await db.user.findMany({ where: { name: { contains: 'Ali' } }, take: 10 });The same code works whether DATABASE_URL is a Postgres, MySQL, SQLite,
DuckDB, SQL Server, or Mongo connection string. forge picks the right
driver from the URL prefix (postgres:, mysql:, sqlite:, duckdb:,
mssql:, mongodb:).
Beyond the basics, forge ships first-class typed support for the things you usually have to drop to raw SQL for:
- Geo —
f.geoPoint()+near/nearTo/withinPolygon, compiling to PostGIS / MySQL spatial / SpatiaLite / DuckDB spatial / MSSQLGEOGRAPHY/ Mongo2dsphere. App-side Haversine fallback when no spatial extension is installed. - Vector similarity —
f.vector(1536, { metric: 'cosine' })+ the samenear/nearTovocabulary, compiling to pgvector / DuckDB vss HNSW / MSSQLVECTOR_DISTANCE/ MySQL 9DISTANCE/ sqlite-vec / Mongo Atlas$vectorSearch. - JSON path queries —
where: { meta: { path: 'profile.age', gte: 18 } }on anyf.json()/f.embed()/f.embedMany()/ array column, compiling to PG->/->>, MySQLJSON_EXTRACT, SQLite / DuckDBjson_extract, MSSQLJSON_VALUE, Mongo dotted-key form. - Full-text search —
f.text().searchable()builds the right index per dialect (Postgres GIN tsvector, MySQLFULLTEXT, SQLite FTS5 with shadow-table triggers, Mongotext, DuckDBfts) and thesearchoperator queries it.
Sandbox / runnable examples
Try forge-orm without installing anything — every example is one click away on StackBlitz:
| Browser-runnable | One-liner |
|---|---|
| SQLite browser todo | OPFS-persisted todo app, no server |
| Offline-first with sync outbox | Optimistic writes + drain loop |
| DuckDB analytics | 5K-row aggregation in a tab |
| Node + SQLite CLI | Smallest possible forge-orm program |
| Hono + PGlite REST API | Backend API, zero external DB |
| Next.js + PGlite full-stack | App Router + Server Actions |
| Bun + SQLite blog | Bun's built-in SQLite, zero deps |
| Feature deep-dives | Pattern |
|---|---|
| Geo search | geoPoint + nearTo (PostGIS / Mongo / wasm fallback) |
| Vector RAG | f.vector(N) + cosine similarity |
| Recipe / BOM | Recursive sub-recipe rollup |
| Multi-tenant scoping | Soft RLS via app-layer wrapper |
| Audit log | db.$events.on("mutation", …) |
| Full-text search | .search() index across dialects |
| Transactions | Atomic batch + nested savepoints |
| Migrations + drift | db.$migrate() + db.$diff() |
| Real DB (point at your own) | Setup |
|---|---|
| MongoDB Atlas blog | Free Atlas tier, paste URI into .env |
| MSSQL ERP / MERGE | Docker SQL Server, .env |
| Postgres + RLS auth | Hard multi-tenant via current_setting |
All 18 examples live at johnsonfash/forge-orm-examples. Clone any folder:
npx degit johnsonfash/forge-orm-examples/01-sqlite-browser-todo my-appContents
- Sandbox / runnable examples
- What forge is, and what it is not
- Install and pick your driver
- Connecting
- Defining a schema
- Reading data
- Writing data
- Grouping and aggregates
- Transactions
- Running raw SQL
- Errors
- Full-text search
- Geo (geoPoint, near, nearTo, withinPolygon)
- JSON path queries
- Vector similarity search
- Browser (sqlite-wasm + OPFS)
- Quickstart
- URL schemes (
opfs:,opfs-sahpool:,:memory:) - The worker file
- Vite setup
- Next.js setup
- Webpack / CRA / Rsbuild setup
db.$migrate()— runtime DDL applybrowserDoctor()— runtime capability probe- Persistent storage and the Safari 7-day eviction
- Multi-tab safety
- Feature parity matrix
- Custom wasm build (vec0 + R-Tree)
- Troubleshooting
- Framework worked examples → docs/BROWSER-FRAMEWORKS.md
- Streaming large results
- Soft delete
- Views and materialised views
- Watching queries
- Creating tables and migrations
- Dropping to raw queries with
.compile - Type safety
- Performance
- Testing
- Limitations and honest notes
- Contributing
Deep-dive companions (docs/)
The README is the surface reference. For more depth — extra examples, edge cases, integration patterns — each major surface has its own companion doc. Eighty files in total, ~80,000 lines of reference material.
Schema and data model
| Topic | File |
|---|---|
| Model definition — full field catalogue, id strategies, enums, views, generated columns, schema namespacing, 5 worked schemas | docs/MODEL.md |
Embeds — f.embed/f.embedMany/f.json, indexing into embeds, Mongo $elemMatch, JSON-null markers, shape migration, 5 worked patterns |
docs/EMBED.md |
| Relations — one/many/inverse/cascade, join tables, polymorphic, self-ref, deep includes, 6 worked patterns | docs/RELATIONS.md |
Indexes — every IndexDef field, partial-filter, expression, INCLUDE, method matrix, drift detection, 6 worked patterns |
docs/INDEXES.md |
Type safety — Row, every Infer* helper, ForgeOf / ForgeModels, autocomplete tricks, generics, 5 worked patterns |
docs/TYPES.md |
| Primary keys — UUIDv4 vs v7 vs ULID vs Snowflake vs serial, fragmentation, per-dialect emit, migration | docs/PRIMARY-KEYS.md |
| Foreign keys — REFERENCES emit, onDelete/onUpdate, deferred checking, composite, online add, per-dialect quirks | docs/FOREIGN-KEYS.md |
Enums — f.enum(...), per-dialect emit, evolution (adding values online, expand/contract), lookup-table alternative |
docs/ENUMS.md |
CHECK constraints — DB-enforced row invariants, NOT VALID+VALIDATE, NULL semantics, vs zod, Mongo $jsonSchema |
docs/CHECKS.md |
| Generated columns — STORED vs VIRTUAL, indexable JSON extracts, per-dialect matrix, common patterns | docs/GENERATED-COLUMNS.md |
Views — CREATE VIEW, updatable rules, SECURITY_BARRIER, indexed views, Mongo collection views |
docs/VIEWS.md |
Materialized views — refresh strategies, CONCURRENTLY, MSSQL indexed views, Mongo $merge/$out, MySQL/SQLite emulation |
docs/MATERIALIZED-VIEWS.md |
| Triggers — DB-side procedural code, audit-log trigger, per-dialect model, Mongo change-stream alternative | docs/TRIGGERS.md |
Reads, writes, transactions
| Topic | File |
|---|---|
| Queries — every operator with per-dialect SQL/Mongo emit, cursor pagination, distinct, streaming, common bugs, 8 worked queries | docs/QUERIES.md |
| Mutations — create/update/upsert/delete asymmetry, atomic ops, nested writes, idempotency, optimistic+pessimistic concurrency, 8 worked patterns | docs/MUTATIONS.md |
| Transactions — callback vs array, per-dialect mechanics, savepoints, isolation, deadlock retry, Mongo replica-set, outbox, 5 worked patterns | docs/TRANSACTIONS.md |
Raw SQL — forgeSql composition, identifier-vs-value safety, per-dialect placeholders, $runCommandRaw, per-dialect worked patterns |
docs/RAW-SQL.md |
Upsert — ON CONFLICT / ON DUPLICATE KEY / MERGE / findOneAndUpdate per dialect, partial updates, race semantics |
docs/UPSERT.md |
Batch ops — createMany/updateMany/deleteMany, bind-parameter limits, chunking, ordered vs unordered, RETURNING |
docs/BATCH.md |
| Aggregations — count/sum/avg/groupBy/having/distinct, per-dialect emit, decimal precision, dashboard patterns | docs/AGGREGATIONS.md |
| Window functions — ROW_NUMBER/RANK/LAG/LEAD/SUM OVER, per-dialect matrix, top-N per group, moving averages, sessionization | docs/WINDOWS.md |
| Pagination — cursor / offset / numbered / infinite-scroll, total-count strategies, Relay/REST shapes, per-dialect | docs/PAGINATION.md |
Streaming — findManyStream per-driver internals, memory profile, backpressure, HTTP streaming, transactions+streaming |
docs/STREAMING.md |
| Locking — SELECT FOR UPDATE, advisory locks, SKIP LOCKED work queues, NOWAIT, deadlock prevention | docs/LOCKING.md |
| Concurrency control — optimistic version columns, ETag/If-Match, retry strategies, per-dialect isolation quirks | docs/CONCURRENCY.md |
Cross-cutting surfaces
| Topic | File |
|---|---|
| Full-text search — every dialect's FTS engine, ranking, multi-column, languages, hybrid BM25+vector, highlighting, 6 worked patterns | docs/FTS.md |
| Geo — SRIDs, dialect matrix, PostGIS, distance models, 3D, MultiPolygon, GeoJSON, spatial joins, H3, realtime tracking | docs/GEO.md |
| Vector / embeddings / RAG — dialect picker, pipeline, hybrid BM25, versioning, HNSW/IVFFlat, quantization, multi-modal, eval | docs/VECTOR.md |
| JSON path queries — per-dialect emit, indexing, migration, operator matrix, null markers, audit/webhook patterns, common bugs | docs/JSON-PATH.md |
| Migrations — push-style model, every CLI flag, drift rules, per-dialect emit, three CI snippets, blue/green, monorepo, runtime split | docs/MIGRATIONS.md |
| Drivers — bring-your-own-driver pattern, every shipped wrapper, six worked wrappers (Neon, Turso, D1, Atlas Data API, Bun, decorator) | docs/DRIVERS.md |
CLI and operations
| Topic | File |
|---|---|
CLI reference — every forge subcommand and flag, exit codes, env config, CI snippets, programmatic equivalents |
docs/CLI.md |
forge push — push semantics, --enable-extensions, --fallback, idempotency, dry-run, per-dialect DDL ordering |
docs/PUSH.md |
forge diff — drift detection rules, DriftItem taxonomy, drift apply, per-dialect quirks, CI gating, the 2.5.1 auto-apply pass |
docs/DIFF.md |
forge doctor — live capability probe, per-dialect checks, fix recipes, browserDoctor, K8s readinessProbe |
docs/DOCTOR.md |
| Rollback — snapshot rollback, forward-only, blue/green, per-dialect destructive-DDL limits, emergency restore | docs/ROLLBACK.md |
| Seeding — idempotent upserts, bootstrap/dev/demo split, large seeds, faker, deterministic random, 3 worked seed programs | docs/SEED.md |
| Deployment — env-per-stage, zero-downtime patterns, blue/green, containerized vs serverless, RDS Proxy, multi-region | docs/DEPLOYMENT.md |
Backup and restore — per-dialect primitives (pg_dump, MariaBackup, litestream, Atlas snapshots), PITR, restore drills, encryption |
docs/BACKUP-RESTORE.md |
| Schema versioning — additive-only rules, expand/contract for breaking changes, multi-app coordination, snapshot diffs | docs/VERSIONING.md |
Per-dialect deep dives
| Topic | File |
|---|---|
| PostgreSQL — pg/postgres.js/Neon HTTP matrix, type round-trip, JSONB, arrays, extensions, CONCURRENTLY, RLS, pgbouncer caveats | docs/POSTGRES.md |
| MySQL / MariaDB — mysql2/mariadb drivers, 5.7 vs 8.x matrix, charset, FULLTEXT, native spatial, online DDL, replication | docs/MYSQL.md |
| SQLite (server) — better-sqlite3/libsql/bun:sqlite, PRAGMAs, WAL, ALTER TABLE limits, extensions, litestream, sharding | docs/SQLITE.md |
| MongoDB — relational-to-document mapping, index types, Atlas Search/Vector, change streams, transactions, sharding | docs/MONGO.md |
| DuckDB — analytical workloads, Parquet/CSV ingestion, S3 httpfs, ATTACH cross-DB joins, EXPORT DATABASE, window funcs | docs/DUCKDB.md |
SQL Server — MERGE upsert, snapshot isolation, geography vs geometry, OPENJSON, Azure SQL specifics |
docs/MSSQL.md |
Observability and errors
| Topic | File |
|---|---|
Events — QueryEvent shape, semanticOp, subscribers, Pino/Sentry/OTel/Prometheus integrations, custom sinks |
docs/EVENTS.md |
| Logging — Pino/Winston/Bunyan wiring, redaction, sampling, request correlation, rotation | docs/LOGGING.md |
| Tracing — OpenTelemetry SDK, span propagation, W3C traceparent, exporters (Jaeger, Tempo, Honeycomb, DataDog) | docs/TRACING.md |
| Metrics — Prometheus, histogram buckets, cardinality discipline, RED/USE dashboards, alerting rules | docs/METRICS.md |
| Errors — every error class, per-dialect code mapping, retry classes, backoff, Sentry/Bugsnag wiring | docs/ERRORS.md |
Performance
| Topic | File |
|---|---|
| Connection pooling — sizing per dialect, per-runtime constraints (Lambda, Workers, Bun), pgbouncer/RDS Proxy caveats | docs/POOLING.md |
Benchmarks — forge:bench methodology, scenarios, Prisma/Drizzle compare mode, profiling, CI regression gating |
docs/BENCHMARKS.md |
| Caching — DataLoader per-request, Redis cache-aside, CDN headers, event-driven invalidation, stampede prevention | docs/CACHING.md |
Preventing N+1 — include vs DataLoader, GraphQL resolvers, detection via event hook, per-dialect cross-product gotchas |
docs/N-PLUS-ONE.md |
Patterns
| Topic | File |
|---|---|
Soft delete — softDelete/restore, partial-filter uniques, query defaults, retention purge, GDPR caveats |
docs/SOFT-DELETE.md |
| Audit log — three shapes (single table, per-model history, append-only event), actor capture, hash-chain tamper resistance | docs/AUDIT-LOG.md |
Multi-tenant — shared-schema/schema-per-tenant/DB-per-tenant trade-offs, scopedDb, RLS, per-tenant migration orchestration |
docs/MULTI-TENANT.md |
| Sharding — shard-key choice, routing, cross-shard query patterns, resharding, native helpers (Vitess, Citus, Mongo) | docs/SHARDING.md |
| Idempotency keys — Stripe-style model, atomic upsert primitive, TTL, webhook receivers, BullMQ jobIds, saga compensation | docs/IDEMPOTENCY.md |
| Watch / change feeds — Mongo change streams, Postgres LISTEN/NOTIFY + logical replication, MySQL binlog, WebSocket fan-out | docs/WATCH.md |
Testing
| Topic | File |
|---|---|
| Testing — in-memory better-sqlite3, FakeWorker for browser, transaction-rollback reset, event-hook assertions | docs/TESTING.md |
Integration testing — testcontainers, Docker Compose, forge:integration:*, parallel-safe schema reset, GH Actions matrix |
docs/INTEGRATION-TESTING.md |
| Fixtures and factories — static fixtures, typed factories, seeded random, snapshot fixtures, browser OPFS fixtures | docs/FIXTURES.md |
Security
| Topic | File |
|---|---|
| Security — parameterized queries, RLS, column masking, field-level encryption, audit log, GDPR/HIPAA/PCI patterns | docs/SECURITY.md |
| Encryption — at-rest (TDE, native), in-transit (TLS/mTLS), field-level (AES-GCM, CSFLE), KMS-backed key rotation | docs/ENCRYPTION.md |
| SQLCipher — encrypted SQLite, driver matrix, key derivation, rekey, mobile lock-screen patterns | docs/SQLCIPHER.md |
| Database auth — password vs IAM vs mTLS vs SSH-tunnel, IAM token refresh, secret rotation, RDS Proxy / Cloud SQL Auth | docs/AUTH.md |
Type-level reference
| Topic | File |
|---|---|
| Runtime validation (zod) — boundary parsing, two-schema asymmetry, transforms, OpenAPI gen, form-lib resolvers | docs/RUNTIME-VALIDATION.md |
Brand types — nominal IDs, zod .brand, multi-brand unions, money/time invariants, FK propagation |
docs/BRAND-TYPES.md |
Dates and times — f.dateTime/f.date/f.time per-dialect emit, timezone strategy, Temporal API, DST/calendar pitfalls |
docs/DATES.md |
Decimal and money — f.decimal({ precision, scale }), integer-cents pattern, dinero.js, per-dialect precision quirks |
docs/DECIMAL.md |
| UUID / ULID / Snowflake — bit layouts, DB-side generators, fragmentation, sortability, ObjectId, choice flowchart | docs/UUID.md |
Runtime targets
| Topic | File |
|---|---|
| Backend — server integration (hyper-express, Fastify, NestJS, Bun+Hono, pools, tx, BullMQ, multi-tenant, replicas, OTel, health, CI) | docs/BACKEND.md |
Browser — full sqlite-wasm + OPFS reference (URL schemes, worker, bundlers, $migrate, browserDoctor, ITP, multi-tab, pro build) |
docs/BROWSER.md |
| Browser frameworks — React+Vite, Next.js, Vue, Nuxt, SvelteKit, Angular, SolidStart, Astro, Remix, React Native, Tauri (11 recipes) | docs/BROWSER-FRAMEWORKS.md |
| React — hooks, TanStack Query, Suspense, server vs client components, optimistic updates, sync, code-splitting, testing, 6 worked patterns | docs/REACT.md |
| Mobile — RN bare, Expo, Capacitor, Tauri, SQLCipher, sync patterns, background tasks, testing, migration cookbooks | docs/MOBILE.md |
| Cloudflare Workers / Vercel Edge — V8 isolate constraints, D1, Hyperdrive-fronted Postgres, Neon HTTP, Turso, cache patterns | docs/WORKERS.md |
| AWS Lambda — handler-scope pool, RDS Proxy, Aurora Serverless Data API, IAM token refresh, SIGTERM drain, cold-start budget | docs/LAMBDA.md |
What forge is, and what it is not
forge is a thin wrapper. It turns a Prisma-style call such as
db.user.findMany({ where: { active: true } }) into the right query for your
database and runs it through the official driver (pg, mysql2,
better-sqlite3, mongodb, @duckdb/node-api, mssql). The drivers do the
actual work; forge builds the queries and shapes the results.
Reach for forge when you want one query API across more than one database, a dependency small enough to read and fork, full TypeScript autocomplete with no generated client to keep in sync, and the option to drop down to raw SQL at any time.
forge is not a replacement for Prisma or Drizzle in maturity. It has fewer features, a smaller ecosystem, and no GUI. If you need those, use Prisma or Drizzle. The honest notes at the end spell this out.
What's new
Full release history is in CHANGELOG.md. Recent highlights:
- 2.5 — MSSQL
MERGEupsert, Mongo cross-fieldnearTo, browser$doctor/$diff, MultiPolygon + GeometryCollection, 3D / Z coordinates, non-WGS84 SRIDs. Closes the entire "Coming soon" list from 2.4. MSSQL upsert now compiles to a properMERGE INTO … USING (VALUES) … WHEN MATCHED THEN UPDATE … WHEN NOT MATCHED THEN INSERT OUTPUT inserted.*(atomic, returns the row). Mongonearfilter on field A +nearToorderBy on field B now both fire — cross-field rewrite emits$geoWithin: { $centerSphere }for A inside the$geoNear.queryso the single-stage limit doesn't drop A.db.$doctor()anddb.$diff()are the browser equivalents of theforge doctor/forge diffCLIs — returns structured reports your app can render.withinPolygonaccepts Polygon-with-holes, MultiPolygon, and GeometryCollection (normalised through every dialect's WKT and the fallback ray-cast — holes correctly excluded via even-odd rule).f.geoPoint({ dims: 3 })opts into XYZ storage (PGgeography(PointZ), SQLitePOINT Z, DuckDBST_Point3D, MSSQLPOINT(x y z)); distance ops remain ground-distance (2D-on-sphere) — altitude round-trips.f.geoPoint({ srid: 3857 })(or any non-4326) routes PG togeometry(Point, srid)(geography is 4326-only); MySQL / SQLite / DuckDB / MSSQL accept the declared SRID at DDL time. Coordinates are user-provided in the target SRID — no auto-reprojection. - 2.4 — Browser adapter: sqlite-wasm + OPFS, runtime
$migrate, bundler plugins. Real SQLite in the browser via@sqlite.org/sqlite-wasmrunning in a Web Worker, persisted on the Origin Private File System. New URL schemesopfs:,opfs-sahpool:, and:memory:; a newwasmSqliteDriver()factory; ready-to-import bundler plugins for Vite (forge-orm/wasm/vite), Next.js (forge-orm/wasm/next), and Webpack 5 (forge-orm/wasm/webpack);db.$migrate()runtime DDL apply (the browser replacement forforge push);browserDoctor()feature-detection (OPFS, FTS5, R-Tree, sqlite-vec, persistent storage); and an opt-in custom wasm build path (forge-orm/wasm/worker-pro) with R-Tree + sqlite-vec compiled in for native geo + vector search. See Browser (sqlite-wasm + OPFS). - 2.3 — DuckDB + MSSQL adapters, end-to-end geo, JSON path queries, vector search.
Two new dialects (
duckdb:andmssql:URL prefixes); typed geo (f.geoPoint()+near/nearTo/withinPolygonacross all 6 dialects, plus a fallback mode for envs without the spatial extension); typed JSON path reads (where: { meta: { path: 'profile.age', gte: 18 } }); typed vector similarity (f.vector(1536, { metric: 'cosine' })+ the samenear/nearTovocabulary, compiling to pgvector / DuckDB vss / MSSQLVECTOR_DISTANCE/ Mongo$vectorSearch);forge doctorlive capability probe;forge push --enable-extensions; a throwaway driver smoke harness (npm run smoke:drivers). - 2.2 —
IndexDefcovers the shapesforge pushcouldn't model. SQL partial indexes (where: 'deleted_at IS NULL'), expression indexes (expression: 'lower(email)'), Postgres access methods (gin/gist/brin/hash) plusINCLUDEcovering columns, MySQLFULLTEXTparser plugins / invisible indexes / multi-valued JSON indexes, and Mongo geospatial ('2dsphere'/'2d'), hashed shard keys, collation, and wildcard projection. - 2.1 — partial indexes on MongoDB. A schema
IndexDefnow acceptspartialFilterExpression, soforge pushcan build a partial index — e.g. a unique index that only covers documents where the field is a string. - 2.0 —
delete()is always a hard delete. Breaking change:delete()/deleteMany()permanently remove rows on every model; the recoverable path is the explicitsoftDelete()/restore()verbs. See Soft delete. - 1.9 — pluggable MySQL + Mongo. MySQL adds
mariadbDriverandplanetscaleDriveralongside the defaultmysql2; Mongo lets you bring your ownMongoClient(mongoDriver) for DocumentDB / Cosmos / FerretDB / custom options. - 1.8 — pluggable Postgres drivers. Use
postgres.js(porsager) instead ofnode-postgres, or any client you wrap, viacreateDb({ driver: postgresJsDriver(...) }). - 1.7 — pluggable SQLite drivers. Run forge in React Native (
expo-sqlite,op-sqlite), on the edge / Turso (libsql), or over any driver you wrap. - 1.6 — richer aggregates.
groupBy'shavingaccepts both Prisma's field-first shape and the bucket-first shape;count({ distinct: [...] })is fixed on MongoDB. - 1.5 —
col()for field-to-field comparison. Compare one column against another inside awhere({ currentUsage: { lt: col('globalLimit') } }), portable across every dialect. - 1.4 — primary-key strategies on
f.id()(auto/uuid/bigserial).
Install and pick your driver
forge ships no database driver of its own. You install only the driver for the
database you use. Each one is an optional peer dependency, so npm install forge-orm on its own pulls nothing extra, and importing forge needs no driver
at all.
| Database | Connection string starts with | Install |
|---|---|---|
| PostgreSQL | postgres:// or postgresql:// |
npm install pg |
| MySQL or MariaDB | mysql:// |
npm install mysql2 |
| SQLite | sqlite: or file: |
npm install better-sqlite3 |
| MongoDB | mongodb:// or mongodb+srv:// |
npm install mongodb |
| DuckDB | duckdb: |
npm install @duckdb/node-api |
| SQL Server (MSSQL) | mssql: or sqlserver: |
npm install mssql |
| Browser (SQLite) | opfs:, opfs-sahpool:, :memory: |
npm install @sqlite.org/sqlite-wasm |
npm install forge-orm # the library, no drivers
npm install pg # add the one you needThe driver loads lazily, the first time you actually run a query against that database. Importing forge, defining a schema, or using one database never needs the other databases' drivers installed. If a driver is missing when you connect, you get a clear message telling you what to install rather than a crash.
There is no lock-in. No generated client to regenerate, no migration state you cannot leave, no framework module to wire in, and no driver bundled inside. It is plain TypeScript over the official drivers, and you can always call the driver directly if you outgrow it.
See more — docs/DRIVERS.md for the bring-your-own-driver pattern, every shipped wrapper, six worked wrappers (Neon HTTP, Turso, Cloudflare D1, Atlas Data API, Bun:sqlite, logging decorator), capability flags, and per-driver perf notes.
Connecting
createDb takes a connection URL and your schema. It returns a typed db
handle whose properties match your model names.
import { createDb } from 'forge-orm';
const db = await createDb({
url: process.env.DATABASE_URL!, // postgres://… | mysql://… | sqlite:… | duckdb:… | mssql:… | mongodb://…
schema: { user: User, post: Post },
});
// later, when shutting down:
await db.$disconnect();Options:
urlis the connection string. The prefix selects the database.schemais your model map.db.<key>exists for each key (for exampledb.user,db.post).type(optional) forces the database type if the URL is ambiguous:'postgres' | 'mysql' | 'sqlite' | 'mongo' | 'duckdb' | 'mssql'.strict(optional, defaultfalse). Whentrue, a query that filters on an unknown field name throws instead of silently matching nothing. Useful for catching typos.
You can also pass connection parts instead of a URL:
await createDb({ type: 'postgres', host: 'localhost', database: 'app', user: 'me', schema });Pluggable drivers
All six databases ship a sensible default driver, and all six let you swap in
another client — for React Native, edge / serverless runtimes, or a managed /
API-compatible backend. Instead of a URL you open the client yourself (you own
its config and lifecycle), wrap it with one of forge's driver factories, and
pass it as driver. The query API is identical whichever client backs it.
const db = await createDb({ schema, driver: someDriver(client) }); // no url neededBuilt-in drivers:
| Database | Default driver | Built-in alternatives |
|---|---|---|
| SQLite | betterSqlite3Driver (better-sqlite3) |
expoSqliteDriver (Expo/RN), opSqliteDriver (bare RN), libsqlDriver (libsql/Turso/edge), wasmSqliteDriver (browser + OPFS) |
| Postgres | pgDriver (pg) |
postgresJsDriver (postgres.js) |
| MySQL | mysql2Driver (mysql2) |
mariadbDriver (MariaDB connector), planetscaleDriver (@planetscale/database) |
| MongoDB | built-in mongodb client |
mongoDriver(client) — your own MongoClient (DocumentDB, Cosmos, FerretDB, custom) |
| DuckDB | duckdbDriver (@duckdb/node-api) |
— |
| MSSQL | mssqlDriver (mssql) |
— |
// SQLite on Expo / React Native
import * as SQLite from 'expo-sqlite';
import { createDb, expoSqliteDriver } from 'forge-orm';
const db = await createDb({ schema, driver: expoSqliteDriver(SQLite.openDatabaseSync('app.db')) });
// SQLite on the edge / Turso
import { createClient } from '@libsql/client';
import { createDb, libsqlDriver } from 'forge-orm';
const db = await createDb({ schema, driver: libsqlDriver(createClient({ url: process.env.TURSO_URL! })) });
// SQLite in the browser — sqlite-wasm + OPFS in a Web Worker.
// Full chapter at "Browser (sqlite-wasm + OPFS)" below.
import { createDb, wasmSqliteDriver } from 'forge-orm';
const worker = new Worker(new URL('forge-orm/wasm/worker', import.meta.url), { type: 'module' });
const db = await createDb({ schema, driver: wasmSqliteDriver({ worker, url: 'opfs-sahpool:///app.sqlite' }) });
await db.$migrate(); // runtime DDL apply (browser replacement for `forge push`)
// Postgres via postgres.js
import postgres from 'postgres';
import { createDb, postgresJsDriver } from 'forge-orm';
const db = await createDb({ schema, driver: postgresJsDriver(postgres(process.env.DATABASE_URL!)) });
// MySQL via the MariaDB connector (pass bigIntAsNumber/insertIdAsNumber for mysql2 parity)
import mariadb from 'mariadb';
import { createDb, mariadbDriver } from 'forge-orm';
const pool = mariadb.createPool({ host, user, database, bigIntAsNumber: true, insertIdAsNumber: true });
const db = await createDb({ schema, driver: mariadbDriver(pool) });
// MongoDB with your own client (custom TLS/auth/pool options, a shared client,
// or a Mongo-API backend: Amazon DocumentDB, Azure Cosmos DB, FerretDB)
import { MongoClient } from 'mongodb';
import { createDb, mongoDriver } from 'forge-orm';
const db = await createDb({ schema, driver: mongoDriver(new MongoClient(uri, { tls: true }), 'mydb') });
// DuckDB (embedded analytics — auto-loads the `spatial` extension at connect)
import { DuckDBInstance } from '@duckdb/node-api';
import { createDb, duckdbDriver } from 'forge-orm';
const instance = await DuckDBInstance.create('analytics.duckdb');
const connection = await instance.connect();
const db = await createDb({ schema, driver: duckdbDriver(connection) });
// SQL Server (Linux / Windows; ARM Macs auto-swap to azure-sql-edge in tests)
import sql from 'mssql';
import { createDb, mssqlDriver } from 'forge-orm';
const pool = await sql.connect({ server: 'localhost', user: 'sa', password: '…', database: 'app' });
const db = await createDb({ schema, driver: mssqlDriver(pool) });Each port is a small interface, so any other client fits too:
- SQLite (
SqliteDriver) —all,get,run,exec,close, optionaliterate. - Postgres (
PostgresDriver) / MySQL (MysqlDriver) —query+transaction+close, optionalstream. - MongoDB (
MongoDriver) — a pre-builtMongoClient(plus an optional database name). - DuckDB (
DuckdbDriver) —run/allover the@duckdb/node-apiconnection. - MSSQL (
MssqlDriver) —query+transactionover amssqlpool.
One caveat: forge push / applyMigration (DDL) still assume each database's
default driver. With an injected driver, run runtime queries through forge
and manage schema/DDL with the default client (or separately).
Wire-compatible databases (no new code needed)
Several databases speak the wire protocol of one of the six forge supports. They work today through the matching adapter — point the existing driver at them:
| Database | Adapter | How |
|---|---|---|
| CockroachDB | postgres | pg or postgresJsDriver against the CockroachDB URL |
| YugabyteDB | postgres | pg or postgresJsDriver |
| Neon | postgres | pg or @neondatabase/serverless wrapped in a PostgresDriver port |
| Supabase | postgres | pg against the Supabase URL |
| TimescaleDB | postgres | pg (TimescaleDB is a Postgres extension) |
| TiDB | mysql | mysql2Driver |
| PlanetScale | mysql | planetscaleDriver — built in |
| AWS DocumentDB | mongo | mongoDriver(new MongoClient(documentDbUri), dbName) |
| Azure Cosmos DB (Mongo API) | mongo | mongoDriver(new MongoClient(cosmosUri), dbName) |
| FerretDB | mongo | mongoDriver(new MongoClient(ferretUri), dbName) |
| Turso | sqlite | libsqlDriver — built in |
| Cloudflare D1 | sqlite | Wrap the D1 client in a thin SqliteDriver port (all/get/run/exec) |
| MotherDuck | duckdb | duckdbDriver against the MotherDuck token URL |
| Azure SQL Database | mssql | mssqlDriver against the Azure SQL URL |
| Azure SQL Edge | mssql | mssqlDriver — used as the ARM-Mac test fallback for SQL Server 2022 |
If your database isn't on the list and doesn't speak one of the six wire protocols, the answer is "implement the matching port interface" — same ~5-method surface every built-in driver implements.
Coming soon
| Item | Status | Target |
|---|---|---|
| 3D distance mode | f.geoPoint({ dims: 3 }) round-trips altitude end-to-end; near / nearTo still compute ground (2D-on-sphere) distance. A 3D Euclidean or ground+vertical distance mode is the open question. |
TBD |
| Auto SRID reprojection | Declared SRID is honoured at DDL time; the user provides coordinates in the target SRID's units. A built-in proj4-backed transform at the IR boundary is on the roadmap (avoids the per-app coordinate-transform boilerplate). |
TBD |
Pre-built @forge-orm/sqlite-wasm-pro |
The custom wasm bundle (R-Tree + sqlite-vec) is one Emscripten command via scripts/wasm-pro/build.sh today; publishing the pre-built artifact as its own npm package is the next gap. |
TBD |
If you need another database, file an issue. The bar to add a new adapter is
~10 small files: dialect, driver, ddl, compile-from-ir, execute,
introspect, migrate, adapter, plus a few registration touches.
Defining a schema
A schema is a plain object mapping a name to a model. You build models with the
helpers exported from forge-orm: f (fields), model, rel (relations),
enums, and embed.
import { f, model, rel } from 'forge-orm';
const User = model('users', {
id: f.id(),
email: f.string().unique(),
name: f.string(),
active: f.bool().default(true),
created_at: f.dateTime().default('now'),
updated_at: f.dateTime().default('now').updatedAt(),
}).relate(() => ({
posts: rel.many('post', { on: 'author_id', refs: 'id' }),
}));
const Post = model('posts', {
id: f.id(),
author_id: f.objectId(),
title: f.string(),
body: f.text(),
}).relate(() => ({
author: rel.one('user', { on: 'author_id', refs: 'id', onDelete: 'Cascade' }),
}));
export const schema = { user: User, post: Post } as const;as const on the schema object is defensive, not required. For the
pattern shown above — each model bound to its own const, then referenced
from the schema literal — TypeScript already preserves the model types and
the literal keys, so db.user.findFirst({ where: { … } }) autocompletes
either way.
Models and automatic values (id, timestamps)
model(tableName, fields) declares a table (or a Mongo collection). The first
argument is the real table name in the database; the object key you give it in
the schema (user, post) is what you type as db.user.
forge fills in three kinds of value for you so you don't have to:
Primary key (f.id()). Every model has one. When you create a row without
passing an id, forge generates one automatically on every database:
await db.user.create({ data: { email: 'a@x.co', name: 'A' } }); // id is generatedThe default id is a string: an ObjectId on Mongo, and a UUID on
Postgres, MySQL, SQLite, DuckDB, and MSSQL. It's a string (not a sequential
number) so the same model is portable across all six databases. You can
still pass your own id if you want to control it, and you can let the
database generate it instead with a UUID default:
id: f.uuid({ default: 'gen_random_uuid' }) // Postgres/MySQL fill it in server-sideCreated-at (f.dateTime().default('now')). Set to the current time when the
row is created. You never pass it.
Updated-at (f.dateTime().default('now').updatedAt()). Set when the row is
created and automatically bumped to the current time on every update, on all
six databases. You never pass it.
const post = await db.post.create({ data: { title: 'Hi' } });
// post.created_at and post.updated_at are both set
await db.post.update({ where: { id: post.id }, data: { title: 'Hello' } });
// updated_at is now refreshed automaticallyf.objectId() is for a column that holds another row's id (a foreign key). On
Mongo it stores an ObjectId; on SQL it is plain text.
Picking a primary-key strategy
If you want something other than the default, pass f.id({ type }):
id: f.id() // default — app-generated string id (string in TS)
id: f.id({ type: 'auto' }) // same as the default; explicit form
id: f.id({ type: 'uuid' }) // DB-typed UUID column (PG `uuid`, MySQL `CHAR(36)`)
id: f.id({ type: 'bigserial' }) // auto-incrementing integer PK — number in TSWhat each one emits per dialect:
| Strategy | Postgres | MySQL | SQLite | DuckDB / MSSQL | Mongo | JS type |
|---|---|---|---|---|---|---|
auto (default) |
text |
VARCHAR(64) |
TEXT |
TEXT / NVARCHAR(64) |
ObjectId |
string |
uuid |
uuid |
CHAR(36) |
TEXT |
(same as auto) |
(same as auto) |
string |
bigserial |
BIGSERIAL |
BIGINT NOT NULL AUTO_INCREMENT |
INTEGER PRIMARY KEY AUTOINCREMENT |
BIGINT … IDENTITY (MSSQL) |
throws at push | number |
bigserial is the SQL-only opt-in. Forge runs forge push on Mongo with a
clear error if you use it ('bigserial' has no Mongo equivalent), so a
schema mistake fails fast instead of half-applying. Use it when you're
running a SQL-only service and you want classic integer keys; stay on
auto or uuid for cross-DB portability.
With bigserial, the DB assigns the id — you don't pass one at create time,
and Row<typeof Model>['id'] is typed as number:
const Order = model('orders', {
id: f.id({ type: 'bigserial' }),
total: f.int(),
});
const o = await db.order.create({ data: { total: 5_000 } });
o.id; // ✓ number — TypeScript knows
await db.order.findFirst({ where: { id: 47 } });Adding bigserial to an existing table? forge diff shows you the column
change before you push, and forge diff apply writes a timestamped
reconciliation migration if you'd rather review the SQL first.
Field types
Every field builder. Chain modifiers (.optional(), .unique(), .default(…),
.searchable(), .softDeleteAt(), .dbgenerated(…), .updatedAt()) onto any
of them.
| Builder | TS type | Storage per dialect / notes |
|---|---|---|
f.id() |
string |
Primary key, auto-generated when omitted. Pass { type: 'auto' | 'uuid' | 'bigserial' }. |
f.objectId() |
string |
Foreign-key style. Mongo ObjectId; SQL TEXT. |
f.string() |
string |
Short text. MySQL VARCHAR(255) (indexable). PG/SQLite/DuckDB TEXT, MSSQL NVARCHAR(255). |
f.text() |
string |
Long text. MySQL TEXT. PG text, MSSQL NVARCHAR(MAX), DuckDB TEXT. |
f.int() |
number |
32-bit integer. |
f.float() |
number |
Floating point. |
f.decimal({ precision, scale }) |
string |
Exact numerics (money). PG numeric(p,s) / MySQL DECIMAL(p,s) / SQLite NUMERIC / Mongo Decimal128. Returned as a string to avoid float-precision loss. |
f.bigint() |
bigint |
64-bit integer. Use 1n literals. PG bigint / MySQL BIGINT / SQLite INTEGER / Mongo Long. |
f.uuid({ default? }) |
string |
UUID. Pass { default: 'gen_random_uuid' } for a server-side default on PG / MySQL. |
f.bool() |
boolean |
Stored as 0/1 on MySQL and SQLite, decoded back to a boolean. |
f.dateTime() |
Date |
Timestamp. Accepts a Date or an ISO string on input. |
f.json() |
any |
Arbitrary JSON. jsonb on Postgres, JSON on MySQL / MSSQL, TEXT on SQLite. |
f.enumOf(['A','B'] as const) |
'A' | 'B' |
A fixed set of string values, checked by the database where supported. |
f.embed(Shape) |
Shape |
One nested object. Stored as JSON on SQL, sub-document on Mongo. |
f.embedMany(Shape) |
Shape[] |
A list of nested objects. Defaults to []. |
f.stringArray() / f.intArray() |
string[] / number[] |
A list of scalars. Native array on Postgres, JSON elsewhere. |
f.geoPoint({ srid?, fallback? }) |
{ lng: number; lat: number } |
2D geographic point (WGS84 / SRID 4326). PG geography(Point, 4326) (PostGIS) / MySQL POINT NOT NULL SRID 4326 / SpatiaLite geometry / DuckDB GEOMETRY (spatial) / MSSQL GEOGRAPHY / Mongo GeoJSON. Pair with method: 'spatial'. fallback: true stores JSON + Haversine post-filter when no extension is installed. |
f.vector(dims, { metric? }) |
number[] |
Dense numeric vector for embeddings / semantic search. PG vector(N) (pgvector) / MySQL VECTOR(N) (9.0+) / SQLite JSON+sqlite-vec / DuckDB FLOAT[N] (vss HNSW) / MSSQL VECTOR(N) (2025+) / Mongo plain array + Atlas Vector Search. metric is 'cosine' (default), 'l2', or 'dot'. Pair with method: 'vector'. |
Field modifiers
Chain these onto any field builder. Modifiers are immutable — they return a
new Field with the modifier applied.
| Modifier | What it does | Notes / dialect quirks |
|---|---|---|
.optional() |
Allows null. The TS type becomes T | null. |
Maps to NULL in SQL DDL, no required-presence check on Mongo. |
.unique() |
Adds a unique index on the column. | Sparse-on-optional is automatic on Mongo. |
.default(value) |
Static default applied at create when no value is passed. | The value is literal — strings, numbers, booleans, objects, arrays. |
.default('now') |
Current timestamp at create. Use on f.dateTime(). |
Drives the created_at pattern. |
.default('autoId') |
Server-generated id (used internally by f.id()). |
Rarely needed by hand. |
.updatedAt() |
Auto-bumped to the current time on every update. | Combine with .default('now') for the canonical updated_at. |
.searchable() |
Tells forge push to build the right full-text index for this column (see Full-text search). |
Postgres GIN tsvector, MySQL FULLTEXT, SQLite FTS5 shadow table + triggers, Mongo text, DuckDB fts. MSSQL: out-of-band (manual FULLTEXT CATALOG). |
.softDeleteAt() |
Marks this f.dateTime() column as the soft-delete column (see Soft delete). |
Forces optional. Reads auto-filter WHERE col IS NULL. One per model. |
.dbgenerated('expr') |
Database-computed column. The wrapper never writes it; the DB evaluates <expr> on every change. |
PG / MySQL emit GENERATED ALWAYS AS (<expr>) STORED; SQLite uses the same shape; Mongo warns and skips. |
f.string().optional() // value can be null
f.string().unique() // unique index on this column
f.string().default('pending') // static default
f.bool().default(true) // bool default
f.dateTime().default('now') // created-at
f.dateTime().default('now').updatedAt() // updated-at (set on create AND auto-bumped on update)
f.text().searchable() // build a full-text index
f.dateTime().softDeleteAt() // mark as the soft-delete column
f.decimal({ precision: 12, scale: 2 })
.dbgenerated('"price" * "qty"') // computed by the DBIndexes and unique constraints
Pass an options object as the third argument to model. The same IndexDef
shape carries every common production index family — partial, expression,
covering, geospatial, vector, hashed, wildcard, full-text, and more. Each
field that doesn't apply on a given dialect is dropped at push with a clear
warning, so one schema can target Mongo and SQL.
const Post = model('posts', {
id: f.id(),
author_id: f.objectId(),
slug: f.string().unique(), // single-column unique
status: f.enumOf(['DRAFT', 'PUBLISHED'] as const),
}, {
indexes: [{ keys: { author_id: 1, status: 1 } }], // a two-column index
uniques: [['author_id', 'slug']], // a combined unique
});The full index vocabulary:
indexes: [
// Partial index. Mongo uses partialFilterExpression; Postgres and SQLite
// use `where` with a raw SQL string. The same entry can carry both so
// the schema works on either side.
{ keys: { sku: 1 }, unique: true,
where: 'deleted_at IS NULL',
partialFilterExpression: { deleted_at: { $exists: false } } },
// TTL (Mongo).
{ keys: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 },
// Mongo geospatial — `$near` / `$geoWithin` queries need this.
{ keys: { location: '2dsphere' } },
// Spatial index — portable across dialects. Forge resolves the right
// native family per dialect (PostGIS GIST, MySQL SPATIAL, DuckDB RTREE,
// MSSQL CREATE SPATIAL INDEX, Mongo 2dsphere, SQLite virtual rtree).
// Pair with f.geoPoint().
{ keys: { location: 1 }, method: 'spatial' },
// Vector index — pgvector HNSW / DuckDB vss HNSW / MSSQL VECTOR HNSW.
// Mongo and SQLite log a clean warning (their vector index is created
// out-of-band). Pair with f.vector(N, { metric }).
{ keys: { embedding: 1 }, method: 'vector' },
// Mongo hashed — required for a hashed shard key.
{ keys: { tenant: 'hashed' } },
// Mongo case-insensitive unique. strength: 2 = case-insensitive.
{ keys: { email: 1 }, unique: true,
collation: { locale: 'en', strength: 2 } },
// Mongo wildcard index — keys: { '$**': 1 } + wildcardProjection.
{ keys: { '$**': 1 } as any, wildcardProjection: { 'data.$**': 1 } },
// Postgres GIN over a jsonb column — supports @> containment.
{ keys: { tags: 1 }, method: 'gin' },
// Postgres covering — answers (customer_id) → (status, total) from the index.
{ keys: { customer_id: 1 }, include: ['status', 'total'] },
// Postgres BRIN for huge append-only tables (logs, analytics).
{ keys: { received_at: 1 }, method: 'brin' },
// Expression index. Postgres / MySQL 8+ / SQLite. Mongo skips with a warning.
{ keys: {}, expression: 'lower(email)' },
// MySQL fulltext with parser plugin — `'ngram'` for CJK, `'mecab'` for Japanese.
{ keys: { body: 1 }, method: 'fulltext', parser: 'ngram' },
// MySQL 8+ invisible index — the optimizer ignores it,
// useful for canary-testing whether an index is load-bearing before drop.
{ keys: { obsolete: 1 }, visible: false },
// MySQL 8+ multi-valued index on a JSON array column — index every element
// of the array. Use `expression` with the CAST that MySQL requires.
{ keys: {}, expression: "(CAST(tags->'$[*]' AS UNSIGNED ARRAY))" },
]What each field does, per dialect:
| Field | Mongo | Postgres | MySQL | SQLite | DuckDB | MSSQL |
|---|---|---|---|---|---|---|
keys: { col: 1 / -1 } |
yes | yes | yes | yes | yes | yes |
keys: { col: 'text' } |
text index | text_pattern_ops |
column kept | column kept | column kept | column kept |
keys: { col: '2dsphere'/'2d'/'hashed' } |
yes | ignored | ignored | ignored | ignored | ignored |
unique / sparse |
yes | yes (sparse auto on optional) | yes/n/a | yes/n/a | yes/n/a | yes/n/a |
expireAfterSeconds |
yes | n/a | n/a | n/a | n/a | n/a |
partialFilterExpression |
yes | n/a | n/a | n/a | n/a | n/a |
where (object) |
alias of PFE | translated to SQL | warn + skip | translated to SQL | warn + skip | translated to SQL |
where (SQL string) |
n/a | WHERE … |
warn + skip | WHERE … |
warn + skip | WHERE … |
include: [cols] |
n/a | INCLUDE (…) |
warn + skip | warn + skip | warn + skip | INCLUDE (…) |
expression: 'sql' |
warn + skip | ((expr)) |
((expr)) |
(expr) |
((expr)) |
warn + skip |
method: gin/gist/brin/hash |
n/a | USING … |
warn (ignored) | warn (ignored) | warn (ignored) | warn (ignored) |
method: 'spatial' |
resolves to 2dsphere | USING GIST |
SPATIAL INDEX |
virtual rtree | USING RTREE |
CREATE SPATIAL INDEX |
method: 'vector' |
warn (Atlas search) | USING hnsw (... opclass) |
warn (community = exact) | warn (use sqlite-vec) | USING HNSW |
USING VECTOR WITH (algorithm='HNSW') |
method: 'fulltext' |
n/a | DB rejects | statement prefix | warn (ignored) | n/a | warn (ignored) |
parser: 'ngram'/'mecab' |
n/a | warn (ignored) | WITH PARSER … (only on fulltext) |
warn (ignored) | warn (ignored) | warn (ignored) |
visible: false |
n/a | warn (ignored) | INVISIBLE (MySQL 8) |
warn (ignored) | warn (ignored) | warn (ignored) |
collation |
yes | n/a (use expression) | n/a | n/a | n/a | n/a |
wildcardProjection |
yes | n/a | n/a | n/a | n/a | n/a |
Relations
A relation says "this model points at that model." You declare it with
.relate(), which takes a function returning a map of relation names. There are
two kinds:
rel.one(target, { on, refs })is the side that holds the foreign key. For example a post has one author, and the post row storesauthor_id.rel.many(target, { on, refs })is the other side, a list. A user has many posts. Nothing is stored on the user row; forge looks posts up by theirauthor_id.
The options mean:
targetis the key in your schema map of the model you are pointing at ('user', not the table name'users').onis the column that holds the foreign key value.refsis the column it points to on the other model (usually'id').onDelete(one-side only) controls what happens to this row when the row it points to is deleted:'Cascade'(delete this too),'SetNull'(clear the foreign key),'Restrict', or'NoAction'.
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', onDelete: 'Cascade' }),
}));A model can point at itself, which is how you build trees such as comment replies:
const Comment = model('comments', { id: f.id(), parent_id: f.objectId().optional() })
.relate(() => ({
parent: rel.one('comment', { on: 'parent_id', refs: 'id' }),
replies: rel.many('comment', { on: 'parent_id', refs: 'id' }),
}));Once a relation exists you can load it with include (see
Choosing fields) and write related rows
in one call (see Writing related records).
Embedded objects
An embedded object is a fixed shape stored inside a row, as JSON on SQL
databases and as a sub-document on Mongo. Use embed to declare the shape.
import { embed, f, model } from 'forge-orm';
const Address = () => embed('Address', {
street: f.string(),
city: f.string(),
zip: f.string(),
});
const User = model('users', {
id: f.id(),
name: f.string(),
address: f.embed(Address).optional(), // one address
history: f.embedMany(Address), // a list, defaults to []
});
await db.user.create({ data: { name: 'A', address: { street: '1 Main', city: 'SF', zip: '94110' } } });You can read into embedded fields with JSON path queries:
await db.user.findMany({
where: { address: { path: 'city', eq: 'SF' } },
});See more — docs/MODEL.md (full field catalogue + id strategies + views + generated columns), docs/EMBED.md (f.embed/f.embedMany/f.json + JSON-null markers + 5 worked patterns), docs/RELATIONS.md (relation shapes + cascade + deep includes + 6 worked patterns), docs/INDEXES.md (every IndexDef field + per-dialect emit + drift detection), docs/PRIMARY-KEYS.md (UUIDv7 / ULID / Snowflake / serial trade-offs), docs/FOREIGN-KEYS.md (onDelete / onUpdate / deferred), docs/ENUMS.md (per-dialect emit + evolution), docs/CHECKS.md (CHECK constraints + Mongo $jsonSchema), docs/GENERATED-COLUMNS.md (STORED vs VIRTUAL), docs/VIEWS.md and docs/MATERIALIZED-VIEWS.md, docs/TRIGGERS.md (DB-side procedural code).
Reading data
Every model has the read methods you expect.
await db.user.findMany({ where: { active: true }, take: 20 });
await db.user.findFirst({ where: { email: 'a@x.co' } }); // first match or null
await db.user.findUnique({ where: { id: 'u1' } }); // by a unique field
await db.user.findFirstOrThrow({ where: { email: 'a@x.co' } }); // throws if missing
await db.user.findUniqueOrThrow({ where: { id: 'u1' } }); // throws if missing
await db.user.count({ where: { active: true } });
await db.user.aggregate({ // bucketed stats
where: { active: true },
_avg: { age: true },
_sum: { credits: true },
});Filtering with where
where accepts either a direct value or an operator object per field, plus
AND, OR, and NOT.
await db.post.findMany({
where: {
status: 'PUBLISHED', // equals
title: { contains: 'forge' }, // text match
views: { gte: 100, lt: 1000 }, // ranges
author_id: { in: ['u1', 'u2'] }, // any of
OR: [
{ pinned: true },
{ created_at: { gt: new Date('2024-01-01') } },
],
},
});Operator reference
All operators, with the field kinds they apply to.
| Operator | Applies to | Meaning |
|---|---|---|
equals / = |
every field | exact match (same as passing a value directly) |
not |
every field | inverse of equals (accepts a value or a nested filter) |
in |
every field | value is one of an array |
notIn |
every field |