Changelog
All notable changes to forge (forge-orm). Forge is a Prisma-shape multi-database wrapper for MongoDB, PostgreSQL, MySQL, SQLite, DuckDB and SQL Server — one code path, no codegen, no external query engine.
2.22.2 — Fix: forge would not load in a browser
object-id.ts, added in 2.21.0, imported randomBytes from node:crypto and built the id with a Buffer. Both are Node-only, and that module is reachable from ANY import of forge — src/index.ts exports objectIdHex and builder/collection.ts imports it. Both calls run at MODULE SCOPE, so a browser app using the IndexedDB adapter did not get broken ids; it got a bundle that threw on import:
(0 , node_crypto_1.randomBytes) is not a function
Measured in a real application: the UI never finished booting — the splash sat there and then showed its chunk-load fallback, because the error lands before any of the app's own code runs.
It is globalThis.crypto.getRandomValues and a Uint8Array now, which exist in browsers, workers, Deno, Bun and Node 18+. The id layout is unchanged: 4-byte seconds, 5-byte per-process random, 3-byte counter, still sorting by time and still ordering ids minted in the same second.
A guard now walks the static import graph from src/index.ts and fails on any top-level node:* import. None of the 1,083 existing tests could have caught this — they all run under Node, where the import resolves perfectly — and the consuming app's typecheck passed too, because types cannot see a missing runtime global. A DYNAMIC await import('node:…') is still allowed and is how the server adapters pull in their drivers: it is not in the static graph, so a bundler never follows it into a browser build.
2.22.1 — Path filters: three things that were silently half-wired
All three were found by pointing a real application at Postgres, and all three were silent — no error, just the wrong rows.
{ meta: { path: 'a', equals: 5 } } compiled to meta->>'a' IS NULL. The builder scanned for the INTERNAL operator spellings (eq, ne), so the form forge's own error message tells people to write matched nothing: the sub-op stayed at its eq default and the value stayed null. The public vocabulary (equals, not, gte, in, …) now works, mapped through the same table the dotted form already used, and the internal names keep working as aliases.
mode: 'insensitive' never reached a path contains. It emitted a bare LIKE where the same operator on a scalar column emits ILIKE, so a case-insensitive search inside a json blob matched only the case it was handed — searching BOLA found nothing for a row holding bola@example.com.
mode on the DOTTED form threw. { 'meta.email': { contains: 'x', mode: 'insensitive' } } raised unsupported operator 'mode', because the loop treated every key as an operator. It is a modifier that qualifies contains, and it is skipped as one now.
2.22.0 — Postgres: five things Mongo was hiding
Found by pointing a real application's 8,700-test suite at the Postgres adapter for the first time. Every one of these worked on Mongo, and each worked there for a reason that does not generalise.
A null inside an in list is a member of the list. { loc: { in: ['L1', null] } } means "L1, or not set", which is what Mongo's $in does — it matches an explicit null AND a missing key. loc IN ('L1', NULL) in SQL is loc = 'L1' OR loc = NULL, and the second half is UNKNOWN, so every row with no value matched nothing. Measured on three rows (one 'L1', one explicit null, one absent): Mongo returned all three, Postgres returned one. notIn was worse — NOT IN (…, NULL) is UNKNOWN for every row, so the filter matched nothing at all. A null in either list is now asked about with IS NULL / IS NOT NULL; a list with no nulls emits exactly what it did before.
updateFirst and deleteFirst could not see a row another writer had just touched. The single-row form was built on ctid, a tuple's physical address — and an update does not change a row in place, it writes a new version at a new address. So the second of two concurrent writers looked for an address that no longer held the row, matched nothing, and the caller got null and raised "not found" for a row sitting right there. Now keyed on the primary key with FOR UPDATE on the subselect. Postgres only: mssql was already keyed on the pk, mysql becomes LIMIT 1, and sqlite/duckdb use rowid, which neither moves.
forge push reported success without adding a column. The planner decided "already applied?" per statement, and a CREATE TABLE counts as applied once the TABLE exists — so a field added to a model never reached a database that already had the table. There is an ADD COLUMN per field now, plus DROP NOT NULL when a field has become optional, which is the one column change safe to make unasked. SET NOT NULL stays manual (it can fail on existing rows) and is printed for the operator to run after a backfill.
A dotted key wrote nowhere. data: { 'customer.id': x } compiled to SET "customer.id" = $1 — the dotted name quoted as one identifier — so Postgres looked for a column of that literal name. The read side had understood these paths for a while, so a filter matched rows that could then not be updated. Now jsonb_set(coalesce(col, '{}'), '{a,b}', value, true), with several writes to one column nesting rather than one losing.
The array filters did not work on a json column. has emitted $1 = ANY(col), valid for text[] and not for jsonb. On a json field these now compile to containment (col @> '["vip"]'), which a GIN index can answer.
2.21.0 — One id shape on every engine
Minor. f.id() mints an ObjectId on Mongo and a UUID v4 on SQL. Each is the native thing on its own engine and that is the right default — but it is the wrong default for a codebase that has to run on both. Old rows keep 24-hex ids, new rows get UUIDs, and everything that knew what an id looks like quietly stops being true.
Measured on a real port, before a single row moved:
- a regex gate on background-job ids rejected every id and skipped the whole queue, one warn line per job;
- an inbound-email parser stopped recognising the thread id that the same module generates;
- an assistant answered "I can't find that" about a row it was holding by its key;
orderBy: { assetId: 'desc' }, standing in for "newest first" via ObjectId's timestamp prefix, started returning an arbitrary 20 rows.
None of them threw.
f.id({ type: 'objectId' }) // per field
createDb({ url, schema, defaultIdType: 'objectId' }) // whole connectionThe connection-level default matters more than it looks: a schema with hundreds of models should not repeat itself on every f.id(), and a model added next year must not get the other shape because somebody forgot. A field's own idType still wins, and a caller-supplied id still wins over both.
The generator is objectIdHex(), exported — 4-byte seconds, 5-byte per-process random, 3-byte counter, MongoDB's own layout, no BSON dependency. Two properties come with it and both are the reason to choose it over a UUID: ids sort by time, which a v4 UUID cannot do at all, and they insert at the right-hand edge of a btree instead of scattering across it.
Nothing changes unless you ask for it. f.id() is still a UUID on SQL.
2.20.7 — A dropped TTL index said nothing
Patch. expireAfterSeconds tells Mongo to delete a row once a date column is old enough. Postgres has no equivalent — expiry is a job there, not an index — so the adapter could not honour it and never could.
What it did was build the index WITHOUT the expiry and say nothing. The index looks right in \d, push reports success, and the rows simply stop being deleted.
On the schema that found this, that was eight indexes, every one on a table that grows a row per authentication attempt: OTP codes, password-reset tokens, refresh tokens, sync ingest keys, portal sessions and OTPs, finished jobs. Exactly the tables the TTLs were added to stop growing.
There is nothing to emit, so push now warns — naming the index, the table, and what it does not do — in the same voice as method: 'vector' and visible: false. The index is still created; only the silence is gone.
2.20.6 — Dual-dialect indexes, json paths in index keys, and $ping
Three things, all found by taking one real 277-model Mongo schema and pushing it at PostgreSQL for the first time.
A dotted index key over a json column is a PATH
Minor. An index may need a different SPELLING per dialect and mean exactly the same thing in both. The case that found this is a compound index over an embedded field: Mongo indexes { 'customer.id': 1 } natively, while Postgres stores the embed as jsonb and needs (("customer"->>'id')). Both say "index the customer id on this invoice".
You could not write both. Setting expression to serve Postgres made the Mongo push skip the index entirely — silently. Push printed nothing, doctor said nothing, listIndexes showed no sign, and the collection went back to a full scan with no warning anywhere. The only way to keep a dual-dialect schema was to declare one dialect and remember to swap the declaration by hand at a cutover, which is a footgun with no safety on it.
indexes: [{
keys: { orgId: 1, 'customer.id': 1, createdAt: -1 }, // Mongo
expression: `"orgId", (("customer"->>'id')), "createdAt" DESC`, // SQL
name: 'idx_invoice_org_customer_created',
}]Mongo now uses keys whenever they are present, and skips only an index that has nothing but an expression — where there is genuinely nothing to build from. Postgres already preferred expression over keys when both were given, so the two sides agree without either having to know about the other.
An index that carries only expression is unaffected, and so is one that carries only keys.
$ping() — "is the database answering", asked the same way
Every engine can be asked and none of them agree on how: Mongo takes { ping: 1 } on the command channel, SQL takes SELECT 1. There was no portable form, so a health endpoint had to know which engine it was talking to — the one place an application should not have to, because a healthcheck is the thing that runs before anyone has worked out what is wrong.
await db.$ping(); // resolves if the database answers, rejects if notIt REJECTS rather than returning a boolean, so the caller can time it and log the real error. IndexedDB has no server to be unreachable, so there it resolves. Available inside a transaction too.
2.20.5 — Mongo indexes had no runtime path
Minor. forge push was the only way to create a declared Mongo index. That is a CLI: it needs a shell, its own connection, and somebody to remember to run it. A server that wants its indexes guaranteed present before it accepts a request had nowhere to call — db.$migrate() threw:
[forge] $migrate() is only supported on sqlite, postgres and indexeddb
adapters today. For mongo use the CLI: 'npx forge push'.So applications wrote their own boot-time createIndex loop, and a hand-rolled loop goes wrong the same three ways every time: no listIndexes diff, so it re-issues every index on every boot and cannot distinguish "absent" from "drifted"; a second list of indexes that drifts away from the schema's own indexes: with nothing comparing them; and every index it creates that the schema does not declare is drift forge diff then reports forever.
db.$migrate() now works on Mongo. A collection needs no DDL, so there it means the index set — the same listIndexes-diff-then-createIndex pass push runs, against the connection the app already holds:
const db = await createDb({ url: process.env.DATABASE_URL!, type: 'mongo', schema });
await db.$migrate(); // declared indexes are now present
app.listen(3000);Idempotent, so it is safe on every boot: against an in-sync database it issues one listIndexes() per collection and writes nothing. applied and skipped hold index names; a failing index (a unique over data that already has duplicates, say) lands in failures rather than killing the boot.
Two new Mongo-only options, on $migrate() and as forge push flags:
await db.$migrate({ dryRun: true }); // forge push --dry-run
await db.$migrate({ prune: true }); // forge push --prunedryRun reports every index that would be created, rebuilt or dropped and writes nothing — the plan that forge diff cannot give you on Mongo, where index names are usually auto-generated. prune drops indexes the schema does not declare; it is off by default because Mongo index names are not namespaced, so an index a person added by hand is indistinguishable from one an older schema version created and prune takes both. Without it those indexes are reported (report.pending, or a trailing list on the CLI) and left alone. _id_ and *_fts shadows never count as undeclared.
The engine is exported. For code that holds a MongoClient rather than a ForgeDb:
import { applyIndexes } from 'forge-orm';
const report = await applyIndexes(client.db('app'), { schema });It moved to src/adapters/mongo/apply-indexes.ts — dependency-free, no console, no process-global client. scripts/push.ts is now the CLI face on top of it and re-exports the whole module, so every existing adapters/mongo/scripts/push import path still resolves and pushAllIndexes keeps its signature and its output. src/index.ts deliberately exports from the engine, not from scripts/push: that one calls dotenv.config() at load, which import 'forge-orm' must not do.
Also fixed, in the drift detector
A Mongo primary key reported as both missing and extra. The schema calls it id and Mongo stores it as _id, and nothing reconciled the two for the primary key itself, for a composite unique containing it, or for a per-field unique. Every collection produced two permanent drift items no push could ever clear:
− [index] widgets: index u:id
+ [index] widgets: unique index u:_id in DB but not in schemaExpected signatures are now grouped per index, holding every spelling that would satisfy it, and the missing-index pass accepts any one of them. Adding both spellings to the flat set — which is what the declared-index path already did — was the bug: it made the check demand both.
TTL drift was invisible. expireAfterSeconds was pushed but never read back: it was missing from IntrospectedIndex, from MongoAdapter.introspect() and from the diff comparator, so a changed retention window — or a TTL that had gone missing entirely, which is the worse direction, since documents just quietly stop expiring — was drift forge diff could not see. All three now carry it.
The deep per-index comparison never ran on Mongo. It matched a declared index to a live one by the declared name only, and Mongo schemas rarely name their indexes — push auto-names them. So the partial filter, collation, wildcard projection, key directions and (now) TTL of a typical Mongo index were never compared at all. The comparison now falls back to the name push would have generated, resolving id → _id and method: 'spatial' → 2dsphere first so it compares what push actually created rather than what the schema literally said. SQL dialects are untouched: their index names derive from the table, not the schema key, so an unnamed declaration stays out of the deep pass there.
method: 'vector' was reported missing on Mongo. Push warns and skips it — Atlas Vector Search is a separate Search Index API, not createIndex — but the differ still expected it, which was permanent drift with no way to clear it.
The auto-name rule now lives in one place, src/adapters/mongo/index-name.ts, shared by push and the differ instead of each keeping a copy, and is exported as mongoIndexNameFor.
2.20.4 — f.bytes() never worked on DuckDB
Patch. Storing binary on DuckDB threw on every write, and a stored value could not be read back as bytes. Both directions were broken:
await db.asset.create({ data: { body: someUint8Array } });
// Error: Cannot create values of type ANY. Specify a specific type.Cause, write side. @duckdb/node-api cannot infer a type for a bare Uint8Array — nor for a Buffer. It needs its own blobValue() wrapper. The param coercion returned the bytes unchanged, which was the right fix for the previous bug (they had been JSON-stringified into a BLOB column as {"0":137,"1":80,…}) but left them unbindable. The driver now wraps them, resolving blobValue lazily so a build that never touches DuckDB does not pull the package in.
Cause, read side. A BLOB comes back as a DuckDBBlobValue wrapper, so f.bytes() returned an object where every other dialect returns a Uint8Array. Unwrapped in the driver — it is what produces the wrapper, this adapter has no field-aware decode step, and doing it there also covers raw queries.
fromDriverBytes now understands the wrapper too, matched on shape, and ordered after the BSON check and gated on _bsontype being absent: Decimal128 also carries a bytes Uint8Array — its 16-byte internal representation — so matching on shape alone would quietly turn a decimal into those raw bytes.
Why it was missed. DuckDB's driver was not installed on the machine the earlier bytes fix was written on, so that dialect's copy of the fix was verified by READING it. The code looked right and had never once run. @duckdb/node-api is a devDependency now and regression-duckdb-bytes.ts (10 checks: round trip, column type is really a blob, both columns, the maxBytes ceiling and its exact edge, a megabyte, and a non-binary value being refused) runs in forge:check and CI alongside the existing DuckDB and geo suites.
The remaining dialect with no execution coverage is MSSQL, which needs a SQL Server to talk to.
2.20.3 — a one-column uniques returned nothing on IndexedDB
Patch. On the IndexedDB adapter, declaring a single-column unique the composite way made every query on that column find no rows:
const Media = model('media', { id: f.id(), pendingId: f.string(), … },
{ uniques: [['pendingId']] });
await db.media.create({ data: { id: 'p1', pendingId: 'p1', … } });
await db.media.findFirst({ where: { pendingId: 'p1' } }); // → null
await db.media.findMany({}); // → the row IS thereThe row was written correctly and readable by primary key — only lookups on the column were blind, and they returned null / [] rather than raising. Writing the same constraint as f.string().unique(), or using two or more columns, was never affected.
Cause. ddl.ts gave every uniques entry an array keyPath, so a one-element combo became a compound index — and IDB keys a compound index by an array (['p1']). The planner synthesises that entry as a single-column index and looks it up with the scalar ('p1'), which matches nothing. The .unique() path and model.indexes both already collapsed length 1 to a scalar; the composite-unique path was the one that did not.
Also fixed: an index whose key shape changed was never rebuilt.diffAgainstLive compared index names only, so the broken index above would have survived this very fix in every database that already had one — and because the adapter resolves indexes by name, the query would keep succeeding against the wrong key shape and keep returning nothing. Indexes are now compared on keyPath, unique and multiEntry, and a mismatch is dropped and recreated (drop before add, or rebuilding a name throws ConstraintError).
The real gap this exposes. The IndexedDB adapter had no execution coverage — driver.ts said the driver wrapper existed "so tests can inject fake-indexeddb", and nothing ever did. The same blind spot for the SQL dialects is what regression-dialect-features.ts was written for. There is now regression-indexeddb.ts (14 checks, in-process via fake-indexeddb), wired into forge:check and CI, covering the unique shapes, the index rebuild, and f.bytes() round-tripping as real binary.
Found from a real app: a media table that stored files as f.bytes() and declared uniques: [['pendingId']].
2.20.2 — an ObjectId in a where threw
Patch. The single most ordinary thing you can write against MongoDB did not work:
db.post.findFirst({ where: { author_id: new ObjectId(id) } })
// [forge] unknown operator 'buffer' on 'posts.author_id'.docs/MONGO.md promises this exact shape — "where: { author_id: 'abc…' } works the same way where: { author_id: new ObjectId('abc…') } does" — so it was documented behaviour that threw.
What went wrong
Deciding whether a where value is a value or an operator container like { gte: 5 } was done with "object, not array, not Date". Every class instance failed that test and was then walked for operators. Object.keys() on an ObjectId returns ['buffer'], which is not an operator, so the build threw.
Every BSON type was affected, not just ObjectId:
| value | enumerates as | thrown operator |
|---|---|---|
ObjectId | ['buffer'] | 'buffer' |
Decimal128 | ['bytes'] | 'bytes' |
Long | ['high','low','unsigned'] | 'high' |
Binary / UUID | ['buffer','sub_type','position'] | 'buffer' |
Buffer | ['0','1',…] | '0' |
Date escaped only because it was special-cased by name.
The fix is a prototype check — a value is an operator container only when it is a plain object:
function isPlainObject(v: any): boolean {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
}That subsumes the isDate special case and the _bsontype guard that the not: branch and ir/build/data.ts already carried — two of the four sites had a guard and two did not, which is what made this reachable. An operator object built with Object.create(null) still reads as operators.
Why it deserves a release on its own
The throw is loud, but a caller that wraps the query in try/catch turns it into silence. A production service hit exactly that: a subscription lookup built with new ObjectId(orgId), a catch that logged and returned "allow", and an entitlement check that fell through to its default tier. The guard never blocked a request and nothing showed why. If you catch around forge queries, re-check those paths after upgrading — the fix makes code that was quietly failing open start working.
2.20.1 — three bugs a real server found
Patch. Every fix here was invisible to the unit suite, because the SQL it emits is correct-looking text and the defect only appears when a database executes it. Found by running the new features against a real PostgreSQL 18 and MySQL 9.7 for the first time.
f.bytes() was stored as JSON text on MySQL, MSSQL and DuckDB
A Buffer is an object, and each of those adapters' parameter coercion JSON-stringified any object on its way to the driver:
// what the column received
{"type":"Buffer","data":[0,1,127,128,255]}The write succeeded, the column filled up, and the bytes were gone. Reading back gave the UTF-8 of that JSON. Postgres and SQLite were unaffected — their coercion never stringified objects.
Binary is now exempted before that branch in mysql/compile-from-ir.ts, mssql/compile-from-ir.ts and duckdb/driver.ts. Proven by a round trip on MySQL: [0,1,127,128,255,13,10,0] in, the same bytes out.
A default on a list column made the table undeployable on Postgres
f.stringArray().default([]) emitted DEFAULT '[]'::jsonb for a text[] column, and Postgres refuses the whole statement:
column "tags" is of type text[] but default expression is of type jsonbSo CREATE TABLE failed outright — not the write, the schema. The ::jsonb fallback is right for json / embed / geoPoint and wrong for an array, which now emits '{}'::text[] or ARRAY['a','b']::text[] (and integer[] for intArray).
A JSON key with a space, a hyphen or a quote could not be queried
jsonPathSpec wrote every segment bare, so { meta: { path: ['order-id'] } } produced $.order-id — not valid JSONPath. MySQL rejected the expression with "Invalid JSON path expression" and the query failed rather than returning rows. Segments that are not plain identifiers are now quoted as $."order-id", which every dialect taking a path string accepts. Note this was never an injection: the path has been a bound parameter since 2.18.0. It failed safe, but it failed.
New: the feature suite that found them
regression-dialect-features.ts executes everything added in 2.18.0-2.20.0 against a real server — f.bytes() round trips and maxBytes, the four array filters, mode: 'insensitive', the bound JSON path (including an injection payload and a hyphenated key), cursor direction on a newest-first feed, the divide / max / min / push atomic ops, updateFirst / deleteFirst including the soft-deleting patch, and soft-delete scoping across include, _count and a relation filter. 31 checks per dialect, and it runs as part of forge:integration:pg and forge:integration:mysql.
One thing it records rather than asserts: a case-SENSITIVE contains is not available on MySQL. The default collation is _ci, so a plain LIKE is already insensitive and forge has no way to force sensitivity without a COLLATE clause.
Tests
984 jest tests across 67 suites, plus 8 transaction checks and 31 feature checks on each of Postgres and MySQL. npm run forge:check exits 0.
2.20.0 — updateFirst and deleteFirst: the doubled round trip on every write path
Minor, purely additive. No behaviour change to any existing API. Two new verbs on the collection wrapper. They exist because of a count: in one consumer codebase there are 330 updateMany call sites across 203 files, and 123 of them are immediately followed by a re-read of the row just written. Nobody wanted two queries. That is what "update this row and hand it back, or tell me it is not there" cost, for as long as the only verb that handed the row back threw when the row was absent.
// The shape those 123 call sites are in.
await db.thing.updateMany({ where: { id }, data }); // round trip 1
const fresh = await db.thing.findFirst({ where: { id } }); // round trip 2
return fresh;update() already returns the row in one trip — but it throws DbKnownError P2025 when the filter matched nothing, which turns a request that should be a 404 into a 500. So the write went through updateMany, whose { count: 0 } is a value rather than an exception, and the row had to be fetched separately.
updateFirst and deleteFirst
const user = await db.user.updateFirst({ where: { id }, data: { name } });
if (!user) return res.status(404).end(); // nothing matched, nothing threw
await db.session.deleteFirst({ where: { token } }); // already gone? not an error.updateFirst({ where, data, select?, include?, omit? }) takes exactly what update() takes, writes one row by a filter that need not be unique, and returns the row. deleteFirst({ where, … }) stands in the same relation to delete(). The only difference from the verbs they mirror is the miss: null instead of a throw. Same statement per dialect, same RETURNING / OUTPUT / findOneAndUpdate, no SELECT before or after.
It compounded with forge's own documentation, which until 2.18.0 said update()'s where was type-narrowed to the model's unique fields and that a non-unique filter had to go through updateMany instead. It never was — but a reader who believed it had no single-round-trip option at all. These verbs close the gap that sentence opened.
The third round trip, on a soft-deleting patch
On a model with a .softDeleteAt() column the re-read pattern does not just cost a trip, it returns the wrong answer. A read is soft-delete filtered, so when the patch is what soft-deletes the row, the follow-up findFirst cannot see the row that was just written and comes back null — every time. The workaround was a read placed before the write and the result assembled by hand from the pre-write row plus the patch: three round trips, and a reconstruction rather than what is in the table.
updateFirst does not have the problem. Like update() and unlike a read it is not soft-delete filtered, so it can update an already-soft-deleted row, and the row it returns is the post-update row as the driver handed it back rather than a re-read. One trip, and the real row.
They are not a try/catch for P2025
This is a correctness point rather than a style one. update() runs nested writes after the row itself, and a nested write can raise the same not-found code — a connect to a row that does not exist. A catch at the outside cannot tell the two apart, so it would return null for a row that had in fact been updated, and the caller would 404 a request that wrote to the database. The miss is handled at the one point where the row is known to be absent: the executor returned no document, before any nested write runs.
Docs
- MUTATIONS.md gains a section for the two verbs: the four-way comparison (
updatethrows,updateFirstisnull,updateManycounts, and the old re-read pattern's two trips — three on a soft-deleting patch), what they compile to, and the migration. Both verbs are in the verb tables and in Returning shape. - CONCURRENCY.md said "forge's
updaterequires a unique where clause". It does not, and never did — the thing that ruled it out of the optimistic pattern was only the throw. Corrected, and the two examples that ended in a re-read now useupdateFirst: the version-with-current-state save, and theIf-MatchPUThandler, which was also setting anETagread back after the write — under a concurrent write that is a version the request never produced, and the client's nextIf-Matchgets an unexplainable412. - SOFT-DELETE.md states the third round trip where the read filter is documented.
- ERRORS.md's
P2025section says which verb to reach for when "no such row" is a 404 rather than a bug, and why catchingP2025around anupdate()is not the same thing. - MULTI-TENANT.md's hand-written
scopedDbrecipe carries both verbs, so a tenant-scoped repository is not pushed back onto the two-trip shape by a missing wrapper method. - README gains the verbs in the write listing and a section of its own, and its "What's new" list — which had stopped at 2.18 — now carries 2.19 and 2.20.
- Retracted or corrected along the way, all found by checking the pages above against the source:
- EVENTS.md documented
opas the driver-level verb, listing'find'/'insertOne'/'findOneAndUpdate'/'updateMany'/'deleteOne'for Mongo and a'raw'for SQL. Every adapter emits the same six IR-level values —'select','count','groupBy','insert','update','delete'— so a MongofindOneAndUpdatereports'update', and'raw'is emitted by nothing. Raw queries fire no event at all:$queryRaw,$executeRawand$runCommandRawgo straight to the driver, which the page now says where it used to describe theirmodelas''. Thesqlfield's Mongo example was'users.findOne'; the real shape is"<collection>.<op>", so'users.select'. - CACHING.md's three
query-event listeners, and TESTING.md's "assert on the captured stream" example, testedevent.semanticOpagainst physical op names — and CACHING.md's also readevent.args.where.id.semanticOpis set only for thesoftDelete/restorefamily and is absent on a direct write, so all three fired on nothing — a cache that looks wired and never invalidates — and there is noargsfield on the event to fall back to. All three now gate onop, skip arowCountof 0, and derive per-row and per-category keys at the call site, which is the only place thewhereexists. The page also claimed the event "runs aftercommit"; it fires when the statement resolves, which inside$transactionis beforeCOMMIT, so a rolled-back transaction has already purged. That is the safe direction, and the page now says so rather than implying durability.db.$raw, named there as the raw escape hatch, is not an API — the methods are$queryRaw/$executeRaw. TESTING.md additionally describedsemanticOpas "the model-level verb (create,update,findFirst,findMany, …)", which is what produced the broken filter; it has four possible values and none of them is any of those. - MUTATIONS.md's soft-delete worked pattern declared its column as
f.timestamp().nullable()— neither of which exists — with no.softDeleteAt(), sosoftDelete()would have thrown on the schema the example hands you, and gave the read opt-out aswithDeleted: true. The flag is_withDeleted, underscore included; without it the key is treated as a field name and either trips strict mode or reaches the database as a column no table has.
- EVENTS.md documented
Tests
src/__tests__/update-first.spec.ts (11) — the row in ONE call, null on a miss, a miss still being one call, update() / delete() still throwing, a non-unique filter accepted, the row returned when the patch is what soft-deleted it, and a P2002 / P2003 still propagating rather than being swallowed as a miss.
Suite totals for the release: 982 jest tests across 67 suites, plus the 8 regression-transactions.ts checks against a real Postgres. npm run forge:check exits 0.
2.19.0 — a transaction that did not reach your repositories, and three more things forge only looked like it did
Minor, with one deliberate behaviour change. Upgrade. Four fixes with one theme: each was code that ran without complaint while doing something other than what it said. A $transaction whose callback body escaped the transaction the moment it called a repository, an array form that was a Promise.all in disguise, two Mongo databases sharing one connection, and soft-deleted rows visible through every include.
The behaviour change is the array form of $transaction: it now takes functions and refuses promises. Read that section before upgrading — every call site you have to change was silently non-atomic.
$transaction did not reach a repository layer
The callback form hands you a tx whose wrappers carry the driver session. That works when the callback does the writes itself, and not at all when it calls a repository:
await db.$transaction(async () => { // tx discarded
await stockRepo.record(orgId, movement); // uses the ambient db
await itemRepo.incrementStock(orgId, sku); // ditto
});stockRepo closes over the root db, not over tx. Neither leg carried the session, so neither leg was in the transaction: both writes went out as they were issued, and the throw that was supposed to undo them undid nothing. The code read as atomic and was not. In a layered codebase this is the normal way to write it, and the only fix available was to thread tx through every function between the route and the query.
The session now lives in an AsyncLocalStorage (new src/session-context.ts). $transaction runs the callback inside runWithSession(adapter, session, …), and a collection wrapper with no session of its own picks the ambient one up — so anything awaited beneath the callback joins the transaction without knowing it exists. The example above is now atomic as written.
Four details that are part of the contract rather than the implementation:
- The session is stored with its adapter and returned only for that adapter, identity-compared. A driver rejects a session it did not create, so one database's transaction must never be picked up by another's queries.
dbB's writes insidedbA.$transaction(...)are outside it, deliberately. - It is resolved per call, not per wrapper. A repository handle built at boot, before any transaction existed, still joins the one that is open when it is called.
node:async_hooksis loaded through a runtime-assembled specifier, because a bundler resolves a literalrequire('node:async_hooks')at build time even inside atry— which breaks a browser build, and forge runs in the browser on sqlite-wasm and IndexedDB. Where there is no async context there is no ambient session, and that is correct rather than degraded: IndexedDB has no interactive transaction to join (see the note below).- An explicit session still wins, so
tx.xis unaffected and an existingAsyncLocalStorage-plus-scoped()pattern of your own keeps working. It is now redundant — see Docs.
Exported: ambientSessionsSupported(), currentSession(adapter), runWithSession(adapter, session, fn).
There is deliberately no withoutAmbientSession(). "Write this row so it survives the rollback" cannot be delivered consistently: on SQLite, PGlite and DuckDB the transaction is the process's one connection, so the statement is physically inside it however it is issued. Measured on PGlite — the row was gone. An API that works on Postgres and silently does not on SQLite is the class of difference this library exists to remove, so the escape hatch is a second createDb(), which is a second connection.
Tests: src/__tests__/ambient-session.spec.ts (12), plus the new regression-transactions.ts against a real Postgres (8 checks).
Behaviour change: $transaction([...]) takes thunks now
The array form was one line — Promise.all(arg) — and gave no atomicity whatsoever. It could not: by the time the array arrives, every element is an already-running promise, because db.a.create(...) dispatched the moment it was evaluated, while the array literal was being built. Promise.all then waits for writes that already happened, independently, each on its own connection, in no transaction. A money transfer written in that shape lost atomicity silently.
The array now holds functions:
await db.$transaction([
() => db.account.update({ where: { id: from }, data: { bal: { decrement: n } } }),
() => db.account.update({ where: { id: to }, data: { bal: { increment: n } } }),
]);They run in order inside one real transaction — one BEGIN, one connection, one COMMIT, and a throw from any of them rolls back the ones before it. Note the thunks call the plain db: the ambient session makes that join, so a thunk may equally call a repository. The tx handle is passed as the thunk's argument too, for code that prefers to be explicit. An empty array opens no transaction and returns [].
An array of already-started promises is now refused, with an error that counts them and shows both working forms:
[forge] $transaction([...]) takes functions, not promises — 2 of 2 items were
already running.Silence was the worse outcome. Note what the refusal cannot do: those promises had already started, so their writes may well have landed before the error was thrown. The exception tells you the shape is wrong; it does not undo anything.
Upgrading: add () => to every element of every $transaction([ ... ]) call. If what you actually wanted was parallel independent queries, that code was always a Promise.all — write Promise.all and say so.
The thunks run sequentially, not concurrently: a Mongo ClientSession rejects overlapping operations, and a single SQL client serialises statements anyway.
The overload's result type unwraps each thunk's return, so r[0].balance typechecks. It was Awaited<T[P]> over the array elements, which typed the results as the functions when given thunks and made every property access on a result an error.
A nested $transaction joins the open one
Including — and this is the common case, not the exotic one — a nested $transaction called on the ambient db from inside a callback, which is what a repository that opens a transaction defensively produces. It joins rather than opening a second: Postgres and Mongo both refuse a nested BEGIN, and on a driver that allows one the inner commit would publish half the outer transaction's work. There is still no SAVEPOINT — an inner failure rolls the whole thing back.
Tests: src/__tests__/batch-transaction.spec.ts (12).
The Mongo client was one per process, not one per createDb()
connect() early-returned when it already held a Db, and read process.env.DATABASE_URL rather than the URL it was given. So a second createDb({ url: B }) silently kept writing to A, and $disconnect() on either handle closed the connection under both.
For a migration tool, a test harness, or a database-per-tenant deployment, that is a data crossover with no error and no log line.
Each MongoAdapter now owns a DatabaseClient, exposed as adapter.db and adapter.mongoClient, and connect(url) takes the URL explicitly. The handle is threaded to the executors through a new ExecOpts.db, at the single mongoOpts() choke point — the same mechanism session already used, rather than nine new call sites.
The exported dbClient is kept and still works: it forwards to the first client created, which is the only one a single-database app has, so consumers using it for raw collection access are unaffected. A second createDb() gets its own client and does not disturb it.
close() now only closes what it opened. A client forge built from a URL is closed; one handed to it through mongoDriver(client) is released — the handle is torn down, the caller's MongoClient is left open. Closing it was a second way to break the same deployment shape: the database-per-tenant recipe is an LRU of createDb({ driver: mongoDriver(sharedClient, dbName) }) handles disposed with db.$disconnect(), and the first eviction would have closed the shared client under every other tenant.
Verified live as well as in unit tests: two createDb() calls against two databases each wrote to their own, and $disconnect() on one left the other usable. Tests: src/__tests__/mongo-client-isolation.spec.ts (11).
Soft-deleted rows came back through every include
Reads are supposed to add WHERE <col> IS NULL, and at the top level they did. Relation sub-selects are built in the IR and run in the adapters, neither of which had any soft-delete awareness, so
db.user.findUnique({ where: { id }, include: { posts: true } })handed back the posts the caller had already soft-deleted. A "deleted" post still listed under the author's account is the user-visible failure, and it is silent — the rows simply appear. docs/SOFT-DELETE.md claimed the opposite in a worked example.
The documented opt-out was broken in the other direction: include: { posts: { where: { _withDeleted: true } } } compiled to a filter on a column named _withDeleted, which no table has, and the database errored out.
Relation sub-selects are now scoped at every depth, using the target model's own column — a User include of posts filters on Post's column, whatever each is called. _withDeleted: true opts out at any level, and the flag is always stripped rather than reaching the where-builder. A caller's own filter on the soft-delete column wins untouched: that is how you ask for "deleted since Friday". select: { posts: true } is covered as well as include, and a bare include: { posts: true } — the shape that leaked, because no sub-node was built for it at all — now gets one whenever the target has a soft-delete column.
New src/ir/build/soft-delete.ts. Tests: src/__tests__/soft-delete-include.spec.ts (18).
Four things then disagreed with the newly-correct include
All four are fixed, and each had its own symptom.
groupBy scoped neither soft-delete nor strict mode, unlike _find, findManyStream and count. Since aggregate({ _sum }) routes through groupBy, a revenue total silently included deleted orders. A typo'd where key was not caught there either — which under strict: true means the filter matched nothing and the aggregate came back as though the rows did not exist.
Relation _count disagreed with the rows returned. _count: { posts: true } said 7 where include: { posts: true } returned 5. The relation counts are hand-written per adapter, outside the IR; all seven now exclude soft-deleted children.
Relation filters matched on deleted children. where: { posts: { some: … } } returned a parent whose only matching post was deleted — with an empty posts array, from a filter that said it had some. The scope goes on the subquery's join condition, not into the nested condition, and that placement is load-bearing: every compiles to NOT EXISTS of the negated inner condition, so a scope folded in there would be negated with it and every would come to mean "every child is live AND matches" — false for any parent that has ever deleted a child. On the join condition it is correct for all five modes (is / isNot / some / every / none). _withDeleted: true inside the relation filter opts out.
IndexedDB ignored a nested where entirely. Rows were fetched by index and handed back unfiltered, so include: { posts: { where: … } } returned everything — and the new soft-delete predicate would have been ignored right along with it. It is now evaluated in JS with the same predicate compiler the top-level residual filter uses.
Tests: src/__tests__/soft-delete-consistency.spec.ts (23).
Behaviour change: a relation filter with no recognised mode is refused
Scoping relation filters put a spotlight on the branch that builds them, and it had the same hole as an all-undefined where. The builder recognises exactly five keys — is, isNot, some, every, none — and a relation filter carrying anything else pushed no predicate at all and fell through, so the filter evaporated and every parent row came back:
// Before: returned EVERY user. Nothing threw.
await db.user.findMany({ where: { posts: { _count: { gt: 0 } } } });That exact shape was documented on docs/RELATIONS.md as a supported feature, with a HAVING clause and a correlated subquery as its claimed emit. None of it existed. So anyone who followed the documentation has been reading a filter that never ran — the same failure class, and the same fix, as the vanished-where guard in 2.18.0: it now throws at IR-build time, names the five modes, and when the offending key is _count adds the advice for what was meant:
[forge] where.posts is a relation, and { _count } is not a relation filter — so
it would have matched every row.
Use one of: is, isNot, some, every, none — e.g. { posts: { some: { … } } }.
Counting related rows in a filter is not supported. `{ posts: { some: {} } }`
is "has at least one", `{ posts: { none: {} } }` is "has none"; for a
threshold use groupBy with `having`.The guard fires only when nothing is recognised, so a filter that does carry a mode is untouched even if it has extra keys alongside it. Tests: src/__tests__/soft-delete-consistency.spec.ts, "a relation filter with no recognised mode is refused".
To-one hydration lost one of its two filters, in both directions
Every SQL adapter built the to-one sub-node as { where: <FK filter>, ...(rel.nested ?? {}) }, with nested spread last, so a caller's include: { author: { where: … } } replaced the FK filter and the sub-select scanned the whole target table. The results still came out right — rows are matched back to parents by key afterwards — so the only symptom was reading every row of a table to return one per parent. That is an index lookup turning into a sequential scan on a page that documented itself as a keyed batch fetch.
Mongo had the mirror of it (mergeNested(...) and then where: <FK> last), which dropped the caller's nested where, so the filter silently did not apply at all and the related document came back whether or not it matched.
Both are fixed by a shared hydrateOneRelation, joining the ten copies of this loader into one — the same treatment hydrateMany got in 2.18.0.
New: regression-transactions.ts
Runs in forge:check and in CI. It uses PGlite, which is in-process and so needs no service container — and it is the only dialect that can tell a propagated session from a shared connection. On SQLite $transaction hands back the one connection the process has, so a rollback test there passes whether or not the session was propagated; a propagation bug is invisible. @electric-sql/pglite is now a declared devDependency.
Suite totals for the release: 971 jest tests across 66 suites, plus the 8 PGlite transaction checks. npm run forge:check exits 0 and npm run build is clean.
Known limits — what 2.19.0 does not change
Two of these are platform facts rather than open bugs, and are stated as such; all three are things to know before you rely on the fixes above.
- SQLite, DuckDB and PGlite share one connection per process, and
$transactionrunsBEGINon it. Sequential work inside the callback is genuinely atomic there. What you do not get is isolation from concurrent work: a write issued outside the transaction while it is open goes down the same connection, lands inside theBEGIN, and rolls back with it — and two overlapping transactions collide. One writer at a time on those dialects. - IndexedDB's
$transactionruns the callback with no session, and each operation opens its own short-lived IDB transaction. That is the platform, not a gap: anIDBTransactionauto-commits as soon as the microtask queue idles, so it cannot survive anawaiton anything that is not an IDB request — an interactive transaction is not expressible. Per-operation transactions are the strongest atomicity available, and it is also why the browser having no ambient session costs nothing: there is no open transaction to join. - Two
createDb()calls in one process share the module-level active schema, so relation targets resolve against whichever was registered last. The Mongo connection is per-instance now; the schema registry is not. Register the same schema in both handles. - A
wherenested inside a relation_count—_count: { select: { posts: { where: … } } }— is ignored. The IR carries relation counts as bare relation names, so nested arguments are discarded when the query is built. Documented as unsupported rather than implemented; count with agroupByover the children instead.
Docs
- TRANSACTIONS.md documented the array form as non-atomic (honestly) and told readers to build their own
AsyncLocalStoragestore with ascoped()accessor in every repository. Both are obsolete. The page now covers the ambient session, its rules, the three helpers, why there is nowithoutAmbientSession(), and what is safe to delete from an existingtxStore/scoped()setup (all of it, on Node). It also states the per-dialect transaction caveats precisely rather than in aggregate, and says why a new transaction test belongs on PGlite. - SOFT-DELETE.md's "known gap" section, added in 2.18.0 when the
includeleak was found, is replaced by the real behaviour: scoping at every depth, the_withDeletedopt-out at any level, an explicit filter winning, and the four consistency fixes — including why the relation-filter scope sits on the join condition. - MONGO.md gains "One client per
createDb()": what the singleton broke,adapter.db/adapter.mongoClient, and whendbClientis still the right call. - RELATIONS.md and N-PLUS-ONE.md document the to-one loader and the filter each direction used to lose.
- Retracted or corrected along the way, all found by checking the pages above against the source:
- A model-level
.softDelete()and adb.x.deleteHard()were still documented in RELATIONS.md, FOREIGN-KEYS.md, MODEL.md and QUERIES.md. Both are v1 shapes and neither exists in v2 —delete()is always hard, the verb issoftDelete(). - Relation
_countwas described as a correlated subquery in the parent's SELECT list (and$lookup+$sizeon Mongo) in RELATIONS.md, AGGREGATIONS.md and N-PLUS-ONE.md. It is one batched grouped query per counted relation, materialised onto the rows afterwards. Filtering inside_countwas documented as working; it is ignored, and is now marked unsupported. - Filtering a parent by
{ <relation>: { _count: … } }was documented as supported; it never was, and now throws — see above. - INDEXEDDB.md described the array form of
$transactionas "one IDBreadwritetxn spanning both stores, committed atomically", which it never was. - FOREIGN-KEYS.md claimed
forge doctorflags FK columns with no index. There is no such probe. - MULTI-TENANT.md's database-per-tenant recipe disposed cached handles with
$disconnect()over one sharedMongoClient. That is safe as of this release (see the Mongo section) and the recipe stands, but it needs 2.19.0 in two ways, both now stated on the page. - BATCH.md and MUTATIONS.md counted
$transaction([N creates])as one round trip. It is N, inside one commit, and the throughput figure beside it was measured on the oldPromise.allform. - Twelve broken intra-page anchors.
- A model-level
2.18.0 — two injections, a filter that emptied tables, and the dialect leaks behind them
Minor, with several deliberate behaviour changes. Upgrade. Almost everything here ran without complaint and produced the wrong answer, or gave the caller more reach than the endpoint did. Nothing threw, nothing logged, and two of them left data behind that needed a repair script.
Three of these are security fixes and are the reason to upgrade now. The rest share one cause, described under Dialect leaks.
Security: SQL injection through a JSON path on MySQL
where: { meta: { path: ['a', 'b'], equals: x } } interpolated the path into the SQL text after escaping it — and on MySQL the escaping applied two passes in the wrong order. A ' became \', and the second pass turned that into \''. MySQL reads \' as an escaped quote, so the next quote closed the string literal and everything after it in the path was executed as SQL.
The path segments are fully caller-controlled. Any endpoint that let a user name a key inside a JSON column — a filter builder, a saved-view feature, a generic search API — was a full injection.
The fix is not better escaping. The path is now bound as a parameter on MySQL, SQLite, DuckDB and MSSQL, built by a shared jsonPathSpec() (new src/adapters/json-path-spec.ts) and passed through a params argument added to Dialect.jsonPathExpr. There is no escaping left to get wrong, and the path no longer appears in the SQL text at all — so it will not show up in slow-query logs or $explain output either. Postgres was never affected; it uses operator syntax rather than a path string.
Security: identifier injection on MSSQL
MssqlDialect.quoteIdent refuses a ] and a NUL byte, which is what makes a bracket-quoted T-SQL identifier safe. The MSSQL compiler bypassed it at around fourteen sites, writing `[${k}]` directly.
Worse, update and upsert take their column names straight from the caller's data object and never required them to exist in the model. So
update({ data: { "role] = 'admin', [x": 1 } })closed the bracket and wrote a column the endpoint never exposed — a mass-assignment escalation on any handler that spread a request body into data. Every identifier now routes through a single q() helper that calls quoteIdent.
Security: an all-undefined where no longer means "every row"
undefined is skipped throughout the query surface, which is what makes where: { status: maybeStatus } mean "don't filter on status". But when every value in a filter was undefined, the whole filter disappeared and the statement applied to the entire table:
await db.account.deleteMany({ where: { tenant_id: req.user?.tenantId } });
// → DELETE FROM "accounts" ← no WHERE at allOne optional chain that returned undefined emptied a tenant table. The single-row verbs were worse, not better: delete({ where: { id: maybeId } }) compiled to WHERE ctid = (SELECT ctid FROM "accounts" LIMIT 1) and destroyed an arbitrary row.
buildSelect, buildUpdate, buildDelete, buildCount and buildGroupBy now throw when a filter was written and none of its values survived:
[forge] deleteMany on 'accounts' was given a filter whose every value is
undefined (tenant_id), so it would apply to EVERY row.
This is almost always an optional value that came back undefined — check
the source of 'tenant_id'.
To act on every row on purpose, omit `where` entirely.The rule is exactly "keys were written, and none of them survived":
| Call | Meaning |
|---|---|
where omitted | every row — allowed |
where: {} | every row — allowed |
where: { a: 'x', b: undefined } | filters on a — allowed |
where: { a: undefined } | throws |
Partial undefined is untouched, which is the whole point of the feature. An omitted where and an explicit where: {} are how you say "every row" deliberately, and they are the escape hatch.
This is a deliberate divergence from Prisma, which allows the all-undefined case and applies it to the whole table. Tests: src/__tests__/vanished-where.spec.ts.
The keyset cursor ignored the sort direction
The comparison was a hardcoded > / $gt, whatever the orderBy said. So the single most common feed query there is —
await db.post.findMany({
orderBy: { created_at: 'desc' }, take: 20, cursor: { created_at: last },
});— asked for rows greater than the last row seen while walking downwards. Page 2 came back full of rows from page 1, and the user saw the same posts again. It is now > for asc and < for desc, per key.
Composite cursors were wrong in a second way. When every key sorts the same direction the compiler now emits a row-value comparison, (a, b) < ($1, $2), which an index on those columns satisfies with one range scan. When the directions are mixed — { score: 'desc' }, { id: 'asc' } — a row comparison cannot express it at all, because (a, b) > (x, y) applies one operator to the whole tuple, so it expands lexicographically instead:
(a < $1) OR (a = $1 AND b > $2) OR …T-SQL has no row-value comparison, so Dialect.rowValueComparison is a new capability flag, false on MSSQL, and MSSQL takes the expansion even for uniform directions.
Mongo had it worse. A composite cursor became
{ $and: [ { a: { $gt: x } }, { b: { $gt: y } } ] }which is not a tuple comparison. Given rows sorted by (a, b), the row after (5, 9) is (5, 10) — and that row has b = 10 > 9 but a = 5, which is not > 5, so the $and rejected it. Every row sharing the cursor's leading value was skipped. Mongo now uses the same $or lexicographic expansion.
Note while you are here: forge's cursor is exclusive — it emits a strict > / < and the cursor row is never returned. Prisma's is inclusive, which is why Prisma code pairs cursor with skip: 1. Carrying that habit into forge skips one row per page. This is a deliberate divergence, now documented in PAGINATION.md; several examples in the docs had it wrong and have been corrected.
Mongo update never coerced its values
create coerced its payload by field kind. where coerced its leaves. update only renamed keys. So writing an id string to an f.objectId() column through update or updateMany stored a BSON String where create would have stored an ObjectId:
await db.session.create({ data: { location_id: id } }); // ObjectId
await db.session.updateMany({ where: { … }, data: { location_id: id } });
// StringAnd because where does coerce, findMany({ where: { location_id: id } }) then searched for the ObjectId and silently never matched those rows. They were invisible, not missing. The same applied to dates: an ISO string handed to update stayed a string, so gt / lt filters stopped matching it.
Nothing counted the rows, so nothing noticed. Undoing it needed a repair script, which is how it was found. $set, $push, $addToSet and $pull now coerce by field kind; $inc, $mul, $max and $min are left alone, because their operands are numbers.
If you have written through update on Mongo on 2.17.0 or earlier, check for mixed types in objectId and dateTime columns before assuming the rows are gone.
Nested take / skip applied to the whole batch
await db.user.findMany({ take: 10, include: { posts: { take: 3 } } });issued SELECT … FROM posts WHERE author_id IN (…10 authors…) LIMIT 3 — three posts in total. The first author got three; the other nine got []. skip skipped rows of the combined result, so it dropped whole authors.
take and skip inside an include are per parent now. With no inner paging, the include is still the single batched IN query — that path is unchanged and still not N+1. With inner paging, it is one query per parent, bounded by the outer take. That cost is the point: the alternative is to fetch every child row and slice in memory, which is one round trip but unbounded — take: 3 over ten authors with a hundred thousand posts each would pull a million rows to return thirty.
Six near-identical copies of this loader, one per adapter, collapsed into one shared hydrateManyRelation.
divide was a multiply by the reciprocal
divide: 3 was rewritten into multiply: 0.3333333333333333 in the IR, before any adapter could see it. An exact decimal money column drifted on every update; an int column rounded. divide is now its own IR op, every SQL dialect emits a native col = col / $n, IndexedDB divides, and divide: 0 is refused when the update is built.
Mongo has no $div update operator, so an exact divide compiles to an aggregation-pipeline update (MongoDB 4.2+) with a real $divide. Every other op in the same call is translated to the matching pipeline expression so the update stays one atomic statement, and literals are wrapped in $literal — inside a pipeline a bare string beginning with $ is a field reference, so { name: '$5 off' } would otherwise store the value of a field called 5 off.
Dialect leaks — the shape of the whole release
One cause sits behind the next four fixes and the MySQL injection above. The five SQL dialects share a compiler that was written against Postgres, and the non-Postgres adapters are that Postgres SQL with targeted rewrites applied. Any construct with no hook to rewrite it leaked Postgres syntax into a database that does not speak it. The symptom was never subtle when it surfaced — a syntax error — but it hid completely until someone used that feature on that dialect, which for several of these was nobody until now.
Each of the four got a capability hook on Dialect, so the next one is a missing implementation rather than silently wrong SQL.
mode: 'insensitive' emitted ILIKE everywhere. contains / startsWith / endsWith with mode: 'insensitive' — the most-used filter in the Prisma vocabulary — compiled to Postgres's ILIKE regardless of dialect, so on MySQL, SQLite and MSSQL the query did not return the wrong rows, it did not run:
near "ILIKE": syntax errorNew Dialect.caseInsensitiveLike. SQLite emits LOWER(col) LIKE LOWER(?) (its built-in LIKE is case-insensitive for ASCII only, and that is a build option), MySQL and MSSQL emit their own forms, Postgres and DuckDB keep ILIKE.
f.stringArray() and f.intArray() were not filterable on three dialects. has / hasSome / hasEvery / isEmpty emitted the Postgres array operators — = ANY(col), col && ARRAY[…], col @> ARRAY[…], array_length — for every dialect. Postgres and DuckDB have real array columns; MySQL, SQLite and MSSQL store these kinds as JSON, so a declared field kind was writable and readable but not filterable on half the supported databases.
New Dialect.arrayFilter:
| Dialect | has / hasSome / hasEvery / isEmpty |
|---|---|
| Postgres | = ANY / && / @> / array_length |
| DuckDB | list_contains / list_has_any / list_has_all / len |
| MySQL | JSON_CONTAINS / JSON_OVERLAPS / JSON_CONTAINS / JSON_LENGTH |
| SQLite | json_each / json_array_length |
| MSSQL | OPENJSON |
The SQLite adapter's own file header had described the json_each approach since the adapter was written. It was never implemented. That comment is now true.
push emitted array_append everywhere, a function that does not exist on MySQL, SQLite or MSSQL. New Dialect.arrayOp — see new atomic operators for the support matrix, which is not uniform and is not pretended to be.
TRUE and FALSE are not T-SQL. An empty list in a filter compiles to a constant — { in: [] } can never match, { notIn: [] } always matches — and the constant was a bare TRUE / FALSE. On MSSQL that is a syntax error, and in: [] is not an exotic input: it is what an empty .map() produces, so any "filter by the ids I selected" endpoint broke on zero selections. New Dialect.trueLiteral / falseLiteral, '1=1' and '1=0' on MSSQL.
New atomic operators: max, min, addToSet, pull
max and min clamp a number in place — write the value only if it exceeds, or falls below, what is stored. addToSet appends to a list column only when the value is not already there; pull removes every occurrence.
All four are in the typed update input now, along with push and unset: true. That matters more than it sounds: push existed in the IR since 2.2 but was never on FieldUpdateValue, so it was only reachable through the internal builder — a documented operator that would not typecheck. The typed shape is now derived from the column: numeric columns offer the numeric ops, list columns offer the array ops, and everything offers set and unset.
addToSet and pull need a value-based contains or remove, and a JSON-backed array column cannot do that portably. Rather than emit something that quietly works for text arrays and not numeric ones, the dialects that cannot express an operation return nothing and the compiler raises an error naming the dialect and the column:
| Dialect | push | addToSet | pull |
|---|---|---|---|
| Postgres | array_append | yes | yes |
| DuckDB | list_append | yes | yes |
| Mongo | $push | $addToSet | $pull |
| IndexedDB | in JS | yes | yes |
| MySQL | JSON_ARRAY_APPEND | yes | no |
| SQLite | json_insert '$[#]' | no | no |
| MSSQL | JSON_MODIFY 'append $' | no | no |
The error tells you to read the array, change it, and write the whole list back — or to move that collection to Postgres or DuckDB, whose array columns support it natively. MySQL's pull is refused specifically because removing by value needs the element's index first, and JSON_SEARCH returns a path only for strings.
orderBy on an aggregate bucket was silently dropped
orderBy: { _sum: { total: 'desc' } } on a groupBy never reached the SQL or the Mongo pipeline. buildOrderBy had no branch for an aggregate bucket: the value is an object, but it has no nearTo and no string sort, so the entry fell through every case and was discarded. The compiler then saw an empty orderBy and emitted no ORDER BY at all.
A mixed sort is what made this hard to catch. The plain-column entry survived and the aggregate entry did not, so
orderBy: [{ _sum: { total: 'desc' } }, { customer_id: 'asc' }]came back ordered by customer_id — a result that looks sorted. "Top ten customers by lifetime value" therefore returned an arbitrary ten customers, and a dashboard renders that indistinguishably from the real answer.
OrderByEntry gained an agg?: { bucket, field } marker and buildOrderBy recognises all five buckets. On SQL the groupBy compiler emits the aggregate expression rather than the SELECT-list alias:
ORDER BY SUM("orders"."total") DESC, "orders"."customer_id" ASCThat is deliberate. An alias reference would only work for a bucket that was also selected; the expression means you can order by an average you did not ask to return. _count: { _all: 'desc' } becomes ORDER BY COUNT(*) DESC. Mongo sorts on the $group output alias, { $sort: { __agg_sum_total: -1 } }, which exists by that stage.
A plain findMany drops an aggregate entry instead of emitting "orders"."_sum", which would be a SQL error — an aggregate order only means something after a GROUP BY. Tests: src/__tests__/aggregate-order.spec.ts.
A bucket that is not a map of fields is refused
_count: true reads naturally and was accepted, and it produced nothing: a bare bucket contributes zero SELECT columns. Alongside another bucket the key was simply absent from the result; on its own the statement came out as SELECT FROM "orders" … and the driver reported a syntax error near FROM, pointing at the one part of the query that was fine.
All five buckets now validate when the query is built:
[forge] groupBy on 'orders': _count takes a map of fields, not true. Use
`_count: { _all: true }` for the row count, or `_count: { <field>: true }`.The check is at IR-build time rather than in the type system, so it catches an untyped caller — a handler that forwards a parsed request body — as well as a mistyped one.
aggregate({ _sum, _avg, _min, _max, _count }) now exists
The docs had described a whole-table aggregate for a long time, on the strength of Prisma having one. The wrapper had only the Mongo pipeline escape hatch, so every one of those examples threw [forge] aggregate() needs a pipeline.
It is now a real overload that delegates to groupBy({ by: [] }) and unwraps the single-element array, so it works on every dialect and returns the payload directly:
const totals = await db.order.aggregate({
where: { status: 'PAID' },
_sum: { total: true },
_count: { _all: true },
});
// → { _sum: { total: 19872 }, _count: { _all: 187 } }Zero matching rows returns the shape that was asked for rather than undefined — counts at 0, every sum / avg / min / max null — so destructuring the result cannot throw on an empty filter. Note that the aggregates come back null and not 0, which is SQL's answer and the one worth guarding with ?? 0 before formatting. aggregate({ pipeline }) and aggregate([...]) still route to the Mongo escape hatch. _count takes an object ({ _all: true }, or a column name for a non-NULL count) — see the bucket-shape guard above. Tests: src/__tests__/aggregate-whole-table.spec.ts.
Behaviour change: a Mongo relation filter throws
A relation filter compiled to {} — match-all. Mongo has no join in a plain find, so there was nothing to compile, and match-all is the most dangerous answer available: the same compiler builds the filter for reads and for writes.
await db.post.deleteMany({ where: { author: { is: { email: 'x' } } } });
// compiled to deleteMany({}) — the whole collectionA read just returned every document instead of the matching ones, with no error. It now throws, naming the relation and its mode, and showing the two-step rewrite (select the ids, then where: { fk: { in: ids } }) plus the aggregate([{ $lookup: … }]) option for a single round trip.
Breaking only in that code relying on the match-all now errors — which is the point.
Behaviour change: divide plus upsert throws on Mongo
An aggregation-pipeline update has no $setOnInsert, so "divide the column if the row exists, seed it if it does not" cannot be one statement on Mongo. Rather than silently dropping one half, forge says so and tells you to split it: upsert the row first, then update with the divide.
The one case that still works is when create seeds the same field the divide touches. That overlap already took the 2.7.0 update-then-create fallback on Mongo, so it never reaches the pipeline compiler and never throws — it is two statements either way. SQL dialects are unaffected throughout: INSERT … ON CONFLICT DO UPDATE SET col = col / $n is one statement there.
New: f.bytes() — a real binary field kind
This shipped in the tree without a changelog entry, so here it is properly. f.bytes() / f.bytes({ maxBytes }) stores raw binary, typed as Uint8Array.
| Dialect | Column |
|---|---|
| Postgres | bytea |
| SQLite | BLOB |
| DuckDB | BLOB |
| MySQL | smallest blob class that fits maxBytes, LONGBLOB undeclared |
| MSSQL | VARBINARY(n) when maxBytes <= 8000, else VARBINARY(MAX) |
| Mongo | BSON BinData |
| IndexedDB | the typed array itself |
Writes take a Uint8Array, a Node Buffer, an ArrayBuffer or any ArrayBufferView; a typed-array view keeps its own window, so storing new Uint8Array(buf, 100, 4) stores four bytes and not the kilobyte behind it. A base64 or hex string is refused, with the decode call in the message — base64 in a text column costs 33% extra storage, bandwidth and cache forever, and is the mistake this field kind exists to stop.
maxBytes is enforced at runtime on every dialect, not only on the ones whose type carries a length, so the same write is accepted or rejected identically on Postgres and MySQL. It counts bytes, not elements.
Reads hand back something that is a Uint8Array, so the row a server reads has the same usable type as the row a browser reads. On Node that is usually the driver's Buffer, a Uint8Array subclass, passed through rather than copied. toBytes, isBytesInput, fromDriverBytes and the type BytesInput are exported for code at the edges.
Drift detection improved with it: bytes is now its own category and the binary type names (bytea, blob, tinyblob, mediumblob, longblob, varbinary, binary, image) are recognised on the database side. Before, a bytes column was uncategorised, and an uncategorised column is skipped — so a bytea that had silently become text was not reported as drift at all.
Two bugs on the update path were found while documenting this and fixed before release. First, the update builder decides between "a value" and "an atomic operator" by asking whether the object has a key named set — and Uint8Array.prototype.set exists, so every bytes value took the operator branch and the method became the column value. It now checks isBytesInput first, and bytes joined the scalar kinds so a stray operator object on a bytes column is refused by name instead of written through. Second, per-dialect inbound coercion ran for create, createMany and upsert's create block but never for update, so maxBytes went unenforced there and a base64 string was accepted and stored as text. A new _coerceUpdateSet runs it over an update's set at all four buildUpdate call sites.
Mongo is deliberately left out of that step: its coerceInbound is the create-payload builder, and running it on an update would stamp create defaults onto the row. Mongo's update coercion comes from remapAndCoerce instead, as above.
Full reference: docs/BINARY.md.
A bundled app could not upgrade past 2.13.0
pglite-driver.ts imported its optional peer with a literal specifier:
mod = await import(/* @vite-ignore */ '@electric-sql/pglite');A bundler resolves a literal specifier at build time even inside a try/catch, and even behind @vite-ignore — that comment steers Vite's dynamic-import analysis, not esbuild's resolver. So any consumer that had never heard of PGlite failed to build from 2.14.0 onwards:
node_modules/forge-orm/dist/adapters/postgres/pglite-driver.js:112:89:
ERROR: Could not resolve "@electric-sql/pglite"There was no way to opt out short of installing a package the app does not use. The specifier now lives in a const, which a bundler cannot resolve statically, so the import stays a runtime one — which is the whole point of an optional peer. @electric-sql/pglite is also declared in peerDependenciesMeta as optional now, where it should have been all along; it was reachable in code while being absent from the manifest.
Found by upgrading a real consumer rather than by reading: the library's own test suite never bundles itself, so nothing here could have caught it.
forge diff --apply and forge rollback no longer lie to the ledger
Two mirror-image bugs in the migration ledger, both of which left forge migrate status disagreeing with the database.
forge diff --apply recorded a migration as applied after running zero statements. That happens for real: a diff whose only entries are the SQLite "cannot ALTER ADD FOREIGN KEY" comment notes splits to no executable SQL. The migration file was written, the row was inserted, and the schema was unchanged — so the drift stayed, invisibly, and the next diff proposed it again. It now refuses, tells you the file contains comments only, and exits non-zero.
forge rollback did the same thing in reverse: a down block with no statements ran nothing and then deleted the ledger row, leaving the database carrying the change while status reported the migration as pending — so the next forge migrate re-ran the up. planRollback now refuses, and says either to write the reverse SQL or to delete the row by hand if the migration genuinely has nothing to reverse.
Separately, forge generate projected foreign keys into its snapshot that no database would ever have — the inverse side of a one-to-many holds no FK, and an on of kind id is the inverse side of a one-to-one, where the constraint lives on the other table. Every snapshot therefore disagreed with every introspection, and generate produced a spurious FK change on a schema that was already in sync. projectForeignKeys now applies the same three skips the expected side applies. Tests: snapshot-foreign-keys.spec.ts, rollback-down-block.spec.ts.
Docs
The correctness fixes above turned up documentation describing behaviour that was never implemented. Retracted or corrected:
update'swherewas documented as type-narrowed to the model's unique fields, with a non-unique field described as a TypeScript error. It never was — every verb takes the fullWhereInput, andInferWhereUniqueis onlyPartial<WhereInput>. The advice that followed from it ("useupdateManyinstead") sent readers down a doubled round trip, becauseupdatealready returns the row.- Per-parent
includewindowing was documented as compiling toROW_NUMBER() OVER (PARTITION BY …)on SQL and$sliceon Mongo. Neither exists anywhere in the tree; the real mechanism is the per-parent query described above. - Soft-deleted rows were documented as hidden inside an
include. They are not — the filter is applied at the top level offindMany,findFirst,countandfindManyStreamonly, and relation subqueries have no soft-delete awareness. Flagged as a known gap rather than quietly rewritten. take: -Nwas documented as a backwards page, with a described implementation (flip the predicate, flip theORDER BY, re-reverse the array). None of it is implemented; the value is passed straight through as the limit. Marked unsupported.- Several cursor examples paired
cursorwithskip: 1, which drops a row against an exclusive cursor. aggregate({ _sum, _avg, … })was documented across eight pages and did not exist. So wasorderByon an aggregate bucket, in eight more places. Both went the other way from the retractions above: the capability was implemented rather than the examples removed, and they are now true as written. The_count: trueform in them was corrected to_count: { _all: true }, which is the shape the compiler reads — and is now the only one it accepts.createDb()returns a Promise and has to be awaited. Unawaited,db.$modelsisundefinedand'User' in dbisfalse, which looks exactly like a schema that never registered.
New page: docs/BINARY.md.
Tests
895 passing across 61 suites. New suites: bytes.spec.ts, cursor-direction.spec.ts, nested-paging.spec.ts, vanished-where.spec.ts, aggregate-whole-table.spec.ts, aggregate-order.spec.ts, snapshot-foreign-keys.spec.ts, rollback-down-block.spec.ts.
2.17.0 — per-dialect entry points
Minor. A third way to connect, for the case the other two handle badly.
import { createDb, f, model } from 'forge-orm/postgres';
const db = await createDb({ url: process.env.DATABASE_URL!, schema });Also forge-orm/mysql, /sqlite, /pglite, /mongo, /duckdb and /mssql — one per package, not per dialect.
Why
createDb({ url }) resolves the driver by calling require(pkg) with a package name computed at runtime. That reads well and lets one env var swap the database — and a bundler cannot see through it. webpack, rollup, esbuild and Vite all lose the dependency, so on a bundled target (Cloudflare Workers, Vercel Edge, a bundled Lambda) the driver is dropped or fails at runtime, a long way from the cause. Nothing can be tree-shaken either, because nothing proves which of the six adapters you use.
The driver option already solved this — you import the client yourself, so the import is static — but it means constructing the pool by hand at every call site that only wanted a URL.
These entries import one driver statically and build it for you. The bundler sees it, the other five adapters fall away, and the call site stays as short as the URL form. What you give up is the env-var swap: that call site is a Postgres call site now, which is the right way round for a deploy target whose bundle is fixed anyway.
Notes
- Each entry re-exports everything the main one does —
f,model,rel, the types, the driver factories — so one import line is enough. - They take a
url. If the client itself needs configuring (pool size, SSL, PGlite extensions, Neon or PlanetScale over HTTP), that is still thedriveroption, and still the only correct answer. - One entry per package, because a static import pins a package. PGlite is the clearest case: it is Postgres, and forge runs it on the postgres adapter — same compiler, same executors — but
pgand@electric-sql/pgliteare different packages, and a singleforge-orm/postgresimporting both would put a WASM Postgres in every bundle that wanted onlypg. SQLite says it louder still: seven packages behind one dialect. - No entry for the alternative drivers (postgres.js, MariaDB, PlanetScale, libSQL, Expo, OP-SQLite, Tauri, sqlite-wasm). Choosing one of those means importing it yourself, so the import is already static and the bundler — Metro included — already sees it. An entry point would save a line and add a module to keep in step with someone else's releases.
forge-orm/sqlitepulls in a native Node addon, so it belongs on a server. React Native and the browser have their own paths, and the README now carries a table mapping every target to its import and driver.
Also
The library finally has CI of its own. Until now only the examples and the docs site were gated, so 677 tests and the typecheck ran on a laptop and nowhere else — and the README carried a build badge pointing at a workflow that lives in a different repository. ci.yml runs the typecheck, the suite, the build, and a check that every path in the exports map resolves to a file that exists.
2.16.0 — three bugs the examples found
Minor. Every one of these came from running an example rather than reading it, and every one has the same shape: a path that handled geoPoint and vector in one place and forgot them in another, so the feature worked right up until you used it the second way.
orderBy: { loc: { nearTo } } ignored fallback: true
f.geoPoint({ fallback: true }) stores JSON and post-filters in the app — that is the documented point of it, and where.near honoured it, emitting a bounding-box prefilter. orderBy did not: it asked Postgres for ST_GeogFromText against a jsonb column and got
function st_geogfromtext(unknown) does not existThe executor was already computing _distanceMeters by haversine and sorting on it, so the correct SQL for a fallback column is none at all. It now emits neither the distance column nor an ORDER BY over an alias that was never selected — which was the same bug's other half.
Fixed in the shared SQL compiler, so sqlite, mysql and duckdb fallback columns are fixed with it.
upsert sent raw values for vector and geoPoint
create() wrapped them through the dialect's value emitter. So did the SET clause. Upsert's VALUES list used a bare placeholder, so an upsert carrying a vector sent the JS array and Postgres answered Vector contents must start with "[". A native geoPoint had it too — it reached the driver as an object instead of ST_GeogFromText(…).
pglite: could not register extensions
The URL form builds the PGlite instance internally, so there was no way to pass one — and a schema with f.vector() failed at CREATE TABLE with type "vector" does not exist. forge now loads pgvector from the PGlite package when that build ships it (@electric-sql/pglite/vector on 0.2.x), registers it, and issues CREATE EXTENSION IF NOT EXISTS vector. Absent from the build, nothing is registered and the failure surfaces where it means something.
PGlite is embedded and single-user, so none of the reasons --enable-extensions is opt-in on a real server — superuser rights, a shared database — apply here.
677 tests passing.
2.15.0 — $migrate() works on postgres
Minor. The other half of making pglite: actually usable.
2.14.0 gave PGlite a URL, and the examples got one step further before stopping at:
[forge] $migrate() is only supported on sqlite + indexeddb adapters
today. For postgres use the CLI: 'npx forge push'.Which is no help when the database is PGlite. There is no server to point forge push at — it is a WASM module in the same process — and the places PGlite is used (StackBlitz, a browser tab, a serverless function) have no shell to run a CLI from either. sqlite-wasm has had a runtime path since 2.4 for exactly this reason; postgres needed the same one.
const db = await createDb({ url: 'pglite:./data', schema })
await db.$migrate() // creates tables, constraints and indexesNo new migration logic: this is the same planMigration / applyMigration the CLI already uses, driven in-process instead of from a pool. So it keeps the properties that matter — it asks the database what exists before deciding what to run, which is what makes it safe to call at every boot (ADD CONSTRAINT has no IF NOT EXISTS), and it reports applied / skipped / failures in the shape the sqlite path already returned.
A driver with no connect() — PGlite, Neon over HTTP, anything single-session — is adapted to the pool interface the migrator expects. It is the session, so the client is itself.
$migrate() still refuses on mysql, mssql, duckdb and mongo, and now names the three adapters that do work.
667 tests passing.
2.14.0 — an empty upsert, and a pglite: URL
Minor. Two bugs, both found by running the examples repo rather than by reading it.
update: {} emitted invalid SQL
db.user.upsert({ where, create, update: {} })is the ordinary "insert if it isn't there, otherwise leave it alone" idiom — the first thing anyone writes for an idempotent seed, and the entire body of examples/04-node-cli. It compiled to:
INSERT INTO "users" (…) VALUES (…) ON CONFLICT ("email") DO UPDATE SET RETURNING *SET with nothing after it. Because a parser blames the token after an empty clause, every dialect reported near "RETURNING": syntax error — pointing at the one part of the statement that was fine.
update({ data: {} }) and updateMany({ data: {} }) had it too.
An empty set now emits a column assigned to its own stored value: valid SQL, provably no change, and RETURNING still yields the row — which upsert's contract requires, since it must hand back the record whether it inserted or not. DO NOTHING would have parsed and is the shorter fix, but it returns no row on conflict, so upsert would have resolved to undefined exactly when the record already existed: a silent wrong answer in place of a loud syntax error.
MySQL needed its own form, and this is the part that mattered. MySQL rewrites each upsert assignment to col = VALUES(col) so the update reuses the INSERT's values — right for a real update, catastrophic for the no-op, because VALUES(`id`) is the id the INSERT proposed. On conflict that would have silently replaced the existing row's primary key with a freshly generated uuid, taking every foreign key that referenced it along with it. MySQL now emits its own self-assignment no-op instead, and real assignments still rewrite to VALUES(col).
Fixed on postgres, sqlite, mysql, duckdb and mssql.
pglite: is a real URL now
const db = await createDb({ url: 'pglite:./data', schema }) // on disk
const db = await createDb({ url: 'pglite:', schema }) // ephemeralPGlite is Postgres compiled to WASM. DRIVERS.md had always documented how to hand-wrap it and pass createDb({ driver }), but there was no URL prefix — so the form above failed with "Could not infer adapter from URL". That included eight examples, two of which the README links as one-click StackBlitz demos.
It resolves to the postgres adapter (same compiler, executors and dialect — only the driver differs), and @electric-sql/pglite is imported lazily, so it is never a hard dependency. Missing package, and you get the install line rather than a module-resolution stack.
- New exports:
pgliteDriver(wrap an instance you built yourself, for extensions or a custom filesystem) andpgliteDataDir. pglite://./xandpglite:both work; a bare scheme means ephemeral.postgres://is untouched — the new prefix does not shadow it.
659 tests passing.
2.13.0 — see the query without running it
Minor. Stage 6, and the last item on the drizzle plan.
The standing complaint about a Prisma-shaped ORM is that you cannot see what it sends. .compile.<op>() answered that for one op, but you had to know the op's name and rebuild the call by hand — which is the moment most people stop bothering. $explain takes the call site instead:
const r = await db.$explain((q) => q.User.findMany({ where: { age: { gt: 40 } } }));
console.log(r.toString());User.findMany → users [sqlite]
SELECT "users"."id", "users"."name", "users"."age" FROM "users" WHERE "users"."age" > ?
params: 40
-- with values inlined (for reading, not for running):
SELECT "users"."id", "users"."name", "users"."age" FROM "users" WHERE "users"."age" > 40{ analyze: true } — the database's own plan
-- plan:
SCAN users ← every row read; that column has no indexforge doctor catches an index that was declared and never created. This catches the one that was never declared, which nothing else can see because it is a property of the query rather than of the schema.
forge never emits EXPLAIN ANALYZE
EXPLAIN plans a statement. EXPLAIN ANALYZE runs it — which on deleteMany deletes the rows. An API whose entire promise is "this does not run" must not delete data because you asked for more detail, so ANALYZE is not offered at all and the docs say why rather than leaving the distinction to be found in a postmortem. Explaining a deleteMany against 500 rows leaves 500 rows.
Two callback forms, and an honest failure
db.$explain((q) => q.User.findMany(…)) — q holds no session and reaches no driver, so nothing can execute and an async callback is safe. This is the one to reach for.
db.$explain(() => db.User.findMany(…)) also works, by intercepting for the duration of the synchronous call. That window shuts at the first await, so a query issued after one runs for real. forge detects the case — an empty capture from a callback that returned a promise — and throws saying the query ran, rather than handing back an empty report it cannot honour.
Notes
- Placeholder splitting is a tokenizer, not a regex.
?and$1both occur inside string literals, quoted identifiers and comments, and Postgres$tag$…$tag$dollar-quoting means$1can be either a parameter or text. A regex gets these wrong silently, producing SQL that still parses. readable(values inlined) is for reading and for handing to a DBA. Never execute it — that quoting serves legibility, not safety.- Refusals name the case:
groupBy/aggregate/findManyStreamhave no single compiled statement;analyzeon a Mongo write would ask the server to plan a change to your data;analyzeon SQL Server needs a connection-state change forge will not make behind your back on a pooled connection.findFirstOrThrowandfindUniqueOrThroware not refused — they differ only after the statement. - New exports:
formatExplain,inlineParams,splitSql,fragmentFromSql, and theExplainReport/ExplainedQuerytypes. - Docs: docs/EXPLAIN.md, a README chapter, and the contents entry.
621 tests passing.
2.12.0 — forge migrate status
Minor. Stage 5. Every other command compares intent — the schema against a snapshot, or against what a database reports. This one compares reality: the .sql files in migrations/ against the rows in _forge_migrations.
npx forge migrate status
npx forge migrate status --check # CI: exit 4 if anything needs attention ✓ 0002_add-org-slug.sql 2026-08-19T16:40:55Z
! 0003_alice-adds-note.sql OUT OF ORDER — sorts before
0004_bob-adds-tier.sql, which is already applied
· 0005_add-index.sql pending
? 0006_from-a-branch.sql NOT IN THIS CHECKOUT applied 2026-09-02The two states nobody reports
Applied and pending every migration tool shows. The other two are where production goes wrong, and no tool — drizzle-kit included — reports either.
NOT IN THIS CHECKOUT. The database has applied a migration that is not in your folder: somebody ran a branch against it. This matters more than it looks — the schema in front of you is not the schema that database has, so every migration you generate from here is built on a state you cannot see. The next forge generate produces a file that is correct against your snapshot and wrong against that database.
OUT OF ORDER. Alice generates 0007 on Monday, Bob generates 0008 on Tuesday, Bob's ships first. When Alice's merges, a migrator walking forward from the highest applied entry skips 0007 in silence — it is never applied at all, and nothing says so. drizzle-kit has this exact failure with journal timestamps. forge would too; the difference is that forge tells you, and says to regenerate on top of the current state rather than leaving you to find out in staging.
Exit codes
--check exits 4, distinct from generate --check's 2 and 3, so a pipeline can tell which gate refused it.
| exit | command | meaning |
|---|---|---|
| 2 | generate --check | the schema has unsafe changes |
| 3 | generate --check | a schema change has no migration |
| 4 | migrate status --check | the database and the folder disagree |
Notes
- This is the one command that genuinely needs
DATABASE_URL. A database is the only thing that knows what it has actually run. buildStatus()takes the ledger as an argument rather than reading it, so the state machine is tested without a database — the four states and both dangerous ones are covered by unit tests, not by an integration suite somebody skips locally.- Point the CI gate at staging. An empty per-PR database has nothing to disagree about, and the check only earns its place against one with history.
- New:
listAppliedWithDates()inmigrate-runtime.ts, so the report can show when each migration ran. - An applied migration missing from this checkout does not mark the others out of order. It would otherwise set the high-water mark and flag every ordinary pending file, under guidance that does not address the real cause.
OUT OF ORDERmeans behind a migration this checkout also has; the ordering risk from an unknown one is reported under the unknown one. Found by running the command against a real database, not by unit test — and now covered by both.
2.11.0 — a rename is not a drop and an add
Minor. Stage 3, and the last silent-data-loss case in the generator.
Comparing two schema states shows only that one column name is gone and another has appeared. A RENAME and a DROP-plus-ADD look identical from there, and they do opposite things to the data: one keeps every row, the other deletes a column's worth of it.
Guessing "drop and add" loses data on a column somebody meant to keep. Guessing "rename" is worse — it keeps a column somebody meant to delete and quietly moves its data under a new name. So forge takes the answer from the schema:
name: f.string().renamedFrom('full_name'),-- up
ALTER TABLE "orgs" RENAME COLUMN "full_name" TO "name";
-- down
ALTER TABLE "orgs" RENAME COLUMN "name" TO "full_name";Without it, forge asks
A same-typed drop and add on one table is refused, naming both columns and printing the line to add. --allow-drop confirms a column really is going.
Only SAME-TYPED pairs are suspected — a dropped text beside a new integer is not a candidate, because a check that fires on every genuine drop is one people learn to skip past, and then it protects nothing.
Why an annotation and not a prompt
drizzle-kit asks the same question interactively. Right question, wrong medium: a prompt answered once at 2am is recorded nowhere, cannot run in CI — exactly where a pipeline would apply the migration — is invisible in review, and has to be answered again by the next person who regenerates. An annotation is in the schema, the diff and the pull request.
Rename AND type change
Both statements are emitted, in order, with a correctly reversed down. That combination is a known drizzle-kit bug (#5499, #3826) where only the rename is emitted and the type change is silently lost.
Renames run before every other column pass, which is not cosmetic: a rename after the ADD finds the new column already there, and one after the DROP has nothing left to rename.
567 tests (was 553).
2.10.0 — a column change is emitted, or refused with the fix
Minor. Stage 2 of the migration plan. A column whose type or nullability changed used to be silently absent from a generated migration: the schema said varchar(255), the database kept varchar(64), the file applied cleanly, and nothing said a word.
Silently omitting a change is the worst of the three options. It is now one of the other two.
Widening is emitted
varchar(64) → varchar(255), varchar → text, int → bigint, numeric(10,2) → numeric(12,2), and dropping NOT NULL. Every existing row still fits, so the statement cannot fail on data.
The down carries a warning, because the reverse of a widening is a narrowing and it can fail on rows written since:
-- narrowing hits back to int can fail on rows added since;
-- review before rolling back.
ALTER TABLE `orgs` MODIFY COLUMN `hits` int;A file that says "rollback" without saying that is lying to whoever runs it at 3am.
Everything else is refused, with the migration to write instead
✖ orgs.name: text → int
orgs.name changes from text to int, which is not a widening — existing
rows may not fit, or may not convert at all.
→ forge will not guess at this. Write it with `forge generate
--custom`: add the new column, backfill it with whatever conversion
is correct for YOUR data, verify, then drop the old one and rename.Exit 2, and nothing is written — including the safe changes in the same diff. A migration that applies cleanly while leaving the schema and the database disagreeing is the failure this removes, not a smaller version of it.
The refusal is the feature. A tool that emits ALTER COLUMN … TYPE int against a column holding text is more dangerous than one that emits nothing, because the migration looks reviewed.
NULL → NOT NULL is always refused
…and the message says why the obvious fix does not work: a DEFAULT applies to new rows, not to the NULLs already there. The guidance is the two-step migration — backfill with --custom, confirm the count is zero, then make the column required and generate again, at which point the refusal turns into the ALTER.
SQLite
Refused for any type or nullability change. SQLite has no ALTER COLUMN; the answer is its documented twelve-step rebuild, and forge will not generate that blind. Copying the column is the easy part — the indexes, triggers and views pointing at the old table are what a generator cannot see, and getting that wrong drops them silently.
Not compared
A type forge cannot categorise (tsvector, a domain or extension type) is left alone rather than rewritten. Mongo is skipped entirely — introspection reports no column types, so there is nothing to compare.
fieldCategory and dbTypeCategory are shared with the drift report rather than copied, so the migration generator and forge diff can never disagree about what two types are.
553 tests (was 538).
2.9.0 — a migration you can generate without a database
Minor. forge generate writes a migration by diffing the schema against the last committed snapshot instead of against a live database.
npx forge generate --name add-org-slugmigrations/
meta/_journal.json ordering
meta/0002_snapshot.json the schema's shape after 0002
0002_add-org-slug.sqlWhy
forge diff apply generates by introspecting DATABASE_URL. That is right for adopting a database somebody else created and wrong for everything else:
- CI has no database, so nothing could verify that a schema change shipped with its migration
- two developers on two branches each generated against their own local state, so each file was correct only relative to a world that stops existing at the merge
- the same schema did not reliably produce the same SQL, so a reviewer could not regenerate a migration to check it against the schema change in the same pull request
A snapshot is simply what introspect() would return if this schema were applied. diffIntrospection already takes a DbIntrospection as its "actual" side and cannot tell where it came from — so the change is about supplying that from a committed file rather than a socket, and the comparison code did not move at all.
The CI gate this unlocks
npx forge generate --check # exit 3, and names what is missing[forge:generate] 1 change(s) are in the schema but not in any migration.
- add orgs.regionThat check is impossible for a database-backed generator, and it is the main reason to adopt snapshots.
Also
--custom writes an empty up/down that takes its place in the ordered history — for a backfill, or the first half of a two-step change (clean the NULLs, then add NOT NULL). Its snapshot is deliberately unchanged: forge cannot know what hand-written SQL does, and guessing would corrupt every later diff.
A create-table migration now contains the CREATE TABLE. It emitted -- create table 'x' via forge:push on the assumption push had just run. Survivable when generating against a live database; not when the file is the only record — a migration whose up is a comment applies cleanly and creates nothing.
Snapshots are sorted — tables, columns, indexes, keys — so reordering two models in the schema file produces no diff, and the diff you see in review is the change you made.
One dialect per folder, refused rather than diffed. Column types and index shapes differ, so diffing a postgres schema against a mysql snapshot would emit an ALTER for every column in the schema.
Mongo refuses, and should: there is no DDL to migrate, indexes are reconciled by forge push, which is idempotent and needs no history.
forge diff apply is unchanged and remains the tool for adopting an existing database. See MIGRATIONS.md and VS-DRIZZLE.md.
538 tests (was 524).
2.8.1 — the 2.8.0 changes, written down
Patch. No code change. 2.8.0 shipped six fixes and two additions and documented them only in this file, which is the wrong place to look them up from. They are now in the docs that cover each subject — see the Documentation note under 2.8.0 — along with a new VS-DRIZZLE.md.
2.8.0 — an index key called id was a real index on nothing
Minor. Six fixes and one addition, all found by indexing a multi-tenant Mongo schema and then asking why nothing got faster.
An index key written as id silently did nothing
The schema calls the primary key id; Mongo stores it as _id. Reads and writes have always translated between the two — coerce.ts maps id → _id on every query. Index keys did not. They were handed to createIndex verbatim, so this:
indexes: [{ keys: { threadId: 1, createdAt: -1, id: -1 } }]created a real Mongo index on a field literally called id, which no document has. The index was useless and nothing said so: push reported created, doctor reported nothing, and it appeared in getIndexes(). Only explain() gave it away — the sort the index existed for was still performed in memory (SORT rather than an index-served fetch). diff then reported permanent drift, comparing the declared id against the stored _id.
The rule was already half-applied: single-field uniques skip kind === 'id' because "_id is automatic". It just never reached compound keys.
Index keys are now translated on the Mongo adapter, in declared indexes, composite uniques and text indexes alike, and diff compares like for like. Existing schemas that wrote _id keep working unchanged; schemas that wrote id get the index they meant, and push rebuilds it once.
doctor called working indexes "ignored at push"
A portable index carries both dialects on purpose — a Mongo partialFilterExpression and a SQL where string. Doctor saw the SQL half and declared the whole index ignored, including UNIQUE ones. It was not true: dropping such an index and re-running push recreated it with unique: true and its filter intact. Acting on that warning meant deleting a working duplicate guard.
Doctor now warns only when Mongo genuinely gets nothing — a string where with no partialFilterExpression beside it, which really does produce an index without the filter.
forge push --help ran the push
Only argv[2] was checked for --help, so the flag fell through to the subcommand, which ignored it. Asking a schema tool what a command does should never be how you find out. --help is now honoured anywhere in the arguments, and each subcommand has its own usage — including an explicit note that push touches indexes only, never tables or rows.
An equivalent index under an older name warned forever
⚠ could not be created: Index already exists with a different name on every push, for an index whose keys and options already match. Nothing was wrong and nothing could be done about it short of dropping an index on live data. It is now a quiet one-line note, with FORGE_RENAME_INDEXES=1 to adopt the schema's name deliberately.
diff described unmanaged tables like a deletion plan
table 'x' in DB but not in schema reads as though push will drop it. Push only reconciles indexes and has never dropped a table — but at least one team wrote a "do not run forge push" warning into their own docs on the strength of that line. It now says what push will actually do: is not managed by forge — push will leave it alone.
f.id({ type: 'string' }) — an application-supplied key
New. auto, uuid and bigserial all generate the value; there was no way to declare a key the application supplies. That left natural keys undeclarable — a counter whose _id is "<orgId>:<series>", so that a single findOneAndUpdate with $inc and upsert is atomic on one document. Such a collection could not be described at all, so push, diff and doctor could not see it.
string generates nothing and stores the value exactly as given; on Mongo it is never coerced to an ObjectId, which would have rewritten a 24-hex natural key into something else and broken every lookup. MySQL widens to VARCHAR(255) for it, since a natural key runs past 64 easily.
scopeBy — and a lint for the class of bug that started this
New. Multi-tenant applications filter every read by a tenant key, usually by wrapping the client in a proxy that injects it. Forge cannot add that filter — it does not know where the value comes from — but it can check that something indexes it:
model('appointments', fields, {
scopeBy: 'orgId',
indexes: [{ keys: { orgId: 1, createdAt: -1 } }],
})doctor warns when nothing does. The check earns its place because the failure is invisible in development: a scoped table holds one row per tenant, so it looks tiny while the schema is young and grows with the customer list rather than with usage — the scan gets slower for everyone at once. In the schema that prompted this, 66 collections holding 27,367 rows had no index on their tenant key. After indexing, one hot query went from 6,486 documents examined to 40.
An index whose first key is a more selective foreign key satisfies the rule too: a thread id already implies its tenant, and indexing the tenant instead would be the worse index.
Documentation
Every change above is written up where it belongs, not only here: PRIMARY-KEYS.md (the new id type), MULTI-TENANT.md (scopeBy, with the measurements), INDEXES.md (id vs _id, and what to expect on the first push), CLI.md (per-subcommand help), PUSH.md (FORGE_RENAME_INDEXES), DOCTOR.md (both lint changes), and the README.
New: VS-DRIZZLE.md — where forge actually stands against drizzle-kit on migrations, what is genuinely missing, a six-stage plan, and the rules that keep closing those gaps from turning forge into a worse copy of something else. It starts by correcting a common claim: push reconciles indexes only, but diff apply already writes reviewable up/down SQL files with a _forge_migrations ledger and a rollback. The real gap is that forge generates by introspecting a live database where drizzle diffs the last snapshot — which is why forge cannot generate a migration in CI, on a plane, or deterministically.
Also
doctor no longer runs on import — it self-executes only when invoked directly, so its lint rules can be reused without launching a full environment check and a database connection.
2.7.1 — you can ask the db what it has
Patch. 2.7.0 made reading an unregistered model throw, which is right — a typo'd model name should be loud. But there was no way to ask first: 'User' in db returned false even for a model that was registered, because the proxy never defined a has trap and in fell through to its empty target. The one idiom that should have let you check before reading was quietly wrong, so the only pattern that worked was try/catch around a property access.
That combination bit a consumer at boot. A module wrote:
const db = getDb() as unknown as { orgIndustryMixView?: ViewLike };
return db.orgIndustryMixView ?? null; // "use the view if it's registered"The key was mis-cased (OrgIndustryMixView), so the read threw, the ?? never ran, and an unhandled throw took down the process at startup. The intent — degrade when the model isn't there — was reasonable. Forge just gave it no way to express that.
in now answers honestly, and never throws:
'User' in db // true — registered
'user' in db // false — case matters
'Typo' in db // false — no throw
'$transaction' in db // true — the $ helpers are reported too
const view = 'OrgIndustryMixView' in db ? db.OrgIndustryMixView : null;New db.$models — the registered model names, sorted. Handy for tooling, diagnostics, and asserting a schema reached createDb:
db.$models // ['Gadget', 'Widget']Both work inside $transaction too; the tx handle reports only the helpers it actually serves, so '$migrate' in tx is false rather than claiming a key whose access would throw.
Unchanged on purpose: reading an unknown model still throws, and Object.keys(db) still returns []. Making model keys enumerable would have made JSON.stringify(db) walk every collection wrapper — a worse regression than the gap it closed. $models covers that need without it.
2.7.0 — a filter that doesn't filter is worse than an error
Minor, deliberately stricter. Five places used to swallow a malformed query and run something else instead. All five now throw with the correction in the message. Found in production: a filter written Mongo-style matched every row in the collection and nothing flagged it — twice.
Unknown where operators throw. Strict mode always rejected an unknown field, but an unknown operator on a real field was dropped from the tree — the condition vanished and the query matched everything:
await db.bill.findMany({ where: { postedAt: { lte: cutoff } } }); // 198 rows
await db.bill.findMany({ where: { postedAt: { $lte: cutoff } } }); // ALL 240 rows, silentlyNow, in every mode (this is IR-level, so it covers all six dialects):
[forge] unknown operator '$lte' on 'bills.postedAt'. Valid: equals, not, in, …
Did you mean 'lte'? forge uses bare operator names, not Mongo's $-prefixed ones.Typos get a closest-match suggestion (contians → "Did you mean 'contains'?"), and container columns are pointed at path filters.
Update operators are validated against the column. A typo in an atomic-op object used to be written through $set, replacing the number with the object — silent data corruption on a schemaless store:
await db.post.update({ data: { view_count: { incrment: 5 } } });
// before: view_count is now the OBJECT { incrment: 5 }
// after: [forge] invalid update for 'posts.view_count': object { incrment }
// is not a valid operator form for a int column …increment on a string column and ambiguous multi-op objects ({ set: 1, increment: 2 }) throw too. json / embed / geo columns are exempt — objects are their values.
not: { filter } actually negates. The typed surface always advertised not?: value | Filter, and the README documents it, but the object form compiled to a literal $ne: { contains: 'x' } — which matches every row. It now builds the inner filter and wraps it in a real NOT, on every dialect. not: null and not: <value> are untouched.
Strict mode recurses. { AND: [{ bogusField: 1 }] } used to pass the strict check (top-level keys only) and then silently match nothing. Strict now walks AND/OR/NOT and relation filters (author: { is: … } is validated against the target model's fields).
upsert no longer loses the create value under an atomic op. On Mongo, a field seeded by create and incremented by update conflicts with $setOnInsert, and the old resolution dropped the seed:
await db.counter.upsert({
where: { key }, create: { key, seq: 100 }, update: { seq: { increment: 1 } },
});
// before: first call returns seq 1 (create's 100 vanished)
// after: first call returns 100; second returns 101That case now runs update-then-create with a duplicate-key retry, which is exactly Prisma's semantics: insert applies create only, update applies update only. Plain $set overlap keeps the single atomic op. SQL dialects (INSERT … ON CONFLICT) and IndexedDB were already correct and are unchanged.
aggregate accepts both call shapes. aggregate([...]) (positional array, the natural way to write it) used to read as an empty pipeline — a silent full-collection scan. Both aggregate({ pipeline }) and aggregate([...]) now work; anything else throws.
Dotted container paths, typed and portable.{ 'address.city': 'sf' } and { 'meta.stats.views': { gte: 10 } } compile through the same IR leaf as path filters — Mongo emits dot notation, SQL dialects emit native JSON paths — instead of only working by accident on Mongo. Strict mode accepts them and validates embed sub-fields against the embed's declared fields:
[forge:strict] unknown embed field 'bogus' in where key 'address.bogus'
on 'users'. Fields of 'address': street, city, zip, country.Optional columns get their operators back (types). An .optional() column's type is T | null, which failed every [T] extends [string] branch in the filter types, so nullable columns silently fell back to equals/not/in/notIn only — no lte, no contains, on exactly the columns that pushed people to as never (and from there into the silent drops above). The branch is on NonNullable<T> now; equals: null / not: null stay typed.
Verified by the new regression-where-op-safety.ts live suite (19 scenarios, wired into forge:regression:mongo), src/__tests__/where-op-validation.spec.ts (15 unit tests), and type-probe-nullable-filters.ts (compile-time probe with negative assertions). All 482 pre-existing jest tests and the Mongo + SQLite integration suites pass unchanged.
Upgrade note: code that was accidentally shipping $gte-style operators, typoed update ops, or object-form not was already broken — it just failed silently. After this release those queries throw at the call site instead. That is the point.
2.6.5 — a schema belongs to its db, not to the process
Patch. Two createDb({ schema }) calls in one process fought over a single global registry. The schema was stored in a module-level slot on a last-write-wins basis, so opening a second connection moved the pointer and every model on the first handle resolved to undefined:
const app = await createDb({ schema: appSchema, url: 'idb:app' });
const sync = await createDb({ schema: syncSchema, url: 'idb:sync' });
app.invoice.findMany(); // TypeError: Cannot read properties of
// undefined (reading 'findMany')A db now binds the map its own createDb received, so several connections with different schemas coexist. $migrate and $diff read that bound map too, instead of whichever schema happened to be active. Handles created without a schema keep reading the global registry, so single-schema code behaves exactly as before.
The bundled sample also stopped overwriting consumer schemas. schema/index.ts installs it at module load; under Node's CJS that always evaluates before createDb runs, but bundlers that defer CJS initialisation give no such guarantee — esbuild wraps a CJS module in a lazy __commonJS whose body runs on first property access, which in a Vite dev graph can land after createDb. It now installs through an internal setDefaultSchema that declines to replace a schema a consumer already set. setActiveSchema keeps last-write-wins for explicit calls.
A missing model no longer resolves to undefined. db.<name> for a key the bound schema does not have throws, naming the key and listing what IS available — the old failure surfaced as Cannot read properties of undefined (reading 'findMany') several frames from the cause:
[forge] unknown model "outbox". Active schema exposes: syncEvent.
Pass your model map as createDb({ schema }) and check the key spelling.2.6.4 — typed JSON columns
Patch. f.json() now takes an optional type parameter that flows through to the row read type and the create/update input:
type Prefs = { theme: 'light' | 'dark'; density: number };
const Account = model('accounts', {
id: f.id(),
prefs: f.json<Prefs>(), // row.prefs is Prefs; writes are checked
meta: f.json(), // no parameter -> unknown (was any)
});The bare f.json() default changed from any to unknown, so a column you haven't typed forces a narrow at the read site instead of silently handing back any. This is the only behavioural change and it is read-only — writes still accept any JSON value, and every existing schema keeps compiling. Pass f.json<any>() to restore the old per-column behaviour. InferRow / InferCreate / InferUpdate carry the parameter through; WhereInput is unchanged.
2.6.3 — @tauri-apps/plugin-sql driver
Patch. New tauriSqlDriver wraps a Database opened via @tauri-apps/plugin-sql and exposes the standard SqliteDriver port. Tauri 2 apps now get a real SQLite backend via sqlx on the Rust side without leaving the forge query API — one schema runs the same on Node (better-sqlite3), browser (sqlite-wasm+OPFS), and Tauri (native sqlx).
import Database from '@tauri-apps/plugin-sql';
import { createDb, tauriSqlDriver } from 'forge-orm';
const sqlite = await Database.load('sqlite:app.db');
export const db = await createDb({ schema, driver: tauriSqlDriver(sqlite) });
await db.$migrate(); // runtime DDL — same seam as the wasm/expo driversThe port handles the plugin's one-statement-per-execute constraint by splitting DDL batches on ;, so the migrator's multi-statement output runs unchanged. Prior versions treated Tauri as an unshipped driver in the docs' walk-through — it's now import { tauriSqlDriver } from 'forge-orm' with a passing regression covering DDL, CRUD, atomic guards, groupBy, transactions, and multi-statement exec.
No other changes. Drop-in for 2.6.2.
2.6.2 — IDB pagination + orderBy double-slice fix
Patch. findMany({ take, skip, orderBy }) on the IndexedDB adapter returned wrong pages when the orderBy field wasn't natively covered by an IDB index. cursorScan sliced the stream in index order first, the executor sorted the survivors in JS, then applied take/skip a second time on the already-sliced result. On a 4-row store, page 2 ({ take: 2, skip: 2 }) came back empty.
Fix: defer cursorScan's slice whenever orderBy needs a JS post-sort (same condition already used for nearTo orderings and cursor pagination). One slice, applied after the sort.
No other changes. Drop-in for 2.6.1.
2.6.1 — $migrate() on indexeddb
Patch. db.$migrate() now dispatches to the IndexedDB adapter's runMigrate alongside the existing sqlite path. Under 2.6.0 the call threw $migrate() is only supported on sqlite adapters today, which contradicted docs/INDEXEDDB.md — the adapter shipped the machinery but the factory didn't wire it up. Fixed.
Behaviour: on IDB, createDb() already runs the schema upgrade inside indexedDB.open()'s native onupgradeneeded, so $migrate() is a second, idempotent open — a metadata check that returns the standard RuntimeApplyReport { applied, skipped, failures, alteredColumns: [], pending, version }. Same signature as the sqlite path.
const db = await createDb({ url: 'idb:app', schema });
const r = await db.$migrate();
// { applied: [...], pending: [...], version: 2, ... }No other changes. Drop-in for 2.6.0.
2.6.0 — IndexedDB adapter: zero-install browser tier
Feature release. Adds a native IndexedDB adapter alongside the sqlite-wasm adapter shipped in 2.4. Every modern browser has IndexedDB natively, so the new tier has no wasm blob to download, no worker file to bundle, no COOP/COEP headers to set, no bundler plugin. Drop-in upgrade — the URL prefix idb: / indexeddb: is new; no other prefix behaviour shifted.
New URL schemes
detectAdapterKind() now recognises two browser-side prefixes that resolve to the new indexeddb kind:
| URL | Backend | Persistence | Multi-tab |
|---|---|---|---|
idb:<name> | IndexedDB | Full | Native — IDB coordinates writers |
indexeddb:<name> | IndexedDB (alias of idb:) | Full | Native |
import { createDb } from 'forge-orm';
const db = await createDb({ url: 'idb:appname', schema });
await db.user.create({ data: { email: 'a@x.co', name: 'Alice' } });The string after the colon is the IndexedDB database name — the same name you would pass to indexedDB.open(name) by hand.
New adapter — forge-orm/indexeddb
The Prisma-shape surface is identical to every other adapter — reads, writes, relations, sorts, paging, aggregations, JSON path queries, geo near / withinPolygon, vector near / nearTo, full-text search, atomic update ops, upsert, soft-delete + restore, .compile escape hatch, $transaction, $migrate, $doctor, $diff.
The adapter is exported at the forge-orm/indexeddb subpath so bundlers tree-shake it out of any server bundle that doesn't import it. A driver factory is exposed alongside the URL form for consumers that want to hand in a pre-opened IDBDatabase:
import { indexedDbDriver } from 'forge-orm/indexeddb';
const db = await createDb({
driver: indexedDbDriver({ name: 'appname' }),
schema,
});Query planner
Every read runs through the same pipeline: Args → IR → planner picks ONE index → cursor scan → JS residual filter → JS sort → limit/offset. The planner scores candidate scan strategies 0–100 and picks the highest:
| Score | Strategy |
|---|---|
| 100 | primary-key eq |
| 95 | full compound-index equality (all keys) |
| 90 | unique-index single-column eq |
| 85 | non-unique compound equality on all keys |
| 70 | non-unique single-column eq |
| 60 | in on indexed column |
| 50 | range op (lt / lte / gt / gte / startsWith) on indexed column |
| 20 | orderBy on indexed column with no where match (free sort) |
| 0 | full-table scan (no index applicable) |
Whichever leaves the range didn't absorb become a (row) => boolean residual predicate applied per cursor result. AND at the root unlocks index optimisation; OR and NOT at the root fall back to a full-table scan + residual. Every plan carries an explain string — feed it through $on('query') to watch which index each request lands on.
Migrations via native IDB versioning
IndexedDB's onupgradeneeded maps cleanly to forge's non-destructive migration model. $migrate() fingerprints the DDL plan (fnv-1a over sorted store + index metadata) and only bumps the IDB version when the fingerprint changes:
| Change | Behaviour on IDB |
|---|---|
| Add a field | No-op — IDB is schemaless |
| Add an index | store.createIndex() runs inside onupgradeneeded; IDB re-scans existing rows and back-populates the index automatically |
| Rename / drop an index | store.deleteIndex() (+ create for renames) |
| Add a store | createObjectStore() |
| Drop a store | Destructive — surfaces in report.pending, opt-in only |
| Change a field's type | No-op at DDL level — coerce at write side |
Change a keyPath | Destructive — not supported natively; surfaced under pending |
Same fingerprint → no version bump → no upgrade cycle at all. $migrate() on an unchanged schema is effectively a boot-time metadata check.
Full-text search via multiEntry index
Every .searchable() field gets a shadow _tokens_<field>: string[] column maintained by the executor at write time, indexed with multiEntry. Search compiles bio: { search: 'baker cyclist' } into one getAll per token, then intersects the ID sets — index-backed AND-of-tokens with real per-token complexity, not a full-table cursor scan. Tokeniser rules: lowercase, split on non-\p{L}\p{N}\s, dedupe, drop tokens longer than 40 chars, single-char tokens kept (matters for CJK and Greek).
Geo — Haversine fallback with bbox prefilter
Points are stored as { lng, lat } (or { lng, lat, alt } for 3D) inside the row — same wire shape the sqlite adapter uses in fallback mode. A [lng, lat] compound index (when declared) cursors just the bounding box; Haversine JS then post-filters for exact withinMeters / withinPolygon / orderBy nearTo. _distanceMeters annotations match every other dialect. MultiPolygon with holes uses the same ray-cast + even-odd rule as the 2.5 fallback path.
Vector — JS brute force
Vectors are plain number[] on the row. Filter and sort route through the same executor pipeline as geo. Metrics: cosine, l2, dot. Dimension mismatch → Infinity distance (silent — the row falls to the back). Brute force is O(N × dims) per query — fine for lists of ≤ ~1 k vectors × 100–1500 dims. Route heavier workloads to the sqlite-wasm-pro adapter (sqlite-vec HNSW compiled in) or to a server tier.
Transactions
IDB transactions auto-commit as soon as the microtask queue idles — awaiting a non-IDB promise inside the callback silently commits the txn early. The adapter's $transaction(fn) opens per-op txns and reuses them within the fn body: best-effort atomicity across the batch, rollback on throw, but not strict serialisability across arbitrary awaits. For strict atomicity of interleaved reads + writes on the same store, use the batch form — $transaction([...ops]) maps to one IDB readwrite txn spanning every store, committed atomically.
Cascade walker
IDB has no foreign-key enforcement, so the adapter runs a JS cascade walker before every delete — same pattern the Mongo adapter uses:
onDelete | Behaviour |
|---|---|
Cascade | Recurse (leaves-first) into child rows, then delete parent |
SetNull | $unset the FK column on child rows, then delete parent |
Restrict | Throw [P2003] Restrict: cannot delete X if children exist |
NoAction / undefined | Skip — orphans allowed |
The walker tracks visited collection:id pairs so self-referential schemas (comment.parent_id → comment.id) can't infinite-loop.
Server-safety guard
The adapter references indexedDB and IDBKeyRange only inside function bodies — never at module import time. On Node / edge / SSR runtimes, import { indexedDbDriver } from 'forge-orm/indexeddb' succeeds; only openDb() / driver.open() triggers a runtime check. When a server code path does reach the adapter, the guard throws a specific [P2010] message naming the runtime instead of a cryptic ReferenceError.
Other changes
AdapterKindwidened to include'indexeddb'.DRIVER_PACKAGE_FORreports(none — browser built-in)for the new kind soforge doctor's driver table stays useful.- New
docs/INDEXEDDB.md(deep-dive companion to the README's new Browser (zero install) section) covering URL scheme, planner scoring, migration semantics, FTS/Geo/Vector/JSON-path behaviour, transactions, cascades, quota + Safari ITP, server safety, and the compatibility matrix. - README gains a "Browser (zero install) — IndexedDB" subsection under Connecting (with the tradeoff table vs sqlite-wasm), a StackBlitz row for the new
19-indexeddb-zero-installexample, and anINDEXEDDB.mdrow in the Deep-dive companions "Runtime targets" table.
Tests
113/113 jest tests in the new test/adapters/indexeddb/ suite via fake-indexeddb. Covers CRUD, every operator, relations, pagination, cursor, aggregations, planner scoring, migrations (add store / add index / drop index / no-op field / destructive pending), geo (Haversine correctness, antipodes, polygon with holes), vector (all three metrics), FTS (Unicode, punctuation, no-match), JSON path (deep nesting, missing intermediate), soft delete, transactions, cascades (all four actions), edge cases (limit 0, offset beyond length, unique constraint, updatedAt stamping), a 1000-row stress test, and the server-guard. Total across the repo: 585 tests (was 472 in 2.5.3).
Compatibility
- Drop-in upgrade for 2.5.x consumers. No schema or API changes for any server-side code path. The URL prefix
idb:/indexeddb:is new; no other prefix behaviour shifted. - Runs in every browser since 2017 (IndexedDB is universal). React Native isn't supported — use
opSqliteDriverthere. Server runtimes throw a specific message when the adapter is reached by mistake.
2.5.3 — Docs expansion to 80 files (no code change)
Docs-only patch. No runtime change; the published dist/ is byte-identical to 2.5.2. The release bumps the npm registry version so the expanded 80-doc tree is the default thing readers land on.
What changed:
docs/grew from 20 files to 80 (~78,500 lines of reference material). The 60 new deep-dives complete the surface coverage:- Schema (8 new): PRIMARY-KEYS, FOREIGN-KEYS, ENUMS, CHECKS, GENERATED-COLUMNS, VIEWS, MATERIALIZED-VIEWS, TRIGGERS.
- Reads/writes (8 new): UPSERT, BATCH, AGGREGATIONS, WINDOWS, PAGINATION, STREAMING, LOCKING, CONCURRENCY.
- CLI and operations (9 new): CLI, PUSH, DIFF, DOCTOR, ROLLBACK, SEED, DEPLOYMENT, BACKUP-RESTORE, VERSIONING.
- Per-dialect deep dives (6 new): POSTGRES, MYSQL, SQLITE, MONGO, DUCKDB, MSSQL.
- Observability and errors (5 new): EVENTS, LOGGING, TRACING, METRICS, ERRORS.
- Performance (4 new): POOLING, BENCHMARKS, CACHING, N-PLUS-ONE.
- Patterns (6 new): SOFT-DELETE, AUDIT-LOG, MULTI-TENANT, SHARDING, IDEMPOTENCY, WATCH.
- Testing (3 new): TESTING, INTEGRATION-TESTING, FIXTURES.
- Security (4 new): SECURITY, ENCRYPTION, SQLCIPHER, AUTH.
- Type-level reference (5 new): RUNTIME-VALIDATION, BRAND-TYPES, DATES, DECIMAL, UUID.
- Runtime targets (2 new): WORKERS (Cloudflare / Vercel Edge), LAMBDA (AWS).
- README's Deep-dive table at the top expanded into eleven sections covering all 80 files.
- New "See more" pointers added for Errors, Soft delete, Views and materialised views, Watching queries, Performance, and Testing chapters. Existing pointers (Defining a schema / Reading data / Writing data / Migrations) expanded to reference the new docs in their respective surfaces.
Docs live in the GitHub repo only — the published npm tarball still ships dist/ + README.md + CHANGELOG.md + LICENSE.
Tests
472/472 jest tests, all green. Same as 2.5.2 — no code changed.
2.5.2 — Docs reorganization (no code change)
Docs-only patch. No runtime change; the published dist/ is byte-identical to 2.5.1. The release bumps the npm registry version so the new README + the 20-doc deep-dive tree is the default thing readers land on.
What changed:
docs/grew from 7 files to 20. New deep-dives for MODEL, EMBED, RELATIONS, INDEXES, QUERIES, MUTATIONS, TRANSACTIONS, RAW-SQL, FTS, MIGRATIONS, TYPES, REACT, DRIVERS — plus the BROWSER full reference split out from the README. Existing BACKEND, MOBILE, GEO, VECTOR, JSON-PATH, BROWSER-FRAMEWORKS expanded with consistent voice.- README's Deep-dive table at the top restructured by section (Schema and data model / Reads, writes, transactions / Cross-cutting / Runtime targets) and grown to cover all 20 docs.
- "See more —" pointer added at the end of every README chapter that has a deep-dive companion: Install, Defining a schema, Reading data, Writing data, Grouping, Transactions, Raw SQL, FTS, Browser, Streaming, Migrations, Type safety. Geo / JSON path / Vector keep their existing "See also" lines.
- Docs filenames are uppercase to match the README.md / CHANGELOG.md convention.
Docs live in the GitHub repo only — the published npm tarball still ships dist/ + README.md + CHANGELOG.md + LICENSE. Relative links in the README resolve to the repo on npmjs.com and github.com, so the 20-file table works in either viewer.
2.5.1 — Browser $migrate() applies non-destructive drift
Patch release. Closes the last "Coming soon" item carried over from 2.4: the in-browser equivalent of forge diff + a safe slice of forge push, rolled into the existing $migrate() call. Drop-in upgrade.
What changed
db.$migrate() now runs a drift-apply pass after the create-pass:
- CREATE IF NOT EXISTS for tables and indexes (unchanged from 2.5.0).
introspectSqlite+diffIntrospectionagainst the active schema.- For every
{ kind: 'column', direction: 'missing' }drift item that's safe — nullable, or has a constant default — emitALTER TABLE … ADD COLUMNinside the same transaction. - Surface destructive drift (column drops, type changes, extra tables, NOT NULL columns with no default) under a new
pendingfield. The runtime never tries to drop or re-type — those are full table-rebuild territory.
const report = await db.$migrate();
// {
// applied: ['items', 'forge_items_unique_name'],
// skipped: [],
// failures: [],
// alteredColumns: ['items.email'], // ADD COLUMN ran for each
// pending: [ // not applied — caller decides
// { kind: 'column', direction: 'missing', table: 'items', detail: "column 'count'" },
// { kind: 'column', direction: 'extra', table: 'items', detail: "column 'legacy_blob' in DB but not in schema" },
// ],
// }Opt out with await db.$migrate({ alter: false }) if you want the strict 2.5.0 create-or-skip behaviour back. db.$diff() still returns the DriftReport directly when you only want the diff without the apply.
Why "non-destructive" is the only auto-applied slice
SQLite ALTER TABLE … ADD COLUMN is the one drift fix that survives a non-empty production table. Adding a NOT NULL column without a constant default would reject; dropping or re-typing a column needs a full table rebuild (CREATE new table, INSERT … SELECT, DROP old, RENAME). The runtime won't pick a rebuild strategy on its own — pending entries land in the report so the caller can decide between wiping the DB, emitting a manual $executeRaw rebuild, or relaxing the schema.
Files added in 2.5.1
| File | Purpose |
|---|---|
src/wasm/drift-apply.ts | introspect + diff + safe-ALTER pass; exported as applyDrift() for direct use |
src/__tests__/wasm-drift-apply.spec.ts | 7 jest tests covering nullable / defaulted / unsafe / extra / opt-out / direct-call paths |
Tests
472/472 jest tests, all green. Was 465/465 in 2.5.0.
2.5.0 — MSSQL MERGE upsert, Mongo cross-field nearTo, browser $doctor/$diff, MultiPolygon + GeometryCollection, 3D / Z coordinates, non-WGS84 SRIDs
Feature release. Closes the entire "Coming soon" list from 2.4, plus a few items previously marked TBD. Drop-in upgrade — no breaking changes; the geo IR's withinPolygon value shape grew a multiPolygon field but the legacy polygon shape is still accepted by every consumer.
MSSQL upsert via MERGE
compileUpdate now emits a real T-SQL MERGE when upsertCreate is set:
MERGE INTO [items] AS tgt
USING (VALUES (@p1, @p2, @p3)) AS src ([sku], [name], [qty])
ON tgt.[sku] = src.[sku]
WHEN MATCHED THEN UPDATE SET [qty] = @p4
WHEN NOT MATCHED THEN INSERT ([sku], [name], [qty])
VALUES (src.[sku], src.[name], src.[qty])
OUTPUT INSERTED.*;- Conflict target derived from the wrapper's eq-leaf where tree (same rule as the PG path: single-column or AND-of-eq).
- Conflict columns missing from
upsertCreateare pulled from the where leaf so the INSERT branch is always complete. - Supports
set,increment(COALESCE(tgt.[col], 0) + …),multiply, andunset(= NULL) on the UPDATE branch. - Returns the row via
OUTPUT INSERTED.*, matching PG'sRETURNING.
The previous 2.3 / 2.4 NotImplemented throw is gone. Atomic upsert works on every dialect now.
Mongo near + nearTo cross-field
A near filter on field A combined with a nearTo orderBy on field B used to drop A (the $geoNear stage's query clause can't contain $near). The fix: walk the artifact filter once, and for every cross- field $near rewrite to:
{ A: { $geoWithin: { $centerSphere: [[lng, lat], meters / 6_371_008.8] } } }This is semantically equivalent ($centerSphere uses radians on the sphere) and IS valid inside a $geoNear.query clause, so both filter and orderBy fire in the same aggregation. Same-field $near still collapses to maxDistance on the stage as before. Handles $and / $or / $nor walks recursively.
Browser $doctor() and $diff()
The runtime equivalents of forge doctor and forge diff:
const doctor = await db.$doctor(); // BrowserDoctorReport on sqlite, DoctorReport elsewhere
const drift = await db.$diff(); // DriftReport for every adapterOn sqlite adapters (including the wasm one), $doctor() routes through the same browserDoctor() introduced in 2.4 — environment probe (OPFS, SAB, persistent storage) + sqlite probe (FTS5, R-Tree, sqlite-vec) + a forge-feature → status table. On other adapters it returns the existing adapter.doctor() shape.
$diff() reads adapter.introspect() and runs the existing diffIntrospection engine, so the same drift detection that backs the CLI now works in-browser. Accepts the same ignore spec (string or RegExp patterns) the CLI takes via --ignore.
MultiPolygon + GeometryCollection
where: { col: { withinPolygon: … } } now accepts:
// 1. Single ring (legacy — unchanged):
{ withinPolygon: [{lng, lat}, {lng, lat}, …] }
// 2. Polygon with holes:
{ withinPolygon: { type: 'Polygon', rings: [outerRing, hole1, hole2, …] } }
// 3. MultiPolygon (multiple disjoint shapes):
{ withinPolygon: { type: 'MultiPolygon', polygons: [[outer1, …holes], …] } }
// 4. GeometryCollection (flattened to its constituent polygons):
{ withinPolygon: { type: 'GeometryCollection', geometries: [Polygon | MultiPolygon, …] } }The IR normalises all four to a uniform multiPolygon: Polygon[] shape (each Polygon = Ring[], each Ring = closed Array<{lng,lat}>). Every dialect compiler consumes the normalised shape:
- New
src/adapters/shared/wkt.ts—toGeoWKT(mp, axis)emitsPOLYGON((…))for a single ring orMULTIPOLYGON(((…)))for everything else; handles the MySQL lat-first axis-order quirk via theaxisparam. toGeoJson(mp)emits GeoJSON Polygon / MultiPolygon for the Mongo path.multiPolygonBbox(mp)computes the union envelope for the SQL fallback prefilter.
Fallback ray-cast (pointInMultiPolygon) honours holes via the even-odd rule — a point inside an outer ring AND inside one of its hole rings is NOT considered inside the polygon. MultiPolygons short-circuit as soon as any constituent polygon contains the point.
3D / Z coordinates
f.geoPoint({ dims: 3 }) opts into XYZ storage. The TS-side shape becomes { lng, lat, alt } and the per-dialect emit:
| Dialect | dims = 2 (default) | dims = 3 |
|---|---|---|
| PG | geography(Point, srid) | geography(PointZ, srid) — PostGIS auto-promotes from POINT Z(x y z) WKT |
| MySQL | POINT NOT NULL SRID srid | Same column type; altitude stored alongside in a JSON field per app (MySQL 8 has no native 3D) |
| SQLite | GeomFromText('POINT(x y)', srid) | GeomFromText('POINT Z(x y z)', srid) — SpatiaLite |
| DuckDB | ST_Point(x, y) | ST_Point3D(x, y, z) — spatial extension's 3D type |
| MSSQL | STGeomFromText('POINT(x y)', srid) | STGeomFromText('POINT(x y z)', srid) — geography accepts Z |
| Mongo | GeoJSON coordinates: [lng, lat] | GeoJSON coordinates: [lng, lat, alt] — 3-element form |
Distance semantics: near / nearTo still compute great-circle ground distance (2D-on-sphere). Altitude round-trips on read/write but doesn't participate in distance — see "3D distance mode" under "Coming soon".
Non-WGS84 SRIDs
f.geoPoint({ srid: 3857 }) (Web Mercator, OSGB 27700, NAD83, etc.) is honoured at DDL time across every dialect. The PG path routes non-4326 SRIDs to geometry(Point, srid) instead of geography(Point, srid) (geography is 4326-only). MySQL / SQLite / DuckDB / MSSQL accept the declared SRID directly. Mongo only supports 4326 (2dsphere is WGS84-only) — non-4326 fields run through fallback mode if fallback: true.
User responsibilities:
- Coordinates passed to
create/update/near/withinPolygonmust be in the target SRID's units — no auto-transformation at the IR layer. proj4(or per-dialectST_Transform) at the call site is the recommended way to convert from 4326 to your storage SRID.- A built-in
proj4-backed transform is on the roadmap.
Other changes
- New
src/adapters/shared/wkt.ts— sharedtoGeoWKT/toGeoJson/multiPolygonBboxso every dialect emits the same WKT/GeoJSON form. pointInPolygonrenamed topointInRinginternally + newpointInMultiPolygonhonouring holes via even-odd. Legacy export alias preserved.FallbackGeoOps.withinPolygonshape extended tomultiPolygon(the uniform internal form); oldpolygonshape still recognised for any caller building leaves by hand.- 20 new jest unit tests covering MSSQL MERGE, MultiPolygon IR + WKT, point-in-multi-polygon with holes, 3D field shape + DDL, non-4326 SRID DDL routing. Total: 465 tests, 35 suites, all green.
Compatibility
- Drop-in upgrade for 2.4.x consumers. No schema or API changes for any server-side code path. The geo IR's
withinPolygonvalue gained amultiPolygonfield; the legacypolygonfield is still accepted by every consumer. - The MSSQL
MERGEis a behaviour change for the upsert path (was a thrown error in 2.3 / 2.4) — apps that had atry/catchworkaround can remove it.
Known limitations carried into 2.6
- 3D distance mode — altitude is preserved but doesn't participate in
near/nearTo. A 3D Euclidean or ground+vertical distance mode is the open question (per-dialect implementation choice). - Auto SRID reprojection — declared SRID is honoured but coordinates are user-provided in target units. A
proj4-backed transform at the IR boundary is on the roadmap. - Pre-built
@forge-orm/sqlite-wasm-proartifact — the custom build is one shell command (scripts/wasm-pro/build.sh) today; the pre-built npm artifact is the next gap.
2.4.0 — Browser adapter: sqlite-wasm + OPFS, runtime $migrate, Vite/Next/Webpack plugins, browserDoctor, custom-build path for vec0 + R-Tree
Feature release. Adds a browser dialect: real SQLite (via @sqlite.org/sqlite-wasm) running in a Web Worker, persisted on the Origin Private File System (OPFS), with the same forge query surface every other dialect uses. The IR, dialect emitter, executor, and DDL generator are reused unchanged — only the connection backend is new.
New driver — wasmSqliteDriver
createDb({ schema, driver: wasmSqliteDriver({ worker, url }) }) opens a SQLite database in the browser. Implements the existing SqliteDriver port verbatim — all / get / run / exec / close — so every adapter call path (executor, migrator, $transaction, $queryRaw) routes through it without code changes. Serialises calls through a tiny promise queue, since SQLite is single-writer at the file level and OPFS sync handles are exclusive per origin.
New URL schemes
detectAdapterKind() now recognises three browser-side prefixes, all resolving to the sqlite kind:
| URL | VFS | Persistence | Multi-tab |
|---|---|---|---|
opfs-sahpool:<path> | SAH-pool (recommended) | Full | Safe |
opfs:<path> | Plain OPFS | Full | Single-tab writer |
:memory: | In-memory | None | N/A |
New worker module — forge-orm/wasm/worker
Ships a ready-to-bundle Web Worker that hosts sqlite-wasm. Consumers wire it in via the standard new Worker(new URL('forge-orm/wasm/worker', import.meta.url)) pattern — Vite, Next, Webpack 5, Parcel, Rspack all resolve it natively. Routes opfs: → oo1.OpfsDb, opfs-sahpool: → installOpfsSAHPoolVfs() + OpfsSAHPoolDb, :memory: → oo1.DB(':memory:'). Sets PRAGMA foreign_keys = ON at open. Best-effort detects sqlite-vec via vec_version().
Bundler plugins
Three zero-config helpers that take care of worker resolution + COOP/COEP headers + the wasm asset rule:
forge-orm/wasm/vite—forgeWasm()Vite plugin. AddsoptimizeDeps.exclude, setsworker.format: 'es', attaches COOP/COEP middleware to the dev server.forge-orm/wasm/next—withForgeWasm(nextConfig)wrapper. Enablesexperiments.asyncWebAssembly+topLevelAwait, adds a.wasmasset/resourcerule, setsexperimental.esmExternals: 'loose', wrapsheaders()to emit COOP/COEP for the matched routes.forge-orm/wasm/webpack—forgeWasmWebpack(config)for webpack 5, CRA (via craco), Rsbuild. Same experiments + asset rule.
All three accept options to disable individual pieces (COOP/COEP, dep optimizer exclusion, header injection) if the host app already handles them.
Runtime DDL apply — db.$migrate()
forge push is a Node CLI; the browser needs a runtime equivalent. db.$migrate() reads the active schema, calls the same buildSqliteSchemaDDL emitter forge push uses, filters out already-existing tables/indexes via a sqlite_master lookup, and applies the rest in one BEGIN/COMMIT batch.
Idempotent — safe to call on every app boot. Returns { applied, skipped, failures } matching the CLI report shape exactly. Verbose mode via db.$migrate({ logger: console.log }).
Lower-level pieces are also exported (buildSqliteSchemaDDL, applySqliteMigration, runMigrate) for apps that want to ship DDL as a build-time asset.
Runtime capability probe — browserDoctor()
The browser analog of forge doctor. Returns a structured BrowserDoctorReport with:
- environment: runtime kind, OPFS availability, sync handle support, SharedArrayBuffer, persistent-storage state (granted / requestable / unavailable), estimated quota + usage.
- sqlite: version, json1, fts5, rtree, sqliteVec, foreign-keys state.
- capabilities: forge-feature → status table (native / fallback / unavailable). Surfaces FTS5 / R-Tree / sqlite-vec presence so apps know whether
f.text().searchable()/f.geoPoint()/f.vector()run on the native code path or in fallback mode. - notes: human-readable remediation hints — including the iOS Safari ITP 7-day eviction warning when persistent storage is requestable but not granted.
Custom wasm build — forge-orm/wasm/worker-pro
The stock @sqlite.org/sqlite-wasm ships FTS5 + json1 but NOT R-Tree or sqlite-vec, so f.geoPoint() and f.vector() run in fallback mode there. This release adds a scripts/wasm-pro/build.sh Emscripten pipeline that compiles a custom wasm bundle with rtree + GeoPoly + sqlite-vec compiled in. The matching worker-pro.ts consumes the local artifact instead of the npm package.
The build script fetches the SQLite amalgamation (default 3.46.1, override via SQLITE_VERSION) and sqlite-vec (default v0.1.6, override via SQLITE_VEC_VERSION), then drives emcc to produce sqlite3.{mjs,wasm} (~1.6 MB). Output goes to dist/wasm-pro/ by default. Host the artifacts under your app and point the worker at them via FORGE_WASM_PRO_URL.
A pre-built @forge-orm/sqlite-wasm-pro npm artifact is on the roadmap.
Other changes
encodeParamsin the SQLite executor swapsBuffer.isBuffer(v)for a guarded check (typeof Buffer !== 'undefined' && …) plus aUint8Arraybranch — needed so wasm/browser bundles don't blow up at module-eval time whenBufferisn't a global.factory.tsextendsForgeDbwith$migrate()(sqlite-only — throws with a clear "use the CLI" message on Mongo / PG / MySQL / DuckDB / MSSQL adapters). Lazy-imports the migrator so non-sqlite bundles don't pull in the SQLite DDL emitter.- New peer dependency:
@sqlite.org/sqlite-wasm(>=3.45.0, optional). tsconfig.lib.jsonaddsDOM+WebWorkerlibs so the wasm entrypoints can referenceWorker/MessageEvent/navigator.storage/ OPFS types.- 6 new jest unit tests covering driver round-trips, error mapping, serial request queueing, close-during-pending semantics, and driver type-guards. Total: 445 tests, 34 suites, all green.
Compatibility
- Existing 2.3.x consumers: drop-in. No schema changes, no API changes for any server-side dialect.
- Server-side SQLite (
sqlite:./app.db, expo, op-sqlite, libsql) is unchanged. The Buffer guard tweak is the only runtime touch. - Browser builds need the new peer dep
@sqlite.org/sqlite-wasmand a bundler that supportsnew Worker(new URL(...))— modern Vite / Next / Webpack 5 / Parcel.
Known limitations
db.$migrate()doesn't yet do drift detection — it skips existing tables/indexes by name but doesn'tALTERcolumns. Hand-roll$executeRawfor schema evolution until full in-browserforge difflands in 2.5.- COOP/COEP headers are an app-wide concern. If a marketing page or auth callback embeds third-party scripts that don't send
Cross-Origin-Resource-Policy, the embed breaks — narrow the matcher inwithForgeWasm()or host the SQLite-using parts on a subdomain. - The pro wasm build needs Emscripten installed and a one-time ~10-minute compile. The pre-built npm artifact will close this gap.
2.3.1 — README rewrite: complete field-type + modifier tables, full operator reference
Docs-only release. No runtime change. The 2.3.0 README hadn't been re-syned end-to-end after the new features landed — f.geoPoint() and f.vector() were absent from the Field types table, and the modifier list was a code block rather than a table. This release rewrites the README from start to finish:
- Field types table now lists every builder (
f.id/objectId/string/text/int/float/decimal/bigint/uuid/bool/dateTime/json/enumOf/embed/embedMany/stringArray/intArray/geoPoint/vector) with TS type + per-dialect storage. - Field modifiers is now a proper table covering all nine (
optional/unique/default(value)/default('now')/default('autoId')/updatedAt/searchable/softDeleteAt/dbgenerated) with dialect quirks. - New top-level Operator reference table covering every
whereoperator (equals/not/in/notIn/lt-gte/contains/startsWith/endsWith/mode/has/hasEvery/hasSome/isEmpty/some/every/none/search/path/near/withinPolygon/AND/OR/NOT) keyed to the field kinds each applies to. - Geo section split into proper subsections (per-dialect emit table, extensions, fallback mode, coord-order rule, polygon containment).
- CLI section now has subsections for
forge doctor(with the per-dialect probe table),--enable-extensions(with a schema-feature → extension table), schema resolution, and--ignore. - Testing picks up a Driver smoke harness subsection covering
npm run smoke:drivers. - Wire-compatible databases table extended with MotherDuck, Azure SQL Database, and Azure SQL Edge.
- Built-in drivers table extended with the DuckDB + MSSQL rows.
- Reading data sample now includes
findFirstOrThrow/findUniqueOrThrow/aggregate(). - Limitations updated with the DuckDB / MSSQL / Mongo geo caveats that landed in 2.3.
The TOC is rebuilt so every heading anchor resolves.
2.3.0 — DuckDB + MSSQL adapters, end-to-end geo, JSON path queries, vector search
Feature release. Two new dialects, a complete geo layer (schema, index, typed where/orderBy, fallback mode, doctor probe, extension auto-install), typed JSON path queries, and typed vector / similarity search. The wrapper surface stays the same: every new feature works through findMany / create / etc. with the same code on every dialect.
Two new database adapters
DuckDB. Embedded analytical OLAP database — PG-compatible SQL with no FK enforcement. Bundled spatial extension auto-loads at connect; vss extension is available for vector search.
import { createDb, duckdbDriver } from 'forge-orm';
import { DuckDBInstance } from '@duckdb/node-api';
const instance = await DuckDBInstance.create('analytics.duckdb');
const connection = await instance.connect();
const db = await createDb({ schema, driver: duckdbDriver(connection) });URL prefix: duckdb: (e.g. duckdb:./data.duckdb, duckdb::memory:). Capabilities: ACID transactions, parallel scans, columnar storage. No foreign-key enforcement (forge's app-side cascade walker handles it), no SAVEPOINT (migration failures abort the batch).
SQL Server (MSSQL). Cross-platform Linux/Windows. On ARM Macs the doctor probe + smoke:drivers script default to azure-sql-edge (multi-arch); on x86 the full mssql/server:2022-latest image works natively.
import { createDb, mssqlDriver } from 'forge-orm';
import sql from 'mssql';
const pool = await sql.connect({ server: 'localhost', user: 'sa', password: '…', database: 'app' });
const db = await createDb({ schema, driver: mssqlDriver(pool) });URL prefix: mssql: / sqlserver:. Capabilities: ACID, native cascades, GEOGRAPHY built-in, VECTOR(N) on SQL Server 2025 / Azure SQL. T-SQL specifics handled by the compile layer:
[brackets]for identifiers,@p1,@p2,…named placeholdersLIMIT/OFFSET→OFFSET … ROWS FETCH NEXT … ROWS ONLYRETURNING *→OUTPUT INSERTED.*/OUTPUT DELETED.*- PG's
ctidsingle-row trick →[pk] IN (SELECT TOP 1 [pk] …) IF NOT EXISTSwrapped inIF NOT EXISTS (SELECT 1 FROM sys.tables …) BEGIN … ENDCREATE EXTENSIONreplaced with the equivalent built-in feature
Upsert (ON CONFLICT) is not supported in 2.3 — the T-SQL MERGE rewrite lands in 2.4. Until then, do findFirst → update / create at the app layer when targeting MSSQL.
Driver smoke harness — verify every driver installs + connects in isolation
scripts/driver-smoke.mjs creates a throwaway tmpdir, npm installs every driver forge-orm supports plus testcontainers, runs a minimal connect → SELECT 1 → close per driver, prints a results table, and tears the tmpdir and any running containers down. Useful for confirming a Node upgrade or a published forge-orm release won't break against the underlying clients in the wild.
npm run smoke:drivers # everything
npm run smoke:drivers -- --only=pg # filter by substring(s)
npm run smoke:drivers -- --keep # leave tmpdir for inspection
npm run smoke:drivers -- --verbose # surface npm install outputCovers: better-sqlite3, @libsql/client, @duckdb/node-api (embedded); pg, postgres (porsager), mysql2, mariadb, mongodb, mssql (server, via Testcontainers); expo-sqlite, @op-engineering/op-sqlite (install-only — exec needs an iOS/Android runtime); @planetscale/database (skipped without PLANETSCALE_URL).
ARM Macs: the MSSQL container auto-swaps to azure-sql-edge (multi-arch) instead of the AMD64-only mssql/server:2022. First-run cost is dominated by Docker image pulls (~3-6 min cold, ~15s warm).
Geo — f.geoPoint(), spatial indexes, typed near / nearTo / withinPolygon
Schema-level geo, end-to-end. The same code targets MongoDB, Postgres (with PostGIS), MySQL 8 spatial, SQLite + SpatiaLite, DuckDB spatial, and SQL Server's GEOGRAPHY.
const Place = model('places', {
id: f.id(),
name: f.string(),
location: f.geoPoint(), // WGS84 / SRID 4326
}, {
indexes: [{ keys: { location: 1 }, method: 'spatial' }],
});
// Insert — always { lng, lat }. Coord-order quirks handled by the compiler.
await db.place.create({
data: { id: 'a', name: 'Lekki', location: { lng: 3.4505, lat: 6.4416 } },
});
// "Find places within 5 km of me, closest first, top 20".
const nearby = await db.place.findMany({
where: { location: { near: { lng: 3.45, lat: 6.44, withinMeters: 5000 } } },
orderBy: { location: { nearTo: { lng: 3.45, lat: 6.44 } } },
take: 20,
});
// nearby[0]._distanceMeters ≈ 0 (meters from the search point)Per-dialect compile:
| Dialect | Column | Spatial index | near filter | nearTo orderBy |
|---|---|---|---|---|
| Mongo | GeoJSON in JSON | 2dsphere | $near + $maxDistance | $geoNear aggregate (auto-routed) |
| Postgres | geography(Point, 4326) | USING GIST (PostGIS) | ST_DWithin(...) | ST_Distance(...) AS _distanceMeters |
| MySQL 8 | POINT NOT NULL SRID 4326 | CREATE SPATIAL INDEX | ST_Distance_Sphere(...) < N | ST_Distance_Sphere(...) AS _distanceMeters |
| SQLite | BLOB (SpatiaLite) | virtual idx_<tbl>_<col> | Distance(..., 1) < N | Distance(..., 1) AS _distanceMeters |
| DuckDB | GEOMETRY (spatial ext) | USING RTREE | ST_Distance_Sphere(...) < N | ST_Distance_Sphere(...) AS _distanceMeters |
| MSSQL | GEOGRAPHY | CREATE SPATIAL INDEX | col.STDistance(...) < N | col.STDistance(...) AS _distanceMeters |
Polygon containment:
const inside = await db.place.findMany({
where: {
location: {
withinPolygon: [
{ lng: 3.20, lat: 6.35 },
{ lng: 3.60, lat: 6.35 },
{ lng: 3.40, lat: 6.55 }, // 3+ vertices; ring auto-closes
],
},
},
});Compiles to ST_Within / Within / STContains / $geoWithin per dialect. Fallback mode emits a bbox prefilter from the polygon's axis- aligned envelope and runs ray-casting point-in-polygon in app — concave polygons work correctly.
Fallback mode for environments without the spatial extension:
location: f.geoPoint({ fallback: true }), // JSON storage + HaversineColumn becomes JSON {lng, lat}. SQL emits a degrees-radius bbox prefilter on the JSON-extracted lng/lat, and the adapter runs an exact Haversine refinement + sort in app. Slower than native (no index on JSON path; O(n) within bbox), but works without any extension. Fine for prototypes and small datasets; migrate to native when traffic justifies.
The Haversine post-filter is wired into the Postgres, MySQL, SQLite, DuckDB, and MSSQL executors. The Mongo executor doesn't have a fallback mode (Mongo's 2dsphere is built-in).
forge doctor extension probe:
$ forge doctor
Live capability probe:
✓ Postgres 16.2 reachable
⚠ PostGIS NOT installed
Install: CREATE EXTENSION postgis;
⚠ pg_trgm NOT installed
Install: CREATE EXTENSION pg_trgm;Per dialect, doctor connects (best-effort), reads the version + extension list, and prints actionable install commands. Failures don't raise — the probe stays optional so the env-only output is still useful when the DB is down.
forge push --enable-extensions: when the schema declares geoPoint fields and you pass --enable-extensions, push issues CREATE EXTENSION IF NOT EXISTS postgis; before the table DDL. DuckDB always LOAD spatials at connect time. SQLite tries load_extension('mod_spatialite') silently at connect time.
Mongo nearTo without near auto-routes to a $geoNear aggregate pipeline (which would otherwise be a community-only no-op). Direction flipping (asc/desc) is honored.
JSON path queries — typed reads + comparisons on nested JSON
The jsonPath op (reserved in the IR since 2.1) is now implemented across all six dialects. User-facing shape:
const Doc = model('docs', { id: f.id(), meta: f.json() });
// Dotted-path navigation, with the same scalar comparison vocabulary as
// regular `where`: eq / ne / gt / gte / lt / lte / contains / in / has.
await db.doc.findMany({
where: { meta: { path: 'profile.age', gte: 18 } },
});
// Array indexing with `[N]` syntax.
await db.doc.findMany({
where: { meta: { path: 'addresses[0].city', eq: 'Lagos' } },
});
// Pass an explicit segment array if you prefer.
await db.doc.findMany({
where: { meta: { path: ['tags', '0'], eq: 'urgent' } },
});
// Substring on the extracted value.
await db.doc.findMany({
where: { meta: { path: 'bio', contains: 'engineer' } },
});Per-dialect compile:
- PG —
(col->'a'->'b'->>'c')::numericwith auto-cast based on operand type. Array indexes are emitted as numeric->Nsegments. - MySQL —
JSON_UNQUOTE(JSON_EXTRACT(col, '$.a.b.c')). UNQUOTE unwraps the JSON-wrapped string so equality against a plain param works. - SQLite —
json_extract(col, '$.a.b.c')(JSON1 extension; built-in in modern builds). - DuckDB —
json_extract(col, '$.a.b.c'). - MSSQL —
JSON_VALUE(col, '$.a.b.c'). - Mongo — dotted-key form:
{ 'meta.profile.age': { $gte: 18 } }.
Works on f.json() / f.embed() / f.embedMany() / f.stringArray() / f.intArray() fields. Non-JSON fields raise a clear error.
Vector — f.vector(dims), vector indexes, typed similarity search
The same near / nearTo vocabulary as geo, applied to embedding vectors. Production-grade similarity search across PG (pgvector), MySQL 9.0+, SQLite (sqlite-vec), DuckDB (vss), MSSQL (SQL Server 2025 / Azure SQL), and Mongo (Atlas Vector Search).
const Doc = model('docs', {
id: f.id(),
body: f.text(),
embedding: f.vector(1536, { metric: 'cosine' }), // OpenAI text-embedding-3-small
}, {
indexes: [{ keys: { embedding: 1 }, method: 'vector' }],
});
await db.doc.create({
data: { id: 'a', body: 'cat', embedding: [0.1, 0.2, /* … 1536 floats … */] },
});
// "Top-10 nearest documents to my query vector, within 0.4 cosine distance."
const matches = await db.doc.findMany({
where: { embedding: { near: { vector: queryVec, withinDistance: 0.4 } } },
orderBy: { embedding: { nearTo: queryVec } },
take: 10,
});
// matches[0]._distance ≈ 0 (cosine distance to the query vector)Metrics: 'cosine' (default — most embedding models), 'l2' (Euclidean), 'dot' (inner product). Pick to match your embedding model's docs.
Per-dialect compile:
| Dialect | Column | Vector index | near filter | nearTo orderBy |
|---|---|---|---|---|
| Postgres | vector(N) (pgvector) | USING hnsw (col vector_<metric>_ops) | (col <=> $vec) < $d | col <=> $vec AS _distance |
| MySQL 9 | VECTOR(N) | basic — exact only (community) | DISTANCE(col, STRING_TO_VECTOR(...), 'COSINE') < $d | DISTANCE(...) AS _distance |
| SQLite | TEXT (JSON) | needs sqlite-vec vec0 virtual table (out-of-band) | brute-force / vec0 raw query | not portable |
| DuckDB | FLOAT[N] | USING HNSW (vss extension) | array_cosine_distance(col, [...]) < $d | array_cosine_distance(...) AS _distance |
| MSSQL | VECTOR(N) | USING VECTOR WITH (algorithm = 'HNSW') | VECTOR_DISTANCE('cosine', col, ...) < $d | VECTOR_DISTANCE(...) AS _distance |
| Mongo | plain array | Atlas Search Index (createSearchIndex) | routed to $vectorSearch pipeline | routed to $vectorSearch pipeline |
Dimension validation: f.vector(1536) rejects a 1024-dim query vector at IR-build time with a clear error — catches embedding-model mismatches before they hit the DB.
Extensions:
- PG:
CREATE EXTENSION vector;— works on every managed PG host (Supabase, Neon, RDS, Crunchy, …) - DuckDB:
INSTALL vss; LOAD vss;— adapter auto-loadsspatial;vssis one extraconnection.runaway - SQLite: install
sqlite-vecextension; the vec0 mirror table is created out-of-band (forge doesn't manage it) - MySQL: 9.0+ has the type built-in; HeatWave Vector Store adds HNSW/IVF
- MSSQL: SQL Server 2025 / Azure SQL only
- Mongo: Atlas Vector Search (Atlas-only, not on-prem)
When a method is unavailable on the target DB, the index emission warns clearly (e.g. "Mongo vector indexes live in Atlas Search, not createIndex — create via Atlas UI/CLI").
Other changes
- Schema linter (
forge doctor) now recognises'vector'and'spatial'as portable methods and points users at the right install command when the live DB lacks the extension. AdapterKindwidened to include'duckdb' | 'mssql'. TheDialectinterface'snameunion and theSQLDialectcompile union widened to match.DialectgainsvalueExpr(per-field insert/update wrapping for geo + vector),geoNearClause/geoDistanceExpr/geoWithinPolygonClause(geo compile hooks),vectorDistanceClause/vectorDistanceExpr(vector compile hooks),jsonPathExpr(per-dialect JSON extraction). All optional — default implementations live in PostgresDialect.- Shared cross-adapter helpers moved to
src/adapters/shared/:haversine.ts(great-circle distance + ray-casting point-in-polygon),mongo-to-sql-where.ts(the where-tree translator from earlier work). IndexMethodgains'vector'.'spatial'and'vector'are cross-dialect aliases that resolve per-dialect to the native index family.- Soft-delete + restore artifacts carry
semanticOp(continued from 2.2.1) so OTel / audit pipelines can filter mutations by intent. - MSSQL upsert returns a clear NotImplemented error pointing at the v2.4 MERGE rewrite, instead of silently falling back to a half-baked INSERT.
Test posture
- 439 unit tests across 33 suites (was 354 in 2.2.1 — +85 new geo / JSON path / vector / Phase-1-6 tests).
- Live regressions on DuckDB for geo (
regression-geo-duckdb.ts— 8/8 incl. polygon) and vector (regression-vector-duckdb.ts— 7/7 through thevssextension end-to-end). - All four existing dialect live integrations (Postgres, MySQL, SQLite, Mongo) still green — no regressions from the unioned changes.
- New driver smoke harness verifies every driver installs and connects on a clean Node.
Migration from 2.2.x
Drop-in. No breaking changes. The four new adapters and the new field kinds are additive; existing schemas keep compiling to the same SQL. The new 'vector' index method is a no-op on dialects without vector support — it warns instead of erroring.
If you're moving to DuckDB or MSSQL, install the driver and add the URL prefix to your connection string. If you're adopting geo, add the f.geoPoint() fields + method: 'spatial' index and run forge doctor to confirm the extensions are available. If you're adopting vector, add the f.vector(dims, { metric }) fields + method: 'vector' and install the dialect's vector extension (e.g. CREATE EXTENSION vector for PG).
2.2.1 — drift detection for the new index shapes, plus a nested-write adapter bug
Bug fix + completeness. forge diff couldn't see drift on the 2.2.0 index shapes because the introspect layer wasn't reading method, where, include, expression, partial filter, collation, or wildcard projection back from the DB. The comparator ran on column-set + uniqueness alone, so if someone manually changed an index's method from btree to gin, or dropped the WHERE clause, forge diff reported "no drift." Fixed across all four adapters:
- Postgres
introspectnow joinspg_amand readspg_get_expr(indpred,…)pg_get_indexdef, surfacing method, partialWHERE, expression body, and INCLUDE columns. The INCLUDE boundary is derived frompg_index.indnattsso covering columns end up in their own slot instead of mixed with the key columns.
- MySQL
introspectreadsINDEX_TYPE(BTREE / FULLTEXT / SPATIAL) andEXPRESSIONfrominformation_schema.STATISTICS. TheEXPRESSIONcolumn doesn't exist on MySQL pre-8.0, so the query falls back when it raises Unknown column. - Mongo
introspectcarriespartialFilterExpression,collation,wildcardProjection, and the rawkeymap (so'2dsphere'/'hashed'/1/-1directions survive the round-trip). diff-corenow emits per-property'mismatch'items when an index of the same name exists on both sides but a tracked property drifted. Whitespace- case are normalized for SQL
WHEREstrings, expression bodies tolerate PG's extra-paren echo, and Mongo collation is projected down to the keys the user declared (so Mongo's filled-in defaults don't read as drift).
- case are normalized for SQL
IntrospectedIndexgains optional fields for everything above. Adapters that can't tell leave them undefined and the diff skips that check.
Also fixed: a nested-write path in the collection wrapper that silently fell back to the default Mongo singleton on Postgres / MySQL / SQLite. _applyNestedWrites constructed the target wrapper without passing this._adapter or this._strict, so a nested create or connect against the relation's target model would try to talk to Mongo even when the parent write was on Postgres. Both sites now propagate both. (This predated 2.2 — it was exposed while auditing the wrapper for completeness.)
Soft-delete events
QueryEvent gains an optional semanticOp field set to 'softDelete', 'softDeleteMany', 'restore', or 'restoreMany' whenever the wrapper dispatches one of those verbs. Up to now they all surfaced as op: 'update', which made audit pipelines unable to filter soft-deletes from regular updates without parsing the SQL or the Mongo update doc. The wrapper threads the hint through ExecOpts.semanticOp; each adapter's _track copies it onto the emitted event. op is unchanged for back-compat.
Doc voice
The 2.2.0 entry below was rewritten to match the rest of the changelog — direct narration of what changed and why, no meta-narrative.
2.2.0 — IndexDef covers the index shapes forge push couldn't model before
Feature release. IndexDef had keys / unique / sparse / name / expireAfterSeconds / partialFilterExpression (Mongo only). That covered plain BTREE compounds, sparse uniques, TTL, and 2.1.0's partial filters, but left geospatial, hashed shard keys, collation, wildcard projection, SQL partial indexes, expression indexes, INCLUDE covering columns, and PG access methods (gin / gist / brin / hash) outside the schema — schemas had to fall back to ``db.$executeRawCREATE INDEX …``` or a manual collection.createIndex` to express them. This release covers all of those.
New fields
where— partial index predicate. On Mongo it's an alias ofpartialFilterExpression(object form). On Postgres and SQLite it's a raw SQL string and compiles to… WHERE <sql>. MySQL has no native partial index and warns + skips. The same schema can carry bothwhere: 'sql…'andpartialFilterExpression: { … }so it works on every dialect.expression— index the result of a SQL expression instead of a column list. Compiles toCREATE INDEX … ((<expr>))on Postgres / MySQL 8+ / SQLite. Mongo doesn't model expression indexes —forge pushwarns and skips. Use this forlower(email)case-insensitive lookups,((data->>'sku'))JSON paths, computed keys.method— index access method.btree(default) /gin/gist/brin/hashfor Postgres, andspatial/fulltextfor MySQL. On MySQLspatial/fulltextare statement-prefix keywords (CREATE SPATIAL INDEX …), not USING clauses, so they're emitted in that form. SQLite and Mongo ignoremethod.include— Postgres covering columns. Emits… INCLUDE (col, …)so the index can satisfy a read from the index alone. Other dialects warn + skip.collation— Mongo only. Build a case- or accent-insensitive index by passing the same shapecollection.createIndexaccepts:{ locale: 'en', strength: 2 }. The push fingerprint includes the collation, and the diff projects Mongo's echoed defaults down to the keys you declared so an in-sync DB doesn't read as drifted.wildcardProjection— Mongo only. Pair withkeys: { '$**': 1 }to control which paths the wildcard index covers.
New IndexKey values
IndexKey gains '2dsphere', '2d', and 'hashed'. They pass through verbatim to collection.createIndex, so geospatial $near queries and hashed shard keys work without going around forge push. The SQL dialects ignore these tokens.
Examples
indexes: [
// SQL partial — soft-delete-aware uniqueness on Postgres / SQLite
{ keys: { sku: 1 }, unique: true, where: 'deleted_at IS NULL' },
// Same intent on Mongo — pass both for cross-dialect schemas
{ keys: { sku: 1 }, unique: true,
partialFilterExpression: { deleted_at: { $exists: false } } },
// Mongo geospatial — $near / $geoWithin queries
{ keys: { location: '2dsphere' } },
// Hashed shard key
{ keys: { tenant: 'hashed' } },
// Case-insensitive unique email (Mongo)
{ keys: { email: 1 }, unique: true,
collation: { locale: 'en', strength: 2 } },
// Postgres GIN on a jsonb column for `@>` containment
{ keys: { tags: 1 }, method: 'gin' },
// Postgres covering index — index-only scans for (customer_id) → (status, total)
{ keys: { customer_id: 1 }, include: ['status', 'total'] },
// BRIN for huge append-only tables (Postgres)
{ keys: { received_at: 1 }, method: 'brin' },
// Case-insensitive email lookup, every SQL dialect
{ keys: {}, expression: 'lower(email)' },
// MySQL spatial / fulltext
{ keys: { geom: 1 }, method: 'spatial' },
{ keys: { body: 1 }, method: 'fulltext' },
]Adapter dispatch on db.<model>.compile
The compile getter on the collection wrapper was hardcoded to the Mongo compile API regardless of dialect, so a Postgres consumer calling compile.findMany(...) got a MongoArtifact back instead of a parameterised SQL string. The getter now dispatches on adapter.kind and returns the matching artifact. Two narrowed getters were added — .compileMongo and .compileSql — that throw at access if the adapter doesn't match, so a misroute surfaces loudly instead of silently returning the wrong shape. MySQL and SQLite gain top-level buildMysqlCompileApi / buildSqliteCompileApi builders (they had compile-from-ir emitters but no top-level wiring).
Soft-delete in compile
softDelete / softDeleteMany / restore / restoreMany have been runtime methods on the collection wrapper since 2.0, but the typed compile surface only listed find / create / update / upsert / delete. They're now on MongoCompileApi and SQLCompileApi; each compiles to the same update-the-soft-delete-column shape the runtime uses, and throws at compile time when the model has no .softDeleteAt() field.
migrate-gen knows the new shapes
When a missing index is detected via the column-set diff, the generated migration SQL now carries method, where, include, and (per dialect) the full statement-prefix for MySQL SPATIAL / FULLTEXT. Before this, the generator stripped to a plain CREATE INDEX (cols) even when the schema asked for a GIN or partial index. Expression indexes are skipped from the column-set diff (they have no columns to compare); forge push is the source of truth for their lifecycle.
Migration
No breaking changes. Every new field is optional. partialFilterExpression still works exactly as it did in 2.1.0.
Not yet covered
- SQLite FTS5 virtual tables.
.searchable()still warns on SQLite. - MySQL invisible indexes and multi-valued JSON-array indexes.
- MySQL FULLTEXT parser configuration (
WITH PARSER ngram). - Generic Mongo query-document → SQL
WHEREtranslation. Pass raw SQL on SQL dialects; pass Mongo objects on Mongo.
2.1.0 — partial indexes on MongoDB (partialFilterExpression)
New feature (MongoDB). A schema IndexDef now accepts partialFilterExpression, so forge push can build a partial index — an index that only covers the documents matching a filter. The canonical use is a unique index that ignores rows where the field is absent or the wrong type:
const Payment = model('payments', {
id: f.id(),
txn: f.string().optional(),
}, {
indexes: [{
keys: { txn: 1 },
unique: true,
name: 'idx_pay_txn',
partialFilterExpression: { txn: { $type: 'string' } }, // unique only over string txns
}],
});forge push creates it with the filter, and the idempotency fingerprint now includes the filter (order-independent), so adding/changing a partialFilterExpression triggers a rebuild while an unchanged one is skipped. The field is MongoDB-only and ignored by the SQL dialects. Covered by new unit tests plus regression-mongo-partial-index.ts (creation, idempotency, and that uniqueness is enforced only over the filtered subset).
2.0.1 — upsert: no more $setOnInsert/update path conflicts (Mongo)
Bug fix (MongoDB). upsert() compiled the entire create payload into $setOnInsert verbatim while the update payload became $set/$inc/$mul/ $push/$unset. When a field appeared in both create and update, Mongo rejected the write with "Updating the path 'x' would create a conflict at 'x'" — so two natural patterns threw on the insert branch:
// counter: create seeds 1, update increments
await db.counter.upsert({ where: { id }, create: { id, seq: 1 }, update: { seq: { increment: 1 } } });
// "set these fields whether inserting or updating"
await db.consent.upsert({ where: { user_id }, create: { user_id, categories }, update: { categories } });Now the compiler drops from $setOnInsert any path the update operators already write (exact match and prefix conflicts like a vs a.b). On insert the update operator sets the value anyway, so both patterns just work; $setOnInsert is omitted entirely when every create field overlaps the update. Insert-only create fields are still emitted. SQL dialects were unaffected.
2.0.0 — delete() is now a hard delete; explicit softDelete() + restore()
Breaking change. Deletes now match Prisma's semantics: delete() and deleteMany() always permanently remove the row, regardless of whether the model declares a .softDeleteAt() column. The recoverable path is a separate, explicit verb.
In v1, declaring a .softDeleteAt() field silently rerouted delete() to set that column instead of removing the row. That made delete() mean two different things depending on the schema, and left no built-in hard-delete or restore. v2 removes the magic and splits the behaviors:
delete()/deleteMany()— always hard delete (runs cascades). Same as on a model with no soft-delete column.softDelete()/softDeleteMany()— new. Set the.softDeleteAt()column to now(); the row is hidden from reads but recoverable. Throws if the model has no soft-delete column.restore()/restoreMany()— new. Clear the.softDeleteAt()column so the row is active again. Throws if the model has no soft-delete column.
Read behavior is unchanged: find* / count still auto-exclude soft-deleted rows, and where: { _withDeleted: true } still reveals them.
Migration from 1.x
This is a runtime semantic change, not a type error — code keeps compiling but behaves differently. Audit every delete() / deleteMany() call on a model that has a .softDeleteAt() column:
// v1 (soft-deleted because the model had .softDeleteAt())
await db.account.delete({ where: { id } });
// v2 — pick the intent explicitly:
await db.account.softDelete({ where: { id } }); // recoverable (old behavior)
await db.account.delete({ where: { id } }); // permanent (new default)Models without a .softDeleteAt() column are unaffected — delete() was already a hard delete for them.
1.9.1 — docs: unify the pluggable-drivers README section
No code change. The README's driver docs grew one release at a time and read as "SQLite plus bolt-ons"; they're now a single Pluggable drivers section that presents all four databases together — one table of every built-in driver, a per-database example, and the small port interface each one implements.
1.9.0 — pluggable MySQL + Mongo drivers (and a MySQL guard fix)
All four databases are now pluggable.
- MySQL routes through a
MysqlDriverport. Built-in wrappers:mysql2Driver(default),mariadbDriver(MariaDB connector),planetscaleDriver(@planetscale/database, serverless). Pass mariadb poolsbigIntAsNumber:true/insertIdAsNumber:truefor type parity with mysql2. - MongoDB — one canonical driver, so pluggability means bringing your own
MongoClientviamongoDriver(client, dbName?): custom options, a shared client, or a Mongo-API backend (Amazon DocumentDB, Azure Cosmos DB, FerretDB).
const db = await createDb({ schema, driver: mariadbDriver(pool) });
const db = await createDb({ schema, driver: mongoDriver(new MongoClient(uri), 'mydb') });Bug fix (MySQL): a col() field comparison — or any non-eq predicate — on a single update()/delete() made the no-RETURNING follow-up SELECT reference IR internals as columns (Unknown column '…kind'). Guarded writes now extract only the eq-identity predicates for the follow-up, and use affectedRows to surface a failed guard as not-found (P2025). This affected the default mysql2 path too; the integration suite simply never exercised a guarded MySQL update.
Verified: default mysql2 (35/35) + mongo (38/38) unchanged through the ports; mariadb verified end-to-end on real MySQL (regression-mariadb-driver.ts), injected MongoClient verified on real Mongo (regression-mongo-driver.ts).
- Note:
forge push/applyMigrationstill assume the defaultmysql2pool.
1.8.0 — pluggable Postgres drivers (postgres.js)
The Postgres adapter now routes through a normalized PostgresDriver port, so node-postgres (pg) is no longer the only option. Wrap any client and pass it to createDb({ driver }):
import postgres from 'postgres';
import { createDb, postgresJsDriver } from 'forge-orm';
const db = await createDb({ schema, driver: postgresJsDriver(postgres(url)) });Built-in wrappers: pgDriver (default, node-postgres) and postgresJsDriver (porsager/postgres.js). The port is query + transaction + close (optional stream); any other client fits the same shape.
- The default
pgpath is unchanged and backward compatible (53/53 PG integration scenarios pass through the port). - postgres.js is verified end-to-end against real Postgres (
regression-postgresjs-driver.ts): DDL, RETURNING writes,col()guards, groupBy/having,count({ distinct }), transaction commit AND rollback. - Note:
forge push/applyMigrationstill assume the defaultpgpool (advisory locks needpool.connect()); with an injected driver, run runtime queries through forge and manage DDL viapgor directly.
1.7.0 — pluggable SQLite drivers (React Native, edge, Turso)
The SQLite adapter no longer hard-depends on better-sqlite3 (a native Node module that can't run in React Native or edge runtimes). All SQLite access now goes through a normalized async driver port (SqliteDriver), and you can hand forge any driver that implements it:
import { createDb, libsqlDriver } from 'forge-orm';
import { createClient } from '@libsql/client';
const db = await createDb({ schema, driver: libsqlDriver(createClient({ url })) });Built-in wrappers: betterSqlite3Driver (default, Node), expoSqliteDriver (Expo/RN), opSqliteDriver (bare RN), libsqlDriver (libsql/Turso/edge). The port is five methods (all, get, run, exec, close, optional iterate), so any other driver fits too. Pass the driver via createDb({ driver }) — no URL needed; you own the driver's lifecycle.
- The synchronous
better-sqlite3path is unchanged and fully backward compatible (all 37 SQLite integration scenarios pass through the port). - The async path is verified end-to-end against real libsql (
regression-libsql-driver.ts): DDL, RETURNING writes,col()guards, groupBy/having,count({ distinct }), and transactions. - Note:
adapters/sqlite.dbnow exposes theSqliteDriverport rather than the rawbetter-sqlite3handle — pass it toapplyMigrationas before.
(PostgreSQL alternative drivers like postgres.js are a planned follow-up.)
1.6.1 — housekeeping: trim comments, remove dead legacy code
No behaviour change. A pass over every source file to cut redundant comments down to the ones that carry real intent (the "why", dialect gotchas, invariants), plus removal of confirmed-dead code:
- Deleted the legacy pre-IR Mongo query path —
adapters/mongo/relations.tsandadapters/mongo/translate/{where,orderby,select-include}.ts(zero importers; the IRcompile-from-ir.ts+execute.tspath superseded it) and their two unit specs. - Dropped small dead bits: an unused
REGEX_ESCAPEconst, an unusedactualNamesset, and a no-op.replace('T', 'T').
Known gap surfaced by the audit: relation filters inside where ({ rel: { is: {…} } }) are not yet supported on Mongo (no $lookup) — they compile to match-all rather than erroring.
1.6.0 — groupBy having accepts Prisma's field-first shape; fix Mongo count({ distinct })
Two correctness items around aggregation, both caught by running groupBy + distinct against real SQLite and Mongo:
havingnow accepts the Prisma field-first shape in addition to the bucket-first shape it already took. Both of these now mean the same thing:tshaving: { total: { _sum: { gte: 120 } } } // field-first (Prisma) having: { _sum: { total: { gte: 120 } } } // bucket-firstNormalisation happens once in
buildGroupBy, so every dialect benefits.Fixed
count({ distinct: [...] })on MongoDB, which previously ignoreddistinctand returned the total document count. It now groups on the distinct field-combination and counts the groups, matching the SQL dialects'COUNT(DISTINCT …).
Locked in with regression-groupby-distinct.ts (wired into forge:integration:mongo) and a groupby-having unit spec.
1.5.1 — fix: update() false not-found on models with a value field
The MongoDB driver v6/v7 returns the bare document from findOneAndUpdate, where v5 returned a { value, ok } envelope. The result-unwrap guessed the shape with raw.value — which collides with any document field literally named value. The effect on update() / upsert() against such a model:
- when the doc's
valuewas falsy (e.g.0), a successful update was reported as a not-found (P2025); - when truthy, the field was returned instead of the document.
No model in the bundled sample schema has a value field, so the unit and integration suites never hit it — promo/discount-style models do. Fixed by forcing includeResultMetadata: true so the envelope is deterministic across driver versions. Added regression-mongo-value-field.ts (wired into forge:integration:mongo) to lock it in.
1.5.0 — col(): field-to-field comparison in where
A new exported helper, col('otherField'), lets a where condition compare one column against another column of the same row instead of against a literal:
import { col } from 'forge-orm';
await db.promo.update({
where: { id, currentUsage: { lt: col('globalLimit') } },
data: { currentUsage: { increment: 1 } },
});Portable across every dialect — one IR, four compilers:
- Mongo →
{ $expr: { $lt: ['$currentUsage', '$globalLimit'] } } - Postgres / MySQL / SQLite →
"t"."currentUsage" < "t"."globalLimit"
This removes the most common reason callers dropped to the raw driver: an atomic, race-safe guarded counter (findOneAndUpdate with a $expr filter) is now expressible through the portable update() API.
Details:
- Accepts only the six comparison operators (
equals,not,lt,lte,gt,gte); any other operator throws at build time. - The referenced field is validated against the model — a typo or a relation name throws, which also closes the only identifier-injection surface (the reference becomes a SQL identifier / Mongo
$fieldpath downstream). - The marker is branded with a registered
Symbol, so it can never collide with a real field name or be smuggled in through a JSON request body. - Both forms work:
{ field: { lt: col('x') } }and the bare{ field: col('x') }(equality). Mixed literal +col()conditions never collide (each$exprlands in its own$andentry on Mongo). - Not yet supported inside relation filters — that path throws a clear error rather than emitting a wrong query.
1.4.1 — docs: surface 1.4's PK strategies at the top of the README
The strategy table + worked example shipped with 1.4.0 but lived deep in the schema section, four scrolls down from the hero. A new user landing on the README could easily assume forge was UUID/ObjectId-only and bounce.
Added an explicit "What's new in 1.4" callout right under the pitch:
- All three strategies (
auto,uuid,bigserial) shown inline. - The three
forgecommands you'd actually run to apply it (push,diff,diff apply). - The "Mongo throws — use auto/uuid for portability" caveat surfaced alongside, not buried under it.
The detailed strategy table further down stays unchanged — it now has a deep link from the callout for readers who want the full per-dialect breakdown.
The worked bigserial example also got a three-step layout so the push command appears in context (declare → push → use), making it obvious nothing new is needed in the CLI.
No code changes.
1.4.0 — primary-key strategy: f.id({ type: 'auto' | 'uuid' | 'bigserial' })
f.id() has always produced a string — ObjectId on Mongo, UUID on SQL — because that was the only thing portable across all four databases. SQL-only users who wanted classic auto-incrementing integer keys had to either give up that wish or work around forge with raw DDL.
f.id() now takes an options bag with a type argument that picks the underlying strategy:
id: f.id() // default — string id, ObjectId/UUID per dialect
id: f.id({ type: 'auto' }) // explicit form of the default
id: f.id({ type: 'uuid' }) // PG `uuid`, MySQL `CHAR(36)`, SQLite TEXT
id: f.id({ type: 'bigserial' }) // PG BIGSERIAL, MySQL BIGINT AUTO_INCREMENT,
// SQLite INTEGER PRIMARY KEY AUTOINCREMENT
// — JS type becomes `number`bigserial is the SQL-only opt-in. Forge throws at forge push time on Mongo with a clear error rather than half-applying — bigserial has no Mongo equivalent, and silently falling back would surprise consumers far more than a loud failure. Use auto or uuid if you need cross-DB portability.
Type narrowing:
const Order = model('orders', {
id: f.id({ type: 'bigserial' }),
total: f.int(),
});
type Row = InferRow<typeof Order>; // { id: number; total: number }
const o = await db.order.create({ data: { total: 5_000 } });
o.id; // number — TypeScript knows
await db.order.findFirst({ where: { id: 47 } });Implementation notes:
- DDL emission lives in each dialect's
columnType+ the dialect'srenderColumnfor the bigserial-only "no default, no separate NOT NULL" rules. PG and MySQL keep the standard table-levelPRIMARY KEYclause; SQLite renders the PK inline on the column (SQLite quirk:AUTOINCREMENTonly works inline). - App-side autogen is dropped for
bigserial—datanever includes the id on insert, the DB assigns it, and the existing PG/SQLiteRETURNING *plus MySQLinsertIdpaths surface the generated value back to the caller. - The Mongo push runs a single pre-flight pass over every model's fields and throws on the first
bigserialid it sees.
13 new specs across the four dialects + the schema-level type narrowing. Total 223 tests pass. Additive non-breaking — the default f.id() shape is unchanged.
1.3.3 — docs: clarify when as const actually matters on the schema
The README previously told readers to write as const on the schema object "so TypeScript reads the model and field names" — which implied autocomplete broke without it. That's not true for the recommended pattern (each model bound to its own const, then referenced from the schema literal): TypeScript already preserves the model types and the literal keys.
The schema-section now spells out the actual situation:
SchemaShape = Record<string, TypedModel<…>>accepts both mutable and readonly maps.- Without
as const, you still getdb.user.findFirst({ where: { … } })autocomplete,Row<typeof User>,InferCreate<typeof Post>, all of it. as constis worth writing anyway — it future-proofs against inlined models, string discriminators that would otherwise widen tostring, and downstreamkeyof typeof schemaconsumers — but it's defensive, not load-bearing.
No code changes.
1.3.2 — comment trim + tightened README intro
Source-side cleanup pass. No public-API changes, no behaviour changes — the goal was to delete restated-by-name comments, internal release tags ("Wave N — …") that meant nothing to library users, and section-banner markers in files small enough to scroll.
Kept: docstrings on every export, "why this looks weird" notes on subtle correctness paths, and the layered-resolver explainer in load-consumer-schema.ts (genuinely earns its length).
README intro tightened — dropped the redundant "young library with no long production track record" line; the limitations section already covers it honestly without prefacing the entire pitch with self-deprecation.
All 210 tests still pass.
1.3.1 — forge diff --ignore for noisy meta-collections
forge diff already filters the migration ledger (_forge_migrations) and engine-generated FTS shadows (*_fts). Every project also accumulates a few collections it doesn't want diff to flag forever — Atlas metadata, cross-service tables, change-stream tokens — and there was no way to suppress them without inheriting them into your schema. This release adds a user-supplied ignore list.
New CLI flag + env var on forge diff:
# exact names + regex (/.../flags), comma-separated
npx forge diff --ignore=sessions,logs,/^_atlas_/i
# env var works the same; CLI flag stacks on top
export FORGE_DIFF_IGNORE='/^_/i,external_events'
npx forge diffIgnored tables surface at the end of the report (ignored 2 tables: logs, sessions) so silent filtering can't hide real drift. When everything that would be drift is in the ignore list, the report goes back to ✓ no drift and exits 0 under --check.
Programmatic API: diffIntrospection(schema, introspection, ignore?) accepts an IgnoreSpec (Array<string | RegExp>). parseIgnoreList(str) parses the same comma-separated form the CLI/env take, so callers can mix both.
7 new specs cover literal/regex matching, the ignored-as-only-drift → in-sync case, malformed-regex fallback, and the report's ignored summary. Existing 191 unit + 163 integration tests untouched.
1.3.0 — direct-from-model type inference (Infer* family)
Take a typeof MyModel and pull out any input/output shape you need — no codegen, no SchemaMap registration, no detour through ForgeOf<'key'>. The existing Row<typeof M> and ForgeOf / ForgeModels APIs still ship; this adds a more direct path for service signatures, DTOs, validation layers, and anywhere outside db.*.
New exported types:
| Helper | What it gives you |
|---|---|
InferRow<typeof M> | Row shape — field types after defaults/nullability resolve |
InferWhere<typeof M> | where input — field filters + AND/OR/NOT |
InferWhereUnique<typeof M> | Partial unique-key lookup |
InferCreate<typeof M> | create input — scalars + nested relation directives |
InferUpdate<typeof M> | update input — plain values + atomic ops on numbers |
InferUpsert<typeof M> | { create, update } pair |
InferOrderBy<typeof M> | { field: 'asc' | 'desc' } per scalar |
InferSelect<typeof M, S?> | Field-level select; second generic for relation walking |
InferInclude<typeof M, S?> | Relation include map; second generic for nested args |
InferOmit<typeof M> | Boolean toggles per scalar |
Infer<typeof M, S?> | One bundle of every alias above |
InferSchema<typeof schema> | Mapped bundle across every model in a schema record |
const User = model('users', { id: f.id(), email: f.string(), age: f.int().optional() });
type UserCreate = InferCreate<typeof User>; // { email?: string; age?: number | null; … }
type UserUpdate = InferUpdate<typeof User>; // includes { age: { increment: 1 } } shape
type UserT = Infer<typeof User>; // bundled .Row / .Where / .Create / …
const schema = { user: User, post: Post } as const;
type T = InferSchema<typeof schema>;
type PostSelect = T['post']['Select']; // relations resolve via schema map13 new type-level tests cover the family; existing 191 unit + 163 integration tests untouched.
1.2.0 — zero-config schema resolution (layered: flag → env → package.json → cache → scan)
forge no longer needs a convention path list. It now resolves the consumer's schema through a layered cascade: explicit pointers first, then a one-time filesystem scan that caches its result, with a hard, actionable failure when nothing turns up.
Resolution order:
--schema=<path>flag — zero ms, highest priority.FORGE_SCHEMA_PATH=<path>env var — zero ms.package.json → forge.schema— config-in-package, Prettier-style.node_modules/.cache/forge/schema-cache.json— cached scan result; instant for every run after the first.- Filesystem scan — walks the project tree (one-time cost), finds files that both import from
forge-ormand export aschemaconst. Skipsnode_modules,dist,build,.git,.next,coverage,.cache,.turbo,.svelte-kit,.nuxt,.parcel-cache,.vercel,.netlify,.serverless,out,.output,.idea,.vscode, test files (*.test.*,*.spec.*),__tests__/,__mocks__/,fixtures/. Eliminates ~99% of files with a raw byte-search for the stringforge-ormbefore doing any deeper work. - Hard fail — no silent fallback to the bundled sample. Error message lists every layer that was searched and gives three concrete ways to fix it (add
package.jsonentry, pass--schema, or check the schema's exports).
Multi-match handling:
If the scan finds two or more candidates (e.g. a real schema + a test fixture), forge prints all of them and asks the consumer to pick one via package.json or --schema.
Performance (measured on a real ~10k-file project / 30-collection schema):
- Scan (cold): ~300 ms
- Cache hit: ~0 ms overhead
- Total push wall-clock: ~1.1 s (down from ~2.3 s cold) for cache-hit reruns
Bundled-sample fallback removed. Forge's own monorepo tests use the explicit --schema= flag in their npm scripts, so the silent fallback path no longer exists. This makes failure modes loud and obvious.
1.1.5 — npx forge binary (Prisma-style subcommands)
New
forgeCLI binary, registered via"bin": { "forge": "..." }inpackage.json. Afternpm install forge-orm, consumers can now run:shnpx forge push # idempotently sync schema → DB npx forge diff # show drift npx forge diff apply # generate + apply reconciliation migration npx forge rollback # undo last migration npx forge doctor # adapter pre-flight checks npx forge --helpNo env vars, no flags, no glue scripts required. Schema is auto-detected from convention paths (
src/schema.ts,src/core/database/schema.ts, etc.) or pointed at with--schema=<path>/FORGE_SCHEMA_PATH.The old
forge:push/forge:diff/forge:diff:apply/forge:rollbacknpm scripts continue to work inside the forge monorepo for our own dev/test runs.
1.1.4 — forge:push exits cleanly when work is done
forge:pushno longer hangs after the push completes. The top-level CLI was relying on Node's natural exit, butpushAllIndexes()leaves the Mongo client's connection pool open, so the process would sit idle for ~30s waiting for the keepalive to time out. The standalone mongo entry point (dist/adapters/mongo/scripts/push.js) was unaffected — it always calledprocess.exit(0). Now the top-level CLI does too.Measured on a real ~30-collection / 111-index schema: 1057 ms of actual work; previously the process would sit at 1 s of work + 30 s of dangling connection before Node figured out it was done.
1.1.3 — forge:push reads the consumer's schema (was: bundled sample)
This release fixes a real bug consumers were hitting silently:
forge:push,forge:diff, andforge:diff:applywere hardwired to forge's own bundled sample schema via a direct relative import. That meant running any of them against your own database silently pushed forge's sample indexes (which don't exist on your collections) instead of yours — and your declared.unique()/@@unique/@@indexconstraints never landed.The bug was caught in production by a consumer whose webhook-idempotency event collection had been protected only by an application-level guard for weeks; the
_idindex was the only index on the collection in the real database, despite the schema declaringeventIdas unique.New resolution order for the consumer's schema, used by all three CLI commands:
--schema=<path>flagFORGE_SCHEMA_PATH=<path>env var- Convention paths (
src/schema.ts,schema.ts,src/core/database/schema.ts, …) auto-detected fromprocess.cwd() - Bundled sample fallback (with a loud warning) — for forge's own monorepo dev/test runs only; consumers should never hit this.
TypeScript schemas are auto-registered with
ts-nodein transpile-only mode when loaded by the CLI, soforge:pushruns in milliseconds even on schemas with dozens of models. Without this, the defaultts-nodewould type-check the whole file (~30-60s on a real schema) before producing any output, which felt like a hang.README updated with the new resolution-order rules.
1.1.2 - auto-generated keys and timestamps on every database
- Auto-generated primary keys on all databases. When you create a row without an
id, forge now generates one on SQL too (a UUID), not just on Mongo (an ObjectId). You no longer assign an id by hand on Postgres, MySQL, or SQLite. .updatedAt()now works on all databases. It was applied only on Mongo; it now auto-bumps the column on every update for Postgres, MySQL, and SQLite as well..default('now')continues to fill created-at columns.- Rewrote the README in plain language, organised by feature rather than by internal development phase, with clearer explanations of relations and the automatic fields, and the incidental content removed.
1.1.1 - standalone & driver-lazy (fixes 1.1.0)
- Fix (critical): importing
forge-ormno longer requires any database driver.mongodb(MongoClient/ObjectId) is now lazy-loaded, so a SQL-only - or import-only - consumer doesn't need the mongodb driver. (1.1.0 crashed on import withCannot find module 'mongodb'.) - Removed the NestJS integration (
DatabaseModule/DatabaseService) and the@nestjs/commondependency. forge is now a fully standalone, framework-agnostic library - no framework coupling, no bundled driver. - Drivers (
pg/mysql2/better-sqlite3/mongodb) are optional peer dependencies: install only the one(s) you use; each isrequire()d lazily on first use against that dialect. Verified:npm install forge-ormwith zero drivers imports cleanly and defines schemas. - README: explicit "install the driver for your database" table + a no-lock-in note.
1.1.0 - drop-in library (schema decoupling)
forge is now a true drop-in library: bring your own schema instead of being tied to the bundled sample. Backward compatible - omit schema and the sample is used. 354 tests green across all four dialects (191 unit + 163 integration).
Added
createDb({ schema })- pass your ownmodel(...)map; the returneddbis typedForgeDb<typeof yourSchema>(fully typed models, where-inputs, relations, select/include - no codegen).- Exported the schema DSL from the package root:
f,model,rel,enums,embed, plusSchemaShape,sampleSchema,setActiveSchema,getActiveSchema, and the schema/field types. - Generic
ForgeDb<S>andCollectionWrapper<F, R, SM>so consumer schemas get full nested include/select typing. examples/custom-schema-demo.ts(npm run forge:example:custom) - runnable end-to-end proof with a non-sample (e-commerce) schema.- Canary:
npm run forge:canary+forge:canary:load(real-traffic HTTP service on an isolated DB) and the findings incanary/README.md/ Production notes.
Changed
- The exported
schemais now a live view of the active schema (a Proxy over an active-schema registry), defaulting tosampleSchema. The ~14 internal consumers are unchanged; consumer schemas flow in via the registry. - Repo restructured: git root + npm package root moved to
forge/; builds todist/with.d.ts;filesallowlist shipsdist+ README + CHANGELOG only.
Notes
- One active schema per process (last
createDb({ schema })wins) - fits the one-schema-per-service norm; use separate workers for multiple.
1.0.0 - Wave 5 (production hardening)
Feature-complete release. 352 tests green across all four dialects (189 unit + PG 53 / SQLite 37 / Mongo 38 / MySQL 35 integration); full forge:all sweep runs in ~13s.
Added
- Comparison bench (5a) -
forge:bench:compare[:pg|:mysql|:sqlite|:mongo]runs identical scenarios through forge vs Prisma vs Drizzle vs the raw driver against the same table, reporting median / p95 / ops·s⁻¹ / overhead. Prisma connects via driver-adapters (@prisma/adapter-{pg,mariadb,better-sqlite3});forge:bench:compare:gengenerates the Prisma clients. - Drift detection (5b) -
forge:diffintrospects the live database (PGpg_catalog/information_schema, MySQLINFORMATION_SCHEMA, SQLitePRAGMA, MongolistCollections/indexes) and reports missing/extra tables, columns, indexes, foreign keys, type-category mismatches, and views. Human-readable +--json;--checkexits non-zero for CI gating. - Schema-diff migrations (5c) -
forge:diff:applygenerates and runs the reconciling SQL (forward), writing timestampedmigrations/<ts>_*.sqlfiles with matching-- up/-- downblocks and recording them idempotently in a_forge_migrationshistory table.forge:rollbackruns the latestdown. SQL dialects only. - Materialised views (5d) -
.asView({ materialised: true })emitsCREATE MATERIALIZED VIEW(PG), a repopulated table (MySQL/SQLite), or an$outcollection (Mongo).db.<model>.refresh()recomputes;db.<model>.scheduleRefresh('30s'|'5m'|'1h')auto-refreshes and returns astop()(timers areunref'd - no leaks). - Native types (5e) -
f.decimal({ precision, scale }),f.uuid({ default }),f.bigint(), and.dbgenerated('<expr>')generated columns, each emitting dialect-correct DDL. strictmode (5e) -createDb({ strict: true })rejects unknownwherekeys at runtime (closes the loose[key: string]: anyescape hatch).select/includeexclusivity (5e) - passing both is now a compile-time type error.
Changed
Adapterinterface gained optionalintrospect()andrefreshView().- Demo schema grew a
post_statsmaterialised view (now 10 registered models). select/includeare mutually exclusive at the type level on read + write methods.
Removed
- Retired the orphaned legacy
adapters/mongo/translate/data.ts(translateUpdateData); its coverage moved to the IR path (buildUpdate→compileUpdate) inmongo-compile-update.spec.ts.
0.x - Waves 0–4c
- Wave 0 - top-level package, optional peer-dependency drivers,
Adapterscaffold. - Wave 1 - adapter-agnostic query IR; IR-driven read + write paths (Mongo).
- Wave 2 - Postgres adapter: compile-from-IR, executor, DDL, migration runner,
$queryRaw/$executeRaw, P1xxx/P2xxx error mapping. - Wave 3 - MySQL and SQLite adapters (compile + execute + DDL + migrate + errors).
- Wave 4a -
db.$on('query'|'error')events,findManyStream,where.search. - Wave 4b -
.searchable()auto-FTS indexes,.softDeleteAt(), native cursor streaming,wireOtel()OpenTelemetry helper. - Wave 4c -
.asView()read-only views; SQLite FTS5 read-route rewriting.