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.20.4 —
f.bytes()never worked on DuckDB. Writing binary threwCannot create values of type ANY— the node bindings take neither aUint8Arraynor aBuffer, only their ownblobValue()— and a read came back as aDuckDBBlobValuewrapper instead of bytes. Broken in both directions, and missed because that dialect's driver was not installed when the earlier bytes fix was written, so its copy was only ever read. The driver is a devDependency now and DuckDB runs in CI.2.20.3 — a one-column
uniquesfound nothing on IndexedDB.{ uniques: [['pendingId']] }compiled to a compound IDB index, whose keys are arrays — while the planner looked it up with a scalar. The row was written and readable by primary key, but everywhereon that column returnednull/[]with no error. Writing it asf.string().unique(), or with two or more columns, was never affected. Index migration now compares key shape rather than just the index name, so a database that already has the broken index gets it rebuilt. Browser-only: Postgres, MySQL, SQLite and Mongo were all verified unaffected. The adapter had no execution coverage at all until now —regression-indexeddb.tscloses that.2.20.2 — an
ObjectIdin awherethrew.where: { author_id: new ObjectId(id) }— the most ordinary query you can write against MongoDB, and the shape docs/MONGO.md promises works — failed withunknown operator 'buffer'. Deciding whether a value was a value or an operator container like{ gte: 5 }used "object, not array, not Date", so every class instance was walked for operators, andObject.keys()on an ObjectId returns['buffer'].Decimal128,Long,Binary,UUIDand a rawBufferall broke the same way. Only a plain object is parsed for operators now. Worth re-checking if youtry/catcharound forge queries: the throw is loud, but acatchturns it into a query that quietly does nothing. See CHANGELOG.md.2.20 —
updateFirst/deleteFirst: one round trip instead of two.updateanddeletereturn the written row but throw when the filter matched nothing, so "update it and hand it back, or tell me it is not there" was written asupdateManyplus a re-read of the same row — two queries on essentially every write path (measured in one consumer codebase: 330updateManycall sites, 123 of them followed by that re-read). The new verbs are the same write returningnullon a miss. On a model with a.softDeleteAt()column the old shape was three trips and still wrong, because a read is soft-delete filtered and cannot see a row the patch just soft-deleted;updateFirstis not filtered and returns the row from the write itself. See CHANGELOG.md.2.19 — four things forge only looked like it did. Each ran without complaint while doing something other than what it said.
$transactiondid not reach a repository layer — a callback that called a repository discarded thetx, so neither leg was in the transaction and the throw meant to undo them undid nothing; the session now lives in anAsyncLocalStorageand a repository joins without being threaded a handle. The array form gave no atomicity at all — it wasPromise.allover writes that had already dispatched; it takes thunks now and refuses already-running promises, which is the one behaviour change in the release. The Mongo client was one per process, not one percreateDb()— a secondcreateDb({ url: B })silently kept writing to A, and$disconnect()on either closed the connection under both. Soft-deleted rows came back through everyinclude— the filter was applied at the top level only, so a "deleted" post still listed under its author; relation sub-selects are now scoped at every depth, along withgroupBy, relation_countand relation filters, which had all disagreed with it. See CHANGELOG.md.2.18 — three things that silently returned or wrote wrong data. A keyset cursor ignored the sort direction, so
orderBy: { createdAt: 'desc' }with a cursor asked for rows greater than the last row seen and page 2 re-served page 1. Mongoupdatenever coerced its values, so an id string written throughupdatewas stored as a BSONStringwherecreatewould have stored anObjectId— and becausewheredoes coerce, those rows became invisible to every later query. Nestedtake/skipapplied to the whole batch instead of per parent, soinclude: { posts: { take: 3 } }over ten users returned three posts in total. Also:divideis an exact division rather than a multiply-by-reciprocal, a Mongo relation filter throws instead of matching every row in the collection, andf.bytes()is a real binary field kind — see docs/BINARY.md.2.7 — malformed queries throw instead of silently doing something else. Unknown
whereoperators ($gte,contians) used to be dropped from the tree, so the filter matched every row; typoed update operators ({ incrment: 5 }) were written through$set, replacing a number with an object. Both throw now, with the correction in the message ("Did you mean 'gte'? forge uses bare operator names").not: { contains: … }actually negates, strict mode recurses into AND/OR/NOT and relation filters,upsertkeeps thecreateseed whenupdateincrements the same field,aggregate([...])works positionally, dotted container paths ('address.city') compile portably on every dialect, and.optional()columns get their full operator set back in the types.2.6 — IndexedDB adapter: zero-install browser tier. A second browser adapter alongside sqlite-wasm — no wasm download, no worker file, no bundler plugin. URL prefix
idb:/indexeddb:selects the adapter and the string after the colon is the IDB database name. The Prisma-shape surface is identical to every other dialect; the executor pipeline runsArgs → IR → planner picks ONE index → cursor scan → JS residual filter → JS sort → limit/offset. Selectivity-scored index selection (primary-key eq > compound eq > unique eq > compound eq > single-column eq >in> range > free sort > full scan), every plan carries anexplainstring. Non-destructive migrations via native IDB versioning (add-field is a no-op,createIndexback-populates existing rows automatically, destructive changes go underpending). FTS via multiEntry token index (index-backed AND-of-tokens, not a full scan). Geo via Haversine JS + bbox prefilter (MultiPolygon with holes,_distanceMetersannotation). Vector via JS brute-force cosine / l2 / dot (fine ≤ 1 k rows). Cascade walker matches the Mongo pattern. Server-safety guard throws a specific[P2010]message on Node / SSR instead of a crypticReferenceError. Ships at theforge-orm/indexeddbsubpath export. See Browser (zero install) — IndexedDB.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/string).