Compatibility and releases
This documentation covers Simfinity.js 3.7.0, for MongoDB and PostgreSQL. All five packages are versioned and released together.
Package availability
| Package | Purpose | Release |
|---|---|---|
| @simtlix/simfinity-js | MongoDB facade, including compatibility exports | 3.7.0 |
| @simtlix/simfinity-postgres | PostgreSQL adapter with generated schema and FKs | 3.7.0 |
| @simtlix/simfinity-sql | Driver-free relational runtime and plugin contract | 3.7.0 |
| @simtlix/simfinity-core | Shared runtime and helpers | 3.7.0 |
| @simtlix/simfinity-mcp | Optional MCP integration for either database | 3.7.0 |
Follow installation and downloads or read the latest stable release notes.
Requirements
| Component | Requirement |
|---|---|
| Library runtime | Node.js 18.18.0 or newer; use a maintained release for a new application |
| GraphQL peer | ^16.11.0 |
| MongoDB facade | Mongoose ^8.24.2; MongoDB 7 or 8 replica set/sharded cluster for mutations |
| PostgreSQL facade | PostgreSQL 15, 16, or 18; pg ^8.16.3 |
| Downloadable starters | Node.js 22.15.0 or newer, npm and the database selected in the corresponding quick start |
| MCP tool generation | Opt-in @simtlix/simfinity-mcp; the SDK is needed for MCP transports |
| Documentation development | Node.js 22 or newer |
Upgrade to 3.7.0
Version 3.7.0 can read generated collection fields in batches, opt-in on both PostgreSQL and MongoDB. Without the option, both adapters send 3.6.0's statements and commands. With it, rows in a total order, filters, scopes, errors and middleware calls are unchanged; unsorted rows and rows with equal sort keys can come in another order. It also gives EnvelopSchemaPlugin one TypeScript declaration across the packages. Keep all directly installed Simfinity packages at the same version. When upgrading from an earlier version, also review the 3.6.0 notes below.
- By default: the bundled adapters read each collection field per parent, as in 3.6.0; the runtime batches only for an adapter that defines
findChildrenBatch. - Opt-in:
batchedCollections: true, for PostgreSQL increatePostgres(options),postgresPlugin(options)orconfigure(), and for MongoDB increateMongoAdapter(), withcollectionBatchSize. - Opt-out: for a custom adapter instance,
adapter.findChildrenBatch = undefined. The batch members, andSQLPlugin.compileChildrenQuery,compileRecordBatchandmaxRecordBatchValues, are declared with| undefined, so that assignment and{ ...plugin, compileRecordBatch: undefined }also compile underexactOptionalPropertyTypes. - TypeScript:
@simtlix/simfinity-mcpre-exports core'sEnvelopSchemaPlugin, andcreateAuthPluginreturns the newAuthSchemaPlugin.
Collection reads
- When the adapter defines the new optional
DatabaseAdapter.findChildrenBatchand its optionalbatchesChildrenpredicate does not returnfalse, the runtime groups the generated collection reads of one request: those of one related type and connection field whose effective arguments, afterfindmiddleware, the query scope and joined scopes, hold the same plain data and that are requested in the same tick. It reads each group with one adapter call of at most 100 distinct parents; a group with one parent still callsfindChildren. See batched collection reads. - Unchanged: middleware, scopes, joined scopes and authorization rules run once per field; each parent gets its own filters, sort and page, and errors stay per field. Rows in a total order, such as a sort that ends in
id, are identical. Unsorted rows and rows with equal sort keys can come in another order, and on a page that does not hold all of a parent's rows, other such rows can fill it. End the sort withidfor stable pages. - Custom adapters without
findChildrenBatch, which includes every 3.6.0 custom adapter, keep the 3.6.0 path exactly: each field callsfindChildrenat once, with the arguments object it built, with no extra promise job and nocastIdcall. - Visible only with a batching adapter:
- fewer, larger adapter calls. Replacing
findChildrenon a bundled adapter, with anObject.assignoverride, a spread copy with anotherfindChildrenorvi.spyOn, turns batching off for that adapter, so the replacement applies to every parent. Count batched reads with a spy onfindChildrenBatch, or by statements or commands; - reads that can join a batch pass the adapter a copy of
params.args, not the object itself. Changeargsbefore your middleware returns; - those reads start after the promise jobs queued so far, which changes the order of adapter calls in traces. A batched read, and the per-field reads after it fails, run in the async context of the first field of its group, so tracing spans and AsyncLocalStorage stores are attributed to that field;
- fields that read the same parent share its child records, each with its own list, as batched references do;
- concurrent requests that share one context object, such as MCP's in-process default
{}or a constantcontextoption, can share a batch, which runs in the async context of one of them. Use one context object per request when reads depend on per-request async state, such as AsyncLocalStorage connection routing; - when a batch rejects or returns lists of another shape, each field reads alone. Failures other than a
SimfinityError, and results of another shape, log oneconsole.warnper type; - the GraphQL
errorsentries of failing collection fields can be listed in another order.
- fewer, larger adapter calls. Replacing
- Custom adapters that add
findChildrenBatchmust follow the pairing rule:batchesChildrenreturnsfalsewhenthis.findChildrenis not the adapter's own function, so that an override is never bypassed. The bundled adapters do. DatabaseAdaptergains the optionalfindChildrenBatchandbatchesChildrenmembers in TypeScript, so 3.6.0-shaped adapters compile unchanged.
PostgreSQL
Batched collection reads, opt-in. With
batchedCollections: true, increatePostgres(options),postgresPlugin(options)orconfigure(), a group of collection reads runs one statement per 100 parents: aUNION ALLof each parent's own statement, each with its parent ID as a parameter of its own, so PostgreSQL plans each parent for its own ID, as it planned the per-parent statement. Onlytrueenables it, whilemultiRowInsertschanges only forfalse. Otherwise the adapter has nofindChildrenBatch, and its statements and connections are 3.6.0's, as a comparison with the 3.6.0 adapter on five query shapes confirmed, so upgrading needs no change.One transaction, JIT off. A batch runs on one connection in one repeatable-read transaction:
BEGIN ISOLATION LEVEL REPEATABLE READ,SET LOCAL jit = off, the statement, one statement per owned table for the owned rows of every child, thenCOMMIT. Each group of up to 100 parents with the same related type, connection field and arguments therefore takes 4 statements, plus 1 per owned table; a larger group runs as several such batches, each in its own transaction. A single distinct parent, such as the children of a record read by ID, keeps the per-parent statement.Why JIT is off. The batch's plan costs about as much as the per-parent plans together, so it can cross
jit_above_cost(100,000 by default) when no parent's own statement does, and JIT then compiles the expressions of every branch, which can take longer than running the statement. On a local PostgreSQL 16, the books of 100 authors with 1,000 each, sorted by title andid, cost 172,043 in one batch and at most 1,788 per parent; with JIT, the batch compiled 1,000 functions in 302 ms of its 381 ms, against 66 ms without. With 5,000 books each it cost 856,761, past the inlining and optimization thresholds, and JIT took 5.4 to 6.6 s per statement.SET LOCALends atCOMMITorROLLBACK, so pooled connections, other statements and per-parent reads keep the server, database or role setting, andjit_above_costis not changed.jitis user-settable, so no privilege is needed.When to enable it. A per-parent read of a type without owned tables is one round trip, and the reads of one level run concurrently on the pool's free connections, so a level of N parents takes about N ÷ pool size round trips. A batch takes 4 round trips one after another on one connection,
BEGIN,SET LOCAL, the statement andCOMMIT, plus 1 per owned table, and runs its branches one after another in one backend, so its statement takes roughly the sum of its branches. The database does about the same work with fewer statements and connections. Batching therefore pays off when the pool is saturated or much smaller than the number of parents per level, as with many parents with small child lists. With free connections, a level with fewer parents than about 4 times the pool size, 40 withpg's default pool of 10, can be slower batched even on a remote database, and large child lists or long sorted pages add serial execution time: on a local server with a 10-connection pool, the books of 100 authors with 1,000 each, sorted, took 86 ms batched against 36 ms per parent, and with 5,000 each 336 ms against 99 ms. Measure your workload before enabling it.Statements. Counted by the integration tests:
Request Default or false, as 3.6.0batchedCollections: true12 authors with their books 13 statements on 13 connections 5 on 2: the list, BEGIN,SET LOCAL, the batch,COMMIT20 books with their author and chapters 22 on 22 6 on 3 12 authors with orders whose owned lines reference a publisher the list, then BEGIN, the orders, their lines when there are any, andCOMMITper author, and the publisher reads7: the list, BEGIN,SET LOCAL, the batch, one read of the owned lines,COMMIT, the publisher read5 authors, their books and the books' chapters, with maxPageSize: 531 9 12 authors with three aliased books fields and two argument sets 37 9 MCP list tool with books and orders, 12 authors 59 10 205 authors with their books 206 13, in calls of 100, 100 and 5 parents Order. Each branch is planned for its own parent's ID, as that parent's own statement was. On the test fixtures, including a table where one parent holds most rows after HOT updates, every parent's rows, unsorted and tied rows included, came in the same order as with per-parent reads. On a table of 600,600 books where 10 authors hold 60,000 each, and ANALYZE estimated 44 distinct authors instead of 210, every branch for 100 small authors read the connection index. PostgreSQL promises no order for unsorted or tied rows, though: on that table, an unsorted page of 100 books of a large author held other books than with per-parent reads. End sorts with
id.Failures and timeouts.
statement_timeoutapplies to the whole batch. A batch that fails for any reason rolls back and is read again parent by parent, which adds latency; PostgreSQL reports its database failures asSimfinityErrors, so a failed batch normally logs no warning. A server or proxy that rejectsSET LOCAL jit = offwould fail every batch this way, so keep the option off with one.Connection proxies. A transaction-mode pooler such as PgBouncer works: a batch is one transaction, so it stays on one server connection and its
SET LOCALends with it. Amazon RDS Proxy pins a client connection to its database connection for PostgreSQLSETcommands and for statements longer than 16 KB, such as a batch of 100 branches, until the client connection closes. Behind it, every pooled connection that runs a batch is pinned and loses multiplexing, so keep the option off there, or watch theDatabaseConnectionsCurrentlySessionPinnedmetric.Tooling. Statement loggers see each batch as
BEGIN ISOLATION LEVEL REPEATABLE READ,SET LOCAL jit = offandSELECT 1::integer AS "__simfinity_parent", row_number() OVER () AS "__simfinity_ordinal", simfinity_children.* FROM (…) simfinity_children UNION ALL SELECT 2::integer …statements of up to 100 branches, about 27 KB of text for a simple statement and 125 KB with relationship and owned-list filters, andpg_stat_statementsrecords one entry for each number of parents in a batch.SQL plugins. The new optional plugin member
compileChildrenQuery(models, description, plan, { column, ids }, configuration)returns one statement for many parents,SQLChildrenStatement, with aparentColumnand optionalsetupstatements, which the adapter runs, in order, on the same connection right before the statement; when any statement of a batch has setup, the whole batch, owned-row reads included, runs in one transaction. PostgreSQL's setup isSET LOCAL jit = off. The adapter uses the compiler only withbatchedCollections: true, and the runtime binds it only when itscompileQueryproperty is the plugin's owncompileQuery, so a copy that overridescompileQuery, such as{ ...postgresPlugin(options), compileQuery }, reads each parent with its override. AcompileChildrenQuerythat is not a function throwsINVALID_SQL_PLUGIN(400); asetupthat is not a list of statements fails the batch with aTypeErrorbefore a connection is taken, logged once per type, and each parent is then read alone. See the plugin contract.SQLPlugingains the optionalcompileChildrenQuery,SQLConfigurationandPostgresConfigurationgain the optionalbatchedCollections, and the new typesSQLChildrenStatement, with its optionalsetup, andSQLChildrenQueryCompilerare exported.
MongoDB
- Batched collection reads, opt-in.
createMongoAdapter({ batchedCollections: true })reads each group with aggregate commands of up tocollectionBatchSizeparents (an integer from 1 to 100, default 5), sent concurrently. Each parent's own pipeline runs as a sub-pipeline, the first as the command's pipeline and the others in$unionWithstages, so each parent keeps its own filters, sort and page. For 20 authors, books take 4 commands instead of 20, and books with awards, or an MCP list tool with both, 8 instead of 40. Without the option the adapter has neither member, so commands, arguments and timing are exactly 3.6.0. The package-root runtime (simfinity.connect()and the rest) keeps one read per parent; create your own adapter and runtime, as forreferentialIntegrity. - Invalid values throw
INVALID_MONGO_ADAPTER_OPTIONS(400). Unknown option names still throwINVALID_MONGO_INTEGRITY_CONFIGURATION(400), whose message now lists the three accepted names. - One read per parent remains for children of models with
aggregatemiddleware, their ownaggregatestatic, a discriminator child model or a wrappedModel.collection.aggregate, which a$unionWithsub-pipeline would bypass; for reads insideconnection.transaction()with Mongoose'stransactionAsyncLocalStorage, because MongoDB rejects$unionWithin a transaction and aborts it; and for adapter copies or overrides offindChildren. Transactional reference integrity is unaffected: collection reads pass no session. - Latency and throughput. A command runs its parents' sub-pipelines one after another, where per-parent reads ran concurrently. Fewer commands mean less pool pressure under load, but a single request can be slower: one measurement with 20 parents per command and large child sets was 1.6 to 2.2 times slower. Measure your workload; a smaller
collectionBatchSizeshortens each command. - Monitoring and query settings. Command monitoring, APM and OpenTelemetry spans, the profiler and slow-query logs see
aggregatecommands with$unionWithstages that end in$replaceWith: { i, d: '$$ROOT' }, one per command. MongoDB 8.0 query settings (setQuerySettings, such asindexHintsorreject: true) and$queryStatsor Atlas Query Insights entries keyed to the per-parent aggregate shape do not match them: an index hint no longer applies, and a rejection no longer blocks the read. Index filters (planCacheSetFilter) still apply. Keep the option off when you rely on query settings. - Timeouts and failures. A
maxTimeMSlimit, such as MongoDB 8.0'sdefaultMaxTimeMScluster parameter, applies to the whole command. If any command of a batch fails, every field of the batch reads alone once all commands have settled, and one warning per type is logged; a MongoDB-compatible service without$unionWithfails every batch this way. All parents of one command are read from one server. - Not detected: driver-level
aggregatewrappers installed on the shared collection prototype or the MongoClient, as some tenant plugins do, see only the command's own pipeline. Keep the option off with them. MongoAdapterOptionsgains the optionalbatchedCollectionsandcollectionBatchSizemembers.MongoAdapterhas the optionalfindChildrenBatchandbatchesChildrenonly when the option is on.
TypeScript: EnvelopSchemaPlugin
@simtlix/simfinity-mcpnow re-exports theEnvelopSchemaPlugindeclaration of@simtlix/simfinity-coreinstead of declaring its own. A module that re-exports@simtlix/simfinity-mcpor@simtlix/simfinity-js/src/mcp.jswithexport *no longer reports TS2308 forEnvelopSchemaPlugintogether with the core root,/author/plugins, the@simtlix/simfinity-sqlor@simtlix/simfinity-postgresroot, MongoDB'ssrc/auth/index.jsorsrc/plugins.js, or the@simtlix/simfinity-jsroot, in either order.- Core's
EnvelopSchemaPlugin.onSchemaChangenow returnsvoid | Promise<void>, as MCP's declaration has since 3.5.3, so MCP's types are unchanged. MCP awaits a returned promise; Envelop and Yoga do not, so plugins shared with them should still install synchronously. createAuthPluginnow returns the newAuthSchemaPlugin, anEnvelopSchemaPluginwhose hook is synchronous and returns nothing. It is exported by the core root,/authand/plugins, by@simtlix/simfinity-sql,@simtlix/simfinity-postgresand@simtlix/simfinity-js, and by MongoDB's legacysrc/auth/index.js,src/plugins.jsandsrc/index.js. Calling the auth plugin's hook yourself still yieldsvoid. Passing the plugin to Yoga, Envelop or MCPschemaPlugins, assigning it to anEnvelopSchemaPluginor an EnvelopPlugin, delegating to its hook, or passing the hook by reference compile and lint as before.- Only code that uses Simfinity's
EnvelopSchemaPlugintype, for its own plugins or for plugins whose hook it calls or passes on, can break. Measured against 3.6.0 in strict mode, the same on TypeScript 5.9, 6.0 and 7.0 with NodeNext and Bundler, and with typescript-eslint 8'srecommendedTypeChecked:- a hook that returns a value, such as
({ schema }) => seen.add(schema)orasync () => 5, reports TS2322 in an object typedEnvelopSchemaPluginorEnvelopSchemaPlugin[], checked withsatisfies EnvelopSchemaPluginor passed as a genericT extends EnvelopSchemaPlugin; TS2345 when its plugin is passed to anEnvelopSchemaPluginparameter; and TS2352 in anas EnvelopSchemaPluginassertion. Return nothing, for example by wrapping the arrow body in braces; a returned schema never replaced the old one, so callreplaceSchema; - an interface that extends
EnvelopSchemaPluginand narrowsonSchemaChangeto a non-void result reports TS2430; declare the result asvoidorvoid | Promise<void>. A class that implementsEnvelopSchemaPluginwith a value-returning hook, as a method or an arrow property, reports TS2416; give the hook a block body, as inonSchemaChange = ({ schema }) => { this.seen.add(schema); };; - a hook that returns a
PromiseLikethat is not aPromisereports TS2322; return a real promise, such asasync () => { await installLater(); }; - the result of an
EnvelopSchemaPlugin-typed plugin's hook wherevoidis declared reports TS2322, asvoid | Promise<void> | undefinedis notvoid: returned from a function or arrow annotated: void, including a delegating hook such asonSchemaChange: (p): void => inner.onSchemaChange?.(p), stored in avoidvariable, or collected in avoid[]bymap. Call the hook in a block, as in{ void plugin.onSchemaChange?.(payload); },awaitit in an async function, type the resultvoid | Promise<void>, drop the: voidof a delegating hook in anEnvelopSchemaPluginliteral, or keep the auth plugin typed as theAuthSchemaPluginthatcreateAuthPluginreturns; - a module augmentation of
@simtlix/simfinity-corethat redeclaresonSchemaChangeonEnvelopSchemaPluginwith 3.6.0's=> voidtype reports TS2717. Remove the member from the augmentation; retyping it fails on 3.6.0 instead; no-misused-promisesreports a void callback that calls the hook of anEnvelopSchemaPlugin-typed plugin, such asplugins.forEach((plugin) => plugin.onSchemaChange?.(payload)), or delegates to it in an EnvelopPlugin,{ onSchemaChange: (p) => plugin.onSchemaChange?.(p) }. It also reports the hook passed by reference where a void function is expected: an EnvelopPlugin'sonSchemaChange: plugin.onSchemaChange, a void listener argument, a variable typed(p) => voidor a function's(p) => voidresult. Wrap it as(p) => { void plugin.onSchemaChange?.(p); }, or usefor…ofwithawait. Delegating in anEnvelopSchemaPluginliteral stays clean;- an unawaited direct call of such a hook reports
no-floating-promises;awaitit or prefix it withvoid; - the same preset no longer reports
no-misused-promiseson anasynchook annotated with core's type, orawait-thenableonawait plugin.onSchemaChange?.(payload). Code that must lint on 3.6.0 too should usevoid, notawait.
- a hook that returns a value, such as
- Unchanged: spreading or assigning such a plugin to an Envelop
PluginorPlugin[], passing it toenvelop({ plugins }), and??on the hook's result. - The
export *fix was checked on TypeScript 5.9, 6.0 and 7.0 with NodeNext and Bundler resolution, and with Node10 on TypeScript 5 only; TypeScript 6 deprecates and 7 removesmoduleResolution: node10. - MCP's declarations now import
EnvelopSchemaPluginfrom@simtlix/simfinity-corethrough MCP's own dependency, as the facade, SQL and PostgreSQL declarations already do. With pnpm andpreserveSymlinks: true, install@simtlix/simfinity-coredirectly next to@simtlix/simfinity-mcp; otherwise tsc reports TS2307, or, underskipLibCheck, the type silently becomesany. - The fix needs one core: the application's
@simtlix/simfinity-coreand MCP's must be the same version, as the policy above requires. With different versions, MCP re-exports its nested copy and TS2308 returns.
Upgrade to 3.6.0
Version 3.6.0 reduces the reads, statements, locks and round trips of generated mutations, MongoDB reference integrity, PostgreSQL reads, writes and initialization, and MCP tools. Results, stored data and errors are unchanged, except where these notes say otherwise. Changes to generated DDL, tool output or retry timing are opt-in. Keep all directly installed Simfinity packages at the same version. When upgrading from an earlier version, also review the 3.5.9 notes below.
- By default: generated mutations skip unused reads; MongoDB reference integrity sends fewer lock writes and reference queries; PostgreSQL reads without a transaction when one statement answers, inserts owned rows in batches, lets indexes serve sorts on required fields, scans a list once for MIN and MAX, and initializes an unchanged schema without locking tables; MCP prepares each tool's operation once and shares input-schema definitions.
- Opt-outs of those defaults: PostgreSQL
multiRowInserts: falseandexplicitNullsOrder: true, and a copy of the PostgreSQL plugin without thesingleStatementReadscapability. - Opt-in: PostgreSQL
refreshTriggers: 'perRoot'andownerIndexes: 'minimal'; MCPnestedCollections,textFormat,retry.retryAfterandretry.jitter. - Documentation only: capping nested writes on servers exposed to MCP clients, and removing MongoDB's unused top-level
idindex.
Mutations
- Generated mutations skip reads by ID whose result they do not use. Responses, stored data, hook arguments, error codes and messages are unchanged.
- On every adapter, a state action and a nested
updatedchild no longer read their record a second time, unless their input merges a singular embedded object or writes a non-empty embedded list: such input still reads the stored embedded values, as in 3.5.9, with a secondSELECT … FOR UPDATEon PostgreSQL. A nesteddeletedchild is read once, by its ownership check, andonDeletereceives that record. On PostgreSQL that read locks the child row; MongoDB reads take no locks. - Two top-level reads now depend on the adapter. A root delete reads the record first only when the controller has
onDelete. A root update of a type with embedded fields reads the stored embedded values only when it merges a singular embedded object, writes a non-empty embedded list, has anonUpdatinghook (so a malformed ID still fails before the hook) or carries nested collection items. The adapter allows these two skips through the new optionalDatabaseAdapter.readByIdIsInert(model), when it returnstrue:- the PostgreSQL adapter always does;
- the MongoDB adapter does for models without
find,findOne,findOneAndUpdateorfindOneAndDeletequery middleware, application_idsetters or overrides offindById,findOne,findByIdAndUpdate,findOneAndUpdate,findByIdAndDeleteorfindOneAndDelete, which includes every generated model. A supplied model with a read guard, application_idsetters, or update or delete middleware or statics, which run before Mongoose casts the ID, keeps these reads and their rejections. So does a model with Mongoose'stimestampsoption, which addsfindOneAndUpdatemiddleware; - a custom adapter without the method keeps every read. One that implements it must make
update()anddelete()reject a malformed ID withNOT_VALID_ID(400) and returnnullfor a missing record, as the built-in adapters do.
- Each skipped read was one
findon MongoDB, and oneSELECTplus one per owned table holding data on PostgreSQL. On PostgreSQL, for example, a plain update runs 4 statements in its transaction instead of 6, and deleting a nested child with owned rows runs 5 instead of 7. - On PostgreSQL, an update that skips the read no longer runs
SELECT … FOR UPDATEfirst: itsUPDATElocks the row, as for types without embedded fields. When such an update changes no column, for example when its input holds onlyid, oridand empty nested collection lists, it runs noUPDATEand takes no row lock, also as for types without embedded fields. Updates withonUpdatingor nested items, and state actions, still lock the parent row first. - Read side effects run fewer times for state actions and nested children: their removed second reads no longer apply
_idsetters or runfindorfindOnemiddleware; the first read of the same record, in the same transaction, still does. Root deletes and updates retain their preliminary read on supplied Mongoose models with application_idsetters. - Generated and custom mutations copy their input once per transaction attempt instead of twice. A retried attempt copies the request input again, so change
argsin middleware before it returns, as documented, not asynchronously while the mutation runs. - Unchanged: nested
added,updatedanddeletedentries are unlimited by default. Each runs its middleware, hooks and writes one at a time inside the parent's transaction, and a retry runs them all again, the middleware of nested children included. Servers exposed to MCP clients or other untrusted callers should cap them withconfigureMutationLimits({ maxNestedOperations }), available since 3.5.3; the MCPlimitsdo not bound them. A default cap is a candidate for 4.0. See limit nested collection operations.
MongoDB
- With
referentialIntegrity: 'transactional', a guarded save or update reads only the stored document's_idand declared reference paths, the fields the startup audit reads. It locks its distinct targets with one write per target collection, up to 1,000 targets per write, instead of one write per target. When one lock write covers several targets, one read in the transaction first confirms that they all exist; a single target keeps its single write. Saving a record that references 20 documents of one collection sent 23 commands and now sends 5. - Results, error codes, messages, write conflicts and retries are unchanged, with one exception. A write that references a missing target fails with
REFERENCE_CONSTRAINT_VIOLATION(409) before the lock write of its batch of targets. It no longer conflicts and retries on another target of that batch that a concurrent transaction holds, as 3.5.9 did when the held target's ID sorted first, which could end withTRANSACTION_RETRY_EXCEEDED(409). A conflict on an earlier target collection, or an earlier batch of 1,000 targets, still comes first, as before. - Only native readers and command monitoring see the other difference: the targets that one write locks share one
_simfinityReferenceLockvalue, and command events show afindon_id: { $in }and anupdatewithmulti: true. - A guarded delete checks each referencing collection with one query, the
$orof its reference paths, instead of one query per path. A document referenced through 9 paths in 4 collections takes 4 queries instead of 9. The error, code and status are unchanged. - A supplied model without an index on one of those paths now scans its collection on every guarded delete of that target, including deletes rejected through an indexed path; before, those stopped at the indexed match. Index every reference path. Generated models index all of them.
- Top-level
idindex, documentation only. Generated models still declare an index on the top-levelidfield,id_1. The record key is_id, and generated reads, filters, sorts, aggregations, relation lookups and MCP tools never useid_1. To stop declaring it for aconnect()type, pass(model) => model.schema.removeIndex({ id: 1 })asconnect()'sonModelCreatedargument. MongooseautoIndexbuilds the index at startup on any process whose schema still declares it, so drop an existing one withdb.getCollection('<Type>').dropIndex('id_1')only once every process runs with the recipe;syncIndexes()drops it as soon as the recipe is in place. Keep it when your code queries the storedid, or when a link is stored underid, as withconnectionField: 'id'. Version 4.0 plans to stop declaring it by default and to remove the storedidpath and the duplicate$set.idthat generated updates write. See automatic MongoDB indexes.
SQL plugins
These changes concern custom SQL plugins; the PostgreSQL plugin implements them. See the plugin contract.
- New optional capability
singleStatementReads, which planning never requires. With it, a sessionless read that one statement answers runs on one acquired connection, withacquire,query(configuration, statement, client)andreleaseand nobeginorcommit: reads by ID without a lock, list reads, batched reference reads and collection fields, of types without owned tables. A confirmed abort thatisRetryable()accepts is retried with the transaction backoff and limit of five retries, without a rollback step. After any other failed driver call,rollback(client)probes the connection outside a transaction, as after a failed statement in a transaction, and a probe failure reachesrelease(client, error), so the driver discards the connection, as reads did in 3.5.9. Failures are normalized the same way, and the read's own error is reported, never the probe's. Advertise it only when one statement reads one consistent snapshot without a transaction, and whenrollbackresolves on a usable connection with no open transaction and rejects on an unusable one. Plugins without it keep a transaction around every read. - Two new optional members enable multi-row inserts of owned rows; the runtime uses them only together.
compileRecordBatch(description, { kind: 'insertMany', table, rows }), typed by the new exportedSQLInsertManyOperation, returns one statement that inserts rows sharing their columns in the same order, without result rows.maxRecordBatchValues, a positive safe integer, bounds the values (rows times columns) of one such statement. A single row still goes throughcompileRecordas aninsert, andmultiRowInserts: falsein the configuration keeps oneinsertper row.bindPluginrejects any other value of either member,nullincluded, withINVALID_SQL_PLUGIN.SQLRecordOperationis unchanged, so exhaustivecompileRecordswitches keep compiling. A copy of a plugin that overridescompileRecordforinsert, such as{ ...postgresPlugin(options), compileRecord }, inheritscompileRecordBatchandmaxRecordBatchValues, so owned rows inserted in runs of two or more no longer reach the override. Such a copy should also overridecompileRecordBatch, set both members toundefined, or setmultiRowInserts: false. describeSchema(plan, configuration)andcompileQuery(models, description, plan, extra, configuration)receive the bound configuration, ornull, as a new last argument. Plugins written for 3.5.9 can ignore it.SQLConfigurationgainsmultiRowInserts?: booleanandSQLCapabilitygains'singleStatementReads'. The SQL adapter reports its reads by ID as inert, so the runtime skips the reads described under mutations.
PostgreSQL
Single-statement reads. Sessionless reads of types without owned embedded tables run as one statement on a pooled connection instead of a
REPEATABLE READtransaction: reads by ID, list reads, batched reference reads and collection fields. A read by ID sends 1 statement instead of 3, and a page of 20 records with a batched reference and a collection field sends 22 instead of 66. One statement sees one snapshot at any isolation level, so results, errors and retries are unchanged, and a connection that fails during such a read, such as one whose backend was terminated, is still discarded. Reads of types with owned tables, locking reads and reads inside a transaction keep one repeatable-read transaction. A pool or plugin wrapper whosebegin()sets per-transaction settings no longer runs it for these reads. To keep a transaction around every read, remove the capability:javascriptimport { createSQL } from '@simtlix/simfinity-sql'; import { postgresPlugin } from '@simtlix/simfinity-postgres'; const plugin = postgresPlugin({ pool, schema: 'library' }); const simfinity = createSQL({ plugin: { ...plugin, capabilities: plugin.capabilities.filter((name) => name !== 'singleStatementReads') }, });Multi-row inserts. The owned rows of a write are inserted in the same order as before, with one multi-row
INSERTfor each run of rows of one table, of at most 32,767 values, instead of one statement per row. Creating a record with 50 owned list items sends 2INSERTstatements instead of 51, and replacing those items sends 1DELETEand 1INSERTinstead of 1 and 50. Stored rows, positions and responses are unchanged.- When a write contains an item that references a missing record and a later item the database rejects for another reason (a value it cannot store, a schema check), the reported error can now be the later item's (
INVALID_VALUE400, orDATABASE_ERROR500 for oversized index entries) instead ofREFERENCE_CONSTRAINT_VIOLATION(409). Nothing is stored. multiRowInserts: false, increatePostgres(options)orconfigure(), restores oneINSERTper row.- A copy such as
{ ...postgresPlugin(options), compileRecord }that overridescompileRecordforinsertinheritscompileRecordBatchandmaxRecordBatchValues, so owned rows inserted in runs of two or more no longer reach the override. Also overridecompileRecordBatch, set both members toundefined, or setmultiRowInserts: false.
- When a write contains an item that references a missing record and a later item the database rejects for another reason (a value it cannot store, a schema check), the reported error can now be the later item's (
Sorts. Sorts on
idand required root fields no longer carry aNULLS FIRSTorNULLS LASTclause, which columns without nulls do not need, so the primary key and declared indexes can return the rows in order instead of sorting them. Nullable, joined, embedded and list paths keep MongoDB's placement, nulls first ascending and last descending. The rows and their null placement are unchanged, with three consequences:- Rows with equal sort keys can come back in a different order. End the sort with a unique field, such as
id, for stable pages. - With a page size, PostgreSQL can now walk an index in sort order and filter rows as it goes. When the filter column correlates with the sort column, a filtered, sorted page can get slower. Add a composite index on the filter and sort columns, or use the opt-out below.
- Indexes that you created with
NULLS FIRST(ascending) orDESC NULLS LASTonidor a required column no longer match these sorts. Declare default-order indexes instead.
explicitNullsOrder: true, increatePostgres(options)orconfigure(), keeps the explicitNULLSclause on every sort term, which is the 3.5.9 SQL.- Rows with equal sort keys can come back in a different order. End the sort with a unique field, such as
MIN and MAX of lists. MIN and MAX facts over list paths evaluate the list once per row: one owned-table scan per root row instead of 8. Results are unchanged.
PostgresConfigurationgains the optionalmultiRowInserts,explicitNullsOrder,refreshTriggersandownerIndexesmembers. The last two are described below.
PostgreSQL initialization
createmode reads the catalog in a fixed number of statements, without table locks. When nothing is missing, it creates nothing, locks no table and does not wait for writers, so restarting a serving application with an unchanged schema no longer blocks writes or times out behind them.When something is missing, it locks, in one
LOCK TABLE … IN SHARE ROW EXCLUSIVE MODEstatement, only the existing tables (root, owned, guard and keys tables) of roots whose maintenance objects are missing: guard or keys tables, the<Root>__owned_validor<Root>__refreshfunctions, or refresh triggers and their functions. It rebuilds the unique keys of those roots only. Other DDL takes its own locks:CREATE INDEXtakesSHAREon its table, adding a foreign key takesSHARE ROW EXCLUSIVEon both the referencing and the referenced table, and adding a trigger takes it on the trigger's table. Writers of the tables that it changes or locks, including existing tables that a new foreign key references, still wait until it commits, and a deadlock or lock timeout still fails it, so check a live runtime withmode: 'validate'.It no longer re-scans existing rows against generated
CHECKconstraints, which PostgreSQL enforces on every write. It also no longer re-validates existing rows against generated function bodies that a migration replaced withCREATE OR REPLACE FUNCTION, as described in PostgreSQL schema validation and queries. After such a migration, check existing data yourself, for eachchecks[].expressionand each maintained root indescribeDatabase():sqlSELECT 1 FROM "app"."Book" WHERE NOT (<check expression>) LIMIT 1; BEGIN; -- Optional, as initialization does: writers of the root wait until COMMIT. LOCK TABLE "app"."Book", "app"."Book__entries", "app"."Book__guard", "app"."Book__entries__code__keys" IN SHARE ROW EXCLUSIVE MODE; DELETE FROM "app"."Book__entries__code__keys"; SELECT "app"."Book__refresh"(id) FROM "app"."Book"; COMMIT;The first query returns a row when stored data violates the check. The transaction re-validates the owned shape of every record of the root and rebuilds its unique keys, and fails on the first record whose stored data violates its owned shape or takes a unique value of another record. Delete the rows of each keys table of the root, the tables whose
uniqueKeys.rootTableis the root, before the refresh, as initialization does:<Root>__refresh(id)rebuilds only its own record's keys, so a bareSELECT <Root>__refresh(id) FROM <Root>can fail with a unique violation (23505) on a stale key of another record that the stored data no longer holds.The result,
{ mode, created }, and the errors are unchanged, including which drift is reported first.
PostgreSQL refresh triggers (opt-in)
The new option
refreshTriggers: 'perRoot', increatePostgres(options),configure()and the low-leveldescribeDatabase(registrations, options), changes how a root with owned tables or embedded unique keys is maintained. Under the default,'perRow', the 3.5.9 layout, a deferred trigger re-validates the root's owned shape and rebuilds its keys once for every changed row: creating a record with 50 owned items writes 5,050 key rows, and replacing them 10,100. Under'perRoot', every write to a root or owned row marks the root's guard row, and one deferred trigger per root and transaction does that work once: 50 and 100 key rows. An unknown value throwsINVALID_DATABASE_SCHEMA(400) fromcreateSchema()ordescribeDatabase().The option applies only to roots created while it is set. Initialization keeps each existing root on the layout that its database holds and accepts both layouts in either mode; a root that holds objects of both fails with
SCHEMA_MISMATCH(refresh triggers of two layouts are present).describeDatabase()andcompileDatabaseSchema()report the configured layout for every root, whatever the database holds, which schema-diff tools will notice.To move an existing root, apply its statements from
compileDatabaseSchema(describeDatabase(registrations, { schema, refreshTriggers: 'perRoot' }))in one transaction:- drop the trigger and function
<Table>__deferredof the root and of each of its owned tables, for exampleDROP TRIGGER "Book__deferred" ON "app"."Book"; DROP FUNCTION "app"."Book__deferred"();; - replace each
<Table>__touchfunction with its new body, running itsCREATE FUNCTIONstatement asCREATE OR REPLACE FUNCTION; - run the
CREATE FUNCTIONandCREATE CONSTRAINT TRIGGERstatements of<Root>__guard__deferred.
Generated names longer than 63 bytes are shortened with a hash, so take the exact names from that output. No data changes. Move a root back the same way, with the statements of the default description.
- drop the trigger and function
Versions up to 3.5.9 cannot start on
'perRoot'roots: bothvalidateandcreatemode fail withSCHEMA_MISMATCHat the root's<Root>__touchfunction (function identity, body, signature or configuration differs), also after the migration above. Upgrade every process before creating or moving such roots, and move them back before a downgrade.The touch trigger becomes the only point of key maintenance. Disabling
<Table>__touchdisables uniqueness and owned-shape enforcement for that table, where under'perRow'it removed only the concurrency guard. After any generated trigger was disabled, repair the root in one transaction that deletes the rows of its keys tables and then runsSELECT <Root>__refresh(id) FROM <Root>, as shown under PostgreSQL initialization. Guard rows left dirty while the guard trigger was disabled are cleaned by the next write to their root.While the guard trigger stays deferred, as it is by default, each transaction writes the guard row of each changed root twice, marking it dirty and then clean, where
'perRow'writes it once per changed row; a transaction that changes one row therefore writes it twice. The guard trigger is declaredAFTER INSERT OR UPDATE … WHEN (new.version < 0), so the clean-up and the guard rows that initialization inserts queue no event. UnderSET CONSTRAINTS ALL IMMEDIATE, or with the guard trigger setIMMEDIATE, its event fires at the end of the guard write inside each touch trigger: each changed row then refreshes its root, as under'perRow', and writes the guard row twice. The once-per-transaction saving applies only while the guard trigger stays deferred.When one transaction has several deferred violations, which one is reported can differ by layout. For example, application SQL that defers constraints and writes both a dangling reference and a taken unique value reports
DUPLICATE_KEYunder'perRow'andREFERENCE_CONSTRAINT_VIOLATIONunder'perRoot'. The transaction fails either way, and Simfinity's own writes check references immediately.SET CONSTRAINTS "<Table>__deferred"names a constraint that'perRoot'roots do not have.SET CONSTRAINTS ALLis unchanged.Applications that stay on
'perRow'should cap the size of embedded lists with validators, such asvalidators.arrayLength(), because every changed row refreshes its whole root.
PostgreSQL owner indexes (opt-in)
- The new option
ownerIndexes: 'minimal', on the same surfaces asrefreshTriggers, leaves out the(__owner_id)index of owned and guard tables, which their primary key or unique(__owner_id[, __position])index already covers, so each owned-row and guard insert maintains one index fewer. Keys tables keep theirs. The default,'all', keeps the 3.5.9 indexes. An unknown value throwsINVALID_DATABASE_SCHEMA(400). - It applies to tables created while it is set. Initialization accepts existing
<Table>____owner_id__idxindexes in both modes and never drops them. Once every process runs this version with'minimal', you may drop them withDROP INDEX CONCURRENTLY. - On a database created or trimmed this way, validation by 3.5.9 or earlier, and by this version with
'all', fails withSCHEMA_MISMATCH(index is missing). Thecreatemode of 3.5.9 re-creates the indexes non-concurrently under its locks on every managed table, re-scanning every check and rebuilding every root's keys; thecreatemode of this version with'all're-creates them with only the lock eachCREATE INDEXtakes and without a backfill. The same applies to a downgrade. - The logical plan that SQL plugins receive is unchanged.
MCP options and execution
nestedCollections, opt-in. MCP tools acceptnestedCollections: 'all' | 'none', also per tool intoolOverrides, where the tool's value wins; a tool with its ownselectionignores it.'none'leaves nested collections, object list fields with apaginationargument such as core's non-embedded one-to-many relations, out of the generated selection andoutputSchema. The default,'all', keeps the 3.5.9 selection. With the defaultselectionDepth: 1, each nested collection is read with one query per returned row, up to 100 items (orconfigureQueryLimits()'smaxPageSizewhen lower), andlimitsdo not bound that work.textFormat, opt-in.textFormat: 'compact'sends the text content block as compact JSON, about half the bytes for nested results, andmaxResultBytesmeasures it, soMCP_RESULT_TOO_LARGEandtruncated.resultBytesreport compact sizes. The default stays'pretty'; it may become compact in 4.0.Retry-Afterand jitter, opt-in. RemoteretryacceptsretryAfter: true, which makes the next retry wait at least theRetry-After(delta-seconds, or an HTTP-date in the IMF-fixdate, RFC 850 or asctime form, read as GMT) of a retried HTTP 429 or 5xx response, and ends the retries at once with that response's error when it asks for more thanmaxRetryAfterMs(default 10000).jitter: trueadds a random wait of up to half ofbackoffMs × kto retry k. Without them the schedule staysbackoffMs × k.maxRetryAfterMsbounds each wait, not their total, so many attempts can exceed an MCP client's request timeout (60 s by default in the TypeScript SDK). HonoringRetry-Afterand jitter may become the default in 4.0.MCP_INVALID_OPTIONS(500) is a new setup error for atextFormatornestedCollectionsother than the values above, global or in anytoolOverridesentry, published or not. It is thrown when the tools, server or HTTP handler are created, beforeschemaPluginsrun.- Prepared operations. In-process tool calls parse and validate each tool's GraphQL operation once and reuse it; the schema is still validated on every call. Results and errors are unchanged, and each call saves about 45 to 145 µs of CPU. Changing the schema's types after a tool's first call, other than wrapping resolvers, is not supported. Tests that spy on
graphql()no longer see MCP calls. - Shared
$defs. Generated tools share one object per input or enum type in theirinputSchema.$defs, which lowers setup time and memory for large schemas. The published schemas are unchanged, andgraphqlArgsToJSONSchema()still returns new objects. Copy a tool definition, for example withstructuredClone(), before changing it in place. - Generated add and update tools accept nested collection writes, which
limitsdo not bound; see mutations. - In TypeScript,
GenerateMCPToolsOptionsgainsnestedCollectionsandtextFormat,MCPToolOverridegainsnestedCollections, andMCPRetryOptionsgainsretryAfter,maxRetryAfterMsandjitter, all optional, typed by the newMCPNestedCollectionsandMCPTextFormataliases where they apply. Existing members keep their 3.5.6 shapes, so code that reads options back compiles unchanged.
Upgrade to 3.5.9
Version 3.5.9 fixes MongoDB embedded objects that hold no data, embedded types that contain each other on MongoDB, the session of custom mutations whose MongoDB models use another connection, schemas whose generated inputs would have no fields, application types named RelationType or FieldExtensionsType, the error classes of the core root and the TypeScript declarations of MongoDB's legacy deep imports. Keep all directly installed Simfinity packages at 3.5.9. When upgrading from an earlier version, also review the 3.5.8 notes below.
MongoDB embedded objects
On MongoDB, an optional singular embedded object that holds no data now reads as
nullwhen one of its required members would read asnull. An object holds no data when every member is absent ornull, every list is empty and every nested embedded object holds no data either. Simfinity decides this from the stored data, without running schema getters or virtuals, and counts a stored_idas the object'sid. These required members do not count:id,readOnlymembers, interface and union members (single or list), members with your own resolver and referenced collections. Previously a selection that named a missing required member failed withCannot return null for non-nullable field Type.member.and returnednullfor the object. This happened in two cases:- an object absent from the stored document, whatever the reason: a create omitted it and Mongoose stored nothing for it, the document was written without it (natively, or before the field existed), or a top-level
nullremoved it. By-ID reads, reference reads and update responses failed; list reads and create responses already returnednull; - an optional object, at any depth, that a create omitted while its type, or an embedded type inside it, has a list member. Mongoose stores such an object with its list defaults, such as
main: { phones: [], geo: { aliases: [] } }, and every read path failed, list reads and the create's own response included, although the record was stored. This includes a nested object omitted inside an object the create supplied, such asgeoinmain: { street: "M" }.
- an object absent from the stored document, whatever the reason: a create omitted it and Mongoose stored nothing for it, the document was written without it (natively, or before the field existed), or a top-level
Behavior change: the rule does not depend on the selection. A selection that names no required member, such as
main { city }, read these objects as{ city: null }and now readsnull:- for absent objects, in by-ID reads, reference reads and update responses. This reaches every record created or written without the object, not only removed ones;
- for objects stored with list defaults, on every read path, list reads included.
List reads and create responses of absent objects, and PostgreSQL, already returned
null.A required list that holds
[]is present, so an object that a generated create or update wrote, such asmain: { tags: [] }, reads back as written. One exception on MongoDB: Mongoose minimizes empty objects when it saves a new document, so a create that writes an empty object for a required embedded member, such asmain: { geo: {}, tags: [] }, stores that member as absent. An object that holds nothing else then reads asnull, like an omitted one, where selections that do not name the member used to read it: in the create response and in list reads, and also in by-ID reads, reference reads and update responses when the model declares the member as a single nested subdocument (with its own schema), as generated models do for an embedded member namedtype. When the member is a nested path, as generated models declare every other embedded member, those reads still return the object, because Mongoose renders the absent nested path as{}. A generated update storesgeo: {}and reads back as written. PostgreSQL rejects such a create withREQUIRED_VALUE.These read as before:
- objects that hold any data;
- objects whose type has no required member that counts, which Simfinity does not read, so no getter runs: an absent one still reads as an object of nulls in by-ID reads, reference reads and update responses, and as
nullin lists and create responses; - objects whose only stored data is an
_id, such as a subdocument of an entity type selected aspart { id }. A supplied subdocument schema without_id: falsegives a stored subdocument that has no_id, such as{}, a new one each time Mongoose hydrates the document, so by-ID reads, reference reads and update responses keep such an object when its type declaresid, with anotheridon each read; list reads read it asnull; - in by-ID reads, reference reads and update responses, objects whose Mongoose schema declares a getter, a virtual other than Mongoose's automatic
id, an alias or a subdocument method on a member at any depth inside the object, including its subdocuments, the items of its lists and the values of its maps. Their stored data does not show what they render, such as a required member that a getter default, a virtual, a method (GraphQL calls one named like a member) or an alias supplies (an alias stores the value under its own path), so Simfinity keeps them as stored, and they render as on 3.5.8; no getter of an unselected member runs. List reads and create responses return the stored data, which no getter, virtual, method or alias renders, so the rule applies there as to any other object. Generated models declare none of these; - non-null embedded fields and the items of embedded lists;
- objects of supplied Mongoose schemas with nested defaults on members of the GraphQL type, such as
default: falseor a string default, in by-ID reads, reference reads and update responses, where Mongoose fills in those defaults, so the objects hold data. List reads and create responses read the stored data, so there a record stored without the defaulted value, such as one written natively or before the default existed, reads asnullwhen it holds nothing else and misses a required member that counts. A default on a nested path that the GraphQL type does not declare, such as an internalsourcefield, is not data: an object that holds nothing else and misses a required member that counts reads asnull.
Promises, dates, identifiers and other values that are neither plain objects nor hydrated records count as data, and so do accessor properties of plain objects, which are never called. A Mongoose map is read by its stored entries, so an empty one holds no data in by-ID reads, reference reads, update responses and list reads; create and state machine action responses, which receive a native
Map, keep it as before. Getters of your schema run only for the members a query selects, as before; if reading the stored data fails, the object reads as before.The rule runs in the resolvers that an adapter's
readEmbeddedValuehook installs on the GraphQL type objects. A runtime without the hook, such as PostgreSQL or a custom adapter, that shares types with a MongoDB runtime also reads data-less objects of those types asnull. A custom adapter that definesreadEmbeddedValuegets the rule.DatabaseAdapterhas a new optional member,rawEmbeddedValue(value), which returns a hydrated record's stored data as a plain object, or the record unchanged, which counts as data, when that data does not show what it renders; the MongoDB adapter defines it. A custom adapter whose records render their members through getters should define it too: without it, such records count as data, so they read as before.Storage, Mongoose documents and the records that hooks such as
onUpdatedreceive are unchanged, and nothing is migrated: such stored objects now read asnull. Filters, sorts, aggregations and native queries on their members behave as before.PostgreSQL is unchanged. A create that omits an object whose type, or an embedded type inside it, has a list member still fails with
REQUIRED_VALUEwhen a required non-list member has no value, for exampleRequired value street is missing, because the list default makes the object present. An omitted object without list members is stored asnulland reads asnull.
See MongoDB embedded objects in hydrated documents.
MongoDB models
- In default MongoDB mode, embedded types that contain each other now make
createSchema()throwINVALID_MODEL(400) with the messageEmbedded cycle at …instead ofRangeError: Maximum call stack size exceededwhen Simfinity generates a model whose embedded fields reach the cycle. This applies to embedded objects and lists, directly (A.b: BandB.a: A) or through other embedded types, also when a field of the cycle isreadOnly. Simfinity generates a model for a type connected without one (connect(null, …)) and for a type registered withaddNoEndpointType()that has relation fields, as every type of an embedded cycle does. Such schemas never started.referentialIntegrity: 'transactional'and PostgreSQL already rejected every embedded cycle with the same code. - When only supplied models reach the cycle, default mode does not check it and behaves as before:
createSchema()fails withINPUT_TYPE_UNRESOLVED, or, when a field of the cycle isreadOnly, the schema starts and serves. This is the case, for example, when every registered type of the cycle is connected with a supplied model. - The path in the message starts at the type whose model is generated first. In default mode that can be an embedded type registered with
addNoEndpointType(), as inEmbedded cycle at B.a.b; transactional mode and PostgreSQL start it at the entity, as inEmbedded cycle at A.b.a. - In default mode, a type that embeds itself keeps the message
A type cannot have a field of its same type and embedded, now as aSimfinityErrorwithINVALID_MODEL(400) instead of a plainError; code that checks the message is unaffected. Transactional mode and PostgreSQL report such a type withEmbedded cycle at Type.field, as before. - Marking a field of the cycle
readOnlydoes not avoid this error in transactional mode, on PostgreSQL or when a generated model reaches the cycle. It avoidsINPUT_TYPE_UNRESOLVEDwith custom adapters that do not validate models and, in default MongoDB mode, when only supplied models reach the cycle. Use a reference or a referenced collection for recursive structures. See embedded objects.
MongoDB custom mutation sessions
In the default
referentialIntegrity: 'off'mode, a registered custom mutation opens its transaction on the default Mongoose connection,mongoose.connection. It now opens it on the MongoDB client of the registered models instead when all of these hold:- the default connection was never opened: no
mongoose.connect(),openUri()orsetClient(); - no model is compiled on it or on any of its
useDb()descendants, which share its client: neither one of yours (mongoose.model(),mongoose.connection.useDb(…).model(),mongoose.connection.useDb(…).useDb(…).model()) nor one that Simfinity generates, so every registered type that needs a model has a supplied one; - every registered model uses one MongoDB client, such as the models of one
mongoose.createConnection()connection and itsuseDb()connections.
- the default connection was never opened: no
Previously such a request waited for the default connection and failed with
Connection operation buffering timed out after 10000ms, or aTypeErrorwithbufferCommands: false, without running the callback.Nothing else changes: the default connection is kept once it has a client, while any model is compiled on it or on one of its
useDb()descendants, and when registered models use several clients.referentialIntegrity: 'transactional'still uses the first protected model's connection.An application that opens the default connection after it starts serving keeps the previous behavior when a model, yours or a generated one, is compiled on that connection or on one of its
useDb()descendants: a request that arrives first waits for the connection. Simfinity does not see a connection created withuseDb(name, { noListener: true }), nor itsuseDb()descendants, nor native writes such asmongoose.connection.collection(…), which compile no model. Without such a model, a request that arrives beforemongoose.connect()now gets a session from the registered models' client, so a callback that writes default-connection collections with that session fails withClientSession must be from the same MongoClient. Compile a model on the default connection, or open it before serving.A direct
adapter.withTransaction(null, callback)call on an off-mode adapter bound to a runtime follows the same rule. An unbound adapter, andwithMongoTransactionfrom the legacy deep import@simtlix/simfinity-js/src/mongo/transactions.js, keepmongoose.connection.Known limitation, kept for compatibility: while the default connection is in use and registered models use another client,
saveObject()with the custom mutation's session still fails withClientSession must be from the same MongoClient. In the default mode only, open a transaction on the model's connection inside the callback instead:javascriptsimfinity.registerMutation('importbook', 'Import a book.', ImportBookInput, BookType, async (input, session, context) => { let book; await BookModel.db.transaction(async (bookSession) => { book = await simfinity.saveObject('Book', input, bookSession, context); }); return book; });Every write that must be atomic has to happen inside that transaction. It commits on its own, independently of the custom mutation's transaction, and work done after it commits is not rolled back. Calling
saveObject()without a session also works, but each call is atomic only by itself.The schema, the API and the TypeScript declarations do not change. No data migration is needed.
See which connection the session uses.
Generated inputs without fields
- Schemas whose generated inputs would have no fields now validate. Previously
createSchema()returned a schema that failed validation withInput Object type … must define one or more fields, so every request failed, and servers that validate the schema at startup did not start. GraphQL input objects need at least one field, so Simfinity now leaves such inputs out, with the operations, fields and mutations that would take them, and logs oneConfiguration issue:warning for each:- A referenced collection whose child has no writable field besides its
connectionField, such as a child with onlyid, the back-reference andreadOnly, interface or union fields, has noaddedoperation:OneToMany<Type>A<field>andOneToMany<Type>U<field>keepupdatedanddeleted. Create such children with their own add mutation or a registered mutation.updatedis left out the same way when the child's update input would be empty, which happens only when the child has no writable fields and no writableid. - An embedded field, single or list, whose embedded type has no writable fields is left out of its owner's create input, update input or both. An embedded type whose only writable field is
idstays in update inputs only, and its warning says that generated updates can still set the field:Doc.ref is left out of generated create inputs, because the create input of its embedded type Ref would have no fields (create inputs leave out ids), …. - A connected type without writable fields has no
add<type>mutation, whileupdate<type>,delete<type>and state machine actions remain. Without a writableid, because it has noidfield or areadOnlyone, it has no update mutation or actions either, onlydelete<type>. - A state machine type whose only writable field is its
state, which the state machine manages, has no add mutation either. Its warning says that a registered mutation must create its records and set the initial state, assaveObject()does.
- A referenced collection whose child has no writable field besides its
- New startup error: a non-null embedded field, such as
stamp: Stamp!orstamps: [Stamp]!, whose embedded type has no writable fields makescreateSchema()throwINVALID_MODEL(400), for exampleDoc.stamp is non-null but its embedded type Stamp has no writable fields, so generated create inputs cannot set it; mark it readOnly and set it in a controller, or make it nullable. Generated creates would otherwise store records without the field.- It applies when the schema's generated mutations reach the owner's create input: an included endpoint type, or a type reached from one through writable embedded fields or collections. Those schemas failed validation before.
- When they do not reach it, for example for a type left out with
includedMutationTypes, the field is only left out, with a warning, as such schemas started before. A latercreateSchema()that reaches it throws. - It is raised while inputs are built, after models are created, like
INPUT_TYPE_UNRESOLVED.
- A required collection, such as
books: [Book]!, whoseaddedoperation is left out keeps a required operation object in the create input. Clients and MCP create tools sendbooks: {}. getInputType(Type)still returns the create input, which has no fields for such a type. Give the registered mutation that creates its records no input, or an input of its own.- Generated MCP tools follow the schema: such types get no create tool, and update tools whose input can no longer carry
addeditems reportidempotentHint: true. - Schemas that validated before keep every type name, field and mutation. They can log new warnings only for types whose inputs no generated mutation reaches, such as a type left out with
includedMutationTypeswhose embedded type has no writable fields.
See what gets generated.
Application types named RelationType or FieldExtensionsType
- An application type named
RelationTypeorFieldExtensionsTypein the first Simfinity schema of a process no longer makescreateSchema()throwSchema must contain uniquely named types but contains multiple types named "RelationType". Simfinity instead names the object types of its__Field.extensionsintrospection metadataSimfinityFieldExtensionsTypeandSimfinityRelationType, with a number appended when the schema also has those names, such asSimfinityFieldExtensionsType2, and logs oneConfiguration issue:warning. The new names apply to every schema the process constructs afterwards, Simfinity's or not. - Introspection queries that select
extensions { readOnly stateMachine relation { … } }by field name are unaffected. Named fragments and permission maps must use the new names, for examplefragment Flags on SimfinityFieldExtensionsTypeorcreateAuthPlugin({ SimfinityFieldExtensionsType: … }). A permission-map entry for your ownRelationTypeapplies to your type. - Schemas without such a type keep
FieldExtensionsTypeandRelationType, and their introspection is unchanged. - The names depend on order, which matters for test suites and processes with several runtimes:
- the first Simfinity schema created in the process fixes the names, whether it renamed them or not;
- a later
createSchema()with a type that has one of the current names throwsRESERVED_TYPE_NAME(500) instead of the duplicate-name error. Those areFieldExtensionsTypeandRelationTypewhen the first schema did not rename them, and the fallback names when it did. It is raised beforeadapter.prepare(), model creation and resolver generation when that type is an included endpoint type, the input or output type of an included registered mutation, an output type that these reach through fields, interfaces or union members, or the type of an argument of those fields. That check never reads the fields of an input object, so a registered input whosefieldsfunction callsgetInputType()keeps working in later schemas of the process. A type nested in the fields of an input object, such as a registered mutation's input or an input-object argument, or a type that only generated types reach, is found when the schema is constructed, after models and resolvers are built; - create the schema that has such a type first in the process, or rename the type.
- A schema construction that fails for another reason, such as a second duplicate type name, puts the previous metadata types back and fixes nothing.
- A schema that your code constructed before a rename keeps the old types in its type map. Its named fragments on them, and permission-map protection of them, no longer match
__Field.extensions. - Schemas that other code assembles still fail with the duplicate-name error when they contain a type with one of the current names. Mixing Simfinity versions in one process is not supported.
See what gets generated and introspection metadata.
TypeScript declarations
UnauthenticatedErrorandForbiddenErrorare now exported from the@simtlix/simfinity-coreroot, as the same classes asauth.UnauthenticatedError,auth.ForbiddenErrorand the/authand/auth/errorsexports. The root declarations already listed them, so TypeScript acceptedimport { ForbiddenError } from '@simtlix/simfinity-core', but Node failed when loading the module (does not provide an export named 'ForbiddenError'), andcore.ForbiddenErrorwasundefined. Their types are unchanged; a regular import used only as a type keeps compiling underverbatimModuleSyntaxand now also runs./auth/errorsnow re-exports the root classes, soexport *from the root together with/author/auth/errorsno longer reports TS2308.@simtlix/simfinity-js,@simtlix/simfinity-postgresand@simtlix/simfinity-sqlstill expose them only throughauth.- A JavaScript module that re-exports the
@simtlix/simfinity-coreroot withexport *together with another module that exports its ownUnauthenticatedErrororForbiddenErrornow makes imports of that name from it fail at load time withSyntaxError: The requested module … contains conflicting star exports for name 'ForbiddenError'. Re-export the class you want explicitly in that module, for exampleexport { ForbiddenError } from './my-errors.js';. TypeScript already reported TS2308 for such modules. Modules that re-export the@simtlix/simfinity-js,@simtlix/simfinity-postgresor@simtlix/simfinity-sqlroot are not affected. /pluginsnow declarescreateAuthPluginas the/authexport it is at run time, soexport *from@simtlix/simfinity-core/authtogether with@simtlix/simfinity-core/pluginsno longer reports TS2308 for it, as it did in 3.5.8. The legacy deep imports@simtlix/simfinity-js/src/auth/index.jsandsrc/plugins.js, declared as described below, can be re-exported together the same way.- MongoDB's legacy deep imports of public API now ship declarations:
@simtlix/simfinity-js/src/index.js,src/auth/index.js,src/auth/errors.js,src/auth/expressions.js,src/auth/rules.js,src/plugins.js,src/scalars.js,src/validators.js,src/mcp.js,src/const/QLOperator.js,src/const/QLSort.js,src/const/QLValue.js,src/errors/simfinity.error.jsandsrc/errors/internal-server.error.js, and their extensionless aliases, such assrc,src/authandsrc/auth/rules. Each has the types of the package root or of the matching@simtlix/simfinity-coresubpath, undermoduleResolutionnode10,node16,nodenextandbundler. Previously they failed with TS7016 in strict projects and wereanyotherwise.src/mongo/*stays internal and undeclared. The JavaScript files and the paths Node resolves are unchanged. - Migrating code that uses these deep imports:
- Code that compiled only because these imports were
any, in non-strict builds or with a@ts-ignoreon the import, can now report errors for calls the declarations reject, as with the core subpaths in 3.5.8. For example, adefaultPolicyheld in astringmust be narrowed to'ALLOW' | 'DENY'. - A
// @ts-expect-errorplaced on one of these imports to silence TS7016 now fails with TS2578 (unused@ts-expect-errordirective); remove it. A@ts-ignorekeeps compiling, and the import's uses are now type-checked. - In CommonJS-format files (a
package.jsonwithout"type": "module", or.ctsfiles) compiled withmodulenode16, ornodenextbefore TypeScript 5.8, these imports now report TS1479, as the package root already does. Usemodule: nodenextwith TypeScript 5.8 or later, or ESM. - A
declare module '@simtlix/simfinity-js/src/...'shim keeps taking precedence over the shipped declarations; remove it to use them. - In 3.5.9 to 3.6.0, a module that combines
export *from@simtlix/simfinity-mcpor@simtlix/simfinity-js/src/mcp.jswithexport *from a module that exports core'sEnvelopSchemaPluginreports TS2308 forEnvelopSchemaPlugin, because@simtlix/simfinity-mcpdeclares its own interface of that name. Such modules are the core root,/auth,/plugins, the SQL or PostgreSQL root,src/auth/index.jsorsrc/plugins.js, and the@simtlix/simfinity-jsroot when the MCP module (@simtlix/simfinity-mcporsrc/mcp.js) comes first. Re-export one of them explicitly, for exampleexport type { EnvelopSchemaPlugin } from '@simtlix/simfinity-core/plugins';. Version 3.7.0 removes the conflict (notes); the explicit re-export keeps compiling and can be removed.
- Code that compiled only because these imports were
Upgrade to 3.5.8
Version 3.5.8 fixes generated collection inputs, nested collection ownership, error metadata, filter values, interface and union fields, aggregation results, pagination counts, field validators, bounded scalars and the TypeScript declarations of the core subpaths. Keep all directly installed Simfinity packages at 3.5.8. When upgrading from an earlier version, also review the 3.5.7 notes below.
Collection inputs
- Two types with a self-referencing collection of the same name, such as
Category.childrenandComment.children, no longer makecreateSchema()fail withSchema must contain uniquely named types but contains multiple types named "OneToManyAchildren". Among the types that the generated mutations of the firstcreateSchema()reach, the first registered keepsOneToManyAchildrenandOneToManyUchildren. The others are named after their type, for exampleOneToManyCommentAchildrenandOneToManyCommentUchildren, as other collections are, and aConfiguration issue:warning names both types. A type registered after acreateSchema()call is named after its type only when the nextcreateSchema()also reaches a type that has the unqualified names and that one is still the type object registered under its name, a case that failed with the same duplicate-name error before. Otherwise it keeps the unqualified names, as before, with no warning: for example a type registered after a schema whose types it never meets, or a type that has the unqualified names and is registered again under the same name with the same or a new type object. Earlier schemas remain valid. Making an earlier-registered type reachable, withconnect()instead ofaddNoEndpointType(), a new collection that leads to it or a wider mutation allowlist, moves the unqualified names to it and renames the other type's inputs, so register the type that should keep them first. Two cases still fail as before: a second such type that only aregisterMutation()input reaches, and a latercreateSchema()that reaches two such types whose inputs earlier calls built with the unqualified names, for example after a narrower first allowlist. See self-referencing collections with the same name. - Collections that reach the same child through the same
connectionField, such asSerie.episodesandSerie.featured, now share oneaddeditem input instead of failing on a duplicateSerieAEpisodeInputForSeriename. Both fields read the same children, unless one has its own resolver. - Items that an add mutation creates through a self-referencing collection (
A<Type>InputFor<ConnectionField>) now accept the item's own self-referencing collections as optional fields, so one create can build a tree more than one level deep, as updates already could. Items that an update adds (U<Type>InputFor<ConnectionField>) keep a required self-referencing collection required. createSchema()now builds models whose writable referenced collections form a cycle across types, such asDepartment.employees(toEmployee) andEmployee.managedDepartments(toDepartment). Previously it threwINPUT_TYPE_UNRESOLVED(Could not build input types for: …) on MongoDB and PostgreSQL, and the only workaround was marking one collectionreadOnly; remove thatreadOnlyto get nested writes. Nested inputs follow the cycle at any depth;configureMutationLimits({ maxNestedOperations })caps the entries one mutation may carry.INPUT_TYPE_UNRESOLVEDstill reports writable lists of lists and embedded types that contain each other, with the same code and status, and its message still starts withCould not build input types for: …. The message now names every field of the listed types whose input cannot be generated, and lists apart the fields that only wait for another listed type, which build once that type does, for example… no input can be generated for Sheet.rows, Sheet.cols, Meta.cells (writable lists whose items have no generated input, such as lists of lists); mark such a field readOnly. These fields only wait for another listed type and build once it does: Sheet.meta waits for Meta.A type that also has a list or embedded field of an unregistered type can now fail withUNREGISTERED_RELATION_TARGETfor that field first. Lists of interface or union types no longer raise it; see interface and union fields.- The
deletedlist of referenced collection inputs is[ID]again for collections with non-null items such as[Book!], as documented and as in 3.1. Versions 3.2.0 to 3.5.7 generated[ID!], which rejected[ID]variables and null entries. Variables declared[ID!]keep working, null entries are ignored, and generated MCP tool schemas describedeleteditems as string or null. - Schemas that built before keep every generated type name. Schema snapshots also show descriptions on self-referencing collection input fields, the new optional fields on
A<Type>InputFor<ConnectionField>, anddeleted: [ID]. These changes are additive and do not break clients. Code generated from the schema that readsdeletednow receives nullable entries. The input schemas of generated MCP add and update tools change the same way:A<Type>InputFor<ConnectionField>gains the optional self-referencing collection fields, self-referencing collection properties gain their field descriptions, anddeleteditems become nullable, so hosts that pin MCP tool definitions may report them as changed.
See generated collection input names.
Nested collection writes
Nested
updatedanddeletedchildren are now checked against the parent's stored identifier, the_id, elseid, of the record the update returns, instead of the ID as the client sent it. An update whose parent ID uses a spelling the backend accepts but does not store, such as an uppercase MongoDB ObjectId or an uppercase PostgreSQL UUID, no longer fails withFORBIDDEN(Child does not belong to this parent) for the parent's own children; the same update without nested items already succeeded. The same applies to grandchildren under a nestedupdatedchild whose ID is sent in another case. Children of another parent are still rejected.addedandupdatedchildren are now linked with the stored identifier. With generated models, and on PostgreSQL, the stored link does not change, because the backend already cast it. On MongoDB, a supplied child model whose connection path does not cast to the parent's key, such as aStringpath for an ObjectId parent, used to store the ID as the client spelled it, so those children were missing fromparent.children. They are now linked canonically.Children already stored with another spelling of the parent ID, for example written by 3.5.7 through an uppercase parent ID, are not listed under the parent, and can no longer be updated or deleted through the parent with that spelling: that now fails with
FORBIDDEN. There is no automatic migration. Normalize such links once, adapting the field name and ID format to your model, for example for ObjectId links stored as strings:javascriptawait Child.collection.updateMany( { parent_id: { $regex: '[A-F]' } }, [{ $set: { parent_id: { $toLower: '$parent_id' } } }], );Or fix them with direct model writes.
tests/integration/runtime-v31.test.jsruns this command against MongoDB.Type validators (the type's
extensions.validations) and theonUpdatinghook of nested children under an updated parent now receive the parent link as the stored identifier: an ObjectId on MongoDB, the lowercase UUID on PostgreSQL. Before, they received the ID string the client sent.The
onSavinghook of a child added under an updated parent receives the new record. On PostgreSQL its link is now the lowercase UUID instead of the client's string. On MongoDB the record is a Mongoose document whose link is cast to the child's path type: with generated models it is an ObjectId, as before; with a suppliedStringpath it is now the parent's lowercase hex string instead of the client's spelling.Children of a newly created parent already received these values.
Custom adapters: the runtime uses the
_id, elseid, of the record thatadapter.update()returns, and falls back to the requested ID when neither is present.
See update a collection.
Errors
- The nested collection ownership denial (
Child does not belong to this parent) is now anauth.ForbiddenError. Its message,FORBIDDENcode and 403 status are unchanged, and it is still aSimfinityError, but itsnameis nowForbiddenErrorinstead ofError. Error-formatter callbacks and audit hooks that checkinstanceof auth.ForbiddenErrornow receive these denials too, including any message masking they apply toForbiddenError. InternalServerErrornow has status 500, throughgetStatus()andextensions.status. Whereextensionshad nostatuskey before, it now includes"status": 500: errors thatbuildErrorFormatterwraps as unexpected, theType.field stores an invalid identifierreference error,InternalServerErrors your own code throws or returns from the formatter callback, and the same errors inside MCP tool results. This matches the 500 thatbuildErrorFormatteralready gave aGraphQLErrorwith theINTERNAL_SERVER_ERRORcode, and PostgreSQL'sDATABASE_ERROR.- A
GraphQLErrorwhoseSimfinityErrorcause is anInternalServerErrornow reports 500 even when it has its own integerextensions.status, because the cause's status wins, as its code already did. - The HTTP status of responses does not change: Simfinity does not set it, and GraphQL Yoga and Apollo Server read
extensions.http, notextensions.status. Code that recognized unexpected errors by a missingextensions.statusshould checkextensions.code === 'INTERNAL_SERVER_ERROR'orerror instanceof InternalServerErrorinstead.
See errors.
Filter values
- Filter values (
QLValue) now accept operation variables inside list literals, for exampleyear: { operator: BTW, value: [$from, $to] }with$from: QLValue, $to: QLValue, and enum literals such asstatus: { value: ACTIVE }or{ operator: IN, value: [ACTIVE, INACTIVE] }, which are read as the enum names. Previously these documents failed validation withFilter values must be scalars or flat scalar lists. Declare item variables asQLValue: GraphQL rejects other declared types, such asInt, in that position. - An item variable the request leaves unset reads as
null, as GraphQL reads list arguments, and is rejected like an explicit null element. Items that a variable sets tonull, an object or a list are rejected withINVALID_FILTER_VALUE, as whole-list variables are. On MongoDB the message is, for example,BTW does not accept null elements; on PostgreSQL it isFilter lists require non-null scalar elements. - On a field that is not an enum, an enum literal is read as its name string, so
name: { value: A }matches the textA.
See operators.
Interface and union fields
- On the default MongoDB runtime and with custom adapters, entity fields of an interface or union type no longer make the generated schema invalid. Previously a single such field gave list, aggregate and collection queries an argument without a type, so every operation failed validation, and a writable list of them, also inside embedded types, made
createSchema()throwINPUT_TYPE_UNRESOLVED. - These fields are now output-only. Generated inputs leave them out, and a field that is not
readOnlylogs oneConfiguration issuewarning. Such fields that already built, such as a single union field in an embedded type, now log it too. - A single such field has no filter argument. A list keeps its existing
QLTypeFilterExpressionargument; send it only asnull.AND/ORconditions that name such a field are unchanged: non-null values are rejected withINVALID_FILTER_VALUE, anullcondition matches every record because nothing is stored, and a field with your own resolver is rejected withFORBIDDEN_FILTER_PATH(403). - Resolve these fields with your own resolver and mark them
readOnly. - A type whose generated input would have no fields, such as a collection child with only
id, the back-reference and such fields, still fails schema validation. - PostgreSQL and
referentialIntegrity: 'transactional'still reject these fields withINVALID_MODEL.
See what gets generated.
Application scalars named JSON
- An application scalar named
JSON, such asGraphQLJSONfrom graphql-scalars or graphql-type-json, no longer makescreateSchema()throwSchema must contain uniquely named types but contains multiple types named "JSON". - When the schema contains such a scalar, through an entity field, a custom mutation input or result, or any type they reach,
QLTypeAggregationResult.groupIdandfactsuse it, and itsserializeformats aggregation results. It must serialize strings, numbers andnullas well as objects. - Schemas without one keep Simfinity's own
JSONscalar unchanged. - A type named
JSONthat is not a scalar still conflicts, as do application types namedRelationTypeorFieldExtensionsType, which every schema contains.
Counted lists without a context object
- A list query with
pagination: { count: true }no longer fails withCannot set properties of undefined (setting 'count')when the request has no context object, for examplegraphql()withoutcontextValue. It returns the rows and skips the count query, since nothing could read the count. - A frozen, sealed or read-only context gets the rows, no count, and one
Configuration issuewarning per runtime, instead of an error. Count plugins still need a fresh mutable context per request.
See count behavior.
Field validators and bounded scalars
validators.arrayLength()validates items whenitemValidatoris a single validator (a plain object or a function withvalidate()) or a helper result such asvalidators.maxLength('Tag', 20), and expands helper results inside an array. Earlier versions skipped item validation for those shapes without an error, so creates and updates whose items break the rule were stored; they now fail withVALIDATION_ERROR. Stored data is not checked.- A helper result applies its
CREATErules to every item on create and update, like the documentedvalidators.maxLength('Tag', 20).CREATE, sonullitems fail helpers that require a value. Lists sent with an array of helper results used to fail with an internalvalidator.validate is not a functionerror; they are now validated. Falsy array entries are skipped. - Other values, including a single class instance such as a Joi or yup schema, are still ignored, and an array entry that is not a validator still makes lists with items fail, now with a
TypeErrorthat names the helper. Both log aConfiguration issuewarning when the helper is created. validators.dateFormat()andvalidators.futureDate()reject impossible calendar dates in strings that start with an ISO date, such as2024-02-31,2023-02-29or2024-04-31T10:00:00Z, and in slashedMM/DD/YYYYstrings such as02/31/2024, with<name> must be a valid date. Earlier versions stored them because JavaScript rolls them into the next month; on aDateorDateTimefield the stored date was the next month's. With'YYYY-MM-DD', values in other shapes keep themust be in formatmessage.- Format strings other than
'YYYY-MM-DD'are still not enforced, and now log a warning when the helper is created; values under them get the same calendar checks as values without a format. Stored values are not checked or changed, but an update that sends one back fails. - A
nullbound means no bound, asundefineddoes, instringLength,maxLength,numberRange,arrayLengthand thecreateBounded*Scalarfactories. Earlier versions compared values with 0 and rejected them with messages such asmust be at most null characters. If you passednullwhere you need 0, pass 0. Reads of stored values that only anullbound excluded no longer fail with a field error. - Bounded scalars with one bound or none are described by the bounds they have, such as
A string with at most 120 characters,An integer of at least 0orA float, instead ofbetween undefined and 120. ANaNbound is left out of the description. The descriptions appear in SDL, introspection and MCP tool definitions, so schema snapshots change, and hosts that pin MCP tool definitions may report them as changed. Scalars with both bounds and every GraphQL name are unchanged.
See available helpers and scalar factories.
TypeScript declarations for core subpaths
@simtlix/simfinity-core/auth,/auth/errors,/auth/expressions,/auth/rules,/plugins,/scalarsand/validatorsnow ship TypeScript declarations, through atypescondition in their export entries (moduleResolutionnode16,nodenext,bundler) and atypesVersionsmap (node10). Previously, undernode16,nodenextandbundler, these imports failed with TS7016 in strict projects and were typed asanyin non-strict ones; undernode10they failed with TS2307 whatever thestrictsetting. Their named and default exports have the same types as the rootauth,plugins,scalarsandvalidatorsnamespaces, and they re-export related types such asAuthRuleFunction,PermissionSchema,FieldValidationsandItemValidators. The JavaScript modules and the files Node resolves are unchanged.requireRole()andrequirePermission()also accept readonly arrays, such as anas constlist or a frozen array, in the root declarations and the subpaths. The declarations ofstringLength,maxLength,numberRange,arrayLengthand thecreateBounded*Scalarfactories acceptnullbounds, andarrayLength'sitemValidatoraccepts the newItemValidatorstype, which the core root, the/validatorssubpath and the@simtlix/simfinity-js,@simtlix/simfinity-postgresand@simtlix/simfinity-sqltype entries export.- Code that compiled only because these imports were
any, in non-strict builds undernode16,nodenextorbundler, can now report errors for calls the declarations do not allow. For example, adefaultPolicyheld in astringvariable must be narrowed to'ALLOW' | 'DENY'(use a literal oras const). Remove any shorthanddeclare module '@simtlix/simfinity-core/...'shim added for TS7016 or TS2307: it keeps taking precedence over the shipped declarations, so the import staysany. - The
internal/*subpaths are not public API and stay undeclared, as do MongoDB's legacy@simtlix/simfinity-js/src/...deep imports. In TypeScript, importauth,plugins,scalarsandvalidatorsfrom the package root. Importing the core subpaths in an application that installs a facade needs@simtlix/simfinity-coreas a direct dependency pinned to the facade's exact version.
Documentation
- The
saveObject()documentation now says that a direct call skips the global middleware of the root record only. Nested non-embedded collection children still run their type's global middleware, controllers and ownership checks, with thecontextpassed tosaveObject(), which isundefinedwhen omitted, as they have since 3.1. Pass the hook's or request's context when you call it from a controller or custom mutation. See saveObject.
Upgrade to 3.5.7
Version 3.5.7 fixes MCP tool results, tool definitions, argument handling, configuration checks, remote execution and the Streamable HTTP handler, and adds field markers to core that the MCP package reads. Keep all directly installed Simfinity packages at 3.5.7, including @simtlix/simfinity-mcp and @simtlix/simfinity-core, as their exact internal dependency already requires. When upgrading from an earlier version, also review the 3.5.6 notes below.
MCP tool results
- When a mutation result exceeds
limits.maxResultBytes, the call no longer reports a failure for a write that already ran. A mutation that succeeded returnsisError: falsewith_meta: { truncated: true }, andstructuredContentand the text hold only the id of the root value (or the ids of a returned list), when it fits within the cap, next to atruncatednotice{ applied: true, message, resultBytes, maxResultBytes }. The message says whether calling the tool again would repeat the change, following the tool'sidempotentHint, and never sends the agent to read a deleted record. With GraphQL errors the result staysisError: truewithMCP_RESULT_TOO_LARGE, a new message,extensions.applied(true, or'unknown'when no root value came back, neverfalse),extensions.firstErrorand the ids indatawhen present. Previously every oversized mutation returnedMCP_RESULT_TOO_LARGEwithout an id, asking the agent to narrow the query, so an agent that retried a create stored the record twice. Code that detected these results byMCP_RESULT_TOO_LARGEmust check_meta.truncatedor thetruncatedkey, and middleware that countsisErrorsees them as successes. Oversized query results keep their error; its message now reports a fractional cap rounded down to whole bytes, as the notice does. See oversized results. - Counted list calls (
pagination: { count: true }, sent or injected throughlimits.defaultPagination) also return the total astotalCountnext to the results, instructuredContentand in the text, so the model sees it;_meta.countstays for client code. A remoteextensions.countbecomestotalCountonly when it is a non-negative integer; Simfinity's count plugins omit a count of zero, so a remote call that matches nothing has nototalCount.totalCountcounts towardmaxResultBytes, so a counted result that was within a few bytes of the cap can now exceed it, and theMCP_RESULT_TOO_LARGEerror of an oversized counted list carriesextensions.totalCount. See counted lists. structuredContentis now the GraphQLdataplus the keys MCP adds next to the root field:totalCountortruncated, named__totalCountor__truncatedwhen the root field itself has that name. Code that comparesstructuredContentwith the GraphQLdata, or assumes it has a single key, must allow for them.
MCP tool definitions
tools/list changes in the ways below. Hosts that pin or hash tool definitions may report the tools as changed.
- Output schemas publish a JSON
typefor a scalar only when it is a GraphQL spec scalar or acreateValidatedScalarchain ending at one.Date,DateTimeandTime, custom scalars with a hand-setbaseScalarTypestorage hint and opaque scalars are published without atypeorformat, with a curated description for date/time scalars that have none. Previously their output schemas carriedformat: 'date','date-time'or'time', or the hint's primitive type, and the SDKClient, which validatesstructuredContentwith formats, threw-32602(Structured content does not match the tool's output schema) for values their serializers produce, such as an ISO instant forDateor'12.30'for aDecimalhinted asFloat, including on mutations that had been committed. These schemas only get looser, so no action is needed. Input schemas keep theformathints; see output schemas for the input/output difference withTime. - Input schemas are closed with
additionalProperties: falseat the top level, and so is the schemagraphqlArgsToJSONSchema()returns. See MCP arguments. - List tools with a
paginationargument declare an optionaltotalCount, and mutation tools declare an optionaltruncatednotice whenlimits.maxResultBytesis set at setup, unless aselectionoverride omits the output schema. The model-facing descriptions ofpaginationandQLPagination.countnow nametotalCountinstead of_meta.count. - Generated update tools whose input can carry collection items to insert, an
addedlist at any depth as in a non-embedded one-to-many collection, now haveidempotentHint: false, because every repeat inserts those items again. Hosts that retry tools marked idempotent stop retrying them. Other updates and deletes keepidempotentHint: true; idempotency describes the stored data, and a repeated delete callsonDeletewithnull. Restore a hint per tool throughtoolOverridesannotations. See annotations. - Mutations are classified by the new core marker
extensions.simfinityMutation, or, without it, by the placeholder description together with the matching name prefix, never by the name alone. State-machine actions without a description that are named likedeleteDraft_book,updateStatus_bookoraddToArchive_booklose theDelete,UpdateandCreatetitles, descriptions and hints of the CRUD tools: they are titled by their field name and described as a transition between their states. Custom mutations registered withregisterMutation()are custom tools whatever their name and description; previously one namedadd*,update*ordelete*was published as a CRUD tool when it had no description, or the placeholder description. Description-less mutations with those prefixes in a hand-built schema also become custom tools, so a hand-built delete loses its explicitdestructiveHint: true, which is still MCP's default. Generated create, update, delete, list, get-by-id and aggregate tools are unchanged. - A root query is a list tool only with a
paginationargument and a get-by-id tool only with anidargument, unless it is a generated list or aggregate query. Other queries, such as acurrentUseror an unpaginatedrecentUsers, change fromGet X/List Xto their field name and a read-only description, and keepreadOnlyHint: true. includeId, in the options and intoolOverrides, is deprecated and ignored. It never changed the generated selection, so selections and output schemas are unchanged.
MCP arguments
callToolrejects an argument key that the caller sent and the tool does not declare with anMCP_UNKNOWN_ARGUMENTerror result, without running the tool or sending a remote request. The message names the keys, suggests close declared names and lists the valid ones. Previously such keys were dropped silently while the tool ran: a misspelled filter returned unfiltered data, and a guesseddryRunon a delete still deleted the record. Keys whose value isundefinedare ignored. See unknown arguments.- Tool middleware that reads a custom key the caller sends must delete it from
call.argsbefore callingnext(). Keys that middleware adds pass through as before. Hosts that validate tool input against the now closed input schema reject undeclared keys before they reach the server and its middleware; there is no option to declare extra keys.
MCP context and counts
- Generated list tools receive the caller's context object itself on counted calls too. Previously counted calls ran with a per-call object whose prototype was the context, so contexts that are class instances with private fields,
Maps, objects checked withObject.hasOwn, spread copies orWeakMapkeys made counted calls fail or be denied while uncounted calls worked, including with the stock auth plugin, and a context factory returningnullfailed with aTypeError. The count now goes through a per-call sink in the GraphQL root value. - In-process calls of generated list tools run with a null-prototype root value, counted or not, where they previously had none. Resolver wrappers and authorization rules on the root field see it as
parent, and every resolver sees it asinfo.rootValue. A nested resolver that readcontext.countduring the same MCP call now readsundefined, and a count that Simfinity middleware forces on an uncounted call is no longer written to the caller's context. - List fields without core's
extensions.simfinityQuery.countSinkmarker, such as custom list queries, keep the per-call object on counted calls. The marker decides, not the resolver that runs. Suppose an application replaced a generated list field's resolver with one that writescontext.count, or wrapped the generated resolver without forwardingparentorinfo. Its counted calls now return no total, and the count is written onto the caller's shared context. Forwardparentorinfo, report the total through the function underparent[Symbol.for('simfinity.countSink')], or set the field'sextensions.simfinityQuery.countSinktofalsebefore creating the tools. See counted lists.
MCP setup errors
limitsis validated when the tools, server or HTTP handler are created:- Falsy values (
false,null,0,'') forlimits,defaultPaginationor a cap mean unset.limits: nullpreviously failed setup with aTypeError. - Numeric-string and bigint caps keep working,
'Infinity'included. ANaNcap still applies no cap and now logs one warning per setup. - A
defaultPaginationthat a published tool'spaginationtype rejects, such as{ size: 20 }withoutpage, a zero, negative, fractional or stringsize, a non-booleancountor an unknown key, now throwsMCP_INVALID_LIMITSnaming the tool. Previously setup succeeded and every call without pagination failed with an error that looked like the agent's mistake. - Caps below 1, such as
-1or0.5,true, blank strings and strings that convert to a number below 1, such as'0'or'0.5', now throwMCP_INVALID_LIMITS, whose message says a cap must be a number of at least 1. Previously they made every call that the cap applies to fail, or, fortrue, every page size above 1. - Non-numeric string caps such as
'abc'and object caps such as{}now throwMCP_INVALID_LIMITS; previously they applied no cap. Array and function caps throw too. - A truthy
limitsthat is not an object, such as'x',5,trueor[], now throwsMCP_INVALID_LIMITS; previously it was ignored, solimits: []applied no limits. A truthydefaultPaginationthat is not an object throws as well; previously every call without pagination failed. limitsis still read on every call, so a cap changed after setup to an invalid value makes calls reject withMCP_INVALID_LIMITS.
- Falsy values (
- The new
MCP_INVALID_EXECUTION_CONFIG(500) is thrown at setup for a remoteendpointthat includes a username or password, for remoteheadersthat are a string, number,true, promise, function or one-shot iterator, and for atimeoutMsthat isInfinity, negative,true, non-numeric or an object. These values previously sent no credentials or junk headers, or failed every call. The messages never quote the endpoint or header values. A getter forheadersortimeoutMsdefined on the execution object itself is not run at setup, as before; what it returns is checked on every call. A getter inherited from a class, or aProxyexecution object, is now read at setup and its value checked; if that read throws, for example until a token loads, setup still succeeds and the property is read and checked on every call, as before. See MCP remote execution. createMCPServer,startStdioMCPServerandcreateHTTPMCPHandlerload the SDK, and the stdio or Streamable HTTP transport, before validating options and runningschemaPlugins, so an SDK error leaves the schema's resolvers untouched and comes before option errors. PreviouslystartStdioMCPServerran the plugins before loading the stdio transport, andcreateHTTPMCPHandlerreportedMCP_INVALID_TRANSPORT_OPTIONSbefore an SDK error.MCP_SDK_INCOMPATIBLEnow also covers SDK modules that lack an expected export, which previously surfaced as a rawTypeErrorafter the plugins had run, and its message suggestsnpm install @modelcontextprotocol/sdk@^1.31.0instead of@latest.
MCP remote execution
execution.endpointmust not include a username or password; send credentials throughexecution.headers. Such an endpoint never worked, becausefetchrejected it on every call, and thefetcherror, which reached the MCP client and the model, quoted the URL with its password. Setup now throwsMCP_INVALID_EXECUTION_CONFIGinstead, and an endpoint changed after setup to such a value fails calls withMCP_REMOTE_REQUEST_FAILED; neither message quotes the URL.- Other endpoints are still accepted at setup, because an application that replaces
globalThis.fetchmay serve them: relative, unparsable and non-http(s)URLs, andRequestinstances, which the defaultfetchaccepts too. Whenfetchrejects such an endpoint with an error that quotes the URL, the call fails withMCP_REMOTE_REQUEST_FAILEDand the messageexecution.endpoint must be an absolute http(s) URL, such as https://api.example.com/graphql. Previously the message was thefetcherror, which quoted a relative or unparsable URL with any query-string API key. Otherfetcherrors keep their message, which does not quote the URL, such as an applicationfetch's own network failure or the defaultfetch'sfetch failedfor another protocol. execution.headersaccepts aHeadersinstance, aMapor an array of[name, value]pairs as well as a plain object, and sends their credentials. PreviouslyHeadersinstances andMaps sent no credentials, and pairs and strings were sent as numbered junk headers. Agents may now see data scoped to the configured identity instead of anonymous results.- A
Content-Typeset in any casing replaces theapplication/jsondefault and is sent once. Previously aContent-Typein another casing was sent next to the default asapplication/json, application/json, which strict parsers such as Express 5'sexpress.json()rejected, so calls failed withMust provide query string. - Any falsy
headersvalue sends no extra headers, as before. Headers are read on every call, as before; a value that cannot be sent fails the call withMCP_REMOTE_REQUEST_FAILEDbefore any request, naming the header but not its value, where previously the error quoted the value. fetchreceives the headers as a plain object with lowercase names. Fetch wrappers or mocks that readAuthorizationmust readauthorization. For the TypeScript types of these forms, see MCP TypeScript declarations.timeoutMsaccepts numeric strings and bigints, rounds fractions up and caps values above 2147483647.'0'disables the timeout like0. Previously numeric strings, fractions and'0'failed every call with a rawTypeErrororRangeError(-32603for SDK clients), and values from 2147483648 to 4294967295 aborted every attempt after about 1 ms.- Client cancellation during a retry backoff now rejects at once with the abort reason, the value an aborted fetch rejects with, and sends no further request. Previously the call waited out the backoff and, with a
fetchthat ignores the signal, could send another request. Tool middlewarefinallyblocks now settle at once. This applies tocallTool, stdio andcreateMCPServerover a persistent transport. WithcreateHTTPMCPHandler, the per-request server settles at once when the client's HTTP connection closes. An MCPnotifications/cancelledarrives as a separate stateless request and cannot reach the call in flight, which completes its backoff and retries.
MCP tool middleware
- A synchronous throw from a tool middleware now rejects the previous middleware's
next()promise, as an asynchronous throw does, so outer.catch(),.finally(),.then(onSuccess, onFailure)andtry { return await next(); }boundaries handle it. Through the SDK, a policy error behind such a boundary becomes the boundary's tool result instead of a JSON-RPC-32603error carrying the raw message. An outer middleware using a synchronoustry { return next(); } catch {}withoutawaitno longer catches such throws; it never caught asynchronous ones. - Every
next()promise is marked as handled. A middleware that startsnext()and awaits other work before awaiting it no longer crashes Node with an unhandled rejection, and a rejectednext()promise that no middleware awaits or returns is dropped.
MCP Streamable HTTP handler
createHTTPMCPHandleranswers every method other than POST with 405,Allow: POSTand a JSON-RPC error body, without callingcontextoronErroror creating a server or transport. Previously a GET that acceptedtext/event-streamopened an idle 200 event stream that held a socket, a server and a transport per client, and SDK clients logged anAbortErroron close; a GET without thatAcceptgot 406, DELETE got 200 and other methods got the SDK's 405 withAllow: GET, POST, DELETE. Mount the handler withapp.all; withapp.post, Express answers the client's GET with 404, which SDK clients report throughonerror. Anything that used GET on the MCP path as a health check now gets 405, and DNS-rebinding host and origin checks no longer run for requests other than POST, which serve nothing.
MCP TypeScript declarations
- The
optionsparameter is now typed by new input-only types:GenerateMCPToolsInitforgenerateMCPTools,MCPServerInitforcreateMCPServerandstartStdioMCPServer, andHTTPMCPHandlerInitforcreateHTTPMCPHandler. They accept every value of the option interfaces, plus theexecutionandlimitsforms of the newMCPOptionsInit.limitsmay be anMCPLimitsInit,nullorfalse, with caps as numbers, numeric strings, bigints,nullorfalseand adefaultPaginationthat may also benullorfalse. A remoteexecutionmay be anMCPRemoteExecutionInit. Itsheadersis anMCPRemoteHeadersInit(a plain object, anMCPHeadersLikesuch as aHeadersinstance, aReadonlyMapor readonly[name, value]pairs),nullorfalse, and itstimeoutMsis a number, numeric string, bigint,nullorfalse. Previously the parameters were the option interfaces, so the wider forms did not compile. GenerateMCPToolsOptions,MCPServerOptions,HTTPMCPHandlerOptions,MCPLimitsandMCPRemoteExecutionOptionskeep their 3.5.6 shapes, so code that reads options back, such as rotatingexecution.headers.authorizationor readinglimits.maxPageSizeas a number, compiles unchanged. Pairs built in a separate variable needas const. See TypeScript option types.
Core
- Generated list fields carry
countSink: trueinextensions.simfinityQuery, and their resolver receivesinfo. When the root value carries a function underSymbol.for('simfinity.countSink'), the resolver hands it the count instead of writingcontext.count. Without one, it writescontext.countas before, so Yoga, Apollo and Envelop count plugins are unchanged. - Generated mutation fields carry
extensions.simfinityMutation:{ typeName, operation }with'save','update'or'delete',{ typeName, operation: 'state_changed', action, from, to }for state-machine actions and{ operation: 'custom_mutation' }for registered custom mutations. Validated scalars carry their base under the non-enumerableSymbol.for('simfinity.validatedScalarBase'). Neither symbol is exported. These markers are not part of SDL, and Simfinity's introspection extension does not expose them, so schemas rebuilt from SDL or introspection fall back to the placeholder descriptions and publish their validated scalars without a JSON type, as do validated scalars created by a core version that predates this marker.
Upgrade to 3.5.6
Version 3.5.6 fixes values that MongoDB and PostgreSQL stored or reported incorrectly, PostgreSQL schema validation and queries, and how SQL runtimes classify errors and track readiness. On MongoDB, createSchema() now rejects fields that Mongoose cannot store; see fields named like Object.prototype members. Keep all directly installed Simfinity packages at 3.5.6. When upgrading from an earlier version, also review the 3.5.5 notes below.
Fields named like Object.prototype members
These changes apply to fields named constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, toString or valueOf, at the root of a type or inside embedded types. See fields named like Object.prototype members.
On MongoDB, a generated create or a
saveObject()call that omitted aStringfield with such a name stored the textfunction toString() { [native code] }, with the member's name, and an omitted[String]list stored a list holding that text. A generated update that did not mention such a field or list replaced its stored value with that text. Fields inside embedded objects were affected too. Now an omitted scalar stores no key, an omitted list stores[], and an update that does not mention a field keeps the stored value.On PostgreSQL, a create that omitted a scalar field with such a name, and an update that did not mention it, failed with
INVALID_FILTER_VALUE(400,Expected a string for toString), and the same writes failed for a list field with such a name withINVALID_VALUE(400,Invalid value for toString). When a write supplied those fields, an omitted embedded object field with such a name was stored as an object built from the inherited function: its members namednameandlengthheld the function's name and arity, such as{"name": "Object", "length": 1}forconstructoror{"name": "valueOf", "length": 0}forvalueOf, and a later partial update of it kept them. Now these writes succeed, and omitted fields are stored as SQLNULL, as absent JSON keys, or as[]for lists.A create that omits a list with such a name, or sends
nullfor it, now stores[]at the root, in embedded objects and in embedded list items, as for a list with another name, and type-level validators and controllers receive[]for it. Previously a create that omitted an embedded list with such a name failed on both backends with aTypeError(args[fieldEntryName] is not iterable); on MongoDB such lists are now rejected at startup, as described below. Collections are not stored on the record and are unchanged.A reference with such a name, stored under its own name or under another
connectionField, no longer fails creates that omit it, or updates that do not mention it, withNOT_VALID_ID(400) on either backend, and an unset one readsnullinstead of failing the read.On MongoDB,
createSchema()now rejects the fields that Mongoose cannot store, withINVALID_MODEL(400,Type.field cannot be stored on MongoDB: …), whereTypeis the type that declares the field, an embedded type for a nested one:- An embedded object or embedded list with such a name, at any depth (
embedded fields cannot be named like Object.prototype members). Previously an embedded object field madecreateSchema()fail with Mongoose'sCannot set nested path …error, or was dropped from every write when namedconstructor. An embedded list was stored, but the MongoDB Node.js driver returns the items of a list with such a name as raw BSON buffers, so every read returned the items with all membersnull. - Any field stored under the path
constructor: a scalar or a scalar list namedconstructor, a reference namedconstructorstored under its own name, and a reference whoseconnectionFieldisconstructor(Mongoose drops a stored path named constructor). Previously Mongoose dropped the value from every write without an error, so it readnull, and reads of such a reference could fail withType.field stores an invalid identifier.
Rename these fields, and give such references another
connectionField. Scalars, scalar lists and references with the other names, and a reference namedconstructorstored under anotherconnectionField, are unchanged. Models that you pass toconnect()are not checked. Embedded list items that earlier versions stored are intact on the server; after you rename the field, move them to the new name, for example withdb.Gauge.updateMany({}, { $rename: { valueOf: 'readings', 'spec.valueOf': 'spec.readings' } })for fields outside lists, or with an update pipeline for fields inside embedded list items. Values stored underconstructorwere never written. PostgreSQL stores all these fields.- An embedded object or embedded list with such a name, at any depth (
Absent values of such fields now read
null. Previously reads returned the inherited member: on MongoDB the text[object Object], or an object withnullmembers for an embedded field, and on PostgreSQL a GraphQL error such asString cannot represent value: {}. Simfinity installs a generated resolver on output fields with these names that have no resolver of their own, on registered types and the embedded types they reach; it reads own values, methods and class getters as before.Values that earlier versions stored are not repaired, and values that were overwritten cannot be recovered. These checks find candidates; they are heuristic, so review every match before you change it. On MongoDB, search each collection for the text of a native function in every path where such a field or list occurs; a regular expression also matches the elements of a list:
javascriptconst nativeCode = /^function \w+\(\) \{ \[native code\] \}$/; db.Gauge.find({ $or: [{ toString: nativeCode }, { valueOf: nativeCode }, { 'spec.valueOf': nativeCode }, { 'items.toString': nativeCode }] });On PostgreSQL, search each JSONB column that holds such an embedded field, at the top of the column or nested, for an object whose
nameandlengthare the inherited function's:Objectand 1 forconstructor, the member's own name and 1 forhasOwnProperty,isPrototypeOfandpropertyIsEnumerable, and the member's own name and 0 fortoLocaleString,toStringandvalueOf. An embedded type that contains references or unique fields is stored in an owned table, whosenameandlengthcolumns you check instead:sqlSELECT id FROM "app"."Gauge" WHERE "constructor"->>'name' = 'Object' OR jsonb_path_exists("spec", 'lax $.**.valueOf ? (@.name == "valueOf" && @.length == 0)');graphql-js 16 reads variable input objects with plain property access, so a JSON variable that omits such a field passes the inherited function, and the request fails before Simfinity runs, with errors such as
Variable "$input" got invalid value [function toString] at "input.toString"; String cannot represent a non string value: [function toString]. Nothing is written. Inline literal arguments are not affected. Clients that send input as variables, including MCP tool calls, must include these fields:nullstores nothing in a create, and clears the stored value in an update, so send the current value to keep it.On MongoDB, a document stored without a list with such a name, such as one written before the field was added, written outside Simfinity, or from which you removed a corrupted value, reads
nullfor that list in by-ID reads and mutation results, where a missing list with another name reads[]; list queries readnullfor both. This applies at the root and in embedded objects. The next update of an embedded object stores[]for its missing list members, but a missing root list stays missing until a write sets it. Store[]in such documents to read[]everywhere.
Commits after a failed statement
- On PostgreSQL, an owned transaction in which a statement failed and your code caught the error, and that then reaches
COMMITwithout running another statement, now fails withDATABASE_ERROR(500,Transaction was rolled back because one of its statements failed), and is not retried. Examples are anonSavedoronUpdatedhook, or awithTransactioncallback or custom mutation that issues no statement after the one it wraps intry/catch. PostgreSQL answers theCOMMITof such a transaction by rolling it back without an error. Like the other PostgreSQL database errors, this one has a non-enumerablecause, here anErrorwith the messageCOMMIT reported ROLLBACK, whicherror.getCause()returns. Previously these generated or custom mutations,saveObject()andwithTransaction()calls reported success, including the new record'sid, while nothing was stored. When another statement runs after the caught failure, such as Simfinity's own write afteronSaving,onUpdatingoronDelete, that statement fails and the operation fails withDATABASE_ERROR(500,Database operation failed), as before. To continue after an expected statement error, runSAVEPOINTbefore the statement andROLLBACK TO SAVEPOINTafter the error.session.query()still rejects with the driver's own error, so the callback can inspect itscode. - The plugin detects the rollback from the command that the
COMMITresult reports,ROLLBACK, aspgclients and pg-native return it. A custom pool or client wrapper that returns results withoutcommand, such as{ rows, rowCount }, keeps working, but then such a transaction reports success with nothing stored, as before; passpg'scommandthrough to get the check.
PostgreSQL [ID] list indexes
New databases no longer get a generated btree index on
[ID]list columns (uuid[]), at the root or in owned tables. That index limited each list to about 167 identifiers: writing 168 or more failed withDATABASE_ERROR(500), PostgreSQL error54000. No generated filter, sort or aggregate used it. SingleIDcolumns and references keep their indexes.describeDatabase()andcompileDatabaseSchema()no longer list these indexes, which schema-diff tooling will notice.Existing databases keep the index:
createandvalidatemodes accept it as an additional non-unique index and do not drop it, so the limit applies until you drop it. While such indexes exist, every successfulinitializeDatabase()call prints one console warning, in both modes, that names them and gives the statements to run:textSimfinity: PostgreSQL schema "app" has btree indexes that earlier versions generated on [ID] list columns: "app"."Post__relatedIds__idx", "app"."Post__links__refIds__idx". Each limits its list to about 167 identifiers and no generated query uses it. Once every process that uses this schema runs this version, drop them: DROP INDEX CONCURRENTLY IF EXISTS "app"."Post__relatedIds__idx"; DROP INDEX CONCURRENTLY IF EXISTS "app"."Post__links__refIds__idx"; See the upgrade notes at https://simtlix.github.io/simfinity.js/resources/compatibility.htmlThe warning names only indexes with the exact generated name and shape, including shortened long names: btree, non-unique, on the list column alone, with no predicate or expression. Indexes that your DBAs created are never named.
To remove the limit, first upgrade every process that uses the schema, as described below. Then run the statements from the warning, or generate them with this catalog query:
sqlSELECT format('DROP INDEX CONCURRENTLY IF EXISTS %I.%I;', n.nspname, ic.relname) FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid JOIN pg_class t ON t.oid = i.indrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_am am ON am.oid = ic.relam JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] CROSS JOIN LATERAL (SELECT t.relname || '__' || a.attname || '__idx' AS name) g WHERE n.nspname = 'app' AND t.relname = ANY(ARRAY['Post', 'Post__guard', 'Post__links']) AND am.amname = 'btree' AND NOT i.indisunique AND NOT i.indisprimary AND i.indnatts = 1 AND i.indpred IS NULL AND i.indexprs IS NULL AND a.atttypid = 'uuid[]'::regtype AND ic.relname = CASE WHEN octet_length(g.name) <= 63 THEN g.name ELSE left(g.name, 50) || '_' || left(encode(sha256(convert_to(g.name, 'UTF8')), 'hex'), 12) END;Replace
appwith your schema, and the array with the table names thatsimfinity.describeDatabase().tables.map((table) => table.name)returns on the new version. The query then selects the indexes that the warning names, and leaves out tables that Simfinity does not describe, such as your own tables or those of removed types, even when their indexes follow the same naming pattern. The shortened-name branch is exact only for ASCII table and field names; review the output before you run it.DROP INDEX CONCURRENTLYcannot run inside a transaction block, such as a migration tool's transaction; for small tables, a plainDROP INDEXinside the migration also works.Earlier versions expect the index. Their
validatemode fails withSCHEMA_MISMATCH(409,index is missing) on a database that the new version created or from which the index was dropped. Theircreatemode re-creates it with a non-concurrentCREATE INDEXwhile it holds locks that block writes, and fails, rolling back the whole initialization, once any stored list has 168 or more identifiers. Upgrade every process that initializes or uses the schema before you drop the indexes, and do not let an older process create or validate a schema that the new version created first. The same applies to a downgrade.Keep treating the
<Table>__<field>__idxnames of these indexes as taken: on a database that still has one, creating a table with the same name, such as the owned table of an embedded field literally named<field>__idx, fails. Drop the index first.
PostgreSQL schema validation and queries
- Filters, sorts, counts and aggregates that go through an embedded list stored in owned tables, because it contains references or unique fields, no longer fail with
DATABASE_ERROR(500) when the physical column order of an owned table differs from the order of the embedded type's fields: after a migration appended columns withALTER TABLE … ADD COLUMN, or after the fields were reordered. This includes nested embedded lists, singular embedded objects inside such a list, and every field reached through them. Generated queries now select those columns by name. No action is required. - Column order is not significant, but a migration must still bring every generated object in line with the new
describeDatabase()orcompileDatabaseSchema()output. Adding or reordering fields can change the bodies of generated functions such as<Root>__owned_valid,<Root>__refreshand<Table>__<field>__json_valid; replace them withCREATE OR REPLACE FUNCTIONbefore you validate. ANOT NULLcolumn added with a temporaryDEFAULTmust have that default dropped. Validation reports whatever differs asSCHEMA_MISMATCH(409). - An enum whose internal values contain a backslash, such as
C:\dir, no longer makesinitializeDatabase()fail withSCHEMA_MISMATCH(409,check constraint differs). Previouslycreatemode always rolled back, and a schema created from exported DDL never validated. Check constants are now compared by value; the generated DDL is unchanged. No action is required. A constraint that holds a different value is still reported.
PostgreSQL and SQL errors
These changes apply to PostgreSQL and to other SQL plugins that follow the plugin contract. MongoDB is unchanged. See PostgreSQL database errors.
Errors that your code throws inside a transaction that Simfinity owns now propagate unchanged, whether or not they carry a
code, as on MongoDB. This covers validators, controllers, nested middleware, state-machine callbacks, custom mutations andwithTransactioncallbacks. Previously any such error with acode, such as a domain error withcode: 'PAYMENT_DECLINED', a Node.js system error from an HTTP client or aDOMException, becameDATABASE_ERROR(500,Database operation failed), and an error whosecodelooked like a PostgreSQL code, such as{ code: '23505' }, was relabelled, here asDUPLICATE_KEY(409). Their messages now reach clients unless you mask them, as messages of errors without acodealready did: buildErrorFormatter wraps them in anInternalServerError, which its callback can hide. A thrownnullor other value that is not an object no longer becomes aTypeError(Cannot read properties of null); GraphQL reports it asUnexpected error value.Errors that PostgreSQL itself reports, recognized by a five-character SQLSTATE and a
severity, are still mapped wherever they come from: Simfinity's statements,session.query(),session.clientand otherpgconnections used inside the callback. A client-side failure of a connection that Simfinity did not open, such as a refused connection or a timeout of your ownpg.Poolused in a hook, is an application error and now propagates unchanged, with the host and port in its message, such asconnect ECONNREFUSED 10.0.0.5:5432. Previously it becameDATABASE_ERROR. Mask it withbuildErrorFormatter().A failure of the driver calls that Simfinity makes, acquiring a connection,
BEGIN, a statement orCOMMIT, that is not a PostgreSQL error, such as a refused or dropped connection,timeout exceeded when trying to connectorConnection terminated unexpectedly, is nowDATABASE_ERROR(500,Database operation failed) on every path. Previously a connection that mutations,saveObject()orwithTransaction()could not acquire, and any such failure without acode, reached clients as the rawpgerror, with the host and port in its message. Code that matched raw driver messages should readerror.getCause(). ADATABASE_ERRORcaused by a connection lost duringCOMMIThas an unknown outcome: the transaction may have committed.Mapped PostgreSQL errors and these masked failures keep the driver error as
error.cause, whicherror.getCause()also returns. The property is not enumerable, soJSON.stringify(), object spread and GraphQL responses leave it out. Server logs that print the cause can show SQL details, such as a constraint name and a duplicate key.Input that PostgreSQL cannot store now fails with
INVALID_VALUE(400) instead ofDATABASE_ERROR(500), with these messages:Date or time value is out of range(22008): a nativeDateTimevalue before 4714-11-24 00:00 BC UTC, written or used in a filter, and out-of-range dates in your own SQL, such asmake_date(2026, 13, 1).Text contains a character the database cannot store(22021,22P05): NUL (U+0000) in text, text lists and JSONB strings, or a character that the server encoding lacks.Value is too long(22001): only from your own SQL, because generated text columns have no length limit.
The same codes from your own SQL inside a transaction are reported the same way, as are
22P02and22003, which were already mapped. Other data exceptions, such as division by zero (22012), and PostgreSQL error54000, such as an index row over the btree size limit, are stillDATABASE_ERROR(500,Database operation failed). MongoDB accepts these values.Unchanged: an application error with
code40001or40P01is still retried and ends asTRANSACTION_RETRY_EXCEEDED(409), now with a cause. Inside the callback,session.query()rejects with the driver's own error, so a savepoint can recover from it; the mapping applies when the error leaves the transaction.withTransaction()with a session you supply passes its callback's errors on unchanged. Errors thatinitializeDatabase()raises, such as a refused connection, are still passed on as the driver reports them.
Initialization and readiness
- A rejected
initializeDatabase()request no longer takes a serving SQL runtime offline. Previously each call first marked storage unavailable, so a request rejected withDATABASE_CREATION_DISABLED(409, a non-empty mode other thanvalidateon a validation-only runtime) orINVALID_INITIALIZATION_MODE(400, an unknown mode) made every later operation fail withDATABASE_NOT_INITIALIZED(503) until another initialization succeeded. Such requests now leave readiness as it was. - A runtime that is ready keeps serving while
initializeDatabase()runs again; previously operations failed withDATABASE_NOT_INITIALIZEDmeanwhile. The first initialization still fails closed until it succeeds. Any failure of an initialization, such asSCHEMA_MISMATCHor a connection error, makes storage unavailable until a later one succeeds. When initializations overlap, the one started last decides. - Running
createmode again on a serving PostgreSQL runtime locks each existing managed table inSHARE ROW EXCLUSIVEmode until it commits, so writers wait, and a deadlock or lock timeout fails the initialization and makes storage unavailable. Usemode: 'validate'to check a live runtime. - The SQL runtime checks the mode before it calls the plugin. On a runtime that allows creation, any mode other than
create,validateor an omitted mode, includingnulland'', fails withINVALID_INITIALIZATION_MODE(400,Unknown initialization mode: …), as PostgreSQL already did. A validation-only runtime (preventCreatingCollection(true)) still validates when the mode isnull,''or another false value, and rejects every other mode exceptvalidatewithDATABASE_CREATION_DISABLED.
State machine fields on SQL backends
- On PostgreSQL and other SQL backends,
createSchema()now rejects a state machine whosestatefield is not a GraphQL enum, such as aString,Int,[String]or embedded object field, withINVALID_MODEL(400,State machine field Type.state must be a GraphQL enum on SQL backends). Previously it failed with aTypeError(getValues is not a function), so no working application is affected. Enumstatefields are unchanged, and MongoDB is unchanged. See state machines.
DDL export
- SQL runtimes created with
createSQL()orcreatePostgres()have a newcompileDatabaseSchema()method. It returns the plugin's DDL for the runtime's description and schema, for review or export, without touching storage, and throwsSCHEMA_NOT_CREATED(500) beforecreateSchema(). - The PostgreSQL module functions
describeDatabase()andcompileDatabaseSchema(), called without arguments, now describe and compile the default module instance in the schema given toconfigure(). PreviouslydescribeDatabase()failed withINVALID_MODEL(registrations must be an array) andcompileDatabaseSchema()with aTypeError. Calls with arguments are unchanged: the low-leveldescribeDatabase(registrations, { schema })still uses thepublicschema when you omitschema, not the configured one. Pass the schema you gave toconfigure()orcreatePostgres(), or call the functions without arguments.
MCP tool results
- In-process MCP execution does not apply
buildErrorFormatter(), so the error changes reach tool results on PostgreSQL as they are. Application errors that carry acode, such as a Node.js system error from a hook's HTTP client (connect ECONNREFUSED 10.0.0.5:8443), now appear with their own message instead ofDatabase operation failed. Connections that Simfinity could not acquire from its pool, which mutations previously reported with the driver's message, now appear asDatabase operation failedwithDATABASE_ERROR. See results and errors.
SQL plugin contract
These changes concern custom SQL plugins; the PostgreSQL plugin already follows them. See the plugin contract.
driver.normalizeError(error)must map its own engine's errors and return every other error unchanged. It receives any object thrown in an owned transaction or operation, application errors included, but never aSimfinityError,nullor another value that is not an object. A plugin that wraps every error still turns application errors into its own. When the plugin returns the same object for a failedacquire,begin,queryorcommitcall, the runtime now replaces it withDATABASE_ERROR(500), keeping it as the non-enumerablecause; previously it reached clients unchanged. That includes a plugin that adds extensions to the error and returns it, so return a new object, or aSimfinityError, to keep your own mapping.driver.isRetryable(error)receives only objects.driver.commit(client)must reject when the engine did not commit, for example when it rolled the transaction back because a statement had failed. The runtime then rolls back, and retries only whenisRetryableaccepts the error: reject with an error it does not accept when a failed statement caused the rollback, as PostgreSQL does withDATABASE_ERROR(500), and with a retryable one only for an abort that is safe to replay, such as a serialization failure reported atCOMMIT. The PostgreSQL plugin rejects when theCOMMITresult reports theROLLBACKcommand; a result withoutcommandresolves, as before.initialize(configuration, description, options)receives onlycreate,validateor no mode. Before it calls the plugin, the runtime rejects any other mode withINVALID_INITIALIZATION_MODE(400) on a runtime that allows creation. On a validation-only runtime it rejects every mode exceptvalidateand a missing or false one withDATABASE_CREATION_DISABLED(409), and passesvalidate. See initialization and readiness.planRelationalSchema()no longer emits an index for[ID]list columns, at the root or in owned tables, so plugins receive one index fewer for each such list. A plugin'screateandvalidatemodes must keep accepting the index that earlier versions generated on databases the plugin already created, as PostgreSQL does.stateNamesappears only on enum state fields, which SQL runtimes now require.compileSchema(description)remains required. The runtime now calls it, only fromruntime.compileDatabaseSchema().
Packages
- The optional
@modelcontextprotocol/sdkpeer for MCP and the MongoDB facade now requires^1.31.0, which includes the fix for GHSA-6qxp-vccf-f47h. Tool generation still works without the SDK. Simfinity uses the SDK server APIs; the upstream advisory concerns OAuth clients. Applications that also use those client APIs should follow the advisory's credential and issuer migration guidance. @simtlix/simfinity-postgresno longer shipssrc/adapter.js,src/records.jsandsrc/transactions.js. Itsexportshas allowed only the package entry (.) since 3.2.0, so they could not be imported. The package entry is unchanged.- The
@simtlix/simfinity-sqlsubpaths./internal/adapter,./internal/recordsand./internal/transactionsare deprecated. They have no type declarations and no Simfinity package uses them any more; they will be removed in 4.0. UsecreateSQL()and the plugin contract instead.
Upgrade to 3.5.5
The 3.5.4 tag was created, but package publication stopped during release validation. Version 3.5.5 is the first published release of the fixes below and updates the vulnerable build, test and documentation dependencies that blocked publication.
Version 3.5.5 fixes stored data, identifiers, query results and error codes that applications can observe. Keep all directly installed Simfinity packages at 3.5.5. When upgrading from an earlier version, also review the 3.5.3 notes below.
Startup errors and warnings
These are raised or logged while the application starts:
INVALID_MODEL(400),Type.field requires a child connectionField: in default MongoDB mode and with custom adapters,createSchema()now rejects a writable non-embedded list relation to an object type when itsconnectionFieldis missing or an empty string and Simfinity would generate its resolver. It throws beforeadapter.prepare()runs or any model is created. Previously the schema built, nestedaddedchildren were saved without a link (a custom adapter received a"null"key), nestedupdatedanddeletedentries failed withFORBIDDEN, and the generated field always returned[]. SetconnectionFieldto the child's back-reference. PostgreSQL andreferentialIntegrity: 'transactional'already rejected these collections with the same message. See relation.- Two cases still build, in default MongoDB mode and with custom adapters only: a collection without
connectionFieldthat has your own resolver, and areadOnlyone. A writable collection with your own resolver logsConfiguration issue: Type.field requires a child connectionField; nested writes through it are rejected, and so does areadOnlycollection without its own resolver, whose generated resolver still reads children through the field name. AreadOnlycollection with your own resolver is silent. Each warning is logged once per runtime. Nested writes through these collections now fail at request time; see mutations and embedded updates. PostgreSQL andreferentialIntegrity: 'transactional'still reject every collection withoutconnectionFieldat startup.
- Two cases still build, in default MongoDB mode and with custom adapters only: a collection without
- New console warnings for fields named like generated query arguments. A field named
sort,pagination,ANDorORon a type that gets a list query or a collection field logsConfiguration issue: Type.name has the name of a generated list query argument, …, and a field namedaggregationon a type with an aggregate endpoint logsConfiguration issue: Type.aggregation has the name of the aggregation argument of list_aggregate, …. Each is logged once per runtime, type and name; nothing is rejected. See fields named like query arguments.
Mutations and embedded updates
- An explicit
nullfor a nullable member of a singular embedded object in an update now clears that member, on both backends. Previously the update reported success and kept the stored value. A scalar, enum, nested embedded object or single reference is stored asnull, a reference under its storage field (connectionField, or the field name); a list member is stored as[]. An explicitnullfor an originally non-null member still keeps the stored value, and a nested embedded object supplied in the patch still replaces the stored one. Controllers'onUpdatingnow receives the cleared members asnullor[]instead of their stored values. Unlike a top-levelnull, which removes the field, a cleared member is stored as an explicitnull; GraphQL filters treat both the same, while native queries such as{ $exists: false }do not. See update selected fields. - On MongoDB, a nested embedded object cleared with
nullreads asnullfrom mutation responses, by-ID reads, reference reads and lists. The hydrated Mongoose document that native code such asonUpdatedreceives still exposes an object at that path; readrecord.toObject()to see the storednull. An optional embedded object that was never stored, or that a top-levelnullremoved, still reads as an object built from its defaults in mutation responses and by-ID reads, as in 3.5.3, so a required member of it fails with a non-null GraphQL error there (changed in 3.5.9: such an object that holds no data reads asnullwhatever the selection when a required member would read asnull; see MongoDB embedded objects). INVALID_MODEL(500),Type.field cannot store nested items because it has no connectionField: nestedadded,updatedordeleteditems through a collection withoutconnectionField, in generated mutations and insaveObject(), are rejected before anything is written or any controller hook runs. Lists that are empty or contain onlynullare still accepted and write nothing. Previously the items were written without a link.- With a custom SQL plugin or custom adapter whose identifiers can be
0, a single reference to0now resolves to the related record instead ofnull, and a collection field of a record whose identity is0reads its children. A reference stored as an empty string still reads asnull. Generated collection fields now pass the parent's_id, falling back toid, tofindChildren; a custom adapter whose records carry both with different values now receives_id. The bundled MongoDB and PostgreSQL backends do not produce such identifiers.
Identifiers
idresolves to the stored identity of every entity type: each type registered withconnect(), and each type that a non-embedded relation of a registered type targets, whateverincludedQueryTypesandincludedMutationTypesinclude. On MongoDB it wasnullfor referenced types registered withaddNoEndpointType()and forconnect()types left out ofincludedQueryTypes, in mutation results, relation reads and lists. The resolver reads_idand falls back toid, so a custom adapter whose records carry onlyidnow returns it for every entity type, including allowlisted ones, and a record whose_iddiffers from itsidreports_id. Embedded-only types,idfields with your own resolver, and top-level PostgreSQL records are unchanged. Clients that treated anullidas "no identity" now receive IDs. The registrations thatgetRegistrations()returns carrystoredIdentity: truefor these types.- Embedded copies of the
connect()types that 3.5.3 resolved withparent._id, those thatincludedQueryTypesincludes (all of them by default), now return their stored declaredidinstead ofnullwhere they have no_id: singular copies on MongoDB, and singular and list copies on PostgreSQL. On PostgreSQL, embedded copies of every entity type return their declaredid:nullfor copies created through add inputs, which have noid, and the suppliedidfor copies written through update inputs,saveObject()or native writes. - On MongoDB, entity types embedded as lists report, for their embedded copies, the automatic
_idthat Mongoose adds to each list item, and<list>.idfilters match that value. In 3.5.3 only allowlistedconnect()types did; referenced types registered withaddNoEndpointType(), which 3.5.3 showed with their declaredid, andconnect()types left out ofincludedQueryTypesnow do too. The declaredidthat update inputs require for these items is still stored, but it is no longer returned,<list>.idfilters do not match it, and the reported_idchanges whenever the list is rewritten. This identity rule follows the type object, so a type that any runtime registers as an entity behaves this way in every runtime that shares it. When clients need a stable embeddedid, embed a separate embedded-only type. See supporting types without endpoints. - On MongoDB, filters, sorts, and aggregation
groupIdand fact paths on<embedded>.idnow use the value that reads return, as PostgreSQL does: the embedded object's declaredidmember. Previously they used_id. EQ and IN conditions on a singular embedded object'sidmatched no record while NE and NIN matched every record, sorts on it had no effect, groups on it collapsed into onenullgroup, and conditions on an embedded list matched Mongoose's hidden automatic subdocument_id. Entity types embedded as lists, whose reads return that automatic_id, and supplied subdocument schemas that declare noidpath keep using_id. For a supplied subdocument schema, only reads that hydrate Mongoose documents expose that value, through theidvirtual; list queries returnnullfor it. See IDs in paths. Review query scopes that filter on embedded IDs: they now admit different records, and an NE or NIN restriction that admitted every record is now enforced.buildQuery()andbuildFilterGroupMatch()emit<embedded>.idinstead of<embedded>._idin$match,$sortand$groupstages. - On MongoDB, a malformed identifier now fails with
NOT_VALID_ID(400,Invalid identifier) instead ofINTERNAL_SERVER_ERROR, as on PostgreSQL, and nothing is written. This covers by-ID queries, updates, deletes, state actions, nestedupdatedanddeletedentries,{ id }reference inputs, scalarIDfields on create and update, and embeddedidmembers of objects and lists. A well-formed ID of a missing record keeps its result:nullfrom a by-ID query, andNOT_VALID_ID(404) from updates, state actions and nested entries.buildErrorFormatter()callbacks now receive aSimfinityErrorrather than anInternalServerErrorfor these errors, and MCP tool results carry the code. In an update of a type without embedded fields, the controller'sonUpdatinghook still runs before the ID is checked and receives the raw value, as before (hook signatures). See error codes. createMongoAdapter().castIdaccepts only an ObjectId or a 24-character hexadecimal string, which it normalizes to lowercase. It throwsNOT_VALID_ID(400) for anything else, includingundefined,null, numbers, empty and 12-character strings, Buffers and other objects. Previously it created an ObjectId fromundefined,null, numbers and 12-byte Buffers, a random one for a missing value, and failed with a driver error for other malformed values. Create new identifiers withnew mongoose.Types.ObjectId(). A relation input passed tosaveObject(), or set by controllers or middleware, needs{ id }: a lean{ _id }document,{},{ id: null }or{ id: 12 }is now rejected, where it previously stored an ObjectId that referenced no record.- Supplied models with a String or Number
_id, or with an_idsetter, keep working as in 3.5.3. By-ID reads, updates and deletes still pass the ID as received to Mongoose, which applies the key's setters and casts the result to the key type when the query runs, sogetFilter()in yourfindOne,findOneAndUpdateandfindOneAndDeletequery middleware still sees the raw ID. A value the key cannot hold after its setters, such asabcfor a Number key, now fails withNOT_VALID_ID(400) instead of an internal error. By-ID reads of a type with aget_by_idscope cast the ID as a filter value, without setters, and fail withINVALID_FILTER_VALUE(400), as in 3.5.3. WithreferentialIntegrity: 'transactional', protected updates and deletes still cast the ID to the key type before querying, without setters, as in 3.5.3. A collection field whose stored parent key cannot be cast to the child's connection path returns no children. - On MongoDB, a stored single-reference value that the target's
_idtype cannot hold, such as one written outside Simfinity or by a supplied model whose reference path type disagrees with the target's key, still fails that relation field with an internal error, as in 3.5.3, when the record comes from a list query, a collection field or a scoped by-ID read. The client sent no identifier, so this is a stored-data error, notNOT_VALID_ID:buildErrorFormatter()reportsINTERNAL_SERVER_ERROR, and a callback that masksInternalServerErrorhides it. The error's message is nowType.field stores an invalid identifierinstead of Mongoose's cast message, and itsgetCause()returns theNOT_VALID_ID(400) error instead of theCastError. A target type with aget_by_idscope keeps failing withINVALID_FILTER_VALUE, and an unscoped by-ID read of the record still returns the field asnullwhen the record's ObjectId path drops the value it cannot cast. - On MongoDB, ID filters, and by-ID reads of a type with a
get_by_idscope, validate the ID as a filter value and keep failing withINVALID_FILTER_VALUE(400); PostgreSQL returnsNOT_VALID_IDthere. On a supplied model, a numeric or 12-character value for anIDfilter on a path its schema does not declare now fails withINVALID_FILTER_VALUEinstead of matching an ObjectId created from it. - With
referentialIntegrity: 'transactional', a directadapter.update()oradapter.delete()call with a malformed ID aborts a supplied transaction, like other guarded-write errors. Generated deletes, and updates of types with embedded fields, read the record first and reject the ID during that read, which leaves a supplied transaction active, as before. See supplied sessions.
Queries and scopes
- A field named
aggregationis now a top-level filter on list queries, counts and collection fields, and infind,get_by_idand joinedfindscopes, on both backends. Previously the filter was silently ignored, so a scope that restricted that field admitted every record. Queries and scopes on such a field now return fewer records. On<list>_aggregate,aggregationremains the aggregation expression and the field is filtered throughAND/ORgroup conditions. - Client filters on that field now follow the path restrictions: read rules and
queryable: falseapply (FORBIDDENorFORBIDDEN_FILTER_PATH), and a field namedaggregationwith its own resolver needsextensions: { queryable: true }to be filtered, otherwise the query fails withFORBIDDEN_FILTER_PATH(403). Previously these filters were accepted and ignored. A scope that assignsargs.aggregationreplaces the caller's filter on that field, as for any other field. - Middleware, scopes and direct
buildQuery()orcreateQueryPlan()calls that put an aggregation expression into list or count arguments now fail with a 400 error instead of having it ignored:INVALID_FILTER_FIELDwhen the type has noaggregationfield. A joined scope that setsargs.aggregationon a type without that field fails the same way. - A field named
conditionscan now be filtered with a top-level argument; previously the query failed withconditions requires an array. A non-array top-levelconditionsvalue for a type without that field, which only middleware or scope code can send, fails withINVALID_FILTER_FIELDinstead ofINVALID_FILTER_VALUE. - A joined
findscope'ssortandpaginationvalues are now checked for shape:sortneeds non-empty terms, each with a field name and anASCorDESCorder, andpaginationneeds the page and size a direct query accepts. Invalid values fail withINVALID_SORTorINVALID_PAGINATION(400); valid values are then ignored as before, and sort field names are not resolved. Previously invalid values were dropped without an error, including on types reached only through references. See relationship paths. - The generated SDL is unchanged, except that a collection field whose child back-reference is named
sort,pagination,ANDorORnow has that control argument; it was previously removed.
Transactions
- Owned MongoDB transactions now wait a random delay before each complete retry, after the failed attempt is aborted: less than 10, 20, 40, 80 and 160 ms for the five retries. Hooks still run once per attempt, and mutations without conflicts are not delayed. Tests that inject
TransientTransactionErrorwhile fake timers are enabled must advance the timers. See transaction boundaries. - Owned PostgreSQL transactions, and those of any SQL plugin, now wait the same random delay before each retry, instead of retrying immediately: less than 10, 20, 40, 80 and 160 ms, which starts once the failed attempt has rolled back and the driver's
release()has completed, so no connection or lock is held while waiting. Only errors that the driver'sisRetryable()accepts are retried, on PostgreSQL serialization failures and deadlocks. The limit of five retries, the error after the last one (TRANSACTION_RETRY_EXCEEDED(409) on PostgreSQL), and supplied sessions, which are never retried, are unchanged. Tests that make a SQL driver fail with a retryable error while fake timers are enabled must advance the timers, even whenMath.randomis stubbed to make the delays zero. - A
WriteConflictthat persists after the five retries of an owned transaction, in generated and custom mutations and insaveObject()without a session, now fails withTRANSACTION_RETRY_EXCEEDED(409,Concurrent write could not be completed), like PostgreSQL's exhausted serialization and deadlock retries. The driver error is available aserror.cause, whicherror.getCause?.()also returns. Previously the rawMongoServerError(code 112) reached clients asINTERNAL_SERVER_ERROR, with the driver's message and no status. Other exhausted transient errors, such as network errors, are still returned unchanged. - The 409 error carries no
TransientTransactionErrorlabel. AsaveObject()call without a session inside an owned mutation runs its own transaction; when its conflict is exhausted, the outer mutation now fails instead of retrying the whole mutation. Code that readerror.code === 112orerrorLabelsfrom such a call should readerror.cause. - Error masking and monitoring that look for
InternalServerErrororINTERNAL_SERVER_ERRORno longer see these conflicts. LogTRANSACTION_RETRY_EXCEEDED, or the driver error inerror.cause, if you track write conflicts. In-process MCP tool results now report this code instead of the driver's message. - Supplied sessions are unchanged: they receive the raw driver error with its
errorLabels, and the caller owns retries.
MongoDB storage and global options
- In default MongoDB mode, a referenced collection whose
connectionFieldnames no single reference on the child type, but equals theconnectionFieldof a collection on the child type, now gives the generated child model its private ObjectId field. This covers chained collections that reuse one name, such asRoot.midsandMid.leavesboth linked throughowner, and self-referencing collections such as a category tree. Nested adds store the link and the collection returns its children. Previously the mutation succeeded, the link was dropped and the collection read empty. Records written earlier without the link are not repaired. The affected generated models gain an index on the private field, which Mongoose builds at startup whenautoIndexis on. WithreferentialIntegrity: 'transactional', these schemas previously failedadapter.initialize()withINVALID_MONGO_INTEGRITY_CONFIGURATIONand now initialize. Supplied models are unaffected. - Importing
@simtlix/simfinity-jsno longer callsmongoose.set('strictQuery', false); the facade leaves global Mongoose options as your application sets them. Applications that never setstrictQuerysee no change, because Mongoose 8 defaults tofalse. Imports are evaluated before the importing module's own code, so only astrictQueryset in a module evaluated before the facade import was overwritten. That setting now applies, also to your own queries on Simfinity-generated models throughgetModel(). Withtrue, filter keys that are not schema paths are removed, so checkdeleteMany()andupdateMany()filters for misspelled keys that would widen them; with'throw', such keys raiseStrictModeError. Generated GraphQL operations, MCP tools andsaveObject()behave the same under every value. To keep the previous behavior, callmongoose.set('strictQuery', false)yourself.
MCP tools
- The list tool of a type that declares a field named
aggregationis now titled and described as a list tool, accepts that field as a filter, and returns_meta.count. Previously it was published as an aggregate tool. Simfinity's generated list and aggregate queries carry their operation inextensions.simfinityQuery. Other queries, including those of a schema rebuilt from SDL, are still published as aggregate tools when they have anaggregationargument. See the MCP reference. - With in-process execution, malformed MongoDB IDs and exhausted MongoDB write conflicts now return
NOT_VALID_IDandTRANSACTION_RETRY_EXCEEDEDtool results instead of internal driver errors; see identifiers and transactions.
Adapter contract
DatabaseAdapterhas a new optional member,readEmbeddedValue(value). When an adapter defines it, every nullable, singular embedded object field without a resolver passes its value through it, so an adapter that hydrates records can report an explicitly storednull. The field is still read as graphql's default resolver reads it: a method on the parent, such as one on a custom mutation result, is called, and the hook receives what it returns, once a returned promise settles. The hook must depend only on the value it receives: these resolvers read no data, so they do not bind their types to the runtime, and a runtime that reaches a shared type may read through another runtime's hook. The MongoDB adapter defines it; adapters without it behave as before. Since 3.5.9, Simfinity then reads asnullan object that holds no data when a required member would read asnull, not countingid,readOnly, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same.castId()may throw for a malformed value, as the MongoDB adapter now does and the PostgreSQL adapter already did, withNOT_VALID_ID(400). A relation input then fails with that error, and a batched reference read reads that ID alone withgetById().castId()must not create a new identifier.- When
getById()rejects a stored single-reference value withNOT_VALID_ID(400), including a value thatcastId()rejected first, the generated reference field now fails with anInternalServerError(INTERNAL_SERVER_ERROR, messageType.field stores an invalid identifier) whosegetCause()is that error, because the client did not send the value. With a custom adapter or SQL plugin, 3.5.3 returned theNOT_VALID_IDerror to the client. The same applies to aNOT_VALID_ID(400) error thatget_by_idmiddleware throws while a generated reference is read. Other errors, includingNOT_VALID_IDwith status 404, are passed on unchanged, and identifiers that clients send still fail withNOT_VALID_ID(400). - Registrations passed to
adapter.prepare()carrystoredIdentity, which istruefor the types this runtime treats as entities, whoseidresolves to the stored_id, falling back toid. Theidresolver lives on the type object, so a type that another runtime sharing it treats as an entity resolvesidthe same way even where its registration saysfalse.
Upgrade to 3.5.3
Version 3.5.3 includes the runtime, authorization, scalar and MCP fixes below. Keep all directly installed Simfinity packages at 3.5.3. When upgrading from an earlier version, also review the 3.5.2 notes below.
New startup errors
These errors are raised while the application starts. Most of them replace configurations that previously started and then misbehaved on requests:
TYPE_BOUND_TO_OTHER_RUNTIME(409): the first schema a runtime builds claims two kinds ofGraphQLObjectType: the types whose relation fields receive its generated resolvers, and the reachable types that still have a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a claimed type in another runtime withconnect()oraddNoEndpointType(), or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, now throws.createSchema()also rejects a type with a relation field copied after another runtime generated its resolver, bynew GraphQLObjectType(Type.toConfig())or a...Type.toConfig().fieldsspread, including a spread inside afieldsthunk that runs later. Previously the second runtime silently read those relations through the first runtime's adapter and middleware, orcreateSchema()failed with a duplicateQLFiltertype. Create type objects once per runtime, for example with a factory function. See types belong to one runtime.- Types without non-embedded relation fields, and types whose relation fields all have your own resolvers, can still be shared. A
toConfig()copy made before the first runtime built its schema can still be used by another runtime. - A copied relation field from which you removed the resolver still builds, as in 3.5.2, and gets the second runtime's resolver. A copied object field with your own resolver is rejected if it keeps the copied
extensionsobject; give it a new one. - A shared custom mutation result type that is not registered and has a non-embedded relation field without a resolver is now rejected in the second runtime, although no resolvers are generated for it; in 3.5.2 both schemas built. Create the result type per runtime, or give its relation field your own resolver.
- When Simfinity generates a relation resolver, it also gives the field a new
extensionsobject with a null prototype and the same entries. For single relation fields, a reference tofield.extensionstaken beforecreateSchema()no longer points at the field's extensions, so changes made through it are ignored; readType.getFields()[name].extensionsagain aftercreateSchema(). List relation fields previously got anextensionsobject withObject.prototype; it now has a null prototype, like theextensionsof every other GraphQL field, so useObject.hasOwn(field.extensions, key)instead offield.extensions.hasOwnProperty(key).
- Types without non-embedded relation fields, and types whose relation fields all have your own resolvers, can still be shared. A
INVALID_SCOPE(500):extensions.scopemust be a plain object whose keys arefind,get_by_idoraggregateand whose values are functions.connect(),addNoEndpointType()andcreateSchema()reject anything else, including misspelled keys such asgetByIdorfindAll, a key set toundefinedsuch as{ find: undefined }(which theTypeScopestype accepts), arrays, a bare function, class instances, andscope: undefinedorscope: null. Non-enumerable keys are checked too. Previously these scopes were skipped, so reads ran without the restriction. See the scope contract.INVALID_MIDDLEWARE(500):use()accepts only functions. Previously anullorundefinedentry silently skipped the middleware registered after it, and other values failed each operation.UNREGISTERED_RELATION_TARGET(500): a list relation, embedded object or embedded list whose type was not registered withconnect()oraddNoEndpointType()now failscreateSchema()with a message naming the field asType.field. Previously it failed with aTypeError. Single references to unregistered types, and read-only lists with their own resolver, still build.MCP_INVALID_SCHEMA_PLUGIN,MCP_UNSUPPORTED_SCHEMA_REPLACEMENTandMCP_INVALID_MIDDLEWARE(500) are thrown when MCP tools, servers or HTTP handlers are created; see MCP below.createAuthPluginthrowsTypeErrorfromonSchemaChange, before wrapping any field, when a schema type comes from another copy of the graphql module; see authorization below.requireAuth,requireRole,requirePermissionandisOwnerthrowTypeErrorwhen the helper is created with anasyncextractor function; see authorization below.- A policy expression with a literal NaN operand, such as
{ eq: [{ ref: 'ctx.user.score' }, NaN] }, makescreateAuthPlugin, the deprecatedcreateAuthMiddlewareandcreateRuleFromExpressionthrowTypeErrorwhen they are called. validators.pattern()andscalars.createPatternStringScalar()throwTypeError(pattern must be a RegExp or a string) when created with any other pattern, including a custom matcher object with atest()method, which 3.5.2 used as is; see validated scalars and pattern helpers below.- The new opt-in
configureMutationLimits()throwsINVALID_MUTATION_LIMITS(400) for invalid options; see runtime behavior below.
Runtime behavior
- A scope changed after
createSchema()into an invalid shape now fails every read of that type withINVALID_SCOPE, including relation reads and filter paths that join it. Previously it was skipped. Omit a key, or the wholescopeproperty, to leave an operation unscoped. - The middleware chain is awaited even when a middleware calls
next()without awaiting it, including when that middleware awaits other work afterwards, so an error from a later middleware cancels the operation instead of becoming an unhandled rejection. A middleware that throws after such anext()call rejects the operation with its own error once the rest of the chain settles. Callingnext()twice runs the rest of the chain once. An error from the rest of the chain now cancels the operation even when a middleware catches it. Omittingnext()still skips only the remaining middleware. - A
next()called after its middleware has finished, for example fromsetTimeout(next)or another callback, now does nothing and returns a resolved promise. Previously it started the remaining middleware after the operation had already run, and their errors became unhandled rejections. Callnext()before the middleware returns, preferably asawait next(). See middleware. - New opt-in
configureMutationLimits({ maxNestedOperations })caps the nestedadded,updatedanddeletedentries of one generated mutation. It is process-wide and unlimited by default, so nothing changes until you set it. Invalid options, including unknown or misspelled keys such asmaxNestedOperation, throwINVALID_MUTATION_LIMITS(400) and keep the current limit. See limit nested collection operations.
Error formatting
buildErrorFormatter() now returns a GraphQLError instead of the Simfinity error, and classifies the errors GraphQL servers pass to it:
- For a field error, the
GraphQLErrorthat graphql-js creates around a value a resolver threw, it classifies the thrown value. ASimfinityErrorthrown by a resolver therefore keeps its code when the server passes the wrapping error. Previously the result wasINTERNAL_SERVER_ERROR. - Request errors from syntax, validation and variable coercion become
BAD_REQUEST(400) instead ofINTERNAL_SERVER_ERROR, unless the server already set a code, which they keep, such as Yoga'sGRAPHQL_PARSE_FAILEDor Apollo Server'sGRAPHQL_VALIDATION_FAILEDandBAD_USER_INPUT. This includes input rejected by built-in scalars and by Simfinity's validated scalars, such as an invalidEmailScalarvalue, whether sent as a variable or an inline literal. They keep their original message, including theVariable "$x" got invalid value … at "x.path"prefix, and their extensions, such as thehttpstatus Yoga adds, so the documented Yoga integration responds with HTTP 400 for invalid variables. Yoga does not pass validation errors, including invalid inline literals, tomaskError, so they keep Yoga's response. ASimfinityErrorthrown by a custom scalar supplies only the code and status. - A scalar literal validation error may wrap a plain
Errorwith the same message, with or without AST nodes. It remains a request error. A subscription source failure that a server reports withoutpathis indistinguishable from that input error: mark unexpected stream failures asInternalServerErrorat their source, or use an execution-aware server hook. For a path-less GraphQL error, the formatter preserves an explicit internal cause through nested GraphQL wrappers for masking. An unmarked path-less GraphQL error keeps its message; see server differences. - A
GraphQLErrorraised while resolving a field keeps its message and extensions when it has its own stringextensions.codeor aSimfinityErrorcause. Its code is that of theSimfinityErrorcause, or its ownextensions.code; its status is the cause's, or its integerextensions.status, or 500 forINTERNAL_SERVER_ERRORand 400 for other codes. ItsoriginalError, such as a database error behind a safe message, is never exposed. Previously such errors becameINTERNAL_SERVER_ERROR. AGraphQLErrorwithout either, such as one a resolver throws without a code, with or without its ownpath, is still anInternalServerError(INTERNAL_SERVER_ERROR), as in 3.5.2; give it anextensions.codeto show its message to clients. This also applies to the errors graphql-js raises when it cannot complete a resolved value, such as a built-in scalar that cannot serialize it or a failedisTypeOfcheck, whose messages can print the value. - A value that is not an
Error, including one a resolver throws, becomesUnexpected error valuewith no cause, and the value is no longer printed in the message, when graphql-js's own executor wraps it. Yoga's executor first turns such a value into anErrorwith the value's text, which then becomes anInternalServerErrorwith that message; maskInternalServerErrorin the callback. - The result keeps the input's locations and path, sets
originalErrorto the classified error, and serializes to{ message, locations, path, extensions }without the cause.
Code that compared the result with its input, checked instanceof SimfinityError, or called getCode() on it should read formatted.originalError and formatted.extensions.code instead. The callback still receives the classified SimfinityError. To mask unexpected errors in it, check error instanceof InternalServerError, not error.getCode() === 'INTERNAL_SERVER_ERROR': an application error can carry that code without being an InternalServerError, and then has no getCause(). Do not pass the formatter directly to Envelop's useErrorHandler. See buildErrorFormatter and server integration.
Validated scalars and pattern helpers
- Validated scalars parse variables with their base scalar before calling the validation callback, as they already did for inline literals. The callback now receives the internal value for variables too, such as a
Datefrom a DateTime base or a string from an ID base, and in a chain the innermost callback runs first. A variable of the wrong type reports the base scalar's error:EmailScalargiven5reportsString cannot represent a non string value: 5instead ofInvalid email format. Input the base rejects by returningundefinedis reported by GraphQL without calling the callback. See createValidatedScalar. - Inline literal kinds follow the root of the chain of validated scalars. A scalar built on
PositiveIntScalarnow accepts4, one built onPositiveFloatScalaraccepts4.5, and one on a Boolean-based scalar acceptstrue; previously these chains rejected every inline literal. When the root is a custom scalar, such as DateTime, itsparseLiteraldecides which literals are valid instead of requiring a string, even if you set abaseScalarTypestorage hint on that scalar. See literal behavior. - Float-rooted validated scalars, such as
PositiveFloatScalar,createBoundedFloatScalar()scalars and chains built on them, now accept integer literals such as4, asGraphQLFloatdoes. Previously they required a float literal such as4.0. This also fixes MCP tools with an argument of such a scalar and an integral default, such asdefaultValue: 5, which failed on every call. validators.pattern()andscalars.createPatternStringScalar()copy the pattern when they are created and test each value from its first character. Agflag no longer rejects every other value, ayflag no longer depends on the previous value, and yourRegExpobject is not changed. A pattern that is not aRegExpor a string throwsTypeErrorwhen the helper is created; previously objects with atestmethod were used as is, and other values failed every validation.validators.url()no longer writes rejected input and its stack trace to the console.
Authorization
requireAuth,requireRole,requirePermissionandisOwneraccept dotted paths or synchronous extractor functions. Anasyncextractor throwsTypeErrorwhen the helper is created. A path that yields a promise at runtime, such as an un-awaitedctx.user, denies withTypeError(Authorization paths must return values synchronously; …). PreviouslyrequireAuth()granted access for a pending user promise. The helpers never callthen()on the value, so a lazy query builder such as a MongooseQueryleft in the context is not executed. Resolve asynchronous identity data in the context factory. See rule helpers.createAuthPluginrejects a schema that contains types from another copy of the graphql module, such as a second installed version or its ESM and CommonJS builds loaded together, withTypeError(Cannot authorize type X: it was created by a different copy of the graphql module…). This happens in every environment and also when MCP appliesschemaPlugins. Previously those fields were left unprotected. Install one graphql version and load it through one entry point. See schema integration.- Policy expressions compare
Datevalues by time. A Date compared with any non-null value that is not a valid Date is invalid, an invalid Date never matches, and Date literals are copied when the rule is created. NaN never matches: comparing it with a non-null value is invalid, and a literal NaN operand throwsTypeErrorwhen the rule is created. Promise operands are invalid. A rejected native promise that a reference yields, or that anincomparison reads from a list, is now handled, so it no longer crashes the process with an unhandled rejection. See JSON policy expressions.
MCP
schemaPluginsentries are validated before any hook runs. A non-array value, a function (pass the factory's result, such ascreateAuthPlugin(permissions)), an array, a promise (an object with athenmethod or getter), a non-functiononSchemaChangeor an object with no plugin hooks throwsMCP_INVALID_SCHEMA_PLUGIN. So does a misspelling ofonSchemaChangeon a plugin that lacks a functiononSchemaChange: an own or inherited method or getter whose name starts withonSchemain any letter case, or is at most two edits fromonSchemaChangeignoring case, such asonSchemaChanged,onschemaChange,onSchemChangeoronShemaChange. The message asks whether you meantonSchemaChange. Helpers such asonSchemaReadyoronSchemaChangeImplnext to a functiononSchemaChangeare accepted.false,nullandundefinedentries are skipped. A plugin with hooks MCP does not run, such as the count plugin'sonExecute, gets one console warning that names them. Validation reads onlyonSchemaChangedirectly and inspects every other property,thenincluded, through its descriptor, so it calls no other getter.- In TypeScript,
schemaPluginsalso accepts typed Envelop and YogaPluginarrays, class instances, including ones withcallorapplymembers, and interface-typed plugins through the new exportedSchemaPluginObjecttype, without a cast. A function, such as an uncalled plugin factory, is still a compile error, and so is a promise. Function entries are excluded throughSymbol.hasInstance, so they compile with alibsetting older than ES2015, but are still rejected at runtime. replaceSchemawith a different schema throwsMCP_UNSUPPORTED_SCHEMA_REPLACEMENT; previously the call was ignored.- An asynchronous
onSchemaChangeis awaited.createMCPServer,startStdioMCPServerandcreateHTTPMCPHandlerresolve after it finishes and reject if it fails.generateMCPToolsstays synchronous; its tool calls wait for installation and reject with the installer's error. A failed installer no longer crashes the process with an unhandled rejection. toolMiddlewareaccepts a single function, which now runs as a one-element stack; previously it was skipped or failed every call. A non-array value or a non-function entry, includingnullfromcondition && middlewareand an empty slot in a sparse array such as[a, , b], throwsMCP_INVALID_MIDDLEWAREat setup with the entry's index. The stack is copied at setup, so later changes to the array have no effect.createHTTPMCPHandlernow throwsMCP_INVALID_LIMITSwhen the handler is created, instead of failing every request.- Validated scalars built on other validated scalars publish their root JSON type in tool schemas, for example
integerfor a scalar built onPositiveIntScalar, instead of an empty schema. A custom scalar's description now appears in tool input and output schemas unless the field or argument has its own description.
See the MCP reference for the complete setup contract.
Upgrade to 3.5.2
Keep all directly installed Simfinity packages at 3.5.2. When upgrading from an earlier version, also review the 3.5.1 notes and 3.5.0 notes below.
These authorization changes apply to @simtlix/simfinity-core/auth and the runtime auth namespaces:
- Policy expressions compare MongoDB
ObjectIdvalues by their hexadecimal string ineqandin, andincompares items the same way for plain arrays and Mongoose arrays. Comparing values of different types, such as'42'with42or a string with a list, is invalid, sonotno longer turns it into a grant;nullremains comparable with any value. A list or plain object on the left ofinis invalid. Policies that already compare normalized strings behave as before. See JSON policy expressions. - A literal
inarray may contain only strings, finite numbers, bigints, booleans,nulland MongoDB ObjectIds, and literal operands cannot contain nested{ ref }values. Such policies now throwTypeErrorat configuration time. A rule keeps the list items it validated; later changes to the configured array have no effect. - Created expression rules fix each operand as a literal or reference at configuration time. Adding a
refproperty to a literal later does not reinterpret it; object literals still compare by identity.requireRoleandrequirePermissionalso keep copies of their validated required lists, so later changes to the caller's arrays do not alter access requirements. User claims are checked on each call. composeRules()andanyRule()throwTypeErrorwhen called without rules. Useallow()for an intentional grant.anyRuleexecutes decorated, proxied and bound rules in full, preserving any additional authorization checks they perform.createAuthPluginwraps each field once per plugin instance. In a schema processed by other plugin instances only, its wrappers defer to theirs, so separate instances no longer combine their rules on shared types; a schema no instance processed still enforces every wrapper. Allowed fields without rules or query paths keep their original resolver. Simfinity'sextensionsmetadata introspection is no longer denied underDENY, unless the permission map namesFieldExtensionsTypeorRelationType(or, since 3.5.9, their fallback namesSimfinityFieldExtensionsTypeandSimfinityRelationType; see application types named RelationType or FieldExtensionsType). A field whose rules are all synchronous, or an unwrapped field with no rule underALLOW, starts its resolver before the rules of the following sibling fields run; keep the context values that rules read stable while resolvers run. See schema integration.- The deprecated
createFieldMiddlewarereturns the same function ascreateAuthMiddleware, so'*'rules and the default policy apply to every field instead of only the named ones. - Root-query permissions must use the schema's type name. Simfinity names its query root
RootQueryType; the plugin warns once when a permission map usesQueryfor a schema without that type.
Upgrade to 3.5.1
Keep all directly installed Simfinity packages at 3.5.1. When upgrading from an earlier version than 3.5.0, also review the 3.5.0 notes below.
- Generated MongoDB models store numeric and boolean enum values as numbers and booleans instead of strings. Documents written by earlier versions need a one-time conversion before lists, filters and aggregations match them; unique enum fields must be converted before new writes. See What gets generated.
- An embedded MongoDB type can declare a field named
type.
Upgrade to 3.5.0
Keep all directly installed Simfinity packages at 3.5.0 and review these behavior changes:
- Client filter, sort and aggregation paths now enforce field read rules and related-type scopes. Rules receive the identity of each path field in
info.fieldName,info.parentTypeandinfo.returnType, with no parent and empty arguments. Parent-dependent rules deny such paths. See authorization and scope restrictions. - Fields with an application-defined resolver reject these paths unless they set
extensions.queryable: true;queryable: falseblocks paths regardless of the resolver. Remove manual relationship resolvers that only load stored references, or explicitly allow querying fields whose resolver returns the stored value.readOnlystill controls mutation inputs only. See path restrictions. - Generated single-reference reads can batch within an object request context. Scoped targets and Mongoose models with any
findorfindOnepre/post hook retain individual reads, including when both operations share a hook function. See relationships. - MongoDB uses the application's GraphQL and Mongoose peers. Install the optional MCP SDK explicitly for transports;
graphql-middlewareis no longer installed by Simfinity. See installation. - Embedded updates enforce required fields when constructing replacement objects or list items; existing embedded objects still accept valid partial patches. SQL sessions reject statements after their callback settles, and a failed rollback discards the connection. See mutations and the SQL plugin contract.
The current downloadable starters use 3.7.0. The Barber examples state their own exact package pin, and historical archives retain their documented package pins; upgrade all their Simfinity dependencies together before relying on newer library behavior.
Upgrade from 3.2.0
Version 3.3.0 extracts the SQL runtime and adds postgresPlugin without changing existing PostgreSQL entry points or generated physical storage. Keep createPostgres, or use the explicit SQL plugin API. An unchanged 3.2.0 domain requires no generated-schema migration for this extraction. All Simfinity packages installed together should use the same version. PostgreSQL is currently the only supported SQL plugin.
Before upgrading
Check the release history and the npm package, then read changes between your installed version and the version you intend to use.
Exercise the operations your application relies on: generated names and input shapes, relationships, permissions, scopes, custom mutations, state actions and MCP allowlists. Keep GraphQL and the selected adapter within supported ranges. PostgreSQL adoption also requires reviewing stronger NOT NULL, FK, uniqueness, embedded-shape, and UUID constraints, plus replacing Mongoose-native calls with the PostgreSQL Model/Session APIs.
The site documents two database adapters for the same GraphQL API. It does not host separate historical documentation archives. The 3.0.1 starter is retained as a published-version entry point; newer reference APIs may not exist in that release.
Get help with an integration
Start with troubleshooting. If an issue remains, open a GitHub issue with the installed versions, minimal schema and operation, expected result, and actual error. Remove credentials and private data from the reproduction.