Skip to content

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.

ts
f.id({ type: 'objectId' })                  // per field
createDb({ url, schema, defaultIdType: 'objectId' })   // whole connection

The 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.

ts
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.

ts
await db.$ping();   // resolves if the database answers, rejects if not

It 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:

ts
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:

ts
await db.$migrate({ dryRun: true });   // forge push --dry-run
await db.$migrate({ prune: true });    // forge push --prune

dryRun 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:

ts
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 schema

Expected 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:

ts
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:

ts
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 there

The 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:

ts
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:

valueenumerates asthrown 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:

ts
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 jsonb

So 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.

ts
// 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 ​

ts
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 (update throws, updateFirst is null, updateMany counts, 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 update requires 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 use updateFirst: the version-with-current-state save, and the If-Match PUT handler, which was also setting an ETag read back after the write — under a concurrent write that is a version the request never produced, and the client's next If-Match gets an unexplainable 412.
  • SOFT-DELETE.md states the third round trip where the read filter is documented.
  • ERRORS.md's P2025 section says which verb to reach for when "no such row" is a 404 rather than a bug, and why catching P2025 around an update() is not the same thing.
  • MULTI-TENANT.md's hand-written scopedDb recipe 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 op as 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 Mongo findOneAndUpdate reports 'update', and 'raw' is emitted by nothing. Raw queries fire no event at all: $queryRaw, $executeRaw and $runCommandRaw go straight to the driver, which the page now says where it used to describe their model as ''. The sql field'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, tested event.semanticOp against physical op names — and CACHING.md's also read event.args.where.id. semanticOp is set only for the softDelete / restore family and is absent on a direct write, so all three fired on nothing — a cache that looks wired and never invalidates — and there is no args field on the event to fall back to. All three now gate on op, skip a rowCount of 0, and derive per-row and per-category keys at the call site, which is the only place the where exists. The page also claimed the event "runs after commit"; it fires when the statement resolves, which inside $transaction is before COMMIT, 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 described semanticOp as "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(), so softDelete() would have thrown on the schema the example hands you, and gave the read opt-out as withDeleted: 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.

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:

ts
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 inside dbA.$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_hooks is loaded through a runtime-assembled specifier, because a bundler resolves a literal require('node:async_hooks') at build time even inside a try — 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.x is unaffected and an existing AsyncLocalStorage-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:

ts
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

ts
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:

ts
// 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 $transaction runs BEGIN on 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 the BEGIN, and rolls back with it — and two overlapping transactions collide. One writer at a time on those dialects.
  • IndexedDB's $transaction runs the callback with no session, and each operation opens its own short-lived IDB transaction. That is the platform, not a gap: an IDBTransaction auto-commits as soon as the microtask queue idles, so it cannot survive an await on 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 where nested 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 a groupBy over the children instead.

Docs ​

  • TRANSACTIONS.md documented the array form as non-atomic (honestly) and told readers to build their own AsyncLocalStorage store with a scoped() accessor in every repository. Both are obsolete. The page now covers the ambient session, its rules, the three helpers, why there is no withoutAmbientSession(), and what is safe to delete from an existing txStore / 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 include leak was found, is replaced by the real behaviour: scoping at every depth, the _withDeleted opt-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 when dbClient is 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 a db.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 is softDelete().
    • Relation _count was described as a correlated subquery in the parent's SELECT list (and $lookup + $size on 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 _count was 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 $transaction as "one IDB readwrite txn spanning both stores, committed atomically", which it never was.
    • FOREIGN-KEYS.md claimed forge doctor flags 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 shared MongoClient. 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 old Promise.all form.
    • Twelve broken intra-page anchors.

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

ts
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:

ts
await db.account.deleteMany({ where: { tenant_id: req.user?.tenantId } });
// → DELETE FROM "accounts"        ← no WHERE at all

One 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":

CallMeaning
where omittedevery 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 —

ts
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:

sql
(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

js
{ $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:

ts
await db.session.create({ data: { location_id: id } });      // ObjectId
await db.session.updateMany({ where: { … }, data: { location_id: id } });
                                                             // String

And 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 ​

ts
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 error

New 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:

Dialecthas / hasSome / hasEvery / isEmpty
Postgres= ANY / && / @> / array_length
DuckDBlist_contains / list_has_any / list_has_all / len
MySQLJSON_CONTAINS / JSON_OVERLAPS / JSON_CONTAINS / JSON_LENGTH
SQLitejson_each / json_array_length
MSSQLOPENJSON

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:

DialectpushaddToSetpull
Postgresarray_appendyesyes
DuckDBlist_appendyesyes
Mongo$push$addToSet$pull
IndexedDBin JSyesyes
MySQLJSON_ARRAY_APPENDyesno
SQLitejson_insert '$[#]'nono
MSSQLJSON_MODIFY 'append $'nono

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

ts
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:

sql
ORDER BY SUM("orders"."total") DESC, "orders"."customer_id" ASC

That 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:

ts
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.

ts
await db.post.deleteMany({ where: { author: { is: { email: 'x' } } } });
// compiled to deleteMany({}) — the whole collection

A 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.

DialectColumn
Postgresbytea
SQLiteBLOB
DuckDBBLOB
MySQLsmallest blob class that fits maxBytes, LONGBLOB undeclared
MSSQLVARBINARY(n) when maxBytes <= 8000, else VARBINARY(MAX)
MongoBSON BinData
IndexedDBthe 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:

ts
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's where was 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 full WhereInput, and InferWhereUnique is only Partial<WhereInput>. The advice that followed from it ("use updateMany instead") sent readers down a doubled round trip, because update already returns the row.
  • Per-parent include windowing was documented as compiling to ROW_NUMBER() OVER (PARTITION BY …) on SQL and $slice on 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 of findMany, findFirst, count and findManyStream only, and relation subqueries have no soft-delete awareness. Flagged as a known gap rather than quietly rewritten.
  • take: -N was documented as a backwards page, with a described implementation (flip the predicate, flip the ORDER 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 cursor with skip: 1, which drops a row against an exclusive cursor.
  • aggregate({ _sum, _avg, … }) was documented across eight pages and did not exist. So was orderBy on 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: true form 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.$models is undefined and 'User' in db is false, 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.

ts
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 the driver option, 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 pg and @electric-sql/pglite are different packages, and a single forge-orm/postgres importing both would put a WASM Postgres in every bundle that wanted only pg. 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/sqlite pulls 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 exist

The 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.

ts
const db = await createDb({ url: 'pglite:./data', schema })
await db.$migrate()      // creates tables, constraints and indexes

No 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 ​

ts
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:

sql
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 ​

ts
const db = await createDb({ url: 'pglite:./data', schema })   // on disk
const db = await createDb({ url: 'pglite:', schema })          // ephemeral

PGlite 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) and pgliteDataDir.
  • pglite://./x and pglite: 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:

ts
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 index

forge 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 $1 both occur inside string literals, quoted identifiers and comments, and Postgres $tag$…$tag$ dollar-quoting means $1 can 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 / findManyStream have no single compiled statement; analyze on a Mongo write would ask the server to plan a change to your data; analyze on SQL Server needs a connection-state change forge will not make behind your back on a pooled connection. findFirstOrThrow and findUniqueOrThrow are not refused — they differ only after the statement.
  • New exports: formatExplain, inlineParams, splitSql, fragmentFromSql, and the ExplainReport / ExplainedQuery types.
  • 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.

bash
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-02

The 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.

exitcommandmeaning
2generate --checkthe schema has unsafe changes
3generate --checka schema change has no migration
4migrate status --checkthe 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() in migrate-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 ORDER means 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:

ts
name: f.string().renamedFrom('full_name'),
sql
-- 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:

sql
-- 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.

bash
npx forge generate --name add-org-slug
migrations/
  meta/_journal.json          ordering
  meta/0002_snapshot.json     the schema's shape after 0002
  0002_add-org-slug.sql

Why ​

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 ​

bash
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.region

That 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:

ts
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:

ts
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:

ts
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:

ts
'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:

ts
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:

ts
await db.bill.findMany({ where: { postedAt: { lte: cutoff } } });   // 198 rows
await db.bill.findMany({ where: { postedAt: { $lte: cutoff } } });  // ALL 240 rows, silently

Now, 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:

ts
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:

ts
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 101

That 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:

ts
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:

ts
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).

ts
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 drivers

The 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.

ts
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:

URLBackendPersistenceMulti-tab
idb:<name>IndexedDBFullNative — IDB coordinates writers
indexeddb:<name>IndexedDB (alias of idb:)FullNative
ts
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:

ts
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:

ScoreStrategy
100primary-key eq
95full compound-index equality (all keys)
90unique-index single-column eq
85non-unique compound equality on all keys
70non-unique single-column eq
60in on indexed column
50range op (lt / lte / gt / gte / startsWith) on indexed column
20orderBy on indexed column with no where match (free sort)
0full-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:

ChangeBehaviour on IDB
Add a fieldNo-op — IDB is schemaless
Add an indexstore.createIndex() runs inside onupgradeneeded; IDB re-scans existing rows and back-populates the index automatically
Rename / drop an indexstore.deleteIndex() (+ create for renames)
Add a storecreateObjectStore()
Drop a storeDestructive — surfaces in report.pending, opt-in only
Change a field's typeNo-op at DDL level — coerce at write side
Change a keyPathDestructive — 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:

onDeleteBehaviour
CascadeRecurse (leaves-first) into child rows, then delete parent
SetNull$unset the FK column on child rows, then delete parent
RestrictThrow [P2003] Restrict: cannot delete X if children exist
NoAction / undefinedSkip — 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 ​

  • AdapterKind widened to include 'indexeddb'. DRIVER_PACKAGE_FOR reports (none — browser built-in) for the new kind so forge 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-install example, and an INDEXEDDB.md row 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 opSqliteDriver there. 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:

  1. CREATE IF NOT EXISTS for tables and indexes (unchanged from 2.5.0).
  2. introspectSqlite + diffIntrospection against the active schema.
  3. For every { kind: 'column', direction: 'missing' } drift item that's safe — nullable, or has a constant default — emit ALTER TABLE … ADD COLUMN inside the same transaction.
  4. Surface destructive drift (column drops, type changes, extra tables, NOT NULL columns with no default) under a new pending field. The runtime never tries to drop or re-type — those are full table-rebuild territory.
ts
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 ​

FilePurpose
src/wasm/drift-apply.tsintrospect + diff + safe-ALTER pass; exported as applyDrift() for direct use
src/__tests__/wasm-drift-apply.spec.ts7 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:

sql
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 upsertCreate are pulled from the where leaf so the INSERT branch is always complete.
  • Supports set, increment (COALESCE(tgt.[col], 0) + …), multiply, and unset (= NULL) on the UPDATE branch.
  • Returns the row via OUTPUT INSERTED.*, matching PG's RETURNING.

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:

js
{ 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:

ts
const doctor = await db.$doctor();   // BrowserDoctorReport on sqlite, DoctorReport elsewhere
const drift  = await db.$diff();     // DriftReport for every adapter

On 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:

ts
// 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) emits POLYGON((…)) for a single ring or MULTIPOLYGON(((…))) for everything else; handles the MySQL lat-first axis-order quirk via the axis param.
  • 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:

Dialectdims = 2 (default)dims = 3
PGgeography(Point, srid)geography(PointZ, srid) — PostGIS auto-promotes from POINT Z(x y z) WKT
MySQLPOINT NOT NULL SRID sridSame column type; altitude stored alongside in a JSON field per app (MySQL 8 has no native 3D)
SQLiteGeomFromText('POINT(x y)', srid)GeomFromText('POINT Z(x y z)', srid) — SpatiaLite
DuckDBST_Point(x, y)ST_Point3D(x, y, z) — spatial extension's 3D type
MSSQLSTGeomFromText('POINT(x y)', srid)STGeomFromText('POINT(x y z)', srid) — geography accepts Z
MongoGeoJSON 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 / withinPolygon must be in the target SRID's units — no auto-transformation at the IR layer.
  • proj4 (or per-dialect ST_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 — shared toGeoWKT / toGeoJson / multiPolygonBbox so every dialect emits the same WKT/GeoJSON form.
  • pointInPolygon renamed to pointInRing internally + new pointInMultiPolygon honouring holes via even-odd. Legacy export alias preserved.
  • FallbackGeoOps.withinPolygon shape extended to multiPolygon (the uniform internal form); old polygon shape 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 withinPolygon value gained a multiPolygon field; the legacy polygon field is still accepted by every consumer.
  • The MSSQL MERGE is a behaviour change for the upsert path (was a thrown error in 2.3 / 2.4) — apps that had a try/catch workaround 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-pro artifact — 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:

URLVFSPersistenceMulti-tab
opfs-sahpool:<path>SAH-pool (recommended)FullSafe
opfs:<path>Plain OPFSFullSingle-tab writer
:memory:In-memoryNoneN/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. Adds optimizeDeps.exclude, sets worker.format: 'es', attaches COOP/COEP middleware to the dev server.
  • forge-orm/wasm/next — withForgeWasm(nextConfig) wrapper. Enables experiments.asyncWebAssembly + topLevelAwait, adds a .wasmasset/resource rule, sets experimental.esmExternals: 'loose', wraps headers() 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 ​

  • encodeParams in the SQLite executor swaps Buffer.isBuffer(v) for a guarded check (typeof Buffer !== 'undefined' && …) plus a Uint8Array branch — needed so wasm/browser bundles don't blow up at module-eval time when Buffer isn't a global.
  • factory.ts extends ForgeDb with $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.json adds DOM + WebWorker libs so the wasm entrypoints can reference Worker / 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-wasm and a bundler that supports new 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't ALTER columns. Hand-roll $executeRaw for schema evolution until full in-browser forge diff lands 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 in withForgeWasm() 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 where operator (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.

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.

ts
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.

ts
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 placeholders
  • LIMIT/OFFSET → OFFSET … ROWS FETCH NEXT … ROWS ONLY
  • RETURNING * → OUTPUT INSERTED.* / OUTPUT DELETED.*
  • PG's ctid single-row trick → [pk] IN (SELECT TOP 1 [pk] …)
  • IF NOT EXISTS wrapped in IF NOT EXISTS (SELECT 1 FROM sys.tables …) BEGIN … END
  • CREATE EXTENSION replaced 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.

bash
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 output

Covers: 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.

ts
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:

DialectColumnSpatial indexnear filternearTo orderBy
MongoGeoJSON in JSON2dsphere$near + $maxDistance$geoNear aggregate (auto-routed)
Postgresgeography(Point, 4326)USING GIST (PostGIS)ST_DWithin(...)ST_Distance(...) AS _distanceMeters
MySQL 8POINT NOT NULL SRID 4326CREATE SPATIAL INDEXST_Distance_Sphere(...) < NST_Distance_Sphere(...) AS _distanceMeters
SQLiteBLOB (SpatiaLite)virtual idx_<tbl>_<col>Distance(..., 1) < NDistance(..., 1) AS _distanceMeters
DuckDBGEOMETRY (spatial ext)USING RTREEST_Distance_Sphere(...) < NST_Distance_Sphere(...) AS _distanceMeters
MSSQLGEOGRAPHYCREATE SPATIAL INDEXcol.STDistance(...) < Ncol.STDistance(...) AS _distanceMeters

Polygon containment:

ts
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:

ts
location: f.geoPoint({ fallback: true }),    // JSON storage + Haversine

Column 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:

bash
$ 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:

ts
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')::numeric with auto-cast based on operand type. Array indexes are emitted as numeric ->N segments.
  • 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.

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).

ts
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:

DialectColumnVector indexnear filternearTo orderBy
Postgresvector(N) (pgvector)USING hnsw (col vector_<metric>_ops)(col <=> $vec) < $dcol <=> $vec AS _distance
MySQL 9VECTOR(N)basic — exact only (community)DISTANCE(col, STRING_TO_VECTOR(...), 'COSINE') < $dDISTANCE(...) AS _distance
SQLiteTEXT (JSON)needs sqlite-vec vec0 virtual table (out-of-band)brute-force / vec0 raw querynot portable
DuckDBFLOAT[N]USING HNSW (vss extension)array_cosine_distance(col, [...]) < $darray_cosine_distance(...) AS _distance
MSSQLVECTOR(N)USING VECTOR WITH (algorithm = 'HNSW')VECTOR_DISTANCE('cosine', col, ...) < $dVECTOR_DISTANCE(...) AS _distance
Mongoplain arrayAtlas Search Index (createSearchIndex)routed to $vectorSearch pipelinerouted 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-loads spatial; vss is one extra connection.run away
  • SQLite: install sqlite-vec extension; 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.
  • AdapterKind widened to include 'duckdb' | 'mssql'. The Dialect interface's name union and the SQLDialect compile union widened to match.
  • Dialect gains valueExpr (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).
  • IndexMethod gains '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 the vss extension 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 introspect now joins pg_am and reads pg_get_expr(indpred,…)
    • pg_get_indexdef, surfacing method, partial WHERE, expression body, and INCLUDE columns. The INCLUDE boundary is derived from pg_index.indnatts so covering columns end up in their own slot instead of mixed with the key columns.
  • MySQL introspect reads INDEX_TYPE (BTREE / FULLTEXT / SPATIAL) and EXPRESSION from information_schema.STATISTICS. The EXPRESSION column doesn't exist on MySQL pre-8.0, so the query falls back when it raises Unknown column.
  • Mongo introspect carries partialFilterExpression, collation, wildcardProjection, and the raw key map (so '2dsphere' / 'hashed' / 1 / -1 directions survive the round-trip).
  • diff-core now 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 WHERE strings, 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).
  • IntrospectedIndex gains 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 of partialFilterExpression (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 both where: 'sql…' and partialFilterExpression: { … } so it works on every dialect.
  • expression — index the result of a SQL expression instead of a column list. Compiles to CREATE INDEX … ((<expr>)) on Postgres / MySQL 8+ / SQLite. Mongo doesn't model expression indexes — forge push warns and skips. Use this for lower(email) case-insensitive lookups, ((data->>'sku')) JSON paths, computed keys.
  • method — index access method. btree (default) / gin / gist / brin / hash for Postgres, and spatial / fulltext for MySQL. On MySQL spatial / fulltext are statement-prefix keywords (CREATE SPATIAL INDEX …), not USING clauses, so they're emitted in that form. SQLite and Mongo ignore method.
  • 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 shape collection.createIndex accepts: { 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 with keys: { '$**': 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 ​

ts
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 WHERE translation. 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:

ts
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:

ts
// 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:

ts
// 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 MysqlDriver port. Built-in wrappers: mysql2Driver (default), mariadbDriver (MariaDB connector), planetscaleDriver (@planetscale/database, serverless). Pass mariadb pools bigIntAsNumber:true / insertIdAsNumber:true for type parity with mysql2.
  • MongoDB — one canonical driver, so pluggability means bringing your own MongoClient via mongoDriver(client, dbName?): custom options, a shared client, or a Mongo-API backend (Amazon DocumentDB, Azure Cosmos DB, FerretDB).
ts
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 / applyMigration still assume the default mysql2 pool.

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 }):

ts
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 pg path 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 / applyMigration still assume the default pg pool (advisory locks need pool.connect()); with an injected driver, run runtime queries through forge and manage DDL via pg or 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:

ts
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-sqlite3 path 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 .db now exposes the SqliteDriver port rather than the raw better-sqlite3 handle — pass it to applyMigration as 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.ts and adapters/mongo/translate/{where,orderby,select-include}.ts (zero importers; the IR compile-from-ir.ts + execute.ts path superseded it) and their two unit specs.
  • Dropped small dead bits: an unused REGEX_ESCAPE const, an unused actualNames set, 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:

  • having now accepts the Prisma field-first shape in addition to the bucket-first shape it already took. Both of these now mean the same thing:

    ts
    having: { total: { _sum: { gte: 120 } } }   // field-first (Prisma)
    having: { _sum: { total: { gte: 120 } } }   // bucket-first

    Normalisation happens once in buildGroupBy, so every dialect benefits.

  • Fixed count({ distinct: [...] }) on MongoDB, which previously ignored distinct and 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 value was 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:

ts
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 $field path 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 $expr lands in its own $and entry 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 forge commands 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:

ts
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:

ts
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's renderColumn for the bigserial-only "no default, no separate NOT NULL" rules. PG and MySQL keep the standard table-level PRIMARY KEY clause; SQLite renders the PK inline on the column (SQLite quirk: AUTOINCREMENT only works inline).
  • App-side autogen is dropped for bigserial — data never includes the id on insert, the DB assigns it, and the existing PG/SQLite RETURNING * plus MySQL insertId paths 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 bigserial id 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 get db.user.findFirst({ where: { … } }) autocomplete, Row<typeof User>, InferCreate<typeof Post>, all of it.
  • as const is worth writing anyway — it future-proofs against inlined models, string discriminators that would otherwise widen to string, and downstream keyof typeof schema consumers — 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:

sh
# 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 diff

Ignored 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:

HelperWhat 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
ts
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 map

13 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:

  1. --schema=<path> flag — zero ms, highest priority.
  2. FORGE_SCHEMA_PATH=<path> env var — zero ms.
  3. package.json → forge.schema — config-in-package, Prettier-style.
  4. node_modules/.cache/forge/schema-cache.json — cached scan result; instant for every run after the first.
  5. Filesystem scan — walks the project tree (one-time cost), finds files that both import from forge-orm and export a schema const. Skips node_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 string forge-orm before doing any deeper work.
  6. 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.json entry, 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 forge CLI binary, registered via "bin": { "forge": "..." } in package.json. After npm install forge-orm, consumers can now run:

    sh
    npx 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 --help

    No 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:rollback npm 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:push no longer hangs after the push completes. The top-level CLI was relying on Node's natural exit, but pushAllIndexes() 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 called process.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, and forge:diff:apply were 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 / @@index constraints 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 _id index was the only index on the collection in the real database, despite the schema declaring eventId as unique.

  • New resolution order for the consumer's schema, used by all three CLI commands:

    1. --schema=<path> flag
    2. FORGE_SCHEMA_PATH=<path> env var
    3. Convention paths (src/schema.ts, schema.ts, src/core/database/schema.ts, …) auto-detected from process.cwd()
    4. 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-node in transpile-only mode when loaded by the CLI, so forge:push runs in milliseconds even on schemas with dozens of models. Without this, the default ts-node would 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-orm no 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 with Cannot find module 'mongodb'.)
  • Removed the NestJS integration (DatabaseModule/DatabaseService) and the @nestjs/common dependency. 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 is require()d lazily on first use against that dialect. Verified: npm install forge-orm with 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 own model(...) map; the returned db is typed ForgeDb<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, plus SchemaShape, sampleSchema, setActiveSchema, getActiveSchema, and the schema/field types.
  • Generic ForgeDb<S> and CollectionWrapper<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 in canary/README.md / Production notes.

Changed ​

  • The exported schema is now a live view of the active schema (a Proxy over an active-schema registry), defaulting to sampleSchema. The ~14 internal consumers are unchanged; consumer schemas flow in via the registry.
  • Repo restructured: git root + npm package root moved to forge/; builds to dist/ with .d.ts; files allowlist ships dist + 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:gen generates the Prisma clients.
  • Drift detection (5b) - forge:diff introspects the live database (PG pg_catalog/information_schema, MySQL INFORMATION_SCHEMA, SQLite PRAGMA, Mongo listCollections/indexes) and reports missing/extra tables, columns, indexes, foreign keys, type-category mismatches, and views. Human-readable + --json; --check exits non-zero for CI gating.
  • Schema-diff migrations (5c) - forge:diff:apply generates and runs the reconciling SQL (forward), writing timestamped migrations/<ts>_*.sql files with matching -- up / -- down blocks and recording them idempotently in a _forge_migrations history table. forge:rollback runs the latest down. SQL dialects only.
  • Materialised views (5d) - .asView({ materialised: true }) emits CREATE MATERIALIZED VIEW (PG), a repopulated table (MySQL/SQLite), or an $out collection (Mongo). db.<model>.refresh() recomputes; db.<model>.scheduleRefresh('30s'|'5m'|'1h') auto-refreshes and returns a stop() (timers are unref'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.
  • strict mode (5e) - createDb({ strict: true }) rejects unknown where keys at runtime (closes the loose [key: string]: any escape hatch).
  • select/include exclusivity (5e) - passing both is now a compile-time type error.

Changed ​

  • Adapter interface gained optional introspect() and refreshView().
  • Demo schema grew a post_stats materialised view (now 10 registered models).
  • select/include are 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) in mongo-compile-update.spec.ts.

0.x - Waves 0–4c ​

  • Wave 0 - top-level package, optional peer-dependency drivers, Adapter scaffold.
  • 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.

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