forge-orm
v2.20.4
Published
Prisma-shape multi-database ORM/wrapper for MongoDB, PostgreSQL, MySQL, SQLite, DuckDB & SQL Server from one codebase — typed geo / vector / JSON path / full-text search, no codegen, no query engine. Bring your own schema.
Maintainers
Readme
forge-orm
One Prisma-shaped query API. Six databases. No codegen.
Documentation · Quick start · Examples · Changelog
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📖 The full documentation reads far better as a website: johnsonfash.github.io/forge-orm — same content, with a sidebar, search, and one page per topic instead of three thousand lines of scroll.
| | | | |---|---|---| | 🐘 PostgreSQL | 🐬 MySQL / MariaDB | 🪶 SQLite | | 🍃 MongoDB | 🦆 DuckDB | 🟦 SQL Server | | 🌐 Browser (sqlite-wasm) | 💾 Browser (IndexedDB) | ⚡ PGlite (embedded PG) |
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: '[email protected]', 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:, pglite:, 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 | | Node CLI (smallest) | Smallest possible forge-orm program | | Hono + PGlite REST API | Backend API, zero external DB | | Next.js + PGlite full-stack | App Router + Server Actions | | IndexedDB zero-install | Full CRUD + geo + vector + FTS, no wasm |
| Auto-runs on CodeSandbox | Why |
|---|---|
| DuckDB analytics | @duckdb/node-api is a native addon |
| Bun + SQLite blog | Bun's SQLite is native |
| 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.$on("query", …) filtering mutating ops |
| 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 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
forge generate— a migration without a database- Column changes — widened, or refused with the fix
renamedFrom— a rename is not a drop and an addforge migrate status— what the database has really applied- Asking a command what it does
- Pointing the CLI at your schema
- Ignoring drift on
forge diff forge doctor— live capability probescopeBy— declare your tenant key, get an index lint- Extensions and
forge push --enable-extensions
- Seeing a query without running it —
db.$explain() - 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 |
| Binary columns — f.bytes(), per-dialect storage, maxBytes enforced everywhere, why base64 is refused, the typed-array view window, driver unwrapping, drift detection | docs/BINARY.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.enumOf(...), 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, updateFirst/deleteFirst and the two-round-trip re-read they replace, atomic ops, nested writes, idempotency, optimistic+pessimistic concurrency, 8 worked patterns | docs/MUTATIONS.md |
| Transactions — the ambient session (a repository joins without tx), thunk array form, 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 |
| $explain — see a query without running it, both callback forms, the plan via analyze, why EXPLAIN ANALYZE is never emitted, per-dialect support | docs/EXPLAIN.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, snapshots and forge generate, ALTER COLUMN safety, renamedFrom, forge migrate status, drift rules, per-dialect emit, 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 |
| vs drizzle-kit — an honest feature-by-feature comparison, what drizzle does better, and the staged plan to close each gap | docs/VS-DRIZZLE.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, include scoped at every depth, _count and relation-filter consistency, 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 |
| IndexedDB (zero-install browser) — planner scoring, IDB versioning migrations, FTS via multiEntry, Haversine + brute-force fallback, quota + ITP, server-safety guard | docs/INDEXEDDB.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.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).
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 |
| PGlite (embedded PG) | pglite: (e.g. pglite:./data) | npm install @electric-sql/pglite |
| 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 |
| Browser (IndexedDB) | idb: or indexeddb: | none — browser built-in |
Those are the same package names the per-dialect entry points
(forge-orm/postgres, forge-orm/mysql, and so on) import statically, so the
install step above does not change with how you choose to connect — see
Connecting for what that choice actually decides.
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. (A per-dialect entry point trades that lazy load for a static import, which is the whole point of it — see option 2 below.)
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 always takes your schema plus something that tells forge how to
reach the database. There are three ways to give it that, and it returns the
same typed db handle — properties matching your model names — whichever you
pick. Everything else in this README reads the same afterwards.
The choice between them is about bundling, not taste. A URL is resolved at runtime, a per-dialect entry point is resolved at build time, and a driver you construct yourself was never forge's to resolve.
| Situation | Use |
|---|---|
| Node server, script, tests | url |
| Bundled Node — Lambda, Next.js/Vite SSR, esbuild | forge-orm/<dialect> |
| Cloudflare Workers, Vercel Edge | driver, with an HTTP client (Neon, PlanetScale, libSQL) |
| Client needs configuring (pool, SSL, extensions, HTTP driver) | driver |
| A client forge has no factory for | driver |
Option 1 — url (the default)
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();forge reads the prefix, works out which adapter that means, and loads it. This is the shortest call site, and it is the only one where a single environment variable moves you from SQLite in tests to Postgres in production with no code change at all.
The mechanism is worth knowing, because it is what limits this option. forge
calls require(pkg), where pkg is a string computed at runtime from the URL
prefix. A bundler cannot see through a computed require: webpack, rollup,
esbuild and Vite all lose the dependency at that line. On a bundled target —
Cloudflare Workers, Vercel Edge, a Lambda you bundle — the driver is either
left out of the output or blows up at runtime, a long way from the code that
caused it. The same opacity is why no adapter can be tree-shaken here; the
bundler cannot prove you are not about to ask for the other five.
None of that bites when node_modules is still on disk at runtime. Ordinary
Node servers, scripts and test runs are exactly that case, and url is the
right answer for them.
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 });Option 2 — a per-dialect entry point (new in 2.17.0)
Import createDb from the entry point for the database you are on, then call
it exactly as in option 1:
import { createDb, f, model } from 'forge-orm/postgres';
const User = model('users', { id: f.id(), email: f.string().unique() });
const db = await createDb({ url: process.env.DATABASE_URL!, schema: { user: User } });Inside that entry, pg is brought in with a static import. A bundler sees it
the way it sees any other import, keeps it, and drops the adapters you did not
reach for. The call site is no longer than the default.
This is for bundled Node, not for true edge runtimes. A bundled Lambda, a
Next.js or Vite server build, a Docker image built with esbuild — those run
pg and better-sqlite3 perfectly well and only ever had a bundler problem.
Cloudflare Workers and Vercel Edge are a different thing: pg needs a TCP
socket and better-sqlite3 is a native addon, so neither runs there at all and
no amount of bundler visibility changes that. On those runtimes you need a
client built for them — Neon or PlanetScale over HTTP, libSQL, postgres.js —
and you reach those through option 3, which was never bundler-hostile in the
first place, because there you write the import yourself.
What you give up is the environment-variable swap. forge-orm/postgres is a
Postgres call site; pointing DATABASE_URL at MySQL changes the connection
string but not the driver the module has already committed to. Swapping
database now means editing the import.
Available entries: forge-orm/postgres, forge-orm/mysql, forge-orm/sqlite,
forge-orm/pglite, forge-orm/mongo, forge-orm/duckdb, forge-orm/mssql —
one per package, each wrapping that dialect's default driver (pg,
mysql2, better-sqlite3, PGlite, the mongodb client, @duckdb/node-api,
mssql).
Per package, not per dialect, and pglite is why. PGlite is Postgres — forge
runs it on the postgres adapter, same compiler, same executors, same dialect;
only the driver differs, because one talks to a server over TCP and the other
is a WebAssembly module in your own process. They are the same database and two
different npm packages, and a static import can only pin one. A single
forge-orm/postgres that imported both would put a WASM Postgres into every
bundle that only wanted pg, which is the opposite of the point.
SQLite makes the same point louder: one dialect, six packages, all of them the
sqlite adapter with identical queries and identical SQL. No two can share an
entry point, because each would drag in a package the others cannot even load —
forge-orm/sqlite is the better-sqlite3 one, which is why it belongs on a
server and nowhere else.
Fifteen driver packages sit behind the six dialects:
| Dialect | Packages | Default (the entry point) | The rest — option 3 |
|---|---|---|---|
| SQLite | 6 | better-sqlite3 | expo-sqlite, OP-SQLite, @libsql/client, @sqlite.org/sqlite-wasm, @tauri-apps/plugin-sql |
| Postgres | 3 | pg | postgres.js, @electric-sql/pglite (its own entry — see below) |
| MySQL | 3 | mysql2 | mariadb, @planetscale/database |
| MongoDB | 1 | mongodb | your own MongoClient — DocumentDB, Cosmos, FerretDB |
| DuckDB | 1 | @duckdb/node-api | — |
| SQL Server | 1 | mssql | — |
| IndexedDB | 0 | none — forge-orm/indexeddb, built into the browser | — |
Nothing in the last column is second-class for being there. driver: is how a
React Native app, a Turso deployment or a browser tab was always going to
connect, and it is the only form that lets you configure the client.
The same logic covers the pg-compatible clients that are not here — postgres.js, Neon, Supabase's pooler. They all speak Postgres and forge treats them as Postgres, but each is its own package, and you are importing it yourself anyway, so they go through option 3:
import postgres from 'postgres';
import { createDb, postgresJsDriver } from 'forge-orm';
const db = await createDb({ schema, driver: postgresJsDriver(postgres(url)) });There is deliberately none for the alternative drivers in the table under
option 3 — postgres.js, MariaDB, PlanetScale, libSQL, Expo, OP-SQLite, Tauri.
That is not an omission. An entry point exists to make a driver visible to a
bundler when forge is the one choosing the package; the moment you pick a
non-default client you are importing it yourself, and a static import is what
you already have. forge-orm/libsql would save you one line and add a module
to keep in step with somebody else's releases.
Each dialect entry re-exports everything the main entry exports — f, model,
rel, the type helpers, the driver factories — so a single import line covers
a whole file and you never end up pulling forge in from two places.
One caveat: a dialect entry still takes a url, so it configures the
connection and nothing else. If the client itself needs options, that is
option 3.
Do not reach for a dialect entry off the server. forge-orm/sqlite pulls
in better-sqlite3, a native Node addon: import it from a React Native app and
Metro fails on a module that cannot exist there, and in a browser bundle it is
the same story. Those targets are not an afterthought — they have entry points
and drivers of their own, and the full map is:
| Target | Import | Driver |
|---|---|---|
| Node, driver chosen at runtime | forge-orm | resolved from the URL prefix |
| Bundled Node (Lambda, SSR, esbuild) | forge-orm/postgres /mysql /sqlite /pglite /mongo | that dialect's default, statically imported |
| Browser — SQLite over OPFS | forge-orm/wasm (+ /wasm/worker, /wasm/vite, /wasm/next, /wasm/webpack) | wasmSqliteDriver |
| Browser — zero install | forge-orm/indexeddb | built in, no package |
| React Native (Expo) | forge-orm | driver: expoSqliteDriver(…) |
| React Native (bare) | forge-orm | driver: opSqliteDriver(…) |
| Tauri 2 desktop + mobile | forge-orm | driver: tauriSqlDriver(…) |
| Cloudflare Workers, Vercel Edge | forge-orm | driver: with an HTTP client — Neon, PlanetScale, libSQL |
| DuckDB, SQL Server | forge-orm/duckdb, forge-orm/mssql | @duckdb/node-api, mssql, statically imported |
Everything in the right-hand column of the last five rows goes through option 3, and none of them wants an entry point: you are importing the client yourself already, so the import is static and the bundler — Metro included — can see it. The entry points exist for the one case where forge, not you, picks the package.
Option 3 — driver (bring your own client)
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 neededThis is required, not merely preferred, whenever the client needs configuring,
because a connection string has nowhere to put that: pool size, SSL or TLS
settings, statement and connection timeouts, the HTTP drivers Neon and
PlanetScale use over their serverless endpoints, PGlite extensions, or a
MongoClient you already share with the rest of the app. It is also how you
use a client forge ships no factory for — you write the construction, so forge
never needs to know the package exists. And because that import is yours and
static, this option bundles as cleanly as option 2 does.
Built-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), tauriSqlDriver (@tauri-apps/plugin-sql — Tauri 2 desktop + mobile) |
| 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' }) });
// SQLite in a Tauri 2 app — @tauri-apps/plugin-sql (sqlx on Rust side).
import Database from '@tauri-apps/plugin-sql';
import { createDb, tauriSqlDriver } from 'forge-orm';
const sqlite = await Database.load('sqlite:app.db');
const db = await createDb({ schema, driver: tauriSqlDriver(sqlite) });
await db.$migrate(); // runtime DDL on first boot
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).
Browser (zero install) — IndexedDB
The IndexedDB adapter is forge's zero-install browser tier. Every browser has IndexedDB natively, so there's no wasm to download, no worker file to bundle, no COOP / COEP headers to set. Trade native SQL power for zero install:
import { createDb } from 'forge-orm';
const db = await createDb({ url: 'idb:appname', schema });
await db.user.create({ data: { email: '[email protected]', name: 'Alice' } });The full Prisma-shape API works — reads, writes, relations, sorts, paging,
aggregations, JSON path queries, geo near / withinPolygon, vector near
/ nearTo, full-text search, upsert, soft-delete + restore, .compile,
$transaction, $migrate, $doctor, $diff.
The tradeoff vs sqlite-wasm:
| | sqlite-wasm | IndexedDB | |---|---|---| | Bundle cost | ~1 MB wasm + worker | zero | | Query engine | real SQL | IR → cursor scan + JS predicate | | Vector | native (sqlite-vec HNSW) | brute-force JS (< 1 k rows) | | Geo | native (R-Tree via SpatiaLite) | Haversine JS + bbox prefilter | | FTS | FTS5 with BM25 | multiEntry token index, AND-of-tokens | | Multi-tab | needs SAHPool VFS | native |
Both adapters read the same schema and take the same query calls, so swapping between them is a URL change.
Deep dive: docs/INDEXEDDB.md.
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: '[email protected]', 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 da
