Skip to content

v3.7.0 · MongoDB & PostgreSQL. Install from npm or download the starters.

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 ​

PackagePurposeRelease
@simtlix/simfinity-jsMongoDB facade, including compatibility exports3.7.0
@simtlix/simfinity-postgresPostgreSQL adapter with generated schema and FKs3.7.0
@simtlix/simfinity-sqlDriver-free relational runtime and plugin contract3.7.0
@simtlix/simfinity-coreShared runtime and helpers3.7.0
@simtlix/simfinity-mcpOptional MCP integration for either database3.7.0

Follow installation and downloads or read the latest stable release notes.

Requirements ​

ComponentRequirement
Library runtimeNode.js 18.18.0 or newer; use a maintained release for a new application
GraphQL peer^16.11.0
MongoDB facadeMongoose ^8.24.2; MongoDB 7 or 8 replica set/sharded cluster for mutations
PostgreSQL facadePostgreSQL 15, 16, or 18; pg ^8.16.3
Downloadable startersNode.js 22.15.0 or newer, npm and the database selected in the corresponding quick start
MCP tool generationOpt-in @simtlix/simfinity-mcp; the SDK is needed for MCP transports
Documentation developmentNode.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 in createPostgres(options), postgresPlugin(options) or configure(), and for MongoDB in createMongoAdapter(), with collectionBatchSize.
  • Opt-out: for a custom adapter instance, adapter.findChildrenBatch = undefined. The batch members, and SQLPlugin.compileChildrenQuery, compileRecordBatch and maxRecordBatchValues, are declared with | undefined, so that assignment and { ...plugin, compileRecordBatch: undefined } also compile under exactOptionalPropertyTypes.
  • TypeScript: @simtlix/simfinity-mcp re-exports core's EnvelopSchemaPlugin, and createAuthPlugin returns the new AuthSchemaPlugin.

Collection reads ​

  • When the adapter defines the new optional DatabaseAdapter.findChildrenBatch and its optional batchesChildren predicate does not return false, the runtime groups the generated collection reads of one request: those of one related type and connection field whose effective arguments, after find middleware, 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 calls findChildren. 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 with id for stable pages.
  • Custom adapters without findChildrenBatch, which includes every 3.6.0 custom adapter, keep the 3.6.0 path exactly: each field calls findChildren at once, with the arguments object it built, with no extra promise job and no castId call.
  • Visible only with a batching adapter:
    • fewer, larger adapter calls. Replacing findChildren on a bundled adapter, with an Object.assign override, a spread copy with another findChildren or vi.spyOn, turns batching off for that adapter, so the replacement applies to every parent. Count batched reads with a spy on findChildrenBatch, or by statements or commands;
    • reads that can join a batch pass the adapter a copy of params.args, not the object itself. Change args before 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 constant context option, 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 one console.warn per type;
    • the GraphQL errors entries of failing collection fields can be listed in another order.
  • Custom adapters that add findChildrenBatch must follow the pairing rule: batchesChildren returns false when this.findChildren is not the adapter's own function, so that an override is never bypassed. The bundled adapters do.
  • DatabaseAdapter gains the optional findChildrenBatch and batchesChildren members in TypeScript, so 3.6.0-shaped adapters compile unchanged.

PostgreSQL ​

  • Batched collection reads, opt-in. With batchedCollections: true, in createPostgres(options), postgresPlugin(options) or configure(), a group of collection reads runs one statement per 100 parents: a UNION ALL of 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. Only true enables it, while multiRowInserts changes only for false. Otherwise the adapter has no findChildrenBatch, 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, then COMMIT. 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 and id, 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 LOCAL ends at COMMIT or ROLLBACK, so pooled connections, other statements and per-parent reads keep the server, database or role setting, and jit_above_cost is not changed. jit is 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 and COMMIT, 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 with pg'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:

    RequestDefault or false, as 3.6.0batchedCollections: true
    12 authors with their books13 statements on 13 connections5 on 2: the list, BEGIN, SET LOCAL, the batch, COMMIT
    20 books with their author and chapters22 on 226 on 3
    12 authors with orders whose owned lines reference a publisherthe list, then BEGIN, the orders, their lines when there are any, and COMMIT per author, and the publisher reads7: the list, BEGIN, SET LOCAL, the batch, one read of the owned lines, COMMIT, the publisher read
    5 authors, their books and the books' chapters, with maxPageSize: 5319
    12 authors with three aliased books fields and two argument sets379
    MCP list tool with books and orders, 12 authors5910
    205 authors with their books20613, 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_timeout applies 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 as SimfinityErrors, so a failed batch normally logs no warning. A server or proxy that rejects SET LOCAL jit = off would 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 LOCAL ends with it. Amazon RDS Proxy pins a client connection to its database connection for PostgreSQL SET commands 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 the DatabaseConnectionsCurrentlySessionPinned metric.

  • Tooling. Statement loggers see each batch as BEGIN ISOLATION LEVEL REPEATABLE READ, SET LOCAL jit = off and SELECT 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, and pg_stat_statements records 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 a parentColumn and optional setup statements, 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 is SET LOCAL jit = off. The adapter uses the compiler only with batchedCollections: true, and the runtime binds it only when its compileQuery property is the plugin's own compileQuery, so a copy that overrides compileQuery, such as { ...postgresPlugin(options), compileQuery }, reads each parent with its override. A compileChildrenQuery that is not a function throws INVALID_SQL_PLUGIN (400); a setup that is not a list of statements fails the batch with a TypeError before a connection is taken, logged once per type, and each parent is then read alone. See the plugin contract.

  • SQLPlugin gains the optional compileChildrenQuery, SQLConfiguration and PostgresConfiguration gain the optional batchedCollections, and the new types SQLChildrenStatement, with its optional setup, and SQLChildrenQueryCompiler are exported.

MongoDB ​

  • Batched collection reads, opt-in. createMongoAdapter({ batchedCollections: true }) reads each group with aggregate commands of up to collectionBatchSize parents (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 $unionWith stages, 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 for referentialIntegrity.
  • Invalid values throw INVALID_MONGO_ADAPTER_OPTIONS (400). Unknown option names still throw INVALID_MONGO_INTEGRITY_CONFIGURATION (400), whose message now lists the three accepted names.
  • One read per parent remains for children of models with aggregate middleware, their own aggregate static, a discriminator child model or a wrapped Model.collection.aggregate, which a $unionWith sub-pipeline would bypass; for reads inside connection.transaction() with Mongoose's transactionAsyncLocalStorage, because MongoDB rejects $unionWith in a transaction and aborts it; and for adapter copies or overrides of findChildren. 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 collectionBatchSize shortens each command.
  • Monitoring and query settings. Command monitoring, APM and OpenTelemetry spans, the profiler and slow-query logs see aggregate commands with $unionWith stages that end in $replaceWith: { i, d: '$$ROOT' }, one per command. MongoDB 8.0 query settings (setQuerySettings, such as indexHints or reject: true) and $queryStats or 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 maxTimeMS limit, such as MongoDB 8.0's defaultMaxTimeMS cluster 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 $unionWith fails every batch this way. All parents of one command are read from one server.
  • Not detected: driver-level aggregate wrappers 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.
  • MongoAdapterOptions gains the optional batchedCollections and collectionBatchSize members. MongoAdapter has the optional findChildrenBatch and batchesChildren only when the option is on.

TypeScript: EnvelopSchemaPlugin ​

  • @simtlix/simfinity-mcp now re-exports the EnvelopSchemaPlugin declaration of @simtlix/simfinity-core instead of declaring its own. A module that re-exports @simtlix/simfinity-mcp or @simtlix/simfinity-js/src/mcp.js with export * no longer reports TS2308 for EnvelopSchemaPlugin together with the core root, /auth or /plugins, the @simtlix/simfinity-sql or @simtlix/simfinity-postgres root, MongoDB's src/auth/index.js or src/plugins.js, or the @simtlix/simfinity-js root, in either order.
  • Core's EnvelopSchemaPlugin.onSchemaChange now returns void | 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.
  • createAuthPlugin now returns the new AuthSchemaPlugin, an EnvelopSchemaPlugin whose hook is synchronous and returns nothing. It is exported by the core root, /auth and /plugins, by @simtlix/simfinity-sql, @simtlix/simfinity-postgres and @simtlix/simfinity-js, and by MongoDB's legacy src/auth/index.js, src/plugins.js and src/index.js. Calling the auth plugin's hook yourself still yields void. Passing the plugin to Yoga, Envelop or MCP schemaPlugins, assigning it to an EnvelopSchemaPlugin or an Envelop Plugin, delegating to its hook, or passing the hook by reference compile and lint as before.
  • Only code that uses Simfinity's EnvelopSchemaPlugin type, 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's recommendedTypeChecked:
    • a hook that returns a value, such as ({ schema }) => seen.add(schema) or async () => 5, reports TS2322 in an object typed EnvelopSchemaPlugin or EnvelopSchemaPlugin[], checked with satisfies EnvelopSchemaPlugin or passed as a generic T extends EnvelopSchemaPlugin; TS2345 when its plugin is passed to an EnvelopSchemaPlugin parameter; and TS2352 in an as EnvelopSchemaPlugin assertion. Return nothing, for example by wrapping the arrow body in braces; a returned schema never replaced the old one, so call replaceSchema;
    • an interface that extends EnvelopSchemaPlugin and narrows onSchemaChange to a non-void result reports TS2430; declare the result as void or void | Promise<void>. A class that implements EnvelopSchemaPlugin with a value-returning hook, as a method or an arrow property, reports TS2416; give the hook a block body, as in onSchemaChange = ({ schema }) => { this.seen.add(schema); };;
    • a hook that returns a PromiseLike that is not a Promise reports TS2322; return a real promise, such as async () => { await installLater(); };
    • the result of an EnvelopSchemaPlugin-typed plugin's hook where void is declared reports TS2322, as void | Promise<void> | undefined is not void: returned from a function or arrow annotated : void, including a delegating hook such as onSchemaChange: (p): void => inner.onSchemaChange?.(p), stored in a void variable, or collected in a void[] by map. Call the hook in a block, as in { void plugin.onSchemaChange?.(payload); }, await it in an async function, type the result void | Promise<void>, drop the : void of a delegating hook in an EnvelopSchemaPlugin literal, or keep the auth plugin typed as the AuthSchemaPlugin that createAuthPlugin returns;
    • a module augmentation of @simtlix/simfinity-core that redeclares onSchemaChange on EnvelopSchemaPlugin with 3.6.0's => void type reports TS2717. Remove the member from the augmentation; retyping it fails on 3.6.0 instead;
    • no-misused-promises reports a void callback that calls the hook of an EnvelopSchemaPlugin-typed plugin, such as plugins.forEach((plugin) => plugin.onSchemaChange?.(payload)), or delegates to it in an Envelop Plugin, { onSchemaChange: (p) => plugin.onSchemaChange?.(p) }. It also reports the hook passed by reference where a void function is expected: an Envelop Plugin's onSchemaChange: plugin.onSchemaChange, a void listener argument, a variable typed (p) => void or a function's (p) => void result. Wrap it as (p) => { void plugin.onSchemaChange?.(p); }, or use for…of with await. Delegating in an EnvelopSchemaPlugin literal stays clean;
    • an unawaited direct call of such a hook reports no-floating-promises; await it or prefix it with void;
    • the same preset no longer reports no-misused-promises on an async hook annotated with core's type, or await-thenable on await plugin.onSchemaChange?.(payload). Code that must lint on 3.6.0 too should use void, not await.
  • Unchanged: spreading or assigning such a plugin to an Envelop Plugin or Plugin[], passing it to envelop({ 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 removes moduleResolution: node10.
  • MCP's declarations now import EnvelopSchemaPlugin from @simtlix/simfinity-core through MCP's own dependency, as the facade, SQL and PostgreSQL declarations already do. With pnpm and preserveSymlinks: true, install @simtlix/simfinity-core directly next to @simtlix/simfinity-mcp; otherwise tsc reports TS2307, or, under skipLibCheck, the type silently becomes any.
  • The fix needs one core: the application's @simtlix/simfinity-core and 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: false and explicitNullsOrder: true, and a copy of the PostgreSQL plugin without the singleStatementReads capability.
  • Opt-in: PostgreSQL refreshTriggers: 'perRoot' and ownerIndexes: 'minimal'; MCP nestedCollections, textFormat, retry.retryAfter and retry.jitter.
  • Documentation only: capping nested writes on servers exposed to MCP clients, and removing MongoDB's unused top-level id index.

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 updated child 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 second SELECT … FOR UPDATE on PostgreSQL. A nested deleted child is read once, by its ownership check, and onDelete receives 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 an onUpdating hook (so a malformed ID still fails before the hook) or carries nested collection items. The adapter allows these two skips through the new optional DatabaseAdapter.readByIdIsInert(model), when it returns true:
    • the PostgreSQL adapter always does;
    • the MongoDB adapter does for models without find, findOne, findOneAndUpdate or findOneAndDelete query middleware, application _id setters or overrides of findById, findOne, findByIdAndUpdate, findOneAndUpdate, findByIdAndDelete or findOneAndDelete, which includes every generated model. A supplied model with a read guard, application _id setters, 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's timestamps option, which adds findOneAndUpdate middleware;
    • a custom adapter without the method keeps every read. One that implements it must make update() and delete() reject a malformed ID with NOT_VALID_ID (400) and return null for a missing record, as the built-in adapters do.
  • Each skipped read was one find on MongoDB, and one SELECT plus 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 UPDATE first: its UPDATE locks the row, as for types without embedded fields. When such an update changes no column, for example when its input holds only id, or id and empty nested collection lists, it runs no UPDATE and takes no row lock, also as for types without embedded fields. Updates with onUpdating or 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 _id setters or run find or findOne middleware; 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 _id setters.
  • Generated and custom mutations copy their input once per transaction attempt instead of twice. A retried attempt copies the request input again, so change args in middleware before it returns, as documented, not asynchronously while the mutation runs.
  • Unchanged: nested added, updated and deleted entries 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 with configureMutationLimits({ maxNestedOperations }), available since 3.5.3; the MCP limits do 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 _id and 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 with TRANSACTION_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 _simfinityReferenceLock value, and command events show a find on _id: { $in } and an update with multi: true.
  • A guarded delete checks each referencing collection with one query, the $or of 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 id index, documentation only. Generated models still declare an index on the top-level id field, id_1. The record key is _id, and generated reads, filters, sorts, aggregations, relation lookups and MCP tools never use id_1. To stop declaring it for a connect() type, pass (model) => model.schema.removeIndex({ id: 1 }) as connect()'s onModelCreated argument. Mongoose autoIndex builds the index at startup on any process whose schema still declares it, so drop an existing one with db.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 stored id, or when a link is stored under id, as with connectionField: 'id'. Version 4.0 plans to stop declaring it by default and to remove the stored id path and the duplicate $set.id that 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, with acquire, query(configuration, statement, client) and release and no begin or commit: reads by ID without a lock, list reads, batched reference reads and collection fields, of types without owned tables. A confirmed abort that isRetryable() 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 reaches release(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 when rollback resolves 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 exported SQLInsertManyOperation, 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 through compileRecord as an insert, and multiRowInserts: false in the configuration keeps one insert per row. bindPlugin rejects any other value of either member, null included, with INVALID_SQL_PLUGIN. SQLRecordOperation is unchanged, so exhaustive compileRecord switches keep compiling. A copy of a plugin that overrides compileRecord for insert, such as { ...postgresPlugin(options), compileRecord }, inherits compileRecordBatch and maxRecordBatchValues, so owned rows inserted in runs of two or more no longer reach the override. Such a copy should also override compileRecordBatch, set both members to undefined, or set multiRowInserts: false.
  • describeSchema(plan, configuration) and compileQuery(models, description, plan, extra, configuration) receive the bound configuration, or null, as a new last argument. Plugins written for 3.5.9 can ignore it.
  • SQLConfiguration gains multiRowInserts?: boolean and SQLCapability gains '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 READ transaction: 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 whose begin() sets per-transaction settings no longer runs it for these reads. To keep a transaction around every read, remove the capability:

    javascript
    import { 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 INSERT for 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 2 INSERT statements instead of 51, and replacing those items sends 1 DELETE and 1 INSERT instead 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_VALUE 400, or DATABASE_ERROR 500 for oversized index entries) instead of REFERENCE_CONSTRAINT_VIOLATION (409). Nothing is stored.
    • multiRowInserts: false, in createPostgres(options) or configure(), restores one INSERT per row.
    • A copy such as { ...postgresPlugin(options), compileRecord } that overrides compileRecord for insert inherits compileRecordBatch and maxRecordBatchValues, so owned rows inserted in runs of two or more no longer reach the override. Also override compileRecordBatch, set both members to undefined, or set multiRowInserts: false.
  • Sorts. Sorts on id and required root fields no longer carry a NULLS FIRST or NULLS LAST clause, 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) or DESC NULLS LAST on id or a required column no longer match these sorts. Declare default-order indexes instead.

    explicitNullsOrder: true, in createPostgres(options) or configure(), keeps the explicit NULLS clause on every sort term, which is the 3.5.9 SQL.

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

  • PostgresConfiguration gains the optional multiRowInserts, explicitNullsOrder, refreshTriggers and ownerIndexes members. The last two are described below.

PostgreSQL initialization ​

  • create mode 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 MODE statement, only the existing tables (root, owned, guard and keys tables) of roots whose maintenance objects are missing: guard or keys tables, the <Root>__owned_valid or <Root>__refresh functions, or refresh triggers and their functions. It rebuilds the unique keys of those roots only. Other DDL takes its own locks: CREATE INDEX takes SHARE on its table, adding a foreign key takes SHARE ROW EXCLUSIVE on 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 with mode: 'validate'.

  • It no longer re-scans existing rows against generated CHECK constraints, which PostgreSQL enforces on every write. It also no longer re-validates existing rows against generated function bodies that a migration replaced with CREATE OR REPLACE FUNCTION, as described in PostgreSQL schema validation and queries. After such a migration, check existing data yourself, for each checks[].expression and each maintained root in describeDatabase():

    sql
    SELECT 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.rootTable is the root, before the refresh, as initialization does: <Root>__refresh(id) rebuilds only its own record's keys, so a bare SELECT <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', in createPostgres(options), configure() and the low-level describeDatabase(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 throws INVALID_DATABASE_SCHEMA (400) from createSchema() or describeDatabase().

  • 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() and compileDatabaseSchema() 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:

    1. drop the trigger and function <Table>__deferred of the root and of each of its owned tables, for example DROP TRIGGER "Book__deferred" ON "app"."Book"; DROP FUNCTION "app"."Book__deferred"();;
    2. replace each <Table>__touch function with its new body, running its CREATE FUNCTION statement as CREATE OR REPLACE FUNCTION;
    3. run the CREATE FUNCTION and CREATE CONSTRAINT TRIGGER statements 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.

  • Versions up to 3.5.9 cannot start on 'perRoot' roots: both validate and create mode fail with SCHEMA_MISMATCH at the root's <Root>__touch function (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>__touch disables 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 runs SELECT <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 declared AFTER INSERT OR UPDATE … WHEN (new.version < 0), so the clean-up and the guard rows that initialization inserts queue no event. Under SET CONSTRAINTS ALL IMMEDIATE, or with the guard trigger set IMMEDIATE, 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_KEY under 'perRow' and REFERENCE_CONSTRAINT_VIOLATION under '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 ALL is unchanged.

  • Applications that stay on 'perRow' should cap the size of embedded lists with validators, such as validators.arrayLength(), because every changed row refreshes its whole root.

PostgreSQL owner indexes (opt-in) ​

  • The new option ownerIndexes: 'minimal', on the same surfaces as refreshTriggers, 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 throws INVALID_DATABASE_SCHEMA (400).
  • It applies to tables created while it is set. Initialization accepts existing <Table>____owner_id__idx indexes in both modes and never drops them. Once every process runs this version with 'minimal', you may drop them with DROP INDEX CONCURRENTLY.
  • On a database created or trimmed this way, validation by 3.5.9 or earlier, and by this version with 'all', fails with SCHEMA_MISMATCH (index is missing). The create mode 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; the create mode of this version with 'all' re-creates them with only the lock each CREATE INDEX takes 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 accept nestedCollections: 'all' | 'none', also per tool in toolOverrides, where the tool's value wins; a tool with its own selection ignores it. 'none' leaves nested collections, object list fields with a pagination argument such as core's non-embedded one-to-many relations, out of the generated selection and outputSchema. The default, 'all', keeps the 3.5.9 selection. With the default selectionDepth: 1, each nested collection is read with one query per returned row, up to 100 items (or configureQueryLimits()'s maxPageSize when lower), and limits do not bound that work.
  • textFormat, opt-in. textFormat: 'compact' sends the text content block as compact JSON, about half the bytes for nested results, and maxResultBytes measures it, so MCP_RESULT_TOO_LARGE and truncated.resultBytes report compact sizes. The default stays 'pretty'; it may become compact in 4.0.
  • Retry-After and jitter, opt-in. Remote retry accepts retryAfter: true, which makes the next retry wait at least the Retry-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 than maxRetryAfterMs (default 10000). jitter: true adds a random wait of up to half of backoffMs × k to retry k. Without them the schedule stays backoffMs × k. maxRetryAfterMs bounds each wait, not their total, so many attempts can exceed an MCP client's request timeout (60 s by default in the TypeScript SDK). Honoring Retry-After and jitter may become the default in 4.0.
  • MCP_INVALID_OPTIONS (500) is a new setup error for a textFormat or nestedCollections other than the values above, global or in any toolOverrides entry, published or not. It is thrown when the tools, server or HTTP handler are created, before schemaPlugins run.
  • 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 their inputSchema.$defs, which lowers setup time and memory for large schemas. The published schemas are unchanged, and graphqlArgsToJSONSchema() still returns new objects. Copy a tool definition, for example with structuredClone(), before changing it in place.
  • Generated add and update tools accept nested collection writes, which limits do not bound; see mutations.
  • In TypeScript, GenerateMCPToolsOptions gains nestedCollections and textFormat, MCPToolOverride gains nestedCollections, and MCPRetryOptions gains retryAfter, maxRetryAfterMs and jitter, all optional, typed by the new MCPNestedCollections and MCPTextFormat aliases 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 null when one of its required members would read as null. An object holds no data when every member is absent or null, 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 _id as the object's id. These required members do not count: id, readOnly members, 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 with Cannot return null for non-nullable field Type.member. and returned null for 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 null removed it. By-ID reads, reference reads and update responses failed; list reads and create responses already returned null;
    • 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 as geo in main: { street: "M" }.
  • 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 reads null:

    • 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 as main: { 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 as main: { geo: {}, tags: [] }, stores that member as absent. An object that holds nothing else then reads as null, 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 named type. 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 stores geo: {} and reads back as written. PostgreSQL rejects such a create with REQUIRED_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 null in lists and create responses;
    • objects whose only stored data is an _id, such as a subdocument of an entity type selected as part { id }. A supplied subdocument schema without _id: false gives 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 declares id, with another id on each read; list reads read it as null;
    • 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: false or 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 as null when 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 internal source field, is not data: an object that holds nothing else and misses a required member that counts reads as null.

    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 readEmbeddedValue hook 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 as null. A custom adapter that defines readEmbeddedValue gets the rule. DatabaseAdapter has 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 onUpdated receive are unchanged, and nothing is migrated: such stored objects now read as null. 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_VALUE when a required non-list member has no value, for example Required value street is missing, because the list default makes the object present. An omitted object without list members is stored as null and reads as null.

See MongoDB embedded objects in hydrated documents.

MongoDB models ​

  • In default MongoDB mode, embedded types that contain each other now make createSchema() throw INVALID_MODEL (400) with the message Embedded cycle at … instead of RangeError: Maximum call stack size exceeded when Simfinity generates a model whose embedded fields reach the cycle. This applies to embedded objects and lists, directly (A.b: B and B.a: A) or through other embedded types, also when a field of the cycle is readOnly. Simfinity generates a model for a type connected without one (connect(null, …)) and for a type registered with addNoEndpointType() 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 with INPUT_TYPE_UNRESOLVED, or, when a field of the cycle is readOnly, 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 in Embedded cycle at B.a.b; transactional mode and PostgreSQL start it at the entity, as in Embedded 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 a SimfinityError with INVALID_MODEL (400) instead of a plain Error; code that checks the message is unaffected. Transactional mode and PostgreSQL report such a type with Embedded cycle at Type.field, as before.
  • Marking a field of the cycle readOnly does not avoid this error in transactional mode, on PostgreSQL or when a generated model reaches the cycle. It avoids INPUT_TYPE_UNRESOLVED with 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() or setClient();
    • 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 its useDb() connections.
  • Previously such a request waited for the default connection and failed with Connection operation buffering timed out after 10000ms, or a TypeError with bufferCommands: 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 with useDb(name, { noListener: true }), nor its useDb() descendants, nor native writes such as mongoose.connection.collection(…), which compile no model. Without such a model, a request that arrives before mongoose.connect() now gets a session from the registered models' client, so a callback that writes default-connection collections with that session fails with ClientSession 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, and withMongoTransaction from the legacy deep import @simtlix/simfinity-js/src/mongo/transactions.js, keep mongoose.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 with ClientSession must be from the same MongoClient. In the default mode only, open a transaction on the model's connection inside the callback instead:

    javascript
    simfinity.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 with Input 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 one Configuration issue: warning for each:
    • A referenced collection whose child has no writable field besides its connectionField, such as a child with only id, the back-reference and readOnly, interface or union fields, has no added operation: OneToMany<Type>A<field> and OneToMany<Type>U<field> keep updated and deleted. Create such children with their own add mutation or a registered mutation. updated is 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 writable id.
    • 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 id stays 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, while update<type>, delete<type> and state machine actions remain. Without a writable id, because it has no id field or a readOnly one, it has no update mutation or actions either, only delete<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, as saveObject() does.
  • New startup error: a non-null embedded field, such as stamp: Stamp! or stamps: [Stamp]!, whose embedded type has no writable fields makes createSchema() throw INVALID_MODEL (400), for example Doc.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 later createSchema() 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]!, whose added operation is left out keeps a required operation object in the create input. Clients and MCP create tools send books: {}.
  • 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 added items report idempotentHint: 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 includedMutationTypes whose embedded type has no writable fields.

See what gets generated.

Application types named RelationType or FieldExtensionsType ​

  • An application type named RelationType or FieldExtensionsType in the first Simfinity schema of a process no longer makes createSchema() throw Schema must contain uniquely named types but contains multiple types named "RelationType". Simfinity instead names the object types of its __Field.extensions introspection metadata SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended when the schema also has those names, such as SimfinityFieldExtensionsType2, and logs one Configuration 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 example fragment Flags on SimfinityFieldExtensionsType or createAuthPlugin({ SimfinityFieldExtensionsType: … }). A permission-map entry for your own RelationType applies to your type.
  • Schemas without such a type keep FieldExtensionsType and RelationType, 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 throws RESERVED_TYPE_NAME (500) instead of the duplicate-name error. Those are FieldExtensionsType and RelationType when the first schema did not rename them, and the fallback names when it did. It is raised before adapter.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 whose fields function calls getInputType() 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 ​

  • UnauthenticatedError and ForbiddenError are now exported from the @simtlix/simfinity-core root, as the same classes as auth.UnauthenticatedError, auth.ForbiddenError and the /auth and /auth/errors exports. The root declarations already listed them, so TypeScript accepted import { ForbiddenError } from '@simtlix/simfinity-core', but Node failed when loading the module (does not provide an export named 'ForbiddenError'), and core.ForbiddenError was undefined. Their types are unchanged; a regular import used only as a type keeps compiling under verbatimModuleSyntax and now also runs. /auth/errors now re-exports the root classes, so export * from the root together with /auth or /auth/errors no longer reports TS2308. @simtlix/simfinity-js, @simtlix/simfinity-postgres and @simtlix/simfinity-sql still expose them only through auth.
  • A JavaScript module that re-exports the @simtlix/simfinity-core root with export * together with another module that exports its own UnauthenticatedError or ForbiddenError now makes imports of that name from it fail at load time with SyntaxError: The requested module … contains conflicting star exports for name 'ForbiddenError'. Re-export the class you want explicitly in that module, for example export { ForbiddenError } from './my-errors.js';. TypeScript already reported TS2308 for such modules. Modules that re-export the @simtlix/simfinity-js, @simtlix/simfinity-postgres or @simtlix/simfinity-sql root are not affected.
  • /plugins now declares createAuthPlugin as the /auth export it is at run time, so export * from @simtlix/simfinity-core/auth together with @simtlix/simfinity-core/plugins no longer reports TS2308 for it, as it did in 3.5.8. The legacy deep imports @simtlix/simfinity-js/src/auth/index.js and src/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.js and src/errors/internal-server.error.js, and their extensionless aliases, such as src, src/auth and src/auth/rules. Each has the types of the package root or of the matching @simtlix/simfinity-core subpath, under moduleResolution node10, node16, nodenext and bundler. Previously they failed with TS7016 in strict projects and were any otherwise. 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-ignore on the import, can now report errors for calls the declarations reject, as with the core subpaths in 3.5.8. For example, a defaultPolicy held in a string must be narrowed to 'ALLOW' | 'DENY'.
    • A // @ts-expect-error placed on one of these imports to silence TS7016 now fails with TS2578 (unused @ts-expect-error directive); remove it. A @ts-ignore keeps compiling, and the import's uses are now type-checked.
    • In CommonJS-format files (a package.json without "type": "module", or .cts files) compiled with module node16, or nodenext before TypeScript 5.8, these imports now report TS1479, as the package root already does. Use module: nodenext with 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-mcp or @simtlix/simfinity-js/src/mcp.js with export * from a module that exports core's EnvelopSchemaPlugin reports TS2308 for EnvelopSchemaPlugin, because @simtlix/simfinity-mcp declares its own interface of that name. Such modules are the core root, /auth, /plugins, the SQL or PostgreSQL root, src/auth/index.js or src/plugins.js, and the @simtlix/simfinity-js root when the MCP module (@simtlix/simfinity-mcp or src/mcp.js) comes first. Re-export one of them explicitly, for example export 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.

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.children and Comment.children, no longer make createSchema() fail with Schema must contain uniquely named types but contains multiple types named "OneToManyAchildren". Among the types that the generated mutations of the first createSchema() reach, the first registered keeps OneToManyAchildren and OneToManyUchildren. The others are named after their type, for example OneToManyCommentAchildren and OneToManyCommentUchildren, as other collections are, and a Configuration issue: warning names both types. A type registered after a createSchema() call is named after its type only when the next createSchema() 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, with connect() instead of addNoEndpointType(), 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 a registerMutation() input reaches, and a later createSchema() 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 as Serie.episodes and Serie.featured, now share one added item input instead of failing on a duplicate SerieAEpisodeInputForSerie name. 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 as Department.employees (to Employee) and Employee.managedDepartments (to Department). Previously it threw INPUT_TYPE_UNRESOLVED (Could not build input types for: …) on MongoDB and PostgreSQL, and the only workaround was marking one collection readOnly; remove that readOnly to get nested writes. Nested inputs follow the cycle at any depth; configureMutationLimits({ maxNestedOperations }) caps the entries one mutation may carry.
  • INPUT_TYPE_UNRESOLVED still reports writable lists of lists and embedded types that contain each other, with the same code and status, and its message still starts with Could 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 with UNREGISTERED_RELATION_TARGET for that field first. Lists of interface or union types no longer raise it; see interface and union fields.
  • The deleted list 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 describe deleted items 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>, and deleted: [ID]. These changes are additive and do not break clients. Code generated from the schema that reads deleted now 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, and deleted items become nullable, so hosts that pin MCP tool definitions may report them as changed.

See generated collection input names.

Nested collection writes ​

  • Nested updated and deleted children are now checked against the parent's stored identifier, the _id, else id, 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 with FORBIDDEN (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 nested updated child whose ID is sent in another case. Children of another parent are still rejected.

  • added and updated children 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 a String path for an ObjectId parent, used to store the ID as the client spelled it, so those children were missing from parent.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:

    javascript
    await 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.js runs this command against MongoDB.

  • Type validators (the type's extensions.validations) and the onUpdating hook 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 onSaving hook 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 supplied String path 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, else id, of the record that adapter.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 an auth.ForbiddenError. Its message, FORBIDDEN code and 403 status are unchanged, and it is still a SimfinityError, but its name is now ForbiddenError instead of Error. Error-formatter callbacks and audit hooks that check instanceof auth.ForbiddenError now receive these denials too, including any message masking they apply to ForbiddenError.
  • InternalServerError now has status 500, through getStatus() and extensions.status. Where extensions had no status key before, it now includes "status": 500: errors that buildErrorFormatter wraps as unexpected, the Type.field stores an invalid identifier reference error, InternalServerErrors your own code throws or returns from the formatter callback, and the same errors inside MCP tool results. This matches the 500 that buildErrorFormatter already gave a GraphQLError with the INTERNAL_SERVER_ERROR code, and PostgreSQL's DATABASE_ERROR.
  • A GraphQLError whose SimfinityError cause is an InternalServerError now reports 500 even when it has its own integer extensions.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, not extensions.status. Code that recognized unexpected errors by a missing extensions.status should check extensions.code === 'INTERNAL_SERVER_ERROR' or error instanceof InternalServerError instead.

See errors.

Filter values ​

  • Filter values (QLValue) now accept operation variables inside list literals, for example year: { operator: BTW, value: [$from, $to] } with $from: QLValue, $to: QLValue, and enum literals such as status: { value: ACTIVE } or { operator: IN, value: [ACTIVE, INACTIVE] }, which are read as the enum names. Previously these documents failed validation with Filter values must be scalars or flat scalar lists. Declare item variables as QLValue: GraphQL rejects other declared types, such as Int, 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 to null, an object or a list are rejected with INVALID_FILTER_VALUE, as whole-list variables are. On MongoDB the message is, for example, BTW does not accept null elements; on PostgreSQL it is Filter 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 text A.

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() throw INPUT_TYPE_UNRESOLVED.
  • These fields are now output-only. Generated inputs leave them out, and a field that is not readOnly logs one Configuration issue warning. 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 QLTypeFilterExpression argument; send it only as null. AND/OR conditions that name such a field are unchanged: non-null values are rejected with INVALID_FILTER_VALUE, a null condition matches every record because nothing is stored, and a field with your own resolver is rejected with FORBIDDEN_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 with INVALID_MODEL.

See what gets generated.

Application scalars named JSON ​

  • An application scalar named JSON, such as GraphQLJSON from graphql-scalars or graphql-type-json, no longer makes createSchema() throw Schema 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.groupId and facts use it, and its serialize formats aggregation results. It must serialize strings, numbers and null as well as objects.
  • Schemas without one keep Simfinity's own JSON scalar unchanged.
  • A type named JSON that is not a scalar still conflicts, as do application types named RelationType or FieldExtensionsType, which every schema contains.

See aggregation input types.

Counted lists without a context object ​

  • A list query with pagination: { count: true } no longer fails with Cannot set properties of undefined (setting 'count') when the request has no context object, for example graphql() without contextValue. 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 issue warning 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 when itemValidator is a single validator (a plain object or a function with validate()) or a helper result such as validators.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 with VALIDATION_ERROR. Stored data is not checked.
  • A helper result applies its CREATE rules to every item on create and update, like the documented validators.maxLength('Tag', 20).CREATE, so null items fail helpers that require a value. Lists sent with an array of helper results used to fail with an internal validator.validate is not a function error; 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 TypeError that names the helper. Both log a Configuration issue warning when the helper is created.
  • validators.dateFormat() and validators.futureDate() reject impossible calendar dates in strings that start with an ISO date, such as 2024-02-31, 2023-02-29 or 2024-04-31T10:00:00Z, and in slashed MM/DD/YYYY strings such as 02/31/2024, with <name> must be a valid date. Earlier versions stored them because JavaScript rolls them into the next month; on a Date or DateTime field the stored date was the next month's. With 'YYYY-MM-DD', values in other shapes keep the must be in format message.
  • 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 null bound means no bound, as undefined does, in stringLength, maxLength, numberRange, arrayLength and the createBounded*Scalar factories. Earlier versions compared values with 0 and rejected them with messages such as must be at most null characters. If you passed null where you need 0, pass 0. Reads of stored values that only a null bound 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 0 or A float, instead of between undefined and 120. A NaN bound 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, /scalars and /validators now ship TypeScript declarations, through a types condition in their export entries (moduleResolution node16, nodenext, bundler) and a typesVersions map (node10). Previously, under node16, nodenext and bundler, these imports failed with TS7016 in strict projects and were typed as any in non-strict ones; under node10 they failed with TS2307 whatever the strict setting. Their named and default exports have the same types as the root auth, plugins, scalars and validators namespaces, and they re-export related types such as AuthRuleFunction, PermissionSchema, FieldValidations and ItemValidators. The JavaScript modules and the files Node resolves are unchanged.
  • requireRole() and requirePermission() also accept readonly arrays, such as an as const list or a frozen array, in the root declarations and the subpaths. The declarations of stringLength, maxLength, numberRange, arrayLength and the createBounded*Scalar factories accept null bounds, and arrayLength's itemValidator accepts the new ItemValidators type, which the core root, the /validators subpath and the @simtlix/simfinity-js, @simtlix/simfinity-postgres and @simtlix/simfinity-sql type entries export.
  • Code that compiled only because these imports were any, in non-strict builds under node16, nodenext or bundler, can now report errors for calls the declarations do not allow. For example, a defaultPolicy held in a string variable must be narrowed to 'ALLOW' | 'DENY' (use a literal or as const). Remove any shorthand declare module '@simtlix/simfinity-core/...' shim added for TS7016 or TS2307: it keeps taking precedence over the shipped declarations, so the import stays any.
  • The internal/* subpaths are not public API and stay undeclared, as do MongoDB's legacy @simtlix/simfinity-js/src/... deep imports. In TypeScript, import auth, plugins, scalars and validators from the package root. Importing the core subpaths in an application that installs a facade needs @simtlix/simfinity-core as 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 the context passed to saveObject(), which is undefined when 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 returns isError: false with _meta: { truncated: true }, and structuredContent and 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 a truncated notice { applied: true, message, resultBytes, maxResultBytes }. The message says whether calling the tool again would repeat the change, following the tool's idempotentHint, and never sends the agent to read a deleted record. With GraphQL errors the result stays isError: true with MCP_RESULT_TOO_LARGE, a new message, extensions.applied (true, or 'unknown' when no root value came back, never false), extensions.firstError and the ids in data when present. Previously every oversized mutation returned MCP_RESULT_TOO_LARGE without 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 by MCP_RESULT_TOO_LARGE must check _meta.truncated or the truncated key, and middleware that counts isError sees 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 through limits.defaultPagination) also return the total as totalCount next to the results, in structuredContent and in the text, so the model sees it; _meta.count stays for client code. A remote extensions.count becomes totalCount only when it is a non-negative integer; Simfinity's count plugins omit a count of zero, so a remote call that matches nothing has no totalCount. totalCount counts toward maxResultBytes, so a counted result that was within a few bytes of the cap can now exceed it, and the MCP_RESULT_TOO_LARGE error of an oversized counted list carries extensions.totalCount. See counted lists.
  • structuredContent is now the GraphQL data plus the keys MCP adds next to the root field: totalCount or truncated, named __totalCount or __truncated when the root field itself has that name. Code that compares structuredContent with the GraphQL data, 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 type for a scalar only when it is a GraphQL spec scalar or a createValidatedScalar chain ending at one. Date, DateTime and Time, custom scalars with a hand-set baseScalarType storage hint and opaque scalars are published without a type or format, with a curated description for date/time scalars that have none. Previously their output schemas carried format: 'date', 'date-time' or 'time', or the hint's primitive type, and the SDK Client, which validates structuredContent with formats, threw -32602 (Structured content does not match the tool's output schema) for values their serializers produce, such as an ISO instant for Date or '12.30' for a Decimal hinted as Float, including on mutations that had been committed. These schemas only get looser, so no action is needed. Input schemas keep the format hints; see output schemas for the input/output difference with Time.
  • Input schemas are closed with additionalProperties: false at the top level, and so is the schema graphqlArgsToJSONSchema() returns. See MCP arguments.
  • List tools with a pagination argument declare an optional totalCount, and mutation tools declare an optional truncated notice when limits.maxResultBytes is set at setup, unless a selection override omits the output schema. The model-facing descriptions of pagination and QLPagination.count now name totalCount instead of _meta.count.
  • Generated update tools whose input can carry collection items to insert, an added list at any depth as in a non-embedded one-to-many collection, now have idempotentHint: false, because every repeat inserts those items again. Hosts that retry tools marked idempotent stop retrying them. Other updates and deletes keep idempotentHint: true; idempotency describes the stored data, and a repeated delete calls onDelete with null. Restore a hint per tool through toolOverrides annotations. 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 like deleteDraft_book, updateStatus_book or addToArchive_book lose the Delete, Update and Create titles, 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 with registerMutation() are custom tools whatever their name and description; previously one named add*, update* or delete* 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 explicit destructiveHint: 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 pagination argument and a get-by-id tool only with an id argument, unless it is a generated list or aggregate query. Other queries, such as a currentUser or an unpaginated recentUsers, change from Get X/List X to their field name and a read-only description, and keep readOnlyHint: true.
  • includeId, in the options and in toolOverrides, is deprecated and ignored. It never changed the generated selection, so selections and output schemas are unchanged.

MCP arguments ​

  • callTool rejects an argument key that the caller sent and the tool does not declare with an MCP_UNKNOWN_ARGUMENT error 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 guessed dryRun on a delete still deleted the record. Keys whose value is undefined are ignored. See unknown arguments.
  • Tool middleware that reads a custom key the caller sends must delete it from call.args before calling next(). 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 with Object.hasOwn, spread copies or WeakMap keys made counted calls fail or be denied while uncounted calls worked, including with the stock auth plugin, and a context factory returning null failed with a TypeError. 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 as info.rootValue. A nested resolver that read context.count during the same MCP call now reads undefined, 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.countSink marker, 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 writes context.count, or wrapped the generated resolver without forwarding parent or info. Its counted calls now return no total, and the count is written onto the caller's shared context. Forward parent or info, report the total through the function under parent[Symbol.for('simfinity.countSink')], or set the field's extensions.simfinityQuery.countSink to false before creating the tools. See counted lists.

MCP setup errors ​

  • limits is validated when the tools, server or HTTP handler are created:
    • Falsy values (false, null, 0, '') for limits, defaultPagination or a cap mean unset. limits: null previously failed setup with a TypeError.
    • Numeric-string and bigint caps keep working, 'Infinity' included. A NaN cap still applies no cap and now logs one warning per setup.
    • A defaultPagination that a published tool's pagination type rejects, such as { size: 20 } without page, a zero, negative, fractional or string size, a non-boolean count or an unknown key, now throws MCP_INVALID_LIMITS naming 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 -1 or 0.5, true, blank strings and strings that convert to a number below 1, such as '0' or '0.5', now throw MCP_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, for true, every page size above 1.
    • Non-numeric string caps such as 'abc' and object caps such as {} now throw MCP_INVALID_LIMITS; previously they applied no cap. Array and function caps throw too.
    • A truthy limits that is not an object, such as 'x', 5, true or [], now throws MCP_INVALID_LIMITS; previously it was ignored, so limits: [] applied no limits. A truthy defaultPagination that is not an object throws as well; previously every call without pagination failed.
    • limits is still read on every call, so a cap changed after setup to an invalid value makes calls reject with MCP_INVALID_LIMITS.
  • The new MCP_INVALID_EXECUTION_CONFIG (500) is thrown at setup for a remote endpoint that includes a username or password, for remote headers that are a string, number, true, promise, function or one-shot iterator, and for a timeoutMs that is Infinity, 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 for headers or timeoutMs defined 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 a Proxy execution 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, startStdioMCPServer and createHTTPMCPHandler load the SDK, and the stdio or Streamable HTTP transport, before validating options and running schemaPlugins, so an SDK error leaves the schema's resolvers untouched and comes before option errors. Previously startStdioMCPServer ran the plugins before loading the stdio transport, and createHTTPMCPHandler reported MCP_INVALID_TRANSPORT_OPTIONS before an SDK error. MCP_SDK_INCOMPATIBLE now also covers SDK modules that lack an expected export, which previously surfaced as a raw TypeError after the plugins had run, and its message suggests npm install @modelcontextprotocol/sdk@^1.31.0 instead of @latest.

MCP remote execution ​

  • execution.endpoint must not include a username or password; send credentials through execution.headers. Such an endpoint never worked, because fetch rejected it on every call, and the fetch error, which reached the MCP client and the model, quoted the URL with its password. Setup now throws MCP_INVALID_EXECUTION_CONFIG instead, and an endpoint changed after setup to such a value fails calls with MCP_REMOTE_REQUEST_FAILED; neither message quotes the URL.
  • Other endpoints are still accepted at setup, because an application that replaces globalThis.fetch may serve them: relative, unparsable and non-http(s) URLs, and Request instances, which the default fetch accepts too. When fetch rejects such an endpoint with an error that quotes the URL, the call fails with MCP_REMOTE_REQUEST_FAILED and the message execution.endpoint must be an absolute http(s) URL, such as https://api.example.com/graphql. Previously the message was the fetch error, which quoted a relative or unparsable URL with any query-string API key. Other fetch errors keep their message, which does not quote the URL, such as an application fetch's own network failure or the default fetch's fetch failed for another protocol.
  • execution.headers accepts a Headers instance, a Map or an array of [name, value] pairs as well as a plain object, and sends their credentials. Previously Headers instances and Maps 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-Type set in any casing replaces the application/json default and is sent once. Previously a Content-Type in another casing was sent next to the default as application/json, application/json, which strict parsers such as Express 5's express.json() rejected, so calls failed with Must provide query string.
  • Any falsy headers value sends no extra headers, as before. Headers are read on every call, as before; a value that cannot be sent fails the call with MCP_REMOTE_REQUEST_FAILED before any request, naming the header but not its value, where previously the error quoted the value.
  • fetch receives the headers as a plain object with lowercase names. Fetch wrappers or mocks that read Authorization must read authorization. For the TypeScript types of these forms, see MCP TypeScript declarations.
  • timeoutMs accepts numeric strings and bigints, rounds fractions up and caps values above 2147483647. '0' disables the timeout like 0. Previously numeric strings, fractions and '0' failed every call with a raw TypeError or RangeError (-32603 for 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 fetch that ignores the signal, could send another request. Tool middleware finally blocks now settle at once. This applies to callTool, stdio and createMCPServer over a persistent transport. With createHTTPMCPHandler, the per-request server settles at once when the client's HTTP connection closes. An MCP notifications/cancelled arrives 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) and try { 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 -32603 error carrying the raw message. An outer middleware using a synchronous try { return next(); } catch {} without await no longer catches such throws; it never caught asynchronous ones.
  • Every next() promise is marked as handled. A middleware that starts next() and awaits other work before awaiting it no longer crashes Node with an unhandled rejection, and a rejected next() promise that no middleware awaits or returns is dropped.

MCP Streamable HTTP handler ​

  • createHTTPMCPHandler answers every method other than POST with 405, Allow: POST and a JSON-RPC error body, without calling context or onError or creating a server or transport. Previously a GET that accepted text/event-stream opened an idle 200 event stream that held a socket, a server and a transport per client, and SDK clients logged an AbortError on close; a GET without that Accept got 406, DELETE got 200 and other methods got the SDK's 405 with Allow: GET, POST, DELETE. Mount the handler with app.all; with app.post, Express answers the client's GET with 404, which SDK clients report through onerror. 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 options parameter is now typed by new input-only types: GenerateMCPToolsInit for generateMCPTools, MCPServerInit for createMCPServer and startStdioMCPServer, and HTTPMCPHandlerInit for createHTTPMCPHandler. They accept every value of the option interfaces, plus the execution and limits forms of the new MCPOptionsInit. limits may be an MCPLimitsInit, null or false, with caps as numbers, numeric strings, bigints, null or false and a defaultPagination that may also be null or false. A remote execution may be an MCPRemoteExecutionInit. Its headers is an MCPRemoteHeadersInit (a plain object, an MCPHeadersLike such as a Headers instance, a ReadonlyMap or readonly [name, value] pairs), null or false, and its timeoutMs is a number, numeric string, bigint, null or false. Previously the parameters were the option interfaces, so the wider forms did not compile.
  • GenerateMCPToolsOptions, MCPServerOptions, HTTPMCPHandlerOptions, MCPLimits and MCPRemoteExecutionOptions keep their 3.5.6 shapes, so code that reads options back, such as rotating execution.headers.authorization or reading limits.maxPageSize as a number, compiles unchanged. Pairs built in a separate variable need as const. See TypeScript option types.

Core ​

  • Generated list fields carry countSink: true in extensions.simfinityQuery, and their resolver receives info. When the root value carries a function under Symbol.for('simfinity.countSink'), the resolver hands it the count instead of writing context.count. Without one, it writes context.count as 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-enumerable Symbol.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 a String field with such a name stored the text function 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 with INVALID_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 named name and length held the function's name and arity, such as {"name": "Object", "length": 1} for constructor or {"name": "valueOf", "length": 0} for valueOf, and a later partial update of it kept them. Now these writes succeed, and omitted fields are stored as SQL NULL, as absent JSON keys, or as [] for lists.

  • A create that omits a list with such a name, or sends null for 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 a TypeError (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, with NOT_VALID_ID (400) on either backend, and an unset one reads null instead of failing the read.

  • On MongoDB, createSchema() now rejects the fields that Mongoose cannot store, with INVALID_MODEL (400, Type.field cannot be stored on MongoDB: …), where Type is 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 made createSchema() fail with Mongoose's Cannot set nested path … error, or was dropped from every write when named constructor. 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 members null.
    • Any field stored under the path constructor: a scalar or a scalar list named constructor, a reference named constructor stored under its own name, and a reference whose connectionField is constructor (Mongoose drops a stored path named constructor). Previously Mongoose dropped the value from every write without an error, so it read null, and reads of such a reference could fail with Type.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 named constructor stored under another connectionField, are unchanged. Models that you pass to connect() 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 with db.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 under constructor were never written. PostgreSQL stores all these fields.

  • Absent values of such fields now read null. Previously reads returned the inherited member: on MongoDB the text [object Object], or an object with null members for an embedded field, and on PostgreSQL a GraphQL error such as String 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:

    javascript
    const 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 name and length are the inherited function's: Object and 1 for constructor, the member's own name and 1 for hasOwnProperty, isPrototypeOf and propertyIsEnumerable, and the member's own name and 0 for toLocaleString, toString and valueOf. An embedded type that contains references or unique fields is stored in an owned table, whose name and length columns you check instead:

    sql
    SELECT 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: null stores 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 null for that list in by-ID reads and mutation results, where a missing list with another name reads []; list queries read null for 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 COMMIT without running another statement, now fails with DATABASE_ERROR (500, Transaction was rolled back because one of its statements failed), and is not retried. Examples are an onSaved or onUpdated hook, or a withTransaction callback or custom mutation that issues no statement after the one it wraps in try/catch. PostgreSQL answers the COMMIT of such a transaction by rolling it back without an error. Like the other PostgreSQL database errors, this one has a non-enumerable cause, here an Error with the message COMMIT reported ROLLBACK, which error.getCause() returns. Previously these generated or custom mutations, saveObject() and withTransaction() calls reported success, including the new record's id, while nothing was stored. When another statement runs after the caught failure, such as Simfinity's own write after onSaving, onUpdating or onDelete, that statement fails and the operation fails with DATABASE_ERROR (500, Database operation failed), as before. To continue after an expected statement error, run SAVEPOINT before the statement and ROLLBACK TO SAVEPOINT after the error. session.query() still rejects with the driver's own error, so the callback can inspect its code.
  • The plugin detects the rollback from the command that the COMMIT result reports, ROLLBACK, as pg clients and pg-native return it. A custom pool or client wrapper that returns results without command, such as { rows, rowCount }, keeps working, but then such a transaction reports success with nothing stored, as before; pass pg's command through 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 with DATABASE_ERROR (500), PostgreSQL error 54000. No generated filter, sort or aggregate used it. Single ID columns and references keep their indexes. describeDatabase() and compileDatabaseSchema() no longer list these indexes, which schema-diff tooling will notice.

  • Existing databases keep the index: create and validate modes 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 successful initializeDatabase() call prints one console warning, in both modes, that names them and gives the statements to run:

    text
    Simfinity: 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.html

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

    sql
    SELECT 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 app with your schema, and the array with the table names that simfinity.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 CONCURRENTLY cannot run inside a transaction block, such as a migration tool's transaction; for small tables, a plain DROP INDEX inside the migration also works.

  • Earlier versions expect the index. Their validate mode fails with SCHEMA_MISMATCH (409, index is missing) on a database that the new version created or from which the index was dropped. Their create mode re-creates it with a non-concurrent CREATE INDEX while 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>__idx names 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 with ALTER 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() or compileDatabaseSchema() output. Adding or reordering fields can change the bodies of generated functions such as <Root>__owned_valid, <Root>__refresh and <Table>__<field>__json_valid; replace them with CREATE OR REPLACE FUNCTION before you validate. A NOT NULL column added with a temporary DEFAULT must have that default dropped. Validation reports whatever differs as SCHEMA_MISMATCH (409).
  • An enum whose internal values contain a backslash, such as C:\dir, no longer makes initializeDatabase() fail with SCHEMA_MISMATCH (409, check constraint differs). Previously create mode 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 and withTransaction callbacks. Previously any such error with a code, such as a domain error with code: 'PAYMENT_DECLINED', a Node.js system error from an HTTP client or a DOMException, became DATABASE_ERROR (500, Database operation failed), and an error whose code looked like a PostgreSQL code, such as { code: '23505' }, was relabelled, here as DUPLICATE_KEY (409). Their messages now reach clients unless you mask them, as messages of errors without a code already did: buildErrorFormatter wraps them in an InternalServerError, which its callback can hide. A thrown null or other value that is not an object no longer becomes a TypeError (Cannot read properties of null); GraphQL reports it as Unexpected 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.client and other pg connections 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 own pg.Pool used in a hook, is an application error and now propagates unchanged, with the host and port in its message, such as connect ECONNREFUSED 10.0.0.5:5432. Previously it became DATABASE_ERROR. Mask it with buildErrorFormatter().

  • A failure of the driver calls that Simfinity makes, acquiring a connection, BEGIN, a statement or COMMIT, that is not a PostgreSQL error, such as a refused or dropped connection, timeout exceeded when trying to connect or Connection terminated unexpectedly, is now DATABASE_ERROR (500, Database operation failed) on every path. Previously a connection that mutations, saveObject() or withTransaction() could not acquire, and any such failure without a code, reached clients as the raw pg error, with the host and port in its message. Code that matched raw driver messages should read error.getCause(). A DATABASE_ERROR caused by a connection lost during COMMIT has an unknown outcome: the transaction may have committed.

  • Mapped PostgreSQL errors and these masked failures keep the driver error as error.cause, which error.getCause() also returns. The property is not enumerable, so JSON.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 of DATABASE_ERROR (500), with these messages:

    • Date or time value is out of range (22008): a native DateTime value before 4714-11-24 00:00 BC UTC, written or used in a filter, and out-of-range dates in your own SQL, such as make_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 22P02 and 22003, which were already mapped. Other data exceptions, such as division by zero (22012), and PostgreSQL error 54000, such as an index row over the btree size limit, are still DATABASE_ERROR (500, Database operation failed). MongoDB accepts these values.

  • Unchanged: an application error with code 40001 or 40P01 is still retried and ends as TRANSACTION_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 that initializeDatabase() 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 with DATABASE_CREATION_DISABLED (409, a non-empty mode other than validate on a validation-only runtime) or INVALID_INITIALIZATION_MODE (400, an unknown mode) made every later operation fail with DATABASE_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 with DATABASE_NOT_INITIALIZED meanwhile. The first initialization still fails closed until it succeeds. Any failure of an initialization, such as SCHEMA_MISMATCH or a connection error, makes storage unavailable until a later one succeeds. When initializations overlap, the one started last decides.
  • Running create mode again on a serving PostgreSQL runtime locks each existing managed table in SHARE ROW EXCLUSIVE mode until it commits, so writers wait, and a deadlock or lock timeout fails the initialization and makes storage unavailable. Use mode: '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, validate or an omitted mode, including null and '', fails with INVALID_INITIALIZATION_MODE (400, Unknown initialization mode: …), as PostgreSQL already did. A validation-only runtime (preventCreatingCollection(true)) still validates when the mode is null, '' or another false value, and rejects every other mode except validate with DATABASE_CREATION_DISABLED.

State machine fields on SQL backends ​

  • On PostgreSQL and other SQL backends, createSchema() now rejects a state machine whose state field is not a GraphQL enum, such as a String, Int, [String] or embedded object field, with INVALID_MODEL (400, State machine field Type.state must be a GraphQL enum on SQL backends). Previously it failed with a TypeError (getValues is not a function), so no working application is affected. Enum state fields are unchanged, and MongoDB is unchanged. See state machines.

DDL export ​

  • SQL runtimes created with createSQL() or createPostgres() have a new compileDatabaseSchema() method. It returns the plugin's DDL for the runtime's description and schema, for review or export, without touching storage, and throws SCHEMA_NOT_CREATED (500) before createSchema().
  • The PostgreSQL module functions describeDatabase() and compileDatabaseSchema(), called without arguments, now describe and compile the default module instance in the schema given to configure(). Previously describeDatabase() failed with INVALID_MODEL (registrations must be an array) and compileDatabaseSchema() with a TypeError. Calls with arguments are unchanged: the low-level describeDatabase(registrations, { schema }) still uses the public schema when you omit schema, not the configured one. Pass the schema you gave to configure() or createPostgres(), 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 a code, 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 of Database operation failed. Connections that Simfinity could not acquire from its pool, which mutations previously reported with the driver's message, now appear as Database operation failed with DATABASE_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 a SimfinityError, null or 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 failed acquire, begin, query or commit call, the runtime now replaces it with DATABASE_ERROR (500), keeping it as the non-enumerable cause; previously it reached clients unchanged. That includes a plugin that adds extensions to the error and returns it, so return a new object, or a SimfinityError, 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 when isRetryable accepts the error: reject with an error it does not accept when a failed statement caused the rollback, as PostgreSQL does with DATABASE_ERROR (500), and with a retryable one only for an abort that is safe to replay, such as a serialization failure reported at COMMIT. The PostgreSQL plugin rejects when the COMMIT result reports the ROLLBACK command; a result without command resolves, as before.
  • initialize(configuration, description, options) receives only create, validate or no mode. Before it calls the plugin, the runtime rejects any other mode with INVALID_INITIALIZATION_MODE (400) on a runtime that allows creation. On a validation-only runtime it rejects every mode except validate and a missing or false one with DATABASE_CREATION_DISABLED (409), and passes validate. 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's create and validate modes must keep accepting the index that earlier versions generated on databases the plugin already created, as PostgreSQL does.
  • stateNames appears only on enum state fields, which SQL runtimes now require.
  • compileSchema(description) remains required. The runtime now calls it, only from runtime.compileDatabaseSchema().

Packages ​

  • The optional @modelcontextprotocol/sdk peer 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-postgres no longer ships src/adapter.js, src/records.js and src/transactions.js. Its exports has allowed only the package entry (.) since 3.2.0, so they could not be imported. The package entry is unchanged.
  • The @simtlix/simfinity-sql subpaths ./internal/adapter, ./internal/records and ./internal/transactions are deprecated. They have no type declarations and no Simfinity package uses them any more; they will be removed in 4.0. Use createSQL() 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 its connectionField is missing or an empty string and Simfinity would generate its resolver. It throws before adapter.prepare() runs or any model is created. Previously the schema built, nested added children were saved without a link (a custom adapter received a "null" key), nested updated and deleted entries failed with FORBIDDEN, and the generated field always returned []. Set connectionField to the child's back-reference. PostgreSQL and referentialIntegrity: '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 connectionField that has your own resolver, and a readOnly one. A writable collection with your own resolver logs Configuration issue: Type.field requires a child connectionField; nested writes through it are rejected, and so does a readOnly collection without its own resolver, whose generated resolver still reads children through the field name. A readOnly collection 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 and referentialIntegrity: 'transactional' still reject every collection without connectionField at startup.
  • New console warnings for fields named like generated query arguments. A field named sort, pagination, AND or OR on a type that gets a list query or a collection field logs Configuration issue: Type.name has the name of a generated list query argument, …, and a field named aggregation on a type with an aggregate endpoint logs Configuration 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 null for 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 as null, a reference under its storage field (connectionField, or the field name); a list member is stored as []. An explicit null for 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' onUpdating now receives the cleared members as null or [] instead of their stored values. Unlike a top-level null, which removes the field, a cleared member is stored as an explicit null; 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 null reads as null from mutation responses, by-ID reads, reference reads and lists. The hydrated Mongoose document that native code such as onUpdated receives still exposes an object at that path; read record.toObject() to see the stored null. An optional embedded object that was never stored, or that a top-level null removed, 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 as null whatever the selection when a required member would read as null; see MongoDB embedded objects).
  • INVALID_MODEL (500), Type.field cannot store nested items because it has no connectionField: nested added, updated or deleted items through a collection without connectionField, in generated mutations and in saveObject(), are rejected before anything is written or any controller hook runs. Lists that are empty or contain only null are 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 to 0 now resolves to the related record instead of null, and a collection field of a record whose identity is 0 reads its children. A reference stored as an empty string still reads as null. Generated collection fields now pass the parent's _id, falling back to id, to findChildren; 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 ​

  • id resolves to the stored identity of every entity type: each type registered with connect(), and each type that a non-embedded relation of a registered type targets, whatever includedQueryTypes and includedMutationTypes include. On MongoDB it was null for referenced types registered with addNoEndpointType() and for connect() types left out of includedQueryTypes, in mutation results, relation reads and lists. The resolver reads _id and falls back to id, so a custom adapter whose records carry only id now returns it for every entity type, including allowlisted ones, and a record whose _id differs from its id reports _id. Embedded-only types, id fields with your own resolver, and top-level PostgreSQL records are unchanged. Clients that treated a null id as "no identity" now receive IDs. The registrations that getRegistrations() returns carry storedIdentity: true for these types.
  • Embedded copies of the connect() types that 3.5.3 resolved with parent._id, those that includedQueryTypes includes (all of them by default), now return their stored declared id instead of null where 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 declared id: null for copies created through add inputs, which have no id, and the supplied id for copies written through update inputs, saveObject() or native writes.
  • On MongoDB, entity types embedded as lists report, for their embedded copies, the automatic _id that Mongoose adds to each list item, and <list>.id filters match that value. In 3.5.3 only allowlisted connect() types did; referenced types registered with addNoEndpointType(), which 3.5.3 showed with their declared id, and connect() types left out of includedQueryTypes now do too. The declared id that update inputs require for these items is still stored, but it is no longer returned, <list>.id filters do not match it, and the reported _id changes 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 embedded id, embed a separate embedded-only type. See supporting types without endpoints.
  • On MongoDB, filters, sorts, and aggregation groupId and fact paths on <embedded>.id now use the value that reads return, as PostgreSQL does: the embedded object's declared id member. Previously they used _id. EQ and IN conditions on a singular embedded object's id matched no record while NE and NIN matched every record, sorts on it had no effect, groups on it collapsed into one null group, 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 no id path keep using _id. For a supplied subdocument schema, only reads that hydrate Mongoose documents expose that value, through the id virtual; list queries return null for 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() and buildFilterGroupMatch() emit <embedded>.id instead of <embedded>._id in $match, $sort and $group stages.
  • On MongoDB, a malformed identifier now fails with NOT_VALID_ID (400, Invalid identifier) instead of INTERNAL_SERVER_ERROR, as on PostgreSQL, and nothing is written. This covers by-ID queries, updates, deletes, state actions, nested updated and deleted entries, { id } reference inputs, scalar ID fields on create and update, and embedded id members of objects and lists. A well-formed ID of a missing record keeps its result: null from a by-ID query, and NOT_VALID_ID (404) from updates, state actions and nested entries. buildErrorFormatter() callbacks now receive a SimfinityError rather than an InternalServerError for these errors, and MCP tool results carry the code. In an update of a type without embedded fields, the controller's onUpdating hook still runs before the ID is checked and receives the raw value, as before (hook signatures). See error codes.
  • createMongoAdapter().castId accepts only an ObjectId or a 24-character hexadecimal string, which it normalizes to lowercase. It throws NOT_VALID_ID (400) for anything else, including undefined, null, numbers, empty and 12-character strings, Buffers and other objects. Previously it created an ObjectId from undefined, 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 with new mongoose.Types.ObjectId(). A relation input passed to saveObject(), 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 _id setter, 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, so getFilter() in your findOne, findOneAndUpdate and findOneAndDelete query middleware still sees the raw ID. A value the key cannot hold after its setters, such as abc for a Number key, now fails with NOT_VALID_ID (400) instead of an internal error. By-ID reads of a type with a get_by_id scope cast the ID as a filter value, without setters, and fail with INVALID_FILTER_VALUE (400), as in 3.5.3. With referentialIntegrity: '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 _id type 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, not NOT_VALID_ID: buildErrorFormatter() reports INTERNAL_SERVER_ERROR, and a callback that masks InternalServerError hides it. The error's message is now Type.field stores an invalid identifier instead of Mongoose's cast message, and its getCause() returns the NOT_VALID_ID (400) error instead of the CastError. A target type with a get_by_id scope keeps failing with INVALID_FILTER_VALUE, and an unscoped by-ID read of the record still returns the field as null when 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_id scope, validate the ID as a filter value and keep failing with INVALID_FILTER_VALUE (400); PostgreSQL returns NOT_VALID_ID there. On a supplied model, a numeric or 12-character value for an ID filter on a path its schema does not declare now fails with INVALID_FILTER_VALUE instead of matching an ObjectId created from it.
  • With referentialIntegrity: 'transactional', a direct adapter.update() or adapter.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 aggregation is now a top-level filter on list queries, counts and collection fields, and in find, get_by_id and joined find scopes, 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, aggregation remains the aggregation expression and the field is filtered through AND/OR group conditions.
  • Client filters on that field now follow the path restrictions: read rules and queryable: false apply (FORBIDDEN or FORBIDDEN_FILTER_PATH), and a field named aggregation with its own resolver needs extensions: { queryable: true } to be filtered, otherwise the query fails with FORBIDDEN_FILTER_PATH (403). Previously these filters were accepted and ignored. A scope that assigns args.aggregation replaces the caller's filter on that field, as for any other field.
  • Middleware, scopes and direct buildQuery() or createQueryPlan() calls that put an aggregation expression into list or count arguments now fail with a 400 error instead of having it ignored: INVALID_FILTER_FIELD when the type has no aggregation field. A joined scope that sets args.aggregation on a type without that field fails the same way.
  • A field named conditions can now be filtered with a top-level argument; previously the query failed with conditions requires an array. A non-array top-level conditions value for a type without that field, which only middleware or scope code can send, fails with INVALID_FILTER_FIELD instead of INVALID_FILTER_VALUE.
  • A joined find scope's sort and pagination values are now checked for shape: sort needs non-empty terms, each with a field name and an ASC or DESC order, and pagination needs the page and size a direct query accepts. Invalid values fail with INVALID_SORT or INVALID_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, AND or OR now 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 TransientTransactionError while 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's isRetryable() 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 when Math.random is stubbed to make the delays zero.
  • A WriteConflict that persists after the five retries of an owned transaction, in generated and custom mutations and in saveObject() without a session, now fails with TRANSACTION_RETRY_EXCEEDED (409, Concurrent write could not be completed), like PostgreSQL's exhausted serialization and deadlock retries. The driver error is available as error.cause, which error.getCause?.() also returns. Previously the raw MongoServerError (code 112) reached clients as INTERNAL_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 TransientTransactionError label. A saveObject() 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 read error.code === 112 or errorLabels from such a call should read error.cause.
  • Error masking and monitoring that look for InternalServerError or INTERNAL_SERVER_ERROR no longer see these conflicts. Log TRANSACTION_RETRY_EXCEEDED, or the driver error in error.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 connectionField names no single reference on the child type, but equals the connectionField of 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 as Root.mids and Mid.leaves both linked through owner, 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 when autoIndex is on. With referentialIntegrity: 'transactional', these schemas previously failed adapter.initialize() with INVALID_MONGO_INTEGRITY_CONFIGURATION and now initialize. Supplied models are unaffected.
  • Importing @simtlix/simfinity-js no longer calls mongoose.set('strictQuery', false); the facade leaves global Mongoose options as your application sets them. Applications that never set strictQuery see no change, because Mongoose 8 defaults to false. Imports are evaluated before the importing module's own code, so only a strictQuery set in a module evaluated before the facade import was overwritten. That setting now applies, also to your own queries on Simfinity-generated models through getModel(). With true, filter keys that are not schema paths are removed, so check deleteMany() and updateMany() filters for misspelled keys that would widen them; with 'throw', such keys raise StrictModeError. Generated GraphQL operations, MCP tools and saveObject() behave the same under every value. To keep the previous behavior, call mongoose.set('strictQuery', false) yourself.

MCP tools ​

  • The list tool of a type that declares a field named aggregation is 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 in extensions.simfinityQuery. Other queries, including those of a schema rebuilt from SDL, are still published as aggregate tools when they have an aggregation argument. See the MCP reference.
  • With in-process execution, malformed MongoDB IDs and exhausted MongoDB write conflicts now return NOT_VALID_ID and TRANSACTION_RETRY_EXCEEDED tool results instead of internal driver errors; see identifiers and transactions.

Adapter contract ​

  • DatabaseAdapter has 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 stored null. 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 as null an object that holds no data when a required member would read as null, not counting id, 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, with NOT_VALID_ID (400). A relation input then fails with that error, and a batched reference read reads that ID alone with getById(). castId() must not create a new identifier.
  • When getById() rejects a stored single-reference value with NOT_VALID_ID (400), including a value that castId() rejected first, the generated reference field now fails with an InternalServerError (INTERNAL_SERVER_ERROR, message Type.field stores an invalid identifier) whose getCause() is that error, because the client did not send the value. With a custom adapter or SQL plugin, 3.5.3 returned the NOT_VALID_ID error to the client. The same applies to a NOT_VALID_ID (400) error that get_by_id middleware throws while a generated reference is read. Other errors, including NOT_VALID_ID with status 404, are passed on unchanged, and identifiers that clients send still fail with NOT_VALID_ID (400).
  • Registrations passed to adapter.prepare() carry storedIdentity, which is true for the types this runtime treats as entities, whose id resolves to the stored _id, falling back to id. The id resolver lives on the type object, so a type that another runtime sharing it treats as an entity resolves id the same way even where its registration says false.

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 of GraphQLObjectType: 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 with connect() or addNoEndpointType(), 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, by new GraphQLObjectType(Type.toConfig()) or a ...Type.toConfig().fields spread, including a spread inside a fields thunk that runs later. Previously the second runtime silently read those relations through the first runtime's adapter and middleware, or createSchema() failed with a duplicate QLFilter type. 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 extensions object; 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 extensions object with a null prototype and the same entries. For single relation fields, a reference to field.extensions taken before createSchema() no longer points at the field's extensions, so changes made through it are ignored; read Type.getFields()[name].extensions again after createSchema(). List relation fields previously got an extensions object with Object.prototype; it now has a null prototype, like the extensions of every other GraphQL field, so use Object.hasOwn(field.extensions, key) instead of field.extensions.hasOwnProperty(key).
  • INVALID_SCOPE (500): extensions.scope must be a plain object whose keys are find, get_by_id or aggregate and whose values are functions. connect(), addNoEndpointType() and createSchema() reject anything else, including misspelled keys such as getById or findAll, a key set to undefined such as { find: undefined } (which the TypeScopes type accepts), arrays, a bare function, class instances, and scope: undefined or scope: 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 a null or undefined entry 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 with connect() or addNoEndpointType() now fails createSchema() with a message naming the field as Type.field. Previously it failed with a TypeError. Single references to unregistered types, and read-only lists with their own resolver, still build.
  • MCP_INVALID_SCHEMA_PLUGIN, MCP_UNSUPPORTED_SCHEMA_REPLACEMENT and MCP_INVALID_MIDDLEWARE (500) are thrown when MCP tools, servers or HTTP handlers are created; see MCP below.
  • createAuthPlugin throws TypeError from onSchemaChange, before wrapping any field, when a schema type comes from another copy of the graphql module; see authorization below.
  • requireAuth, requireRole, requirePermission and isOwner throw TypeError when the helper is created with an async extractor function; see authorization below.
  • A policy expression with a literal NaN operand, such as { eq: [{ ref: 'ctx.user.score' }, NaN] }, makes createAuthPlugin, the deprecated createAuthMiddleware and createRuleFromExpression throw TypeError when they are called.
  • validators.pattern() and scalars.createPatternStringScalar() throw TypeError (pattern must be a RegExp or a string) when created with any other pattern, including a custom matcher object with a test() method, which 3.5.2 used as is; see validated scalars and pattern helpers below.
  • The new opt-in configureMutationLimits() throws INVALID_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 with INVALID_SCOPE, including relation reads and filter paths that join it. Previously it was skipped. Omit a key, or the whole scope property, 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 a next() call rejects the operation with its own error once the rest of the chain settles. Calling next() 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. Omitting next() still skips only the remaining middleware.
  • A next() called after its middleware has finished, for example from setTimeout(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. Call next() before the middleware returns, preferably as await next(). See middleware.
  • New opt-in configureMutationLimits({ maxNestedOperations }) caps the nested added, updated and deleted entries 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 as maxNestedOperation, throw INVALID_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 GraphQLError that graphql-js creates around a value a resolver threw, it classifies the thrown value. A SimfinityError thrown by a resolver therefore keeps its code when the server passes the wrapping error. Previously the result was INTERNAL_SERVER_ERROR.
  • Request errors from syntax, validation and variable coercion become BAD_REQUEST (400) instead of INTERNAL_SERVER_ERROR, unless the server already set a code, which they keep, such as Yoga's GRAPHQL_PARSE_FAILED or Apollo Server's GRAPHQL_VALIDATION_FAILED and BAD_USER_INPUT. This includes input rejected by built-in scalars and by Simfinity's validated scalars, such as an invalid EmailScalar value, whether sent as a variable or an inline literal. They keep their original message, including the Variable "$x" got invalid value … at "x.path" prefix, and their extensions, such as the http status Yoga adds, so the documented Yoga integration responds with HTTP 400 for invalid variables. Yoga does not pass validation errors, including invalid inline literals, to maskError, so they keep Yoga's response. A SimfinityError thrown by a custom scalar supplies only the code and status.
  • A scalar literal validation error may wrap a plain Error with the same message, with or without AST nodes. It remains a request error. A subscription source failure that a server reports without path is indistinguishable from that input error: mark unexpected stream failures as InternalServerError at 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 GraphQLError raised while resolving a field keeps its message and extensions when it has its own string extensions.code or a SimfinityError cause. Its code is that of the SimfinityError cause, or its own extensions.code; its status is the cause's, or its integer extensions.status, or 500 for INTERNAL_SERVER_ERROR and 400 for other codes. Its originalError, such as a database error behind a safe message, is never exposed. Previously such errors became INTERNAL_SERVER_ERROR. A GraphQLError without either, such as one a resolver throws without a code, with or without its own path, is still an InternalServerError (INTERNAL_SERVER_ERROR), as in 3.5.2; give it an extensions.code to 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 failed isTypeOf check, whose messages can print the value.
  • A value that is not an Error, including one a resolver throws, becomes Unexpected error value with 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 an Error with the value's text, which then becomes an InternalServerError with that message; mask InternalServerError in the callback.
  • The result keeps the input's locations and path, sets originalError to 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 Date from 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: EmailScalar given 5 reports String cannot represent a non string value: 5 instead of Invalid email format. Input the base rejects by returning undefined is 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 PositiveIntScalar now accepts 4, one built on PositiveFloatScalar accepts 4.5, and one on a Boolean-based scalar accepts true; previously these chains rejected every inline literal. When the root is a custom scalar, such as DateTime, its parseLiteral decides which literals are valid instead of requiring a string, even if you set a baseScalarType storage 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 as 4, as GraphQLFloat does. Previously they required a float literal such as 4.0. This also fixes MCP tools with an argument of such a scalar and an integral default, such as defaultValue: 5, which failed on every call.
  • validators.pattern() and scalars.createPatternStringScalar() copy the pattern when they are created and test each value from its first character. A g flag no longer rejects every other value, a y flag no longer depends on the previous value, and your RegExp object is not changed. A pattern that is not a RegExp or a string throws TypeError when the helper is created; previously objects with a test method 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, requirePermission and isOwner accept dotted paths or synchronous extractor functions. An async extractor throws TypeError when the helper is created. A path that yields a promise at runtime, such as an un-awaited ctx.user, denies with TypeError (Authorization paths must return values synchronously; …). Previously requireAuth() granted access for a pending user promise. The helpers never call then() on the value, so a lazy query builder such as a Mongoose Query left in the context is not executed. Resolve asynchronous identity data in the context factory. See rule helpers.
  • createAuthPlugin rejects 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, with TypeError (Cannot authorize type X: it was created by a different copy of the graphql module…). This happens in every environment and also when MCP applies schemaPlugins. Previously those fields were left unprotected. Install one graphql version and load it through one entry point. See schema integration.
  • Policy expressions compare Date values 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 throws TypeError when the rule is created. Promise operands are invalid. A rejected native promise that a reference yields, or that an in comparison reads from a list, is now handled, so it no longer crashes the process with an unhandled rejection. See JSON policy expressions.

MCP ​

  • schemaPlugins entries are validated before any hook runs. A non-array value, a function (pass the factory's result, such as createAuthPlugin(permissions)), an array, a promise (an object with a then method or getter), a non-function onSchemaChange or an object with no plugin hooks throws MCP_INVALID_SCHEMA_PLUGIN. So does a misspelling of onSchemaChange on a plugin that lacks a function onSchemaChange: an own or inherited method or getter whose name starts with onSchema in any letter case, or is at most two edits from onSchemaChange ignoring case, such as onSchemaChanged, onschemaChange, onSchemChange or onShemaChange. The message asks whether you meant onSchemaChange. Helpers such as onSchemaReady or onSchemaChangeImpl next to a function onSchemaChange are accepted. false, null and undefined entries are skipped. A plugin with hooks MCP does not run, such as the count plugin's onExecute, gets one console warning that names them. Validation reads only onSchemaChange directly and inspects every other property, then included, through its descriptor, so it calls no other getter.
  • In TypeScript, schemaPlugins also accepts typed Envelop and Yoga Plugin arrays, class instances, including ones with call or apply members, and interface-typed plugins through the new exported SchemaPluginObject type, 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 through Symbol.hasInstance, so they compile with a lib setting older than ES2015, but are still rejected at runtime.
  • replaceSchema with a different schema throws MCP_UNSUPPORTED_SCHEMA_REPLACEMENT; previously the call was ignored.
  • An asynchronous onSchemaChange is awaited. createMCPServer, startStdioMCPServer and createHTTPMCPHandler resolve after it finishes and reject if it fails. generateMCPTools stays 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.
  • toolMiddleware accepts 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, including null from condition && middleware and an empty slot in a sparse array such as [a, , b], throws MCP_INVALID_MIDDLEWARE at setup with the entry's index. The stack is copied at setup, so later changes to the array have no effect.
  • createHTTPMCPHandler now throws MCP_INVALID_LIMITS when 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 integer for a scalar built on PositiveIntScalar, 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 ObjectId values by their hexadecimal string in eq and in, and in compares items the same way for plain arrays and Mongoose arrays. Comparing values of different types, such as '42' with 42 or a string with a list, is invalid, so not no longer turns it into a grant; null remains comparable with any value. A list or plain object on the left of in is invalid. Policies that already compare normalized strings behave as before. See JSON policy expressions.
  • A literal in array may contain only strings, finite numbers, bigints, booleans, null and MongoDB ObjectIds, and literal operands cannot contain nested { ref } values. Such policies now throw TypeError at 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 ref property to a literal later does not reinterpret it; object literals still compare by identity. requireRole and requirePermission also 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() and anyRule() throw TypeError when called without rules. Use allow() for an intentional grant.
  • anyRule executes decorated, proxied and bound rules in full, preserving any additional authorization checks they perform.
  • createAuthPlugin wraps 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's extensions metadata introspection is no longer denied under DENY, unless the permission map names FieldExtensionsType or RelationType (or, since 3.5.9, their fallback names SimfinityFieldExtensionsType and SimfinityRelationType; see application types named RelationType or FieldExtensionsType). A field whose rules are all synchronous, or an unwrapped field with no rule under ALLOW, 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 createFieldMiddleware returns the same function as createAuthMiddleware, 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 uses Query for 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.parentType and info.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: false blocks 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. readOnly still 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 find or findOne pre/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-middleware is 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.

Open source. Released under the Apache 2.0 License.