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