Database compatibility contract
This ledger records the v3.6.0 behavior shared by the MongoDB and PostgreSQL implementations and the remaining differences. The MongoDB contract is in tests/integration/mongodb.test.js, PostgreSQL execution in the runtime/lifecycle/options suites, and direct result/schema comparison in the query-parity suites, using the graph in tests/contracts/model-fixtures.js.
The test paths below are available in the v3.6.0 source. To try the API, use the released starters.
When running the source tests, set SIMFINITY_MONGODB_URI and SIMFINITY_POSTGRES_URI to disposable databases for the cross-backend suites. Set SIMFINITY_TEST_MONGODB_URI to a separate disposable MongoDB database for upstream opt-in regressions. Mongo setup drops its configured databases, so these URIs must never identify application data. When a variable is absent, its integration suites are reported as skipped.
MongoDB contract
| Behavior | Decision | Executable evidence |
|---|---|---|
A non-embedded relation with no connectionField stores its ObjectId under the GraphQL field name. | Fixed and shared | Unit regression plus real ContractSerie.label create/read/clear flow. |
Clearing a nullable singular reference unsets its storage field (connectionField when declared, otherwise the GraphQL field). Clearing an inverse collection does not unset the child connectionField on the parent. | Fixed and shared | Unit regression and real update followed by a raw MongoDB record check. |
An explicit null for a nullable member of a singular embedded patch clears it: a list member becomes [], other members are stored as null (a reference under its storage field), and a non-null member keeps its stored value. Every GraphQL read path returns the cleared value; a top-level null still removes the field. | Fixed and shared (3.5.5) | Memory regressions in tests/embedded-update-required.test.js; tests/integration/embedded-update-required-parity.test.js compares update responses, by-ID and list reads, plus raw MongoDB documents, in default and transactional MongoDB modes and on PostgreSQL. |
On MongoDB, an optional singular embedded object that holds no data (members absent or null, lists empty, nested embedded objects likewise) reads as null on every read path, whatever the selection, when a required member would read as null: an object absent from the stored document, because a create omitted it, the document was written without it or a top-level null removed it, and one that a create omitted and Mongoose stored with list defaults. id, readOnly, interface and union, application-resolved and referenced-collection members do not count. The rule reads stored data (the adapter's rawEmbeddedValue), never a schema getter or virtual, counts a stored _id as id, reads nothing of a type without a member that counts, and returns the object unchanged when reading fails. MongoDB reads Mongoose maps by their entries, and its hydrated reads (by ID, references, update responses) keep, as 3.5.8 rendered them, objects whose schema declares a getter, a virtual other than the automatic id, an alias or a subdocument method anywhere inside them; generated models declare none. A create that writes an empty object for a required embedded member stores the member as absent (Mongoose minimizes it), so an object that holds nothing else also reads as null, except in by-ID and reference reads and update responses where the member is a nested path, as generated models declare every embedded member except one named type. Runtimes that share the type read it the same way. PostgreSQL rejects such creates with REQUIRED_VALUE. | Fixed (3.5.9); creates differ by backend | objects that hold no data in tests/runtime.test.js and tests/mongodb-ids.test.js; tests/integration/embedded-required-reads.test.js checks update responses, by-ID and list reads and raw MongoDB documents, and PostgreSQL's results, and keeps an object whose only data is a reference stored under its connectionField; it also pins the minimized empty object with a generated model and with a supplied single nested schema, and, with supplied models, that no getter of an unselected member runs, that an embedded entity whose only stored data is its _id reads its id on list and by-ID reads, as on 3.5.8, that by-ID, reference and update-response reads of objects whose required member a getter default, a virtual or an alias supplies render as on 3.5.8, that an empty Mongoose map reads null on by-ID, reference, update and list reads, and that a generated model with an embedded list still reads data-less objects as null. reading stored members and the stored _id test in tests/runtime.test.js and the getter, subdocument _id, getter/virtual/alias at any depth, map and generated-model tests in tests/mongodb-ids.test.js pin the same without a database. |
| A scalar-only no-endpoint type receives a model when another registered type references it. It still receives no root endpoint. | Fixed and shared | Unit regression and the real ContractLabel reference fixture. |
Every entity type, registered with connect() or targeted by a non-embedded relation, resolves id to its stored identity whatever the query and mutation allowlists include. Embedded-only types return their declared id. Embedded copies of entity types return their declared id (null for copies created through add inputs, the supplied id for copies written through update inputs, saveObject() or native writes), except that on MongoDB copies in an embedded list report the automatic subdocument _id. | Fixed (3.5.5); embedded list copies of entity types differ by backend | tests/integration/id-resolver-parity.test.js on both backends and entity id resolution in tests/runtime.test.js. |
onUpdated receives the resolved updated record after the database operation completes. | Fixed and shared | Unit regression and live hook assertions. |
| Multiple flat terms on one relation are all present in the generated match. | Fixed and shared | Unit regressions for author.name plus author.id, and two predicates on the same author.name path. |
Filters, sorts, groupId and fact paths on <embedded>.id use the declared id member, the value that reads return. On MongoDB, entity types embedded as lists use the automatic subdocument _id, which their reads also return. Supplied subdocument schemas without an id path keep using the subdocument _id, which only hydrated reads expose, through Mongoose's id virtual; list queries return null for that id. | Fixed and shared (3.5.5); MongoDB exceptions as noted | tests/integration/embedded-query-parity.test.js filters, sorts and groups by every displayed embedded ID on both backends; generated, supplied and schema-less paths in tests/mongodb-query-paths.test.js. |
| Root reads and generated non-embedded relationships run target middleware and scopes. Relationship IDs and parent predicates cannot be redirected by mutable arguments. | v3.1.0 shared | Upstream authorization integration tests and cross-backend runtime-v31.test.js. |
A one-to-many filter uses $lookup plus $unwind; a root with two matching children appears twice. Count uses the same multiplicity. | Preserve current cardinality | Live collection-filter result contains the same root twice and sets context.count to 2. |
| Generated mutations own a transaction and pass its native session plus the GraphQL context to validation and controller hooks. A nested failure rolls back the parent write. | Preserve | Live nested validation failure leaves neither parent nor child records; hook assertions observe an active session and the original context. |
In default mode, a custom mutation's owned session uses the default Mongoose connection, except while that connection has no client, no model is compiled on it or on any of its useDb() descendants and every registered model shares one MongoDB client: then it uses that client. | Fixed (3.5.9); MongoDB only | tests/mongodb-custom-mutation-connection.test.js with stub connections and real useDb() children and grandchildren of the unopened default connection; tests/integration/mongodb-custom-mutation-connection.test.js commits and rolls back writes through one mongoose.createConnection() connection and its useDb() children, and keeps the default connection for models on two clients or a generated model; tests/integration/mongodb-custom-mutation-usedb.test.js and tests/integration/mongodb-custom-mutation-usedb-nested.test.js keep it for an application model on a useDb() connection of the default one, or on a useDb() connection of that one, when the app connects after the request. |
saveObject(typeName, args, session, context) owns the entire workflow transaction without a supplied session. Active supplied sessions remain caller-owned. | v3.1.0 shared | Standalone nested middleware/hook/FK failure rolls back parent, child and embedded writes. |
Accepted empty strings, false, 0 and empty arrays survive materialization. Nullable embedded list items retain null; null referenced-operation items are skipped. | v3.1.0 shared | Upstream materialization regressions and live standalone save/update checks on both backends. |
A malformed record, reference, scalar ID or embedded id value fails with NOT_VALID_ID (400) before anything is written; supplied models' _id setters still apply. A well-formed ID of a missing record keeps null from by-ID reads and NOT_VALID_ID (404) from writes. ID filters and scoped by-ID reads keep INVALID_FILTER_VALUE. A stored reference value that the adapter rejects is a data error: its field fails with INTERNAL_SERVER_ERROR. | Fixed and shared (3.5.5); MongoDB filters keep their own code | malformed identifiers and supplied keys with setters in tests/integration/mongodb.test.js, tests/mongodb-ids.test.js, and supplied-session aborts in tests/integration/mongodb-integrity.test.js. Stored references in tests/runtime.test.js and tests/integration/id-resolver-parity.test.js. PostgreSQL already returned NOT_VALID_ID. |
Fields named aggregation or conditions are ordinary top-level filters on list, count and collection queries and in scopes; aggregation is the aggregation expression only on aggregate queries. | Fixed and shared (3.5.5) | tests/integration/query-parity.test.js and tests/integration/query-path-authorization.test.js on both backends. |
Embedded types that contain each other, directly or through other embedded types, as objects or lists, are rejected at createSchema() with INVALID_MODEL (400, Embedded cycle at …), also with readOnly links: always in transactional MongoDB mode and on PostgreSQL, and in default MongoDB mode when Simfinity generates a model whose embedded fields reach the cycle. In default mode a cycle that only supplied models reach is not checked, as before: it fails with INPUT_TYPE_UNRESOLVED, or builds with a readOnly link. In default mode a type that embeds itself keeps its message, now with INVALID_MODEL (400); transactional mode and PostgreSQL report it as Embedded cycle at Type.field. | Fixed (3.5.9); in default mode the path can start at an embedded type, and supplied models are not checked | embedded cycles in tests/mongodb-models.test.js. |
GraphQLNonNull controls GraphQL inputs but does not automatically add a Mongoose required validator. | Preserve for MongoDB | PostgreSQL schema metadata may use the declaration for NOT NULL; this is a documented storage-level difference. |
Lists preserve outer and item nullability ([T], [T!], [T]!, [T!]!). Update fields allow omission while retaining item wrappers. | Fixed and shared | Runtime input/schema regressions; real scalar/date/embedded list operations. |
| Inverse relations may use a scalar ID or an alias for the child's reference storage field. | Fixed and shared | Explicit connection-field normalization and real nested create/read/update regressions. |
A referenced collection whose private connectionField equals the connectionField of a collection on the child type, including a self-referencing collection, gives the generated child model its own private ObjectId field, so nested writes store the link. | Fixed and shared (3.5.5) | Model-path regressions in both modes (tests/mongodb-regressions.test.js), nested create/read with raw link checks on both backends (tests/integration/relationship-aliases.test.js) and transactional initialization (tests/integration/mongodb-integrity.test.js). |
A referenced collection requires a non-empty connectionField. A writable one whose resolver Simfinity generates is rejected at startup with INVALID_MODEL (400) in every mode, as PostgreSQL does. In default mode, one with its own resolver or marked readOnly builds with a warning, and its nested writes fail with INVALID_MODEL (500). | Fixed (3.5.5); the exemption is default-MongoDB-only | referenced collection connectionField in tests/runtime.test.js and the startup cases in tests/mongodb-regressions.test.js. |
Writable referenced collections may form a cycle across types, such as Department.employees and Employee.managedDepartments, and nested inputs follow it at any depth. Several types may have a self-referencing collection with the same name: the first registered type that the generated mutations of the first createSchema() reach keeps OneToManyA<field>/OneToManyU<field>, and the other types that a schema reaches together with it are named after their type. A type registered again, or after a createSchema() call, keeps the unqualified names unless its schema also reaches another type, still registered, that has them. Collections that share a child's connectionField share one added item input. deleted is [ID] whatever the item nullability, and null IDs are ignored. | Fixed and shared (3.5.8) | tests/integration/collection-inputs.test.js on both backends; tests/collection-inputs.test.js on core, the MongoDB facade and PostgreSQL with a stub pool. |
A generated input that would have no fields is left out with a Configuration issue warning: the added or updated operation of a collection whose child has no writable fields for it, an embedded field whose type has none, and the add, update and state-action mutations of a type whose create or update input would be empty. A non-null embedded field without a create input is rejected with INVALID_MODEL (400) when the generated mutations reach its owner. | Fixed and shared (3.5.9) | tests/empty-generated-inputs.test.js on core, the MongoDB facade and PostgreSQL with a stub pool, plus a transactional MongoDB case; tests/integration/empty-generated-inputs.test.js creates children and updates and deletes them through such a collection on both backends. |
Nested updated and deleted children are owned through the parent's stored identifier, and added and updated children are linked with it, so any spelling of the parent ID that the backend accepts, such as an uppercase ObjectId or UUID, owns its children. A foreign child raises ForbiddenError (FORBIDDEN, 403). | Fixed and shared (3.5.8) | Uppercase parent IDs in tests/integration/runtime-v31.test.js on both backends, including a MongoDB child model that stores the link as a String; nested collection ownership in tests/runtime.test.js. |
Filter list literals accept QLValue variables as items, an unset item reads as null and is rejected like an explicit null element, and enum literals read as their names. | Fixed and shared (3.5.8) | tests/integration/query-parity.test.js compares the results with the literal forms on both backends; tests/query-correctness.test.js. |
validators.arrayLength() runs helper results and single validators on list items; dateFormat() and futureDate() reject impossible ISO and MM/DD/YYYY dates; null bounds mean no bound in validators and bounded scalars. | Fixed and shared (3.5.8) | tests/integration/validation-helpers-parity.test.js on both backends, including a Date field with a DD/MM/YYYY format. |
| Aggregation through inverse collections uses the child FK and counts contributing rows. | Fixed and shared | Live cross-database SUM/COUNT and scalar Boolean MIN/MAX comparison. |
| Embedded lists can be restored after being cleared. Object patches stay shallow, list patches replace the list. | Fixed and shared | Runtime regression and real owned-reference-list clear/restore flow. |
| Retried generated/custom mutations start with fresh typed input structures; root middleware executes once, while the middleware of nested collection children runs again with the retried transaction. | Fixed and shared | Synthetic retry tests retain enum identity and reset nested lists/objects/Dates; real PostgreSQL aborted-attempt rollback; a retried transaction runs the middleware of nested collection children again, not the root middleware in tests/runtime.test.js (3.6.0). |
State storage/guards use a single adapter-specific representation. SQL backends require the state field to be a GraphQL enum and reject any other with INVALID_MODEL (400) at createSchema(). | Shared orchestration; enum requirement fixed for SQL (3.5.6) | Mongo preserves state names; PostgreSQL stores/decodes enum internal values. Real tests cover overlapping names/values and concurrent transitions. SQL state machine fields in tests/sql-plugin.test.js covers the startup error and unchanged enum stateNames. |
Fields named like Object.prototype members (constructor, toString, valueOf, …) hold only their own values: an omitted scalar or reference is not stored and an omitted list stores [], an update that does not mention a field keeps the stored value, an absent one reads null, and a reference with such a name, under any connectionField, reads that field. | Fixed and shared (3.5.6); MongoDB rejects at createSchema() with INVALID_MODEL the fields Mongoose cannot store: embedded objects and lists with these names, and any field stored under constructor. On MongoDB, a collection whose connectionField is constructor and whose child type has no such field is rejected at startup the same way, naming Child.constructor. Open on MongoDB: a document stored without such a list reads null for it in by-ID reads, where a missing list with another name reads [] | tests/integration/prototype-field-names-parity.test.js on both backends, fields named like Object.prototype members in tests/runtime.test.js and tests/mongodb-models.test.js, and SQL records with fields named like Object.prototype members in tests/sql-plugin.test.js. |
Errors that application code throws in an owned transaction (validators, controllers, nested middleware, custom mutations, withTransaction callbacks) propagate unchanged, with or without a code. | Fixed and shared (3.5.6); PostgreSQL maps the errors it reports and masks failed driver calls as DATABASE_ERROR | SQL error classification and SQL runtime error propagation in tests/sql-transactions.test.js, PostgreSQL error normalization in tests/postgres-configuration.test.js, and tests/integration/postgres-lifecycle.test.js. |
The facade leaves global Mongoose options, including strictQuery, as the application sets them; generated queries do not depend on them. | Fixed (3.5.5) | tests/mongodb-global-options.test.js checks fresh processes; the MongoDB contract suite runs under strictQuery: 'throw'. |
PostgreSQL execution contract and limits
The query parity corpus compares actual schemas and responses for operators, nested logical filters, dates, enums, scalar arrays, nullable values, array sorts, embedded paths, scopes, joined row multiplicity, pagination/counts and aggregate facts. Typed invalid values and unsupported paths produce explicit errors. PostgreSQL text comparisons use binary C collation, including on databases configured with an ICU language locale; only the case-insensitive text operators compare the case fold of both sides, translate(lower((x) COLLATE "pg_catalog"."und-x-icu"), from, to), which caseFoldExpression returns and which needs a UTF8 database on a server built with ICU. Since 3.6.0, sorts on id and required root fields omit the NULLS clause, so the primary key and declared indexes serve them; nullable, joined, embedded and list paths keep MongoDB's null placement, and explicitNullsOrder: true restores the 3.5.9 SQL.
PostgreSQL owner/embedded reads use one repeatable-read snapshot; since 3.6.0, other sessionless reads are a single statement, without BEGIN/COMMIT. Generated and custom mutations share one transaction for hooks, parent rows, owned embeddeds and independent nested writes. Supplied session handles are joined without commit/release; expired/foreign handles are rejected. Confirmed serialization/deadlock aborts retry up to five times, each after a random backoff that starts once the failed attempt has rolled back and released its client. Standalone saveObject also wraps its full lifecycle, including hooks and nested writes, in that transaction.
PostgreSQL identities are UUIDs and native models/sessions are not Mongoose objects. Optional root scalar storage uses SQL NULL for absence. Embedded records preserve absent versus explicit-null fields through private presence markers, materialize descendant list defaults and minimize empty inline objects; native hooks/code still use plain records rather than Mongoose documents. Required typed database columns and real FKs are stronger storage constraints than the Mongo generator. Native methods and pipeline helpers are outside GraphQL API parity.
Unsupported schema mappings are rejected before DDL: implicit many-to-many reciprocal lists, non-embedded collections inside embeddeds, embedded cycles, nested list wrappers, whole embedded-object uniqueness, unrecognized custom scalar storage and conflicting metadata. Whole embedded-object sorting/grouping remains unsupported. Scalar-list leaves inside nested embedded lists support filters/sorts and ragged group projections. Array facts use SUM=0/AVG=null, joined-row COUNT and whole-array lexicographic MIN/MAX. Aggregate sorting selects immediate array extrema, including default groupId order; ties need an additional sort term. Date and string-enum outputs and state-name aggregates retain Mongo's logical JSON values. For numeric and boolean enums, PostgreSQL compares, sorts and groups the text form (aggregate groupId "10", sorted before "2"), while MongoDB uses numbers and booleans. Generated immutable comparison helpers and private presence columns are validated during initialization; existing schemas need an explicit migration. See PostgreSQL documentation for setup and details.
Reference and nested writes retain the generated GraphQL input shapes. The live fixture creates a parent with an ObjectId reference and nested child, patches an embedded object, updates and deletes the child through the parent mutation, clears the reference, reads generated relation fields, and deletes the parent. Scalar list order and duplicates remain storage data. Aggregate queries retain { groupId, facts }, default ascending ordering by groupId (ties are unspecified), and MongoDB numeric results.
Verification ledger
The regression suite was first run against the unchanged implementation. All five new tests failed for the expected reasons: the omitted relation fallback discarded the ObjectId, null clearing targeted the API field, the inbound no-endpoint target had no model, onUpdated received a pending query, and only the last flat relation term survived.
Final foundation verification (2026-09-10):
- Full
npm testwith both disposable database URIs: 19 files, 361 tests passed, no skips. - PostgreSQL integration: 11 tests passed on each of PostgreSQL 15, 16 and 18. Includes live constraints, nullable embedded/enum items, disabled-trigger detection, schema drift, concurrent initialization and orphan-data rollback.
npm run lint: passed. Diff whitespace check passed with the original package JSON CRLF convention preserved.npm run test:packages: core, PostgreSQL and MongoDB packed installations passed; core/PostgreSQL installed no MongoDB/Mongoose/MCP packages.- The review added regressions for collection-null storage isolation and repeated same-path terms; both fixes pass with the existing filter/update suite.
The foundation verification above is retained as the first increment's baseline. Runtime verification is recorded below after the complete shared-engine and PostgreSQL execution changes.
Independent runtime review reproduced and resolved: inconsistent owner/embedded read snapshots, enum-name/value state collisions, DDL prevention, locale-dependent text comparisons, unsupported array group projections, Boolean/UUID MIN/MAX, and partial standalone owner writes after an embedded FK failure. Review repeated these cases against PostgreSQL 16, including an ICU-configured database, with no open findings.
Runtime verification (2026-09-11):
- Full
npm testwith disposable MongoDB 7 and PostgreSQL 16: 28 files, 442 tests passed, no skips. - PostgreSQL 15 and 18: 68 schema/runtime/parity integration cases passed on each version, including connection-field aliases and private inverse FKs against MongoDB.
npm run lintand the diff whitespace check passed.npm run test:packagesinstalled all three actual archives outside the workspace, exercised their runtimes/exports/introspection, and compiled strict TypeScript consumers with GraphQL 16. Core/PostgreSQL installed no MongoDB, Mongoose or MCP packages.- At this runtime checkpoint, CI provisioned MongoDB 7 and PostgreSQL 15/16/18; normal unit runs skipped database suites explicitly when their URI was absent.
v3.1.0 authorization and query limits
Nested child saves/updates/deletes run target middleware with root argument shapes. Update/delete ownership is checked from persisted records after middleware selects the effective ID, against the parent's stored identifier (3.5.8), so any spelling of the parent ID that the backend accepts owns its children. Missing records raise NOT_VALID_ID; foreign-parent records raise ForbiddenError (FORBIDDEN). Hooks cannot clear or replace the required parent connection. Query scopes authorize reads only; custom resolvers and native database access retain application-defined checks.
configureQueryLimits({ maxPageSize }) is available on MongoDB and PostgreSQL facades and runtime instances. Its shared process-wide default is 1000; unpaged lists use the smaller of 100 and the configured maximum. Explicit page/size and calculated skip must be positive/safe integers within the maximum; unpaged aggregates remain unbounded. Invalid sort/filter paths and malformed filter values fail with domain errors. v3.1.0 rejects whole-array EQ values and null elements in filter lists; these older permissive parity cases now assert rejection on both backends. The later process-wide configureMutationLimits({ maxNestedOperations }) setting, also on both facades and runtime instances, caps nested collection operations per generated mutation and is unlimited by default.
MongoDB transactions use the registered model connection, retry transient bodies and uncertain commits separately, and await owned cleanup. Each complete MongoDB retry first waits a random backoff that starts after the abort, and a WriteConflict that outlasts the retries fails with TRANSACTION_RETRY_EXCEEDED (409), the code PostgreSQL uses for its exhausted serialization and deadlock retries. PostgreSQL waits the same backoff before each retry, once the failed attempt has rolled back and released its client. Borrowed active sessions remain caller-owned in default Mongo mode. With opt-in transactional reference integrity, violations and guarded-write errors abort supplied sessions; those sessions require snapshot reads and majority commits. See MongoDB reference integrity. PostgreSQL validates instance-owned active handles, retains repeatable-read atomicity, and retries confirmed serialization/deadlock aborts only.
Embedded parity also covers native/GraphQL default and minimization differences, direct SQL presence, uniqueness after omitted-parent defaults, and UTC historical Date parameters under America/New_York. PostgreSQL keeps required constraints: an omitted optional inline parent materialized by descendant array defaults must contain any required scalar (MongoDB instead stores the defaulted parent without it and, since 3.5.9, reads it as null unless that scalar is id, readOnly or resolved by the application); GraphQL create omits explicit null before applying defaults. See the PostgreSQL native/default contract for the explicit-null native alternative.
Embedded query completion verification (2026-09-12): full suites passed 47 files /954 tests on each of PostgreSQL15/Mongo7 and PostgreSQL18/Mongo8; PostgreSQL16/Mongo8 integration plus schema checks passed 12 files /266 tests. All ran under America/New_York. Full lint and all five packed runtime/TypeScript consumers passed. The new embedded differential suite contributes 122 cases.
v3.2.0 package and CI delivery
The MongoDB facade, core, PostgreSQL, and MCP packages use version 3.2.0 in lockstep with exact internal dependency versions. All four packages are released on npm. Release archives and registry publication are ordered core, MCP, PostgreSQL, then the MongoDB facade. Core/PostgreSQL install no MongoDB, Mongoose, or MCP dependency chain; MCP and its SDK remain opt-in.
Database CI runs three bounded full-suite jobs: PostgreSQL 15/MongoDB 7, PostgreSQL 16/MongoDB 8, and PostgreSQL 18/MongoDB 8. Each job sets both MongoDB environment variables to distinct databases so the differential and upstream regression suites run without colliding.
PostgreSQL initialization exposes generated tables, primary keys, real FKs, indexes, presence columns, private ownership/key tables, functions, triggers, and maintenance metadata through a runtime's describeDatabase() and the low-level describeDatabase(registrations, { schema }), whose schema defaults to public. Since 3.5.6, the module function called without arguments describes the configured default instance. These physical constraints are PostgreSQL behavior rather than a claim that native Mongoose APIs or storage coercions are identical. See the canonical quick start and detailed storage reference.
v3.3.0 SQL core and PostgreSQL plugin
The new @simtlix/simfinity-sql package owns driver-free relational planning, records and session orchestration. PostgreSQL supplies physical schema/query/record compilation, native codecs, initialization and driver operations through postgresPlugin. Existing PostgreSQL factories and namespace entry points use the same SQL runtime and remain compatible. The explicit composition API is createSQL({ plugin: postgresPlugin({ pool, schema }) }); see the SQL plugin contract.
All five packages use exact internal 3.3.0 versions. Publication order is core, SQL, MCP, PostgreSQL, then MongoDB. Installing SQL alone brings no PostgreSQL, MongoDB, Mongoose or MCP driver chain. PostgreSQL is the only supported SQL plugin initially.
Logical capability checks reject missing guarantees before physical compilation and initialization. Foreign keys, embedded ownership, presence/default behavior, list ordering, NULL-equal uniqueness, scopes, hooks and state semantics are preserved. Three golden fixtures compare complete PostgreSQL descriptions and DDL with 3.2.0, including nested/null/unique/enums and special identifiers. An unchanged model requires no generated-schema migration for this extraction. The default options still produce that output; tests/fixtures/postgres-opt-in-schema.json pins the output of the opt-in refreshTriggers: 'perRoot' and ownerIndexes: 'minimal', including for a maintained root with long generated names (3.6.0). Each opt-in snapshot must differ from the default output, and a fixture has one exactly for each opt-in that changes its description.
Verification adds a non-PostgreSQL recording plugin, early contract/capability failures, fixed binding, transaction/session delegation, and real PostgreSQL interoperability between the old and new factories. Recording tests establish the plugin boundary; they do not establish support for a second SQL engine.
SQL extraction verification (2026-09-16): the full suite passed 51 files / 1,029 tests with disposable PostgreSQL 18 and MongoDB 8, with no skips. All six isolated packed runtime/strict TypeScript consumers passed. A separate registry-installed 3.2.0 runtime created real schema and nested data; 3.3.0 createSQL validated that schema without DDL, produced identical metadata/SQL, preserved the records and successfully updated them. Barber passed both backend unit suites (69 PostgreSQL and 59 MongoDB tests), its PostgreSQL 67-operation/native-constraint corpus, MongoDB transaction checks, shared 19-operation HTTP/MCP contract on each backend, both dataset load/delete suites and frontend query validation against the generated schema.
v3.4.0 optional MongoDB reference integrity
Mongo adapters accept immutable referentialIntegrity: 'off' | 'transactional' configuration. The default remains off. Transactional mode requires awaited startup auditing, coordinates target-document writes against concurrent deletion, and rejects missing direct/embedded/inferred/private/link references with REFERENCE_CONSTRAINT_VIOLATION. Supplied transactions require snapshot/majority options and abort on guarded-write errors. Native writers remain outside the guarantee. See the full contract. PostgreSQL physical schemas and FK behavior are unchanged.
Verification: 53 files / 1,071 tests passed against disposable MongoDB 8 replica-set and standalone deployments plus PostgreSQL 18, with no skipped tests. The 42 new cases include real GraphQL HTTP rejection, owned/borrowed rollback, post-write hook errors, three ordered concurrency schedules, independent runtime/client locks, actual transient retries, legacy off-mode Query compatibility and strict startup rejection. All six packed runtime/strict TypeScript consumers, lint and the website build passed.
v3.5.0 query authorization and reference batching
Client filter, sort and aggregation paths enforce read rules for every segment and restrict referenced records through the target's scope. Fields with custom resolvers require an explicit queryable: true to allow stored-value queries; queryable: false blocks them. Path rules receive the checked field's identity, with no parent and empty arguments. See the upgrade notes for compatibility and scope restrictions.
Generated single references can batch by request, type and nesting level. Both adapters preserve per-field middleware, while scoped targets and MongoDB models with any find or findOne pre/post hook retain individual reads. Shared hook functions do not establish equivalent behavior: hooks may depend on the operation or filter shape.
Regression coverage includes tests/query-path-authorization.test.js, tests/relation-batching.test.js, and their cross-backend counterparts in tests/integration/. Embedded replacement validation is covered by embedded-update-required-parity.test.js; SQL transaction tests cover expired sessions and failed rollback cleanup. The release matrix runs these cases with PostgreSQL 15/16/18 and MongoDB 7/8.
v3.5.5 data-correctness fixes
Version 3.5.5 fixes data-correctness defects in the shared runtime, the driver-free planner, MCP classification and the MongoDB adapter, and adds a retry backoff to SQL transactions. The table rows marked "3.5.5" record the resulting contract; see the upgrade notes for the changes applications can observe.
The new regression tests were checked against the 3.5.3 code, where they fail; tests that pin unchanged behavior pass on both. Beyond the suites named in the table, the coverage includes tests/query-plan.test.js, tests/scope-args.test.js, tests/query-path-authorization.test.js and tests/mcp.test.js for fields named like query arguments; tests/mutation-transactions.test.js for the MongoDB retry backoff and the 409 mapping, with real write conflicts in tests/mutation-transactions.integration.test.js and tests/integration/mongodb-integrity.test.js; tests/sql-transactions.test.js for the SQL retry backoff and its order after rollback and release; and tests/sql-plugin.test.js and tests/runtime.test.js for references to a numeric 0 identifier, read one at a time and in batches.
v3.5.6 SQL and PostgreSQL fixes
Version 3.5.6 fixes data-loss, schema-validation, query and error-classification defects in the SQL runtime, the PostgreSQL plugin and the shared runtime, and makes the MongoDB adapter reject at startup the fields named like Object.prototype members that Mongoose cannot store. The table rows marked "3.5.6" record the shared contract; see the upgrade notes for the changes applications can observe. PostgreSQL-specific results:
- Generated queries select owned-table columns by name, so filters, sorts, counts and aggregates through reference-bearing or unique embedded lists keep their results after a migration appended columns or the embedded fields were reordered, for top-level lists, nested lists and singular embedded objects inside lists.
tests/postgres-query.test.jschecks the generated SQL;tests/integration/postgres-column-order.test.jscompares the results before and after such a migration, with nullable and required items. - Initialization compares check constants by value, so enum internal values with backslashes and quotes create and validate, including from DDL exported with
standard_conforming_stringsoff:tests/postgres-schema.test.jsandenum internal values containing backslashes and quotesintests/integration/postgres-constraints.test.js. [ID]list columns get no generated index, so lists of 1000 identifiers are stored at the root and in owned tables; databases with the earlier index still validate, keep it and log a warning that names it:tests/sql-schema.test.jsandPostgreSQL [ID] list columnsintests/integration/postgres-schema.test.js.- A transaction in which a statement failed and was caught, and that reaches
COMMITwithout another statement, fails withDATABASE_ERRORinstead of reporting success with nothing stored. The plugin rejects only when theCOMMITresult reportsROLLBACK, so a pool wrapper whose results carry nocommandresolves as before:PostgreSQL commitintests/postgres-configuration.test.jsandtests/integration/postgres-lifecycle.test.js. - Text with NUL (U+0000), native
DateTimevalues before 4714-11-24 BC and the other input-caused data exceptions are rejected withINVALID_VALUE(400) on PostgreSQL, in writes and filters. MongoDB stores such values and matches them in equality filters, while itsLIKEfilter rejects NUL with a driver error. An index row over the btree size limit staysDATABASE_ERROR(500) on PostgreSQL.tests/postgres-configuration.test.jsandtests/integration/postgres-constraints.test.js. - Rejected initialization requests keep readiness, a ready runtime keeps serving while it is initialized again, and the initialization started last decides:
SQL storage readinessintests/sql-initialization.test.js. - SQL and PostgreSQL runtimes export DDL for their own schema, and the module's
describeDatabase()andcompileDatabaseSchema()work without arguments:SQL runtime DDL exportintests/sql-plugin.test.jsandPostgreSQL DDL export for configured runtimesintests/postgres-schema.test.js. Bothtests/sql-plugin.test.jsandPostgreSQL DDL exported by the configured default instanceintests/integration/postgres-schema.test.jsrun the exported DDL in an empty non-public schema, which validation then accepts. - The PostgreSQL package no longer ships the wrapper modules its
exportsnever exposed, and none of its modules imports the deprecated SQL./internal/*subpaths, which remain available:tests/postgres-packaging.test.js.
The new regression tests fail on the 3.5.5 code and pass with the fixes. Tests that pin unchanged behavior pass on both, such as savepoint recovery after a statement error, retries of an application error with code 40001, and unchanged stateNames for enum states.
v3.5.7 MCP tool contract
Version 3.5.7 fixes MCP tool results, tool definitions, argument handling, configuration checks, remote execution and the Streamable HTTP handler. The MCP package behaves the same with either backend. Core gains the field markers it reads, extensions.simfinityQuery.countSink and extensions.simfinityMutation; Yoga, Apollo and Envelop integrations are unchanged. See the upgrade notes for the changes applications can observe and the MCP reference for the contract.
| Behavior | Decision | Executable evidence |
|---|---|---|
A mutation whose result exceeds maxResultBytes and succeeded returns isError: false, _meta.truncated and only its root id(s), when they fit within the cap, next to a truncated notice whose wording follows the operation and idempotentHint and which reports the cap rounded down to whole bytes; with GraphQL errors it stays MCP_RESULT_TOO_LARGE with extensions.applied (true or 'unknown', never false) and firstError. Queries keep the error, whose message also reports the cap rounded down. | Fixed (3.5.7) | oversized mutation results and the fractional maxResultBytes query rows in tests/mcp-execution.test.js; an oversized generated add with the real runtime in tests/mcp.test.js; SDK Client output validation, including fractional caps, and Streamable HTTP in MCP totalCount and truncation notice contracts in tests/mcp-contracts.test.js. |
Counted list calls return totalCount next to the results (__totalCount on collision), keep _meta.count, declare it on list tools with a pagination argument, count it toward maxResultBytes, and put it in extensions.totalCount of an oversized list's error. | Fixed (3.5.7) | Count tests in tests/mcp-execution.test.js, tests/mcp.test.js and MCP totalCount and truncation notice contracts in tests/mcp-contracts.test.js, including a remote count that is not a non-negative integer. |
Generated list tools receive the caller's context object on counted calls; the count reaches MCP through a per-call root-value sink that the generated find resolver reads from parent, then info.rootValue, and otherwise writes context.count. List fields without the countSink marker keep the per-call context layer; the marker, not the resolver that runs, decides, so an application that replaces or wraps a generated list resolver forwards parent or info, reports through the sink or clears the marker before creating the tools. | Fixed (3.5.7) | MCP counted list calls keep the caller context in tests/mcp.test.js (class, Map, own-property, spread, WeakMap, frozen and null contexts, the stock auth plugin, three-argument wrappers, concurrent calls) and pagination count reporting in tests/runtime.test.js. |
Output schemas publish a JSON type only for spec scalars and validated chains ending at one; Date, DateTime, Time, storage-hinted and opaque scalars are untyped, with a curated date/time description. Input schemas keep their hints. | Fixed (3.5.7) | nullable output schemas in tests/mcp-options.test.js; serializer-defined scalar outputs over Streamable HTTP in tests/mcp-transports.test.js with the SDK 1.32.1 Client. |
Input schemas are closed at the top level, and undeclared keys the caller sent return MCP_UNKNOWN_ARGUMENT without executing; keys middleware adds pass, and caller keys middleware deletes pass, including keys named like Object.prototype members. | Fixed (3.5.7) | MCP unknown arguments in tests/mcp-contracts.test.js (including no remote request) and MCP unknown arguments on generated tools in tests/mcp.test.js. |
limits is validated at setup: falsy values are unset, caps are numbers of at least 1, defaultPagination is coerced against each published tool's pagination type; limits is still read on every call. | Fixed (3.5.7) | setup validation in the limits tests of tests/mcp-execution.test.js and the createHTTPMCPHandler setup table in tests/mcp-contracts.test.js. |
includeId is accepted and ignored; selections and output schemas are unchanged. | Deprecated (3.5.7) | selectionDepth and includeId guards in tests/mcp-options.test.js. |
Remote headers accept plain objects, Headers, Maps and pairs, send one Content-Type, are read on every call and never quote values in errors; remote timeoutMs is normalized; invalid containers and timeouts throw MCP_INVALID_EXECUTION_CONFIG at setup, where a getter defined on the execution object for either is not run and a read that throws (an inherited getter or a Proxy) is left to each call. A remote endpoint with credentials throws at setup; other endpoints the default fetch rejects (relative, unparsable, non-http(s)) are accepted at setup, as a Request is, and fail each call; no message quotes the URL. | Fixed (3.5.7) | endpoint, headers, timeoutMs and remote headers and timeoutMs in tests/mcp-execution.test.js, including the endpoint rows, a relative and a Request endpoint with a stubbed fetch, the headers getters (own, inherited, a Proxy execution object and a Proxy headers object), the timeoutMs getters (own and inherited), and an application fetch error kept for a relative endpoint; real HTTP headers, a Request endpoint and endpoints changed after setup in MCP remote response contracts, and the createHTTPMCPHandler setup table and per-call endpoint rows, in tests/mcp-contracts.test.js. |
Client cancellation during a remote retry backoff rejects at once without another request, for callTool, stdio and createMCPServer over a persistent transport. Through the stateless createHTTPMCPHandler, an MCP cancellation notification cannot reach the call in flight; only a closed HTTP connection cancels it, which is documented, not changed. | Fixed (3.5.7) | cancellation during a retry backoff in tests/mcp-execution.test.js and the SDK Client cancellation over an in-memory transport in tests/mcp-contracts.test.js. |
Generated updates whose input can carry added collection items are not advertised as idempotent; deletes stay destructive and idempotent. | Fixed (3.5.7) | update idempotency in tests/mcp-options.test.js, which repeats an update with added items against the real runtime. |
Mutations are classified by extensions.simfinityMutation, or by placeholder description plus name prefix, never by name alone; state-machine actions are transitions; queries without pagination or id are read-only custom queries. | Fixed (3.5.7) | mutation classification, custom root queries and MCP operation classification of core-generated schemas in tests/mcp-options.test.js, including a schema with the marker removed. |
A synchronous tool-middleware throw rejects the previous next() promise, and no dispatched promise becomes an unhandled rejection. | Fixed (3.5.7) | toolMiddleware tests in tests/mcp-execution.test.js and the createMCPServer boundary test in tests/mcp-transports.test.js. |
The server factories load the SDK and their transport before validation, createHTTPMCPHandler's transportOptions check included, and before schemaPlugins; all validation precedes the plugins, so setup errors leave resolvers untouched. | Fixed (3.5.7) | MCP SDK loading before schemaPlugins in tests/mcp-sdk-setup.test.js; schemaPlugins validation guards in tests/mcp-execution.test.js and tests/mcp-contracts.test.js. |
The stateless HTTP handler answers methods other than POST with 405 and Allow: POST, opening no stream. | Fixed (3.5.7) | createHTTPMCPHandler over Streamable HTTP in tests/mcp-transports.test.js, including an SDK 1.32.1 client that leaves no idle stream. |
The factories' TypeScript parameters are the input-only GenerateMCPToolsInit, MCPServerInit and HTTPMCPHandlerInit, which accept the MCPRemoteExecutionInit and MCPLimitsInit forms (numeric strings, bigints, null, false, Headers, Maps and pairs); the option interfaces keep their 3.5.6 shapes for code that reads options back. | Fixed (3.5.7) | The mcpTypes consumer in scripts/test-packages.js (npm run test:packages), with read-back lines and @ts-expect-error cases. |
Each new regression test was checked to fail without its fix and to pass with it; tests labeled as guards pass on both. The unused scripts/repro-mcp.mjs reproduction is replaced by the transition tests above.
Verification (2026-10-06): the full suite passed 82 files / 2,279 tests against disposable MongoDB and PostgreSQL databases, with no skips. The MCP suites ran against @modelcontextprotocol/sdk 1.32.1, within the ^1.31.0 peer range.
3.5.9: MongoDB fixes and follow-ups
Version 3.5.9 fixes MongoDB reads of embedded objects that hold no data, embedded cycles in default MongoDB mode and the session of custom mutations whose models use another connection. It also follows up 3.5.8: generated inputs that would have no fields, application types named like the introspection metadata types, and the error classes of the core root. The table rows marked "3.5.9" record the resulting contract; see the upgrade notes for the changes applications can observe. Other coverage:
- Application types named
RelationTypeorFieldExtensionsType:tests/field-metadata-names.test.jsrunstests/fixtures/field-metadata-names.jsin fresh processes on core, the MongoDB facade and PostgreSQL with a stub pool, for the fallback names,RESERVED_TYPE_NAMEbeforeadapter.prepare()and, for a type inside a registered input's fields, when the schema is constructed, an input-object clash, a failed construction and a second evaluation of the runtime. Registered mutation inputs whosefieldsfunction callsgetInputType()build and run in a second runtime of the process and after a firstcreateSchema()on the same runtime. - The error classes of the core root and the declarations of MongoDB's legacy deep imports:
tests/core-subpath-types.test.js, andnpm run test:packages, whose MongoDB consumer compiles every declared deep import with exact type equality under NodeNext, Node16, Bundler and Node10. Both consumers also compileexport *of theauthandpluginsdeclarations together, core subpaths and legacy deep imports. - The MongoDB filter builder lost dead code without a behavior change;
MongoDB filters on NonNull object fieldsintests/mongodb-query-paths.test.jspins its results.
Each new regression test was checked to fail without its fix and to pass with it; tests labeled as guards pass on both.
3.6.0: fewer reads, statements and round trips
Version 3.6.0 reduces the reads, statements, lock writes and round trips of generated mutations, MongoDB reference integrity, PostgreSQL reads, writes and initialization, and MCP tools. It also adds opt-in PostgreSQL schema options and MCP options. The rows marked "(3.6.0)" record the resulting contract; see the upgrade notes for the changes applications can observe. The executable evidence is included in the v3.6.0 source.
| Behavior | Decision | Executable evidence |
|---|---|---|
Generated mutations skip reads by ID whose result they do not use. State actions and nested updated children read their record once, or twice when their input merges a singular embedded object or writes a non-empty embedded list; a nested deleted child is read once, by the ownership check, and onDelete receives that record. A root delete without onDelete, and a root update that merges no embedded value and has neither onUpdating nor nested items, skip their read only when adapter.readByIdIsInert(model) returns true: always on PostgreSQL, and on MongoDB for models without find, findOne, findOneAndUpdate or findOneAndDelete middleware and without application _id setters or overrides of the by-ID statics. Responses, stored data, hook arguments and errors are unchanged. | Default (3.6.0) | tests/mutation-reads.test.js; tests/integration/mutation-reads.test.js asserts each scenario's response, stored record, hook arguments, reads and in-transaction commands or statements on default and transactional MongoDB and on PostgreSQL, the parent SELECT … FOR UPDATE that PostgreSQL still runs first, and the error scenarios. Guards: a supplied Mongoose model with a findOne guard › still rejects a delete without onDelete and an update that merges no embedded value; a supplied Mongoose model with write middleware › rejects an invalid ID before the write middleware runs; a supplied Mongoose model that overrides the by-ID statics; a supplied Mongoose model with an operation-specific ID setter. |
| Each transaction attempt receives its own typed input copy: the first is taken before the transaction starts, and a retry copies the request input again. | Default (3.6.0) | input copies per transaction attempt in tests/runtime.test.js. Guards: a retried attempt starts from the input as middleware left it, a later root field that reuses the input variable gets the request input and the existing retry-isolation tests. |
MongoDB transactional integrity reads only a guarded record's _id and reference paths, and locks its targets with one write per target collection, in batches of 1,000, after one existence read when a batch holds several targets, so a missing target fails with REFERENCE_CONSTRAINT_VIOLATION without a retry, unless a target of an earlier collection or an earlier batch of 1,000 is held by another transaction. A guarded delete queries each referencing collection once, with the $or of its paths. | Default (3.6.0); MongoDB only | guarded write round trips in tests/integration/mongodb-integrity.test.js: the save, update, 1,000-target and missing-target tests, a missing target that sorts after a target held by another transaction fails at once without retries, and the delete-probe, self-reference and probe-plan tests. Guards: the single-target test, the sorts before variant, the three conflict tests, a guarded update locks exactly the targets its stored document references, whatever their shape and a single reference through … blocks the delete of its target. |
onModelCreated: (model) => model.schema.removeIndex({ id: 1 }) removes the top-level { id: 1 } declaration of a connect() type; generated models still declare it. | Documented (3.6.0); MongoDB only | Top-level id index opt-out in tests/objectid-indexes.test.js. |
SQL plugins may advertise singleStatementReads; sessionless reads of types without owned tables then run on one connection without begin and commit, and are retried without a rollback step; after a failure that isRetryable() rejects, rollback probes the connection and a probe failure reaches release, so a dead connection is discarded. Plugins without it keep a transaction around every read. PostgreSQL advertises it. | Default for PostgreSQL (3.6.0) | SQL single-statement connections and SQL single-statement reads in tests/sql-transactions.test.js; SQL sessionless reads in tests/sql-plugin.test.js; runs sessionless reads of types without owned tables as single statements in tests/integration/postgres-statements.test.js. Guards: the plugin without the capability, keeps one repeatable-read transaction for a read that loads owned rows, and discards a connection terminated during a single-statement read, so the next read succeeds in tests/integration/postgres-connection-loss.test.js. |
Owned rows are inserted with multi-row statements when the plugin has compileRecordBatch and maxRecordBatchValues (32,767 values on PostgreSQL) and multiRowInserts is not false. A later item's database error can be reported instead of an earlier item's REFERENCE_CONSTRAINT_VIOLATION; nothing is stored. | Default with opt-out (3.6.0) | SQL owned-row batches in tests/sql-plugin.test.js; PostgreSQL owned-row batches in tests/postgres-query.test.js; tests/integration/postgres-statements.test.js, including error precedence of a write with several rejected items. Guards: the per-row cases of SQL owned-row batches and inserts one owned row per statement with multiRowInserts: false, from the factory options or configure(). |
PostgreSQL sorts on id and required root columns omit the NULLS clause, so default-order indexes serve them; other paths keep MongoDB's null placement. explicitNullsOrder: true restores the 3.5.9 SQL. | Default with opt-out (3.6.0) | PostgreSQL sort null placement in tests/postgres-query.test.js; serves sorts on id and required indexed columns from an index in tests/postgres-sargable.test.js. Guards: keeps the explicit NULLS clause on every sort term when explicitNullsOrder is set, as 3.5.9 did, returns the same pages for required and nullable sorts with a unique tiebreaker, pages sorts on required fields with ties identically in tests/integration/query-parity.test.js, and the explicitNullsOrder test in tests/integration/postgres-statements.test.js. |
| PostgreSQL MIN and MAX facts over list paths evaluate each list once per row. | Default (3.6.0) | PostgreSQL list aggregates in tests/postgres-query.test.js and scans an owned list once per root row for MIN and MAX in tests/postgres-sargable.test.js. Guard: aggregates MIN, MAX and COUNT of several list paths in one aggregation in tests/integration/embedded-query-parity.test.js. |
PostgreSQL create initialization reads the catalog in a constant number of statements without table locks, locks nothing when nothing is missing, otherwise explicitly locks only the tables of roots whose maintenance objects it creates, in one statement, while other DDL takes its own locks, rebuilds only their keys, and no longer re-scans checks. | Default (3.6.0) | tests/integration/postgres-initialization.test.js: takes no table locks and runs no backfill when nothing is missing, reads the catalog in a constant number of statements, does not wait for writers on managed tables when nothing is missing, adding an index does not wait for writers on other managed tables, rebuilds no keys and scans no checks, locks only the tables of the root whose keys it rebuilds, in one statement and the four stale-root tests. Guards: reports drift after a missing function exactly as before and reports a foreign key that existing rows violate before later trigger drift, exactly as before. |
refreshTriggers: 'perRoot' refreshes each changed root once per transaction through a deferred trigger on its guard table, while that trigger stays deferred (the default). Initialization keeps each existing root on its layout and rejects a root with objects of both. The default description and DDL are unchanged. | Opt-in (3.6.0) | tests/integration/postgres-refresh-triggers.test.js, including the equivalence cases under both layouts; tests/integration/postgres-constraints.test.js runs its suite under both layouts; tests/sql-schema.test.js keeps the 3.2 golden for the default options and pins the opt-in output in tests/fixtures/postgres-opt-in-schema.json, including a long-named maintained root; tests/integration/postgres-long-names.test.js creates and validates such a root; tests/postgres-schema.test.js rejects unknown values. |
ownerIndexes: 'minimal' omits the (__owner_id) index that the primary key or a unique index covers on owned and guard tables; existing ones are accepted and kept. | Opt-in (3.6.0) | tests/integration/postgres-owner-indexes.test.js, tests/integration/postgres-long-names.test.js and tests/postgres-schema.test.js. Guard: accepts and keeps the owner indexes of a database created with them. |
MCP nestedCollections: 'none' leaves nested collections out of the generated selection and outputSchema, globally or per tool. Invalid values throw MCP_INVALID_OPTIONS at setup, for every toolOverrides entry. | Opt-in (3.6.0) | nestedCollections in tests/mcp-options.test.js; tests/integration/mcp-nested-collections.test.js on both backends; the createHTTPMCPHandler rows in tests/mcp-contracts.test.js. Guard: keeps the 3.5.9 selection and outputSchema when unset or 'all' (guard). |
MCP textFormat: 'compact' sends compact JSON text, which maxResultBytes measures. | Opt-in (3.6.0) | MCP textFormat compact, accepts a payload exactly at its compact size with textFormat: 'compact' and the MCP_INVALID_OPTIONS rows in tests/mcp-contracts.test.js. Guard: keeps the pretty-printed text with textFormat %j (guard). |
MCP remote retry.retryAfter and retry.jitter are opt-in; without them, retry k waits backoffMs × k. | Opt-in (3.6.0) | Retry-After and jitter in tests/mcp-execution.test.js, including reads %s Retry-After as GMT, waiting as long as the IMF-fixdate in any process time zone for the asctime and RFC 850 forms. Guards: the tests that ignore Retry-After without the opt-in, keep the linear schedule, fall back to the backoff for a missing or invalid header (text that is not an HTTP-date, such as May 2030, included), never retry a mutation, and apply timeoutMs to each attempt on real timers. |
In-process MCP calls parse and validate each tool's operation once, validate the schema on every call, and return what graphql() returns. | Default (3.6.0) | tests/mcp-operation-cache.test.js. Guard: MCP in-process results equal graphql() results in tests/mcp-execution.test.js. |
Generated MCP tools share one $defs entry per input or enum type; serialized input schemas are unchanged. | Default (3.6.0) | shared $defs in tests/mcp-options.test.js. Guards: publishes the inputSchema graphqlArgsToJSONSchema builds for each tool, key order included (guard), which also pins the 3.5.9 $defs key order and one tool's full serialized inputSchema, and keeps graphqlArgsToJSONSchema returning independent objects (guard). |
| MCP option interfaces gain only optional members; existing members keep their 3.5.6 shapes for code that reads options back. | Default (3.6.0) | The mcpTypes consumer in scripts/test-packages.js (npm run test:packages), with read-back lines and @ts-expect-error cases. |
Core, SQL and PostgreSQL declarations gain only optional members: DatabaseAdapter.readByIdIsInert, SQLPlugin.compileRecordBatch and maxRecordBatchValues, the trailing configuration argument of describeSchema and compileQuery, the singleStatementReads capability, multiRowInserts, explicitNullsOrder, refreshTriggers, ownerIndexes, and DatabaseTrigger.events and when, plus the new SQLInsertManyOperation type. SQLRecordOperation is not widened. | Default (3.6.0) | The coreTypes, sqlTypes and postgresTypes consumers in scripts/test-packages.js (npm run test:packages): the 3.5.9-shaped adapter, plugin and configuration objects still compile, the new members compile, and @ts-expect-error cases reject wrong value types. |
Each new regression test was checked to fail on 3.5.9 and to pass with the change; tests labeled as guards pass on both.
3.7.0: batched collection reads and one EnvelopSchemaPlugin
Version 3.7.0 can read generated collection fields in batches, opt-in on PostgreSQL and MongoDB, and makes @simtlix/simfinity-mcp re-export core's EnvelopSchemaPlugin. The rows below record the resulting contract; see the upgrade notes for the changes applications can observe. The evidence is in the repository's tests/ and scripts/test-packages.js.
| Behavior | Decision | Executable evidence |
|---|---|---|
Generated collection fields of one request that share a related type, a connection storage field and effective arguments of plain data, requested in the same tick, are read with one findChildrenBatch call of at most 100 distinct parents, when the adapter defines it and batchesChildren does not return false; a group with one parent calls findChildren. Middleware, scopes, joined scopes and auth rules run per field; fields of one parent share its records with their own lists; a failed or misshapen batch reads each field alone and warns once per type unless the error is a SimfinityError. Without the method, or when the predicate returns false, each field calls findChildren at once with its own arguments object, as in 3.6.0. | Default when the adapter defines findChildrenBatch and batchesChildren does not return false | tests/collection-batching.test.js on a memory adapter, including the grouping key block, read at once, with the arguments object the field built, and call nothing else, which pins the 3.6.0 path, a findChildren override disables batching through the pairing check, so the override applies, concurrent requests with the same arguments keep separate batches, each in its own async context, the the parent a batched record shows block and the failed batches that change their arguments; tests/integration/collection-batching-core.test.js passes both bundled adapters' records and raw parent IDs through a batch. Guards: a lone parent reads with findChildren, a middleware change to a shared variable object reaches each parent as it would per parent, middleware that waits for I/O still returns the per-parent results, cyclic, accessor, sparse, inherited, sealed, hidden and exotic arguments read per parent, a request without a context object reads per parent without asking the adapter and invalid pagination fails each field without a batch. |
With batchedCollections: true, PostgreSQL reads a group with one statement per 100 parents, a UNION ALL of each parent's statement with its own parent parameter, ordered by parent position and row number, so each parent is planned for its own ID, in one repeatable-read transaction on one connection: BEGIN, SET LOCAL jit = off, the statement, one owned-row statement per owned table, COMMIT. Total orders return the same rows; unsorted and tied rows normally keep their order, which PostgreSQL does not promise, and pages that hold all of a parent's rows hold the same rows. Only true enables it: otherwise the adapter has no findChildrenBatch and sends 3.6.0's statements on 3.6.0's connections. A plugin copy whose compileQuery is not the one compileChildrenQuery names reads per parent. | Opt-in (PostgreSQL batchedCollections: true) | tests/postgres-collection-batching.test.js; tests/integration/postgres-collection-batching.test.js, which compares every request with a batchedCollections: false runtime, exactly, unsorted and tied rows included, with the skewed parents after HOT updates block and its plan test, the skewed connection statistics block, whose EXPLAIN shows every small parent read through the connection index, with no sequential or primary-key scan filtering on the connection column, and each parent of a mixed batch planned as its own statement, and the JIT block, whose batch costs more than jit_above_cost with a JIT block under jit = on, and none after the SET LOCAL jit = off the adapter sends, while the connection is back at the server's setting after release; tests/integration/postgres-statements.test.js (books{chapters} in 6 statements on 3 connections instead of 22 on 22); tests/sql-plugin.test.js. Guards, which assert 3.6.0's statements: defines neither member by default, nor for any batchedCollections value other than true, sends 3.6.0's statements by default and with batchedCollections: false: one per field, each on its own connection, sends exactly the statements of batchedCollections: false, 3.6.0's, by default, reads each collection field with its own statement by default and with batchedCollections: false, from the factory options or configure(), leaves compileQuery's per-parent statement as it was and turns batching off for a plugin copy whose compileQuery hides rows, so its filter applies to every parent. |
A children statement may carry setup statements, which the SQL adapter runs, in order, on the same connection right before it; when any statement of a batch has setup, the whole batch, owned-row hydration included, runs in one owned transaction, even for types without owned tables. A setup that is not a list of { text } statements fails the batch with a TypeError before a connection is taken, which the runtime reports once per type before reading each parent alone. PostgreSQL's setup is SET LOCAL jit = off, a fresh list per statement. | Plugin contract, used with batchedCollections: true | runs a plugin's setup statements in order before its statement, and a batch without setup as findChildren runs, splits more than 100 distinct parents into statements of 100 in one transaction, each after its setup, fails with a TypeError before taking a connection when setup is not a list of statements, reads each parent alone, and reports it once, when a plugin returns setup that is not a list of statements, fails with DATABASE_ERROR, and rolls the transaction back, when a row names no parent of its statement and asks for JIT to be off before every batched statement, with a fresh setup list each time in tests/postgres-collection-batching.test.js. |
With createMongoAdapter({ batchedCollections: true }), MongoDB reads a group with concurrent aggregate commands of up to collectionBatchSize parents (1 to 100, default 5), each parent's pipeline in its own $unionWith sub-pipeline. Models with aggregate middleware, an own aggregate static, a discriminator child model or an own collection.aggregate, sessions, transactionAsyncLocalStorage transactions and findChildren overrides keep per-parent reads. Without the option the adapter has neither member. Invalid values throw INVALID_MONGO_ADAPTER_OPTIONS (400). | Opt-in (MongoDB only) | tests/mongodb-collection-batching.test.js and tests/integration/mongodb-collection-batching.test.js: 20 authors read their books with 4 commands instead of 20, total orders compared exactly and other orders as sets, rotated parent order, a supplied model without an index on its connection field, and the gates. Guards: is off by default, and then the adapter has neither batching member, keeps rejecting unknown and misspelled options as before, keeps per-parent reads for aggregate middleware and discriminators, which a batch would bypass and keeps per-parent reads for an override of findChildren, so the override applies. |
Declarations gain only optional members: DatabaseAdapter.findChildrenBatch and batchesChildren, SQLPlugin.compileChildrenQuery, SQLConfiguration and PostgresConfiguration.batchedCollections, and MongoAdapterOptions.batchedCollections and collectionBatchSize, plus the new SQLChildrenStatement, with its optional setup, and SQLChildrenQueryCompiler types. | Default | The coreTypes, sqlTypes, postgresTypes and mongoTypes consumers in scripts/test-packages.js (npm run test:packages): 3.6.0-shaped objects still compile, the new members compile, and @ts-expect-error cases reject wrong shapes. The exact-optional.ts fixtures compile the documented undefined opt-outs with exactOptionalPropertyTypes. |
@simtlix/simfinity-mcp re-exports core's EnvelopSchemaPlugin, whose hook returns void or Promise<void>, and createAuthPlugin returns the new AuthSchemaPlugin, whose hook returns void. So export * of MCP or src/mcp.js with the core root, /auth, /plugins, the database packages or MongoDB's legacy deep imports exports one declaration. | Fixed: one declaration, plus AuthSchemaPlugin | schema-plugins.ts in the core consumer, whose shared shape also pins the void-sink and implementing-class breaks with @ts-expect-error; barrel.ts in the mcp (NodeNext, Node16, Bundler and Node10), mcp-sdk, sql and postgres consumers; legacy-mcp-auth.ts, legacy-mcp-plugins.ts and legacy-mcp-root.ts in the mongo consumer of scripts/test-packages.js; EnvelopSchemaPlugin declarations in tests/core-subpath-types.test.js. Guard: legacy-mcp-workaround.ts, the explicit 3.5.9 re-export, which compiles on both versions. |
Each new regression test was checked to fail on 3.6.0 and to pass with the change; tests labeled as guards pass on both. 3.6.0 rejects the MongoDB options batchedCollections and collectionBatchSize, so the MongoDB guards ran there through a test shim that drops those options before creating the adapter. The PostgreSQL tests of the opt-in, of setup and of JIT were also checked to fail on an earlier draft that batched by default without a transaction, and the default's statements were compared with the 3.6.0 adapter's on five query shapes.
3.8.0: virtual fields and plain connection fields
Version 3.8.0 adds virtual fields (extensions.virtual), which no backend stores and which are not part of the query language. The rows below record the contract; see the upgrade notes for the changes applications can observe. The evidence is in the repository's tests/ and scripts/test-packages.js.
| Behavior | Decision | Executable evidence |
|---|---|---|
extensions.virtual accepts exactly true or a plain object { mcp?: 'tool' | 'select' | false }, on a field with its own resolver. Any other value, an unknown option or mcp value, a missing resolver, relation, unique: true, validations, queryable: true or readOnly: false alongside it, and a virtual identity field, extensions.indexes entry, state-machine state field or connectionField throw INVALID_MODEL (400), naming the type and field, before adapter.prepare(); describeModels() rejects the same declarations. | Shared (opt-in) | virtual field declarations in tests/virtual-fields.test.js. |
Virtual fields have no storage on either backend, in either MongoDB mode: describeModels() leaves them, and the types only they reach, out of every entity and embedded type; PostgreSQL gets no column, presence flag, check, JSON validation function, owned table, index or foreign key; generated Mongoose models get no path, [] default or index, and an embedded type whose members are all virtual becomes a strict subdocument without paths rather than a Mixed path; transactional integrity never names them. Any output type works as the result, registered or not, without relation, and non-null fields need no input. Adding one to a deployed type is not a schema change. | Shared (opt-in) | virtual fields are not stored in tests/virtual-fields.test.js; tests/integration/virtual-fields-postgres.test.js, which compares descriptions and DDL with the schema without each field, validates a database created before the fields existed and reads raw rows; tests/integration/virtual-fields-mongodb.test.js, which compares generated paths, indexes and stored documents with the type without its virtual fields in default and transactional modes, on a replica set and a standalone server, and checks the documents that an embedded type whose members are all virtual stores (an embedded type whose members are all virtual stores nothing, rather than becoming a Mixed path). |
Generated inputs never contain virtual fields, and generated writes drop values that middleware or saveObject() passes for them, with no $set or $unset. The backends drop values that a controller assigns: native PostgreSQL writes ignore them, and generated Mongoose models drop them in strict mode; supplied models keep the paths they declare. | Shared (opt-in) | generated creates and updates write nothing for them, whatever middleware adds in tests/virtual-fields.test.js; the native-write and raw-row checks in the PostgreSQL suite; the insert and findAndModify command checks and a supplied model keeps its own paths in the MongoDB suite. |
List, aggregate and collection fields have no argument for a virtual field. A client filter, AND/OR, sort, groupId or fact path through one fails with FORBIDDEN_FILTER_PATH (403); a path that middleware or a find, get_by_id, joined or collection scope adds fails with INVALID_MODEL (500) before the adapter runs, and the check is skipped when no schema of the runtime declares a virtual field. PostgreSQL native find() and MongoDB buildQuery() and buildFilterGroupMatch() reject such paths with INVALID_FILTER_FIELD (400). | Shared (opt-in) | virtual fields are not part of the query language in tests/virtual-fields.test.js; compiles no statement for a path through a virtual field in the PostgreSQL suite; native query builds reject paths through virtual fields in the MongoDB suite. |
Virtual fields resolve wherever their type appears, with the hydrated record (_id on MongoDB, id and _id on PostgreSQL), the coerced arguments and the context; a parent that a scope hides never reaches the resolver. Introspection reads extensions { readOnly } as true for them; FieldExtensionsType gains no field. | Shared (opt-in) | virtual fields resolve wherever their type appears and virtual field introspection in tests/virtual-fields.test.js; the by-ID, list, reference and collection reads of both integration suites. |
createSchema() warns once per runtime about each readOnly field of a registered type that has arguments and its own resolver but is not virtual, a reference or a collection. The advice names the settings that the field must drop to become virtual, such as extensions.relation on an embedded value. | Default | the warning for readOnly fields with arguments and their own resolver in tests/virtual-fields.test.js. |
A PostgreSQL database that still holds storage of a field that is now virtual fails initialization with SCHEMA_MISMATCH (409), and the runtime's message names the fields and lists the migration statements, found in the catalog already read, without another statement; the plugin receives initialize(configuration, description, options, { virtualFields }) only when virtual fields exist. A mismatch that no virtual field explains keeps its message, and only the functions of a field's own column or root are compared. When the field was the last member with its own table of an embedded value, which is then stored as JSON, the message names it and describes the data migration. Columns named after a former reference's connectionField, or a former collection's private back-reference, are not attributed; the upgrade notes give their migration. | PostgreSQL only | names each field that left storage, with the migration that makes validation pass (both refresh-trigger layouts), names one list of JSON objects that became virtual, names the last member with its own table of an embedded value, which then is stored as JSON, and its data migration (with and without other owned values), blames no virtual field for a changed function of another column or root and keeps the earlier message for a mismatch that no virtual field explains in the PostgreSQL suite, which also runs the migrations and writes afterwards. |
Generated MCP tools give each virtual field with a required argument, on a type with a generated by-ID query, a read tool <byIdQuery>_<field> that takes id and the field's arguments, unless mcp is false; its input schema and its operation both declare id as required and non-null, so a call that omits it or sends null returns isError: true with the GraphQL variable error, not { <byIdQuery>: null }; these tools come after every root tool, so a collision skips them, and a field argument named id throws MCP_INVALID_OPTIONS. Below any list position (the items of a list-returning root field, of a nested collection or embedded list, or of a list-valued per-field tool's result), every tool selects only mcp: 'select' virtual fields; elsewhere tools select every virtual field without required arguments. A field argument named id throws only when the tool would be published, so not when the tool is skipped as a duplicate. | Opt-in, per field | tests/virtual-fields-mcp.test.js, including an SDK client that validates results against the output schema, the missing and null id calls through callTool and an SDK client, and a remote endpoint. |
| For the virtual-field feature alone, schemas without virtual fields are unchanged: SDL and introspection, storage descriptions, PostgreSQL DDL and statements, Mongoose models and commands, inputs, filter arguments, warnings and MCP tools. | Unchanged | virtual fields change nothing else in tests/virtual-fields.test.js, which compares a schema with and without its virtual fields; outside the suite, the contract fixtures were also compared with 3.7.0's models, DDL, statements, Mongoose schemas, query pipelines and MCP tool definitions. |
VirtualFieldOptions and VirtualFieldDeclaration are exported by core, the MongoDB facade, SQL and PostgreSQL; SQLPlugin.initialize gains the optional context parameter, typed SQLInitializationContext. | Default | The coreTypes, sqlTypes, postgresTypes and mongoTypes consumers in scripts/test-packages.js (npm run test:packages), with @ts-expect-error cases for invalid declarations. |
Core exports isVirtualField(field) and virtualFieldOptions(field) from its root. The GraphQL types that DatabaseAdapter.prepare() and createModel() receive still contain virtual fields, which a custom adapter that builds storage from fields must skip. | Additive | custom adapters in tests/virtual-fields.test.js; the packed root-export list and the coreTypes consumer in scripts/test-packages.js. |
The virtual-field regressions were checked against 3.7.0. Version 3.8.0 also rejects dotted and non-string connectionField declarations; see the migration notes. tests/connection-field-paths.test.js verifies pre-model rejection in both MongoDB modes and PostgreSQL, including unregistered embedded types.
3.9.0: literal text operators
Version 3.9.0 adds six literal text operators to QLOperator: ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH and IEQ. The rows below record the contract; see the upgrade notes for the changes applications can observe. The evidence is in the repository's tests/ and scripts/test-packages.js, and the shared fixture and cases are in tests/helpers/query-text-operators.js.
| Behavior | Decision | Executable evidence |
|---|---|---|
The six operators match a string value literally, with no wildcard: ILIKE as a substring ignoring case, STARTS_WITH and ISTARTS_WITH as a prefix, ENDS_WITH and IENDS_WITH as a suffix, respecting or ignoring case, and IEQ as the whole value ignoring case. %, _, regular-expression metacharacters and a trailing newline match themselves. "" matches every non-null string, except with IEQ, which matches only "". They apply to String fields and String lists, where some element must match, and to validated scalars by their base scalar; null and missing values never match. Both backends return the same rows for every case. | Shared (additive) | ITEM_CASES in tests/integration/query-text-operators-parity.test.js, which runs the same generated GraphQL operations on both backends over data written by generated mutations; rows in tests/integration/query-text-operators-mongodb.test.js, on a replica set and a standalone server; the row cases of tests/integration/query-text-operators-postgres.test.js. |
They work wherever LIKE works: field filters, relation terms, AND/OR conditions, references, embedded values and lists, collections (one row per matching child), pagination.count, aggregate filters, collection fields, batched or not, find, get_by_id and aggregate scopes, joined scopes, middleware and MCP tools. | Shared | counts and aggregates text conditions alike, filters collection fields with text conditions alike and applies text conditions that scopes and joined scopes add alike in the parity suite; compile wherever LIKE compiles and the surface tests of the MongoDB suite; counts, aggregates and pages text conditions, filters batched collection fields with text conditions and applies text conditions that scopes and joined scopes add in the PostgreSQL suite; tests/integration/query-text-operators-mcp.test.js on both backends, with an SDK client that validates tool input and output. |
A value that is not a string or that contains U+0000, and a field whose base scalar is not String, fail with INVALID_FILTER_VALUE (400) before compiling, through one core rule that both backends call. LIKE keeps its 3.7.0 checks. | Shared | rejects the same invalid text conditions with INVALID_FILTER_VALUE in the parity suite; query plans with text operators in tests/query-text-operators.test.js; reject anything but a string without U+0000 on a String field, before compiling and keep path errors ahead of value errors in the MongoDB suite; rejects text operators on other scalars and invalid values in plans built by hand in the PostgreSQL suite; returns INVALID_FILTER_VALUE tool errors for invalid text values in the MCP suite. |
MongoDB compiles them to escaped regular expressions: unanchored for ILIKE, anchored with ^ for the prefixes and with \z for the suffixes, both for IEQ, with the i option for the case-insensitive ones. Only STARTS_WITH reads a prefix range of an index. A value whose pattern is longer than the 32,764 bytes MongoDB accepts fails with INVALID_FILTER_VALUE (400) before the query runs, for LIKE too, which the server rejected before with a 500; PostgreSQL has no limit. The operators of 3.7.0 keep their pipelines and their other errors. | MongoDB | text operators and operators of 3.7.0 in MongoDB text operator compilation, whose five 3.7.0 tests assert the literal pipelines and errors; reject a value whose pattern is longer than MongoDB accepts with INVALID_FILTER_VALUE, as LIKE does and, on the servers, the longest pattern the server accepts runs, and a longer value fails with INVALID_FILTER_VALUE, not a server error; rejects a value longer than MongoDB regular expressions allow with INVALID_FILTER_VALUE on MongoDB only in the parity suite; \z anchors at the very end, and ^ at the very start, without multiline matching; STARTS_WITH reads a prefix range of the field index; the other operators read the whole index, from explain. |
PostgreSQL binds every value. STARTS_WITH and ENDS_WITH compare code points under the C collation with starts_with and right. ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ compare the case fold of both sides, translate(lower((x) COLLATE "pg_catalog"."und-x-icu"), from, to), with = for IEQ and LIKE and an escaped pattern for the others, so that application indexes on that expression serve them. The exported caseFoldExpression(sqlExpression) returns it, exactly as the compiler writes it, for application indexes, with the expression in parentheses so that COLLATE applies to all of it, and throws a TypeError for anything but a non-empty string. Each operator compiles in the same statement as LIKE, with only the comparison and its value changed, and the operators of 3.7.0 keep their statements. | PostgreSQL | compiles each text operator on %s wherever LIKE compiles, inside the same statement, folds both sides of the four case-insensitive operators, and only theirs, with caseFoldExpression, pins the case-insensitive statements, keeps the 3.7.0 statements of LIKE in every place, keeps the 3.7.0 statements of every other operator and binds escaped LIKE patterns, so values never become SQL or wildcards in the PostgreSQL suite; writes the fold that the compiler writes, for any SQL expression and gives index statements in the queries guide that are on exactly the expression caseFoldExpression writes; its plans block checks with EXPLAIN that indexes built with caseFoldExpression serve the operators: a btree IEQ, one with text_pattern_ops also ISTARTS_WITH, a trigram index ILIKE, ISTARTS_WITH and IENDS_WITH, and a btree on a jsonb member IEQ on it, whether or not the member passed to caseFoldExpression has parentheses of its own, that those spellings give the same index expression while COLLATE on the member's text without parentheses does not, that an index on lower() alone serves neither, that the column's btree serves STARTS_WITH, and that no btree serves LIKE, ENDS_WITH or IENDS_WITH; keeps an application index built with caseFoldExpression in both initialization modes, and it serves the operators; the postgres packed check and the postgresTypes consumer of scripts/test-packages.js. |
Both backends ignore case through the Unicode simple case foldings, which map one code point to one code point: MongoDB through PCRE2's case-insensitive matching, PostgreSQL through ICU lower() and translate() of the 22 lowercase variants that lower() keeps and simple folding maps to another letter (U+00B5, U+017F, U+0345, U+03C2, U+03D0, U+03D1, U+03D5, U+03D6, U+03F0, U+03F1, U+03F5, U+1C80 to U+1C88, U+1E9B, U+1FBE), the list in packages/postgres/src/query/case-fold.js. Neither applies a length-changing folding, apart from PostgreSQL's lowering of İ, below. Mapping ς to σ also makes a value that starts or ends with a capital Σ fold as it does in the stored text, so each case-insensitive operator returns every row its case-sensitive form returns on both backends (ISTARTS_WITH "ΚΑΣ" finds ΚΑΣΤΡΟ). The backends return the same rows except on the two limitations that #108 accepts: İ, which lower() turns into i followed by U+0307 and MongoDB matches only with itself, and letters newer than one side's Unicode tables, such as U+A7DC (Unicode 16), which PostgreSQL folds only when its ICU knows them and MongoDB 8.0 does not. | Shared, with documented differences | FOLDING_CASES, which pins the two differences and expects the same words on both backends for every other case, among them the 22 variants and the sigma at both ends of a value; returns, with each case-insensitive operator, every word that its case-sensitive form returns, on both backends, over every prefix, suffix and substring of the folding words, which fails on any miss; returns the same words on both backends for every case-insensitive search of each substring of the folding words, in each case, except the accepted differences, which allows only the words of ACCEPTED_FOLDING_DIFFERENCES as extra PostgreSQL rows; and folds Unicode 16 letters on PostgreSQL only with an ICU that knows them, never on MongoDB 8.0 in the parity suite; case folding in the MongoDB suite, with 54 pairs, each searched both ways; folds case for %s, folds each lowercase variant that ICU lower() keeps as its simple case folding, on the server, finds, with a case-insensitive value that starts or ends with a capital Σ, every word that its case-sensitive form finds and maps 22 lowercase variants that lower() keeps, each one code point to one code point, to their simple case foldings in the PostgreSQL suite. |
The fold needs a UTF8 database on a server built with ICU. A statement that folds case fails there with 22P05 when the encoding lacks the fold's characters, as LATIN1 does, and with 42704 when the collation und-x-icu is missing, on a server without ICU or in an encoding that ICU does not support, such as SQL_ASCII; the driver marks the errors of statements that contain the fold, and the plugin reports those two codes of them as UNSUPPORTED_QUERY (400), ILIKE, ISTARTS_WITH, IENDS_WITH and IEQ need a UTF8 database with the ICU collation und-x-icu, with the driver error as the cause. Other statements keep their mapping of both codes, and initialization, writes and the other operators keep working on such databases. The fold is an inline expression, not a database object, so initialization creates and checks nothing for it and existing databases need no migration. | PostgreSQL | marks, unchanged, the errors of statements that fold case, and reports 22P05 and 42704 of those as UNSUPPORTED_QUERY (400) and initializes %s and serves every other operation, and fails only the case-insensitive operators, with UNSUPPORTED_QUERY, which creates a LATIN1, a SQL_ASCII and a UTF8 scratch database without und-x-icu on the test server and drops them, in the PostgreSQL suite; the postgres packed check of scripts/test-packages.js. |
An adapter supports the operators that queryFeatures.operators lists, or the ten of 3.7.0 without the member. The runtime rejects any other QLOperator value with UNSUPPORTED_QUERY (400) in list, count, aggregate and collection reads and in by-ID reads through a get_by_id scope: in the client's arguments before middleware, and in the final arguments before the adapter call. Path errors come first, unknown names in the declaration are ignored, operators outside QLOperator still reach the adapter, and a malformed declaration throws INVALID_QUERY_FEATURES (500). The MongoDB adapter declares every operator. | Default for custom adapters | runtime with an adapter that declares no queryFeatures, queryFeatures declarations and shared operator module in tests/query-text-operators.test.js; queryFeatures in the MongoDB suite; returns UNSUPPORTED_QUERY without calling an adapter that declares no queryFeatures, and still runs LIKE in tests/mcp-text-operators.test.js. Guards: sends the operators of 3.7.0 unchanged and leaves operators outside QLOperator to the adapter, as before. |
A SQL plugin declares queryFeatures the same way; bindPlugin rejects a malformed one with INVALID_SQL_PLUGIN (400) and gives a plugin without it the operators of 3.7.0. The SQL adapter exposes the declaration and passes it to createQueryPlan as operators, so its own plans, as in getModel(Type).find(), reject the others with UNSUPPORTED_QUERY before compileQuery. A declaration that names, as queryFeatures.compileQuery, another function than the plugin's compileQuery gives the operators of 3.7.0. postgresPlugin() declares every operator for its own compileQuery, so a copy that replaces it, such as { ...postgresPlugin(options), compileQuery }, keeps the operators of 3.7.0 until it declares its own. | SQL plugin contract | SQL plugin queryFeatures, rejects the new operators for a plugin without queryFeatures, also in reads outside the runtime and rejects the new operators for a copy of the plugin that replaces compileQuery, before its compiler runs in the PostgreSQL suite; the sql and postgres packed checks in scripts/test-packages.js. |
Generated MCP tools list every operator in the QLOperator enum of their input schemas and in the list and aggregate tool descriptions, and pass text conditions through unchanged. | Shared | tests/mcp-text-operators.test.js; tests/integration/query-text-operators-mcp.test.js. Guard: reaches the operator enum from field filters, relation terms and group conditions. |
Core exports the QueryOperator and QueryFeatures types; DatabaseAdapter.queryFeatures and SQLPlugin.queryFeatures are optional, typed QueryFeatures | null | undefined and SQLQueryFeatures | null | undefined, where the SQL package's SQLQueryFeatures adds the optional compileQuery; createQueryPlan accepts operators; MongoAdapter.queryFeatures is readonly. The untyped @simtlix/simfinity-core/internal/query-operators subpath, which is not public API, holds the shared rules. | Default | The coreTypes, sqlTypes, postgresTypes and mongoTypes consumers and the exact-optional.ts fixtures in scripts/test-packages.js (npm run test:packages), whose packed checks also run the operators against each archive; keeps the internal subpaths untyped and unchanged in tests/core-subpath-types.test.js. |
Each new test was checked to fail on 3.8.0 (0e4f2fc) for the expected reason, such as the missing enum value, an unknown or unsupported operator or the missing declaration, except the guards that pin 3.8.0 behavior and pass on both: path errors before value errors, the 3.7.0 plans, pipelines, statements and errors, an adapter without queryFeatures receiving the client's arguments unchanged, an operator outside QLOperator reaching the adapter, four LIKE row cases and the MCP schema shape. Outside the suites, the 3.7.0 operators were also compiled on 3.8.0 and with the change: 385 MongoDB pipelines and 44 error messages, over every surface, and 920 PostgreSQL statements, over every operator, path and mode, were byte-identical. The case-folding tests were also run against the plain lower() fold of an unreleased draft, where 81 failed for the expected reason: its statements, the words that lower() keeps apart or that a capital Σ at an end of a value misses, plans of indexes built with caseFoldExpression, and rows or DATABASE_ERROR (500) instead of UNSUPPORTED_QUERY on the scratch databases. Of the 1,440 PostgreSQL statements of the sixteen operators over every path, surface and form, only the 360 of the four case-insensitive operators differ from that draft, each only by caseFoldExpression(x) in place of lower(x COLLATE "pg_catalog"."und-x-icu") on both sides of its condition, also with explicitNullsOrder: true.
3.10.0: emptiness operators
Version 3.10.0 adds IS_EMPTY and IS_NOT_EMPTY to QLOperator, for the queried record's own fields; emptiness through references and collections is rejected until a later release. The rows below record the contract; see the upgrade notes for the changes applications can observe. The evidence is in the repository's tests/ and scripts/test-packages.js, and the shared fixture and cases are in tests/helpers/query-emptiness.js.
| Behavior | Decision | Executable evidence |
|---|---|---|
IS_EMPTY holds for a String, or a scalar whose base scalar is String, that is missing, null or "" (" " is not empty); for any other scalar or enum, a state machine's state, an application scalar and an inferred inverse reference, when missing or null; for a scalar list or an embedded list that is missing, null or [] ([null], [""] and [{}] are not empty); and for an embedded object that is missing, null or whose stored members are all empty, nested single embedded objects by the same rule and list members by the list rule, virtual members left out. A type without stored members is always empty. The record identity takes the null rule whatever its declared scalar, so it is never empty, while an embedded id member keeps its declared scalar's rule. IS_NOT_EMPTY is the exact complement. Both backends return the same rows for every case, written by generated create and update mutations and natively. | Shared (additive) | EMPTINESS_CASES of the shared helper in tests/integration/query-emptiness-parity.test.js, on both backends, including the root id: String entity and embedded id members; the rows of tests/integration/query-emptiness-mongodb.test.js, on a replica set and a standalone server, with native writes of missing, null, "", " ", [], [null], [""], {} and all-null members; the rows of tests/integration/query-emptiness-postgres.test.js, with native 'null'::jsonb, {"tags":null}, text[] {NULL} and owned states; describeEmptiness and the emptiness test helper in tests/query-emptiness.test.js. |
The value must be omitted or null; any other value, "", [], {}, false and 0 included, fails with INVALID_FILTER_VALUE (400) before compiling, on both backends, through one core rule. | Shared | accepts only an omitted or null value, with INVALID_FILTER_VALUE for any other and accepts only an omitted or null value, after the path rules and before rule 6 in tests/query-emptiness.test.js; the error-code parity of the parity suite; the INVALID_FILTER_VALUE tool errors of tests/integration/query-emptiness-mcp.test.js. |
The operators work in field filters on scalars and scalar lists, in relation terms and in AND/OR conditions, on every surface where filters apply: list queries, pagination.count, aggregate filters, collection fields, batched or not, find, get_by_id and aggregate scopes, middleware and MCP list and aggregate tools. A term or group condition whose path is omitted, null or "" tests the field itself, also next to sibling terms with paths; a group condition may name a dotted field. Written on an object filter itself, without terms or beside them, they fail with MISSING_FILTER_PATH (400). Other operators keep their forms and codes. | Shared | query plans with emptiness operators, collectQueryOperators and collectQueryPathEntries in tests/query-emptiness.test.js, whose keeps the codes of path-less terms of other operators, and of sort, groupId and fact paths (regression pins, pass on 3.9.0) passes on 3.9.0 by design; the counts, aggregates, collection fields, scope- and middleware-added conditions of the parity suite; the batched collection fields of both backend suites; tests/integration/query-emptiness-mcp.test.js. |
One emptiness condition is checked in a fixed order, the same on every caller, so that no permanent code changes when reference emptiness arrives: on client paths and their members, the authorization plugin's read rules and the queryable: false, application-resolver and virtual-field checks (403); form, fields and group depth with the existing codes; an undeclared operator (UNSUPPORTED_QUERY); INVALID_FILTER_PATH for a path inside an embedded list or a collection, or an interface or union field or member; INVALID_FILTER_OPERATOR for re-entry through single embedded members (a type that reaches itself only through a list member is accepted); INVALID_FILTER_VALUE; and UNSUPPORTED_QUERY for a path that crosses or ends at a reference or ends at a collection, and for reference and collection members. The runtime applies it to the client's arguments before middleware and to the final arguments before the adapter call; the FORBIDDEN_FILTER_PATH checks of scoped collections and of scopes that filter through a collection run after that gate, so they never apply to an emptiness condition, which the gate rejects with 400 when it enters a reference or a collection; createQueryPlan, the PostgreSQL compiler and MongoDB's buildQuery and buildFilterGroupMatch apply it without path restrictions. | Shared | applies the order of checks, rejects an operator the compiler does not declare after rule 1 and before the other rules (rule 2), rejects interface and union fields and members with INVALID_FILTER_PATH, before references (rule 3) and rejects re-entry through single embedded members with INVALID_FILTER_OPERATOR (rule 4) in tests/query-emptiness.test.js; the runtime code table and the cases of the gate around middleware in tests/query-emptiness-runtime.test.js; the error-code parity of the parity suite. |
A condition that crosses or ends at a reference, ends at a collection, or reads a reference or collection member, fails with UNSUPPORTED_QUERY (400), whoever writes it: a client, middleware, a scope or a joined scope. One that continues inside a collection, such as seasons.year, fails first with the permanent INVALID_FILTER_PATH. A joined find scope that uses the operators below a client's reference path, also one reached through a sort term, a groupId or a fact path, fails with a message that names the scope and suggests EQ null, after the existing FORBIDDEN_FILTER_PATH checks of scoped collections; an invalid scope condition gets its own permanent code. A rejected client condition never runs a scope, and gets the same code for every caller. | Temporary, until reference emptiness | rejects %s.%s with %s, whose reviews.text and shop.items.note rows pin the permanent INVALID_FILTER_PATH, explains each rejection, rejects a collection member with UNSUPPORTED_QUERY (rule 6), never reading it as a list and rejects a reference or an interface member inside a nested single embedded object, as one at the top (rules 3 and 6) in tests/query-emptiness.test.js; the cases of the gate around middleware and of joined scopes in tests/query-emptiness-runtime.test.js; the UNSUPPORTED_QUERY tool errors of the MCP suite. |
For a client's condition on a whole embedded object, every stored member path the member rule reads is a client path: inspectClientPaths reports FORBIDDEN_FILTER_PATH (403), naming the member path, for a member with queryable: false or an application-defined resolver, and the authorization plugin reports FORBIDDEN (Access denied to Type.field) or the rule's own error, sharing its Type.field checks with named paths. Reference, collection, interface and union members are checked without being entered; virtual members are left out; conditions that middleware and scopes add are not checked. | Fail closed | emptinessMemberPaths and expands the members an emptiness condition reads when asked, marked as members, after the named path in tests/query-emptiness.test.js; the member-authorization cases of tests/query-emptiness-runtime.test.js, with and without the authorization plugin; tests/query-path-authorization.test.js, unchanged. |
MongoDB compiles plain query operators inside the existing $match: { p: { $in: [null, ""] } }, { p: null } (the identity as _id), { "p.0": { $exists: false } } and { $or: [{ p: null }, { $and: [<members>] }] }, with $nin, $ne, $exists: true and $nor for IS_NOT_EMPTY, and a fixed always-true form for a type without stored members. Embedded id members resolve their key through identifierLeaf, so an object with an automatic subdocument _id is never empty. Several object conditions in one query all apply. The String form reads an index range. Existing pipelines are unchanged. | MongoDB | The pinned pipelines, <path>.id members, two object conditions in one query, the application JSON scalar, the accepted list cycle, the explain IXSCAN, batched collection fields and the unchanged pipelines of tests/integration/query-emptiness-mongodb.test.js; the cases added to tests/mongodb-query-paths.test.js. |
PostgreSQL compiles every emptiness predicate with one walker, before path(), from the storage location: IS NULL and = '' on columns, COALESCE(cardinality(c), 0) on native arrays, ->> without casts and a guarded jsonb_array_length on jsonb, and IS DISTINCT FROM 'present' or NOT EXISTS subqueries with fresh aliases on owned tables, owned-in-owned included, without the __item_present filter. IS_NOT_EMPTY uses the positive leaf forms and NOT (…) of object and owned forms. Nothing is bound. The String form uses a BitmapOr of btree scans. Existing statements are unchanged. | PostgreSQL | The pinned statements for root columns, the root identity, jsonb, owned single objects and lists, owned rows with native-array and jsonb members and owned-in-owned, the native writes, the EXPLAIN BitmapOr, batched reads and the unchanged statements of tests/integration/query-emptiness-postgres.test.js. |
On PostgreSQL, a filter path of an operator other than IS_EMPTY and IS_NOT_EMPTY that has more than one segment and ends at an object fails with INVALID_FILTER_PATH (400), as on MongoDB, instead of MISSING_FILTER_PATH; a bare object field keeps MISSING_FILTER_PATH on both, and groupId and fact paths keep their per-backend codes. | Changed (PostgreSQL) | rejects longer filter paths that end at an object with INVALID_FILTER_PATH, and keeps MISSING_FILTER_PATH for a bare field in tests/query-emptiness.test.js; the section-5 case of tests/integration/query-emptiness-postgres.test.js; the error-code parity of the four object-ending paths and a bare field in the parity suite. |
MONGO_QUERY_OPERATORS and the PostgreSQL plugin's queryFeatures.operators list both operators. An adapter or SQL plugin that does not declare them rejects them with UNSUPPORTED_QUERY (400) before middleware and before the adapter call; declaring them does not lift the rejection of references and collections. | Default for custom adapters | The queryFeatures pins of tests/integration/query-text-operators-mongodb.test.js and tests/integration/query-text-operators-postgres.test.js; the undeclared plugin of tests/integration/query-emptiness-postgres.test.js; the undeclared-adapter consumer in scripts/test-packages.js. |
Generated MCP tools list both operators in the QLOperator enum and in the list and aggregate tool descriptions, describe that they take no value and that a term or group condition without path tests an embedded field itself, accept those forms and pass them through without a value key. | Shared | says that IS_EMPTY and IS_NOT_EMPTY take no value and test an embedded field itself without a path and the guards that name every operator in tests/mcp-text-operators.test.js; tests/integration/query-emptiness-mcp.test.js on both backends. |
The QueryOperator union gains 'IS_EMPTY' and 'IS_NOT_EMPTY'; QueryPredicate, createQueryPlan and DatabaseAdapter.queryFeatures document them. The shared rules are in the untyped @simtlix/simfinity-core/internal/query-operators subpath, which is not public API. | Default | The Equals<QueryOperator, …> check and the QUERY_OPERATORS pins of scripts/test-packages.js (npm run test:packages); tests/core-subpath-types.test.js. |
Each new test was checked to fail on 3.9.0 (d57c3e0) for the expected reason, such as the unknown enum value, the missing shared module, an undeclared operator or a description that does not name the operators, except the regression pins, whose names say that they pass on 3.9.0 by design: the codes of path-less terms of other operators, also in a joined target scope, and of sort, groupId and fact paths; the MISSING_FILTER_PATH of a bare object field in a group condition on both backends; MongoDB's handling of other operators on object filters, where an operator beside terms is left out; the describeModels rejections of cyclic and unsupported scalars; a term on a scalar field, which the compiler rejects by the filter's own operator; the 403s of a restricted caller's legacy paths into a scoped collection or into a type whose scope crosses a collection; the MongoDB codes of virtual, unknown and already rejected paths in tests/mongodb-query-paths.test.js; the EQ rows of an application scalar on MongoDB; the check of the native values the PostgreSQL cases store; and the unchanged pipelines and statements of the other operators. The tests of the shared helper's seeding and native writers, the existing native MongoDB writer included, also pass on 3.9.0, with the helper copied along, since they use no emptiness operator.
3.11.0: reference and collection emptiness
Version 3.11.0 answers IS_EMPTY and IS_NOT_EMPTY through references and collections for adapters that declare queryFeatures.referenceEmptiness, as both bundled adapters do. For those adapters it supersedes the 3.10.0 row marked "Temporary, until reference emptiness"; for other adapters that row still holds, with the reworded message. The rows below record the contract; see the upgrade notes for the changes applications can observe. The fixture and its expected rows are createReferenceEmptinessTypes, seedReferenceEmptiness and REFERENCE_CASES in tests/helpers/query-emptiness.js, with the scope shapes admin, NE, EQ, proOnly, shopNE and shopEQ.
| Behavior | Decision | Executable evidence |
|---|---|---|
A client's condition reads each reference and collection through the target type's find scope, never its get_by_id scope: a reference is empty when no record it points to exists that the scope admits, a collection when no such child references the record, and a field through single references by its own rule, also when a hidden or absent reference on the way makes it empty; a hop after a scoped one stays scoped, even when its own scope adds nothing. A hidden record reads exactly like a missing one, under scopes that accept null and under scopes that reject it. The id right after a reference takes the null rule whatever the target declares, so shop.id IS_EMPTY matches shop IS_EMPTY; an embedded id member keeps its declared scalar's rule. Both backends return the same rows for every case and scope shape, the dangling MongoDB reference included. | Shared (additive) | REFERENCE_CASES, checked against the definitions by gives every case the rows that the definitions give (helper check, passes on 3.10.0) in tests/reference-emptiness.test.js, run through the runtime by gives every case its rows for every shape, as a term, a group condition and trusted code (dangling %s) in tests/reference-emptiness-runtime.test.js, and run on both backends for every shape by returns the rows of %s on both backends for every shape, from the client and from trusted code, in lists, counted pages and aggregates and filters the collection field Shop.products, read %s, by %s alike for every shape in tests/integration/reference-emptiness-parity.test.js, which also has reads the String identity right after a reference exactly as the reference, client and trusted, on both backends, correlates the hop after a scoped crossing to that scoped join on both backends, also when its own scope adds nothing, and keeps trusted constructs on plain joins, reads MongoDB's dangling product as empty wherever the cases say, client and trusted, for every case and shape and fills the client markers of an _aggregate call from each target's find scope, not from its aggregate scope, on both backends; describes %s %s from GraphQL types and from model descriptions alike, keeps an embedded id member under its declared scalar after a reference, and the null rule for the identity there and describes a collection member inside an embedded type with the storage column of its connectionField in tests/reference-emptiness.test.js. |
Conditions that middleware, scopes, joined scopes and direct compiler callers write are trusted and read stored data: no stored reference that resolves to an existing record, no stored child, and plain joins for fields; a dangling MongoDB reference counts as empty, and PostgreSQL compiles a trusted reference as "ref" IS NULL. Scopes never recurse. | Shared | The trusted rows of REFERENCE_CASES, which the parity cases above also run from trusted code; plans emptiness through references with stored semantics, without scopes, by default in tests/reference-emptiness.test.js; trusted constructs read stored data: frames without a scope, and leaves through the plain lookup aliases and IS_EMPTY matches the dangling reference and IS_NOT_EMPTY does not, for every caller in tests/integration/reference-emptiness-mongodb.test.js; the trusted reference, trusted collection, trusted join, trusted chain and trusted reference end in an owned row cases of pins the statements of a %s, compiles a scope in its own frame: its joins inside the subquery after its alias, its emptiness trusted, and nested frames and serves a trusted reference IS_EMPTY with the generated index of its column in tests/integration/reference-emptiness-postgres.test.js; plans a reference crossing, a reference member or a reference end that middleware or a scope adds as a trusted condition, without scopes and plans a reference crossing that middleware or a scope adds to an aggregate or a collection field as a trusted condition, without scopes in tests/query-emptiness-runtime.test.js; reads what middleware and scopes add through references with stored semantics on both bundled adapters, and keeps UNSUPPORTED_QUERY for it when the adapter or plugin does not declare referenceEmptiness in tests/integration/query-emptiness-parity.test.js. |
Each list, aggregate and collection-field call deep-copies the client's filter arguments before middleware, with every adapter: every filter, term, condition and group gets its own object, prototypes are kept and value arrays are copied shallowly, where 3.10.0 copied only the AND and OR groups and their conditions lists. With a declaring adapter, the runtime marks each client emptiness term or group condition that reads a reference or a collection with __scope: { id }, records no joined scope for it, counts its scoped joins and frames against maxReferenceFrames after every 2a check, and, after middleware and the query's own scope, checks every marker, applies the final gate and fills each marker with the raw target scopes of its final path. A missing, unknown or malformed marker, a marked condition changed to another operator while its path still reads a reference or a collection, or one marked object placed both where it reads a reference or a collection and where it reads none gives DETACHED_CLIENT_FILTER (500) before any adapter call; a condition narrowed to the record's own fields loses its marker, and so does a spread copy placed where it reads none. The later collection-frame section supersedes the collection part of the operator-change restriction; non-emptiness reference-only predicates outside frames still fail. | Fail closed | In tests/reference-emptiness-runtime.test.js, with a declaring and an undeclared adapter: keeps a middleware edit of a shared variable within its call when the adapter %s reference emptiness, copies every place an inline literal or a variable sits, with its prototype, so middleware edits one place only, marks only the client's constructs that read references or collections, with ordinal ids middleware sees, for a declaring adapter only, fills every use of one condition, group or term variable used twice in a call, with no DETACHED_CLIENT_FILTER, marks and fills its own copy, in each call, of a nested group or a term variable that two query fields and sibling collection-field calls share (batch %s), marks and fills its own copy, in each call, of a term or condition variable that two _aggregate fields, or an _aggregate and a list field in either order, share, accepts spreading, cloning or duplicating it, reading the hidden target as missing at every place, fills one marked term placed under a second key with the union of both places' scopes, unless one place reads no reference, reads the final path: an edited path keeps the client's scopes, and one narrowed to an own field reads as 2a with no marker, checks every marked construct before any target scope runs, so a later construct's DETACHED_CLIENT_FILTER comes first, gives DETACHED_CLIENT_FILTER on %s when middleware or a scope detaches it, before the adapter runs, for a list with a count, an aggregate and collection fields batched and not, never reads a marker planted inside a value, so a rebuilt construct still gives DETACHED_CLIENT_FILTER, runs the detach check, then the final gate, then the fill, so no target scope runs before a DETACHED_CLIENT_FILTER or a 2a code and runs each target type's scope once per call, after middleware and the query's own scope, so it sees what middleware set. On both backends, marks and fills its own copy, in each query field, _aggregate field and collection-field call, of a nested group and a term that they share, alike (%s), runs a spread copy of a marked term placed where it reads no reference as a trusted condition, and fails the same object placed there with DETACHED_CLIENT_FILTER, alike and fails a rebuilt marked construct with DETACHED_CLIENT_FILTER (500) and the same message on both backends before the adapter runs, in counted lists and aggregates in tests/integration/reference-emptiness-parity.test.js. |
createQueryPlan() takes referenceEmptiness, true by default only without operators; with false, these conditions fail with UNSUPPORTED_QUERY (400), … the database adapter does not support emptiness through references and collections. It lifts a filled __scope into scopes on the 2a predicate, one key per crossing, end and member, each target scope planned as a find on its target type, or null; there is no new node kind. A marker that is not filled for every path gives DETACHED_CLIENT_FILTER (500), after every 400 check. | Shared | defaults referenceEmptiness to true without operators and to false with them, as the adapters declare it, lifts a filled marker into scopes, one key per join and frame, each planned on its target type, lifts only the keys of each position, plans scopes as trusted arguments, and gives the same plan without GraphQL types, plans a marker on a scope from Review through two references, reads the operators once, so a one-time iterable applies inside scopes as an array does, plans each lifted scope as a find on its target, also in count and aggregate modes, fails closed with DETACHED_CLIENT_FILTER (500) on a marker it cannot honor, gives the 400 codes of the order of checks before the marker check, and never reads a marker elsewhere and keeps rule 6 as UNSUPPORTED_QUERY, reworded, when the caller does not support reference emptiness in tests/reference-emptiness.test.js; the rule-6 pins of tests/query-emptiness.test.js, now in pairs, such as describes %s.%s through references and collections, and rejects it with UNSUPPORTED_QUERY without reference emptiness. |
configureQueryLimits({ maxReferenceFrames }) defaults to 10 and takes a positive safe integer, validating every option before it sets any; an omitted option takes its default again. One set of conditions that needs more distinct (scoped join or frame, path) pairs fails with FILTER_DEPTH_EXCEEDED (400). The client's arguments of each list, aggregate, collection-field or named view call are one set. A view's where is a separate set held to the same limit. Middleware and scope conditions are not counted; the limit never counts the whole GraphQL operation. The count for the client's arguments runs before middleware, after the 403s of the client's paths, the UNSUPPORTED_QUERY of undeclared operators and every 2a check of every client emptiness condition, so it comes before the field and value checks of conditions with other operators and the joined-scope 403s, which run later. A view's where set is checked after middleware. | Shared | gives what the reference frame limit counts: distinct joins and frames per path, defaults to 10, takes a positive safe integer, and restores the default when a later call omits it and rejects anything else with INVALID_QUERY_LIMITS (400) and keeps both limits in tests/reference-emptiness.test.js; passes 10 distinct scoped joins and frames and rejects 11, for an admin and a restricted caller alike, before middleware, comes after every 2a code of any client condition, counts each query field apart, and leaves trusted constructs out, comes before the field and value checks of other conditions and the joined-scope 403s, and after the 403s of client paths, undeclared operators and the 2a codes, takes configureQueryLimits({ maxReferenceFrames }), keeps it on an invalid call and restores the default when omitted and counts nothing for an adapter that does not declare reference emptiness, which rejects the constructs instead in tests/reference-emptiness-runtime.test.js. |
queryFeatures.referenceEmptiness is a boolean; undefined and null mean false, and any other value fails createRuntime() with INVALID_QUERY_FEATURES (500), where 3.10.0 ignored it; in a SQL plugin, it fails bindPlugin, and so the SQL adapter's construction, with INVALID_SQL_PLUGIN (400), also when the declaration names another compileQuery. createMongoAdapter() and postgresPlugin() declare it; bindPlugin binds it with operators under the same compileQuery rule and the SQL adapter passes it to createQueryPlan(). A copy of createMongoAdapter() or of the SQL adapter keeps the declaration, so a copy whose methods compile filters themselves must apply the markers or declare its own queryFeatures. Without it, the conditions keep UNSUPPORTED_QUERY with the reworded message, nothing is marked and every path keeps its joined scope. | Default for custom adapters | reads it as a boolean next to the operators, false without a declaration and rejects any other value with INVALID_QUERY_FEATURES (500), in createRuntime too in tests/reference-emptiness.test.js; declares referenceEmptiness in the PostgreSQL plugin, and bindPlugin copies it under the compileQuery identity rule, rejects queryFeatures.referenceEmptiness %j with INVALID_SQL_PLUGIN, also with a declaration that names another compileQuery, reads batched children through the adapter's findChildrenBatch with the flag its plugin declares, and rejects them for a plugin that does not declare it and rejects emptiness through references and collections with UNSUPPORTED_QUERY, in GraphQL and in reads outside the runtime in tests/integration/reference-emptiness-postgres.test.js; the adapter declares every QLOperator value, listed explicitly, and reference emptiness in tests/integration/query-text-operators-mongodb.test.js; keeps referenceEmptiness in spread copies of both bundled adapters, whose find receives the filled marker and returns the bundled rows and rejects emptiness through references and collections with UNSUPPORTED_QUERY and the same message on both backends when the adapter or plugin declares the operators without referenceEmptiness in tests/integration/reference-emptiness-parity.test.js; the undeclared variants of tests/query-emptiness-runtime.test.js, tests/integration/query-emptiness-postgres.test.js, tests/integration/query-emptiness-mongodb.test.js and tests/integration/query-emptiness-parity.test.js; the packed checks of scripts/test-packages.js, the spread copy of createMongoAdapter() included. |
Authorization: shop IS_EMPTY needs the read rule of the named path only, and fields through a reference need every segment's rule. Frames and scoped joins apply the target's scope inside the condition, so the FORBIDDEN_FILTER_PATH checks of scoped collections and of scopes that cross a collection or a reference inside an embedded list do not apply to them; legacy paths keep their 403. | Shared | authorizes a reference or member end by its own path only, so Shop needs no rule under DENY, denies a leaf through a reference whose field has no rule (regression pin, passes on 3.10.0 by design) and answers a restricted caller's reference end with no 403 when its target scope crosses a collection or a reference inside a list, which a legacy path keeps in tests/reference-emptiness-runtime.test.js; reads a client's path-less condition on a scoped collection through the Review frame scope, run after middleware, for an admin and a restricted caller alike and reads a queryable reference member through its target's frame scope, run after middleware, for an adapter that declares reference emptiness in tests/query-emptiness-runtime.test.js, where keeps the 403 of a restricted caller's legacy path into a scoped collection, or into a target whose scope crosses a collection (regression pin, passes on 3.9.0) keeps the legacy 403. |
A joined find scope that uses the emptiness operators is prefixed and compiled as a trusted condition; one below a list prefix keeps the permanent INVALID_FILTER_PATH. Legacy client paths beside a marked condition, sort, groupId and fact paths and the text operators keep their joined scopes, which also restrict the marked conditions of their group or query. | Shared | applies a target scope that uses an emptiness operator below a client's relation path as trusted conditions, for an adapter that declares reference emptiness, applies a joined scope reached only through a sort term, a groupId or a fact path as a trusted condition, for an adapter that declares reference emptiness and keeps the decision-3 rejection for a prefix through a reference alone only when undeclared, and applies EQ null scopes below list prefixes in tests/query-emptiness-runtime.test.js, with their undeclared variants; applies a joined target find scope that uses an emptiness operator as a trusted condition on both bundled adapters, alike in tests/integration/query-emptiness-parity.test.js, which also keeps the 403 of a legacy path whose joined scope tests a collection; in tests/integration/reference-emptiness-parity.test.js, under scopes that accept and reject null, returns the rows of %s on both backends for every shape, in lists, counted pages and aggregates for legacy branches, legacy conditions in the same group and a sort beside a marked construct, returns the documented rows of a legacy branch, a legacy condition in the same group and a sort beside a marked construct on the same reference, under scopes that accept and reject null, keeps the rows of legacy EQ null and of a text operator through a reference, with their joined scopes, on both backends (regression pin, passes on 3.10.0 by design) and restricts a marked construct with the joined scope of a text operator in its group, not in another, and reads more rows than legacy EQ null under a scope that accepts null. |
MongoDB compiles a client's crossings as the plain __sf_lN lookups of trusted ones while every scope on the path so far is null, and from the first crossing with a scope as scoped joins, $lookup with the scope's stages and { $limit: 1 } into __sf_jN, then an $unwind that keeps the record, and frames as $lookup with { $limit: 1 } and { $project: { _id: 1 } } into __sf_qN, tested with '__sf_qN.0' and, for collections, a null guard on the local key; the counters are separate from __sf_lN, every alias is removed, and a target without a model gives INVALID_FILTER_PATH (400) before any lookup. Legacy and 2a pipelines are byte-identical. Reference frames and joins use _id_ and collection frames the connection field's index. | MongoDB | In tests/integration/reference-emptiness-mongodb.test.js, the pinned pipelines of client constructs read through scoped joins and frames, in a field filter term and in a group condition, a client construct whose scopes are all null compiles exactly as the trusted one, so an admin shares its joins, a hop after a scoped crossing stays scoped, also when its own scope is null, legacy conditions keep their lookups, aliases and match when client constructs share the query, a reference stored under another name frames and joins on its storage column, trusted and client, each target scope compiles with this compiler in a fresh alias space, with the target model, joins and frames are shared by scope object, and a scope that adds nothing reads as none, counts and aggregates add the joins and frames before $count and $group and a marker that is not filled for every path the construct reads fails closed, after every 400; on a replica set and a standalone server, every construct case, client in a term and a group condition and trusted, for every shape, IS_EMPTY matches the dangling reference and IS_NOT_EMPTY does not, for every caller, legacy branches, a sort and trusted conditions beside client constructs keep the documented rows, collection reads filter children one parent at a time and batched with $unionWith, a collection member inside an embedded type of a supplied model reads the subdocument _id, with the null guard and joins and frames probe the _id index and the connection index, and the root predicate keeps its index bounds, whose explain the CI runs on MongoDB 7.0 and 8.0; rejects %s, whose related model is not available, with INVALID_FILTER_PATH and no lookup and compiles references and collections with stored semantics, crossed, reached or as members in tests/mongodb-query-paths.test.js; references and collections are accepted by both, and rejected by a plan without reference emptiness in tests/integration/query-emptiness-mongodb.test.js. |
PostgreSQL compiles a client's crossings as the plain LEFT JOINs of trusted ones while every scope on the path so far is null, and from the first crossing with a scope as scoped joins, LEFT JOIN LATERAL (… LIMIT 1) … ON TRUE, and frames as NOT EXISTS/EXISTS subqueries, each in a frame with its own joins and global alias() and bind(), scope values bound, member frames inside owned rows, and a trusted reference, or a client's reference whose own scope is null, as "ref" IS NULL. Legacy and 2a statements are byte-identical. | PostgreSQL | In tests/integration/reference-emptiness-postgres.test.js, pins the statements of a %s, with alias numbering, for cases such as client reference with a null scope, scoped join, null hop after a scoped crossing, null end after a scoped crossing, scoped hop after a null crossing and reference end in an owned row; pins member frames inside owned rows, and inside an owned row of an owned row, with their own scopes, shares a scoped join between client constructs with the same chain, and never with a trusted or legacy path, keeps a legacy condition, a joined scope and a sort on the plain join beside a client construct on the same reference, compiles the same forms in finds, counts, aggregates and batched children, scope parameters before the parent IDs, binds every scope value as a parameter, never as text, fails closed with DETACHED_CLIENT_FILTER (500) on scopes that createQueryPlan does not write, after the checks with their 400 codes, returns the rows of owned objects inside owned objects with a reference member, client and trusted, probes the target's primary key from frames and scoped joins, and the connection index from collection frames, serves a trusted reference IS_EMPTY with the generated index of its column, probes the same indexes in every branch of a batched children statement and filters batched collection fields through references and collections with one batched statement, as reads without batching do; compiles emptiness through references and collections with stored semantics, and plans without referenceEmptiness keep UNSUPPORTED_QUERY and answers emptiness through references and collections, through the SQL runtime and in reads outside it in tests/integration/query-emptiness-postgres.test.js. |
| Generated MCP tools describe that the conditions test references and collections where the database adapter supports it, and pass them through. | Shared | says that IS_EMPTY and IS_NOT_EMPTY take no value, test the field itself without a path, and test references and collections where the adapter supports it, which replaces the 3.10.0 test of the descriptions, in tests/mcp-text-operators.test.js; on both backends, returns the rows of conditions through references and collections, which the bundled adapter declares, from list and aggregate tools, returns UNSUPPORTED_QUERY tool errors for conditions through references and collections when the adapter declares the operators without referenceEmptiness and reads a record that the target's find scope hides as missing in reference-emptiness calls, and passes the filled marker to the adapter in tests/integration/query-emptiness-mcp.test.js; read through references with a scope marker for an adapter that declares reference emptiness in tests/query-emptiness-runtime.test.js. |
QueryFeatures.referenceEmptiness?: boolean | null | undefined, QueryPredicate's scopes?: Record<string, QueryPredicate | null>, createQueryPlan's referenceEmptiness? option and the exported QueryLimitsOptions with maxReferenceFrames. | Default | The coreTypes, sqlTypes, postgresTypes and mongoTypes consumers of scripts/test-packages.js (npm run test:packages); declares null and undefined in QueryFeatures, as createRuntime accepts them in tests/reference-emptiness.test.js. |
Each new test was checked to fail on 3.10.0 (f655b86) for the expected reason, such as the UNSUPPORTED_QUERY of rule 6, a missing export or option, the missing marker or an unchanged description, except the regression pins and helper checks, whose names say that they pass on 3.10.0 by design, since they use no reference emptiness or keep a 3.10.0 result: in tests/reference-emptiness.test.js, the 2a shapes and permanent codes, the value rule, and the checks of the fixture, its oracle, its seeding and its native writer; in tests/reference-emptiness-runtime.test.js, denies a leaf through a reference whose field has no rule (regression pin, passes on 3.10.0 by design); in tests/integration/reference-emptiness-mongodb.test.js, a marker anywhere else is not read (regression pin, passes on 3.10.0) and the check of the stored fixture; in tests/integration/reference-emptiness-postgres.test.js, keeps the statements of legacy conditions and of 2a emptiness on this fixture (regression pin, passes on 3.10.0 by design), the check of the stored uuids, and returns the rows of %s, which reads no reference and no collection, for every shape, client and trusted, as terms and group conditions (regression pin, passes on 3.10.0 by design) for odim.w; and in tests/integration/reference-emptiness-parity.test.js, the odim.w cases, returns the rows of %s, which reads no reference and no collection, on both backends for every shape, from the client and from trusted code (regression pin, passes on 3.10.0 by design) and filters the collection field Shop.products, read %s, by %s, which reads no reference and no collection, alike for every shape (regression pin, passes on 3.10.0 by design), the planted value keeps the 3.10.0 result of a value that holds __scope when no construct is marked: MongoDB rejects it as an _id, PostgreSQL reads its id (regression pin, passes on 3.10.0 by design), and keeps the rows of legacy EQ null and of a text operator through a reference, with their joined scopes, on both backends (regression pin, passes on 3.10.0 by design). The test files are tests/reference-emptiness.test.js, tests/reference-emptiness-runtime.test.js, tests/integration/reference-emptiness-mongodb.test.js, tests/integration/reference-emptiness-postgres.test.js and tests/integration/reference-emptiness-parity.test.js, with the flipped and extended cases of the 3.10.0 suites listed above; among those, the list-prefix codes and the declared-adapter messages of tests/query-emptiness-runtime.test.js and keeps the code of other operators on a relation whose model is not available (regression pin, passes on 3.10.0) in tests/mongodb-query-paths.test.js are pins named as passing on 3.10.0 too.
3.12.0: named views
Version 3.12.0 adds named views over a registered type's generated list on both backends. The rows below record the contract; see the upgrade notes for the changes applications can observe. The fixture is createNamedViewTypes, registerNamedViewFixture and seedNamedViews in tests/helpers/named-views.js, with openItems(minStock: Int = 1), shopItems(shopId: ID!) and orphanItems.
| Behavior | Decision | Executable evidence |
|---|---|---|
A view returns the same rows, order and count as the generated list with the same client arguments and the where groups sent as client AND groups, on MongoDB and PostgreSQL. This includes defaults, own-field and reference filters, groups, text operators, emptiness and counted pages. | Shared (additive) | The openItems and orphanItems equality matrix in tests/integration/named-views.test.js. |
The base type's find scope applies to the list and count for every caller. A view never widens visibility. GraphQL, MCP and in-process execution return the same rows and count and move together when where changes. | Shared | isolates tenants through the base scope and serves the same rows and count to GraphQL, MCP and in-process execute, and moves together when where changes in tests/integration/named-views.test.js. |
GraphQL coerces params, then the resolver removes them before inspection, middleware, scopes and adapter calls. Middleware gets operation: 'find' and entry: <view name>, with the client's list arguments and before where adds its groups. | Shared | gives middleware the view name as entry and the client arguments, without the params and before the where in tests/integration/named-views.test.js; the param and adapter assertions in tests/register-view.test.js. |
A reference in where receives its target's find scope. A hidden reference reads as missing, including in emptiness conditions. | Shared | reads a hidden reference as missing and answers orphanItems through the shop scope in tests/integration/named-views.test.js. |
MCP publishes a read-only list tool titled by the view name, with the view description followed by the generated filter sentence. Params, generated arguments and totalCount keep the list contract; overrides keep precedence. | Shared | tests/register-view-mcp.test.js. |
Core declares registerView, ViewOptions, ViewParam, ViewParamType, FilterGroup and FilterCondition; the MongoDB, PostgreSQL and SQL packages re-export them. | Shared | tests/register-view-types.test.js and the coreTypes, mongoTypes, postgresTypes and sqlTypes consumers of scripts/test-packages.js (npm run test:packages). |
The four new test files, tests/register-view.test.js, tests/register-view-mcp.test.js, tests/register-view-types.test.js and tests/integration/named-views.test.js, were checked to fail on 3.11.0 (ca9b7bc).
3.13.0: optimistic concurrency
Version 3.13.0 adds opt-in optimistic concurrency on MongoDB and PostgreSQL. The older versioned sections above remain historical contracts. The rows record the shared contract and its executable evidence; see the upgrade notes before enabling protection on existing storage.
| Behavior | Decision | Executable evidence |
|---|---|---|
Safe integer versions from 1 through 9007199254740991; missing/null stored values read as 1; other stored values fail | Add shared RecordVersion, MAX_RECORD_VERSION and generated RecordVersionRef; JSON strings are rejected | tests/record-version.test.js; tests/core-subpath-types.test.js; packed consumers in scripts/test-packages.js (all five packed packages verified). |
Declare protection before the first schema; registered entities only, never embedded; no storage collision or other stored RecordVersion field | Opt-in extension, fixed type/scalar identities, startup adapter-support and reserved-name validation; failed injection can remain on type objects | tests/optimistic-concurrency-schema.test.js. |
| Types neither protected nor owning collections of protected items keep their previous SDL/inputs/read paths/statements/pipelines | Preserve unprotected behavior, including call arity and direct model handles | Explicit pin: cases in tests/optimistic-concurrency-schema.test.js, tests/optimistic-concurrency-lifecycle.test.js, tests/integration/optimistic-concurrency-mongodb.test.js, tests/postgres-optimistic-concurrency.test.js and tests/integration/optimistic-concurrency-postgres.test.js; tests/integration/optimistic-concurrency-parity.test.js. |
| Parent inputs change for protected children even when the parent is unprotected | updated entries need child versions; deleted becomes [RecordVersionRef] in create/update inputs; null entries remain skipped | tests/optimistic-concurrency-schema.test.js, tests/optimistic-concurrency-lifecycle.test.js; tests/integration/optimistic-concurrency-parity.test.js. |
| Creates start at 1; accepted updates/actions advance once, including version-only edits; deletes preserve the removed version | Capture the precondition after middleware, keep it through retries, guard managed hook assignments | tests/optimistic-concurrency-lifecycle.test.js; MongoDB/PostgreSQL integration suites; tests/integration/optimistic-concurrency-parity.test.js. |
| Child ownership precedes its version; readable callers fail stale/exhausted versions before that record's validators/hooks/action | get_by_id scope governs early conflict visibility; hidden callers run normal hooks before adapter conflict. Malformed stored versions may fail before controller hooks | tests/optimistic-concurrency-visibility.test.js; tests/integration/optimistic-concurrency-parity.test.js. |
| Core diagnoses inconsistent results, but cannot prove arbitrary wrapper atomicity or promise supplied-session aborts for all invalid results | Updates require expected + 1 and advancement past the immutable pre-read; deletes return the expected version; wrappers must forward every argument | tests/optimistic-concurrency-visibility.test.js, tests/optimistic-concurrency-lifecycle.test.js, tests/postgres-optimistic-concurrency.test.js. |
| MongoDB missing/null versions count as 1; atomic writes set the explicit next value | Use conditional findOneAndUpdate/findOneAndDelete; never $inc. Supplied query/save middleware must preserve predicates, increments, initialization and returned values | tests/integration/optimistic-concurrency-mongodb.test.js in both integrity modes; native save middleware preservation is an application obligation, not universal enforcement. |
| Hydrated MongoDB reads must not turn corrupt stored versions into valid managed versions | Preserve invalid raw values for the managed resolver while retaining native model values, other field getters, sessions, projections and unprotected aliases | tests/integration/optimistic-concurrency-parity.test.js: generated/supplied models, by-ID and batched reference reads, identity-preserving post-middleware/query conversions, non-extensible and reused results, selected-field/auth ordering and native-value regression pins. |
PostgreSQL versions use bigint NOT NULL DEFAULT 1; plugins must declare support and compile operation.version | Conditional updates run even with empty data; deletes return a row; bad compiler results fail with DATABASE_ERROR | tests/postgres-optimistic-concurrency.test.js, tests/integration/optimistic-concurrency-postgres.test.js. |
| Supplied-session ownership and driver retries remain explicit; transactional MongoDB aborts adapter-detected conflicts | Core early/result diagnostics do not add universal aborts. Retry complete owned transactions; losing MongoDB checks/hooks may repeat, rejected edits never call onUpdated | tests/optimistic-concurrency-lifecycle.test.js, MongoDB/PostgreSQL integration suites; tests/integration/optimistic-concurrency-parity.test.js. |
| Derived values stay in the same revision | MongoDB native updateOne without version changes; SQL/PostgreSQL model handles use { session, version: false }; other native writers own their protocol | tests/integration/optimistic-concurrency-postgres.test.js, tests/integration/application-skills-example.test.js, tests/integration/optimistic-concurrency-parity.test.js. |
| Enabling PostgreSQL protection needs reviewed migration and coordinated cutover/rollback | Explain missing/differing/leftover version columns without auto-migration. Unprotected instances cannot start with the column; already-running old writers create a deployment gap on both engines | tests/integration/postgres-version-column.test.js; deployment coordination is an application obligation. |
| The MongoDB inspection recipe must match managed decoding, including Decimal128, NaN, infinities and long bounds | Gate BSON numeric types to double/int/long; qualify default BSON decoding; custom Long-object decoding needs separate inspection | tests/integration/optimistic-concurrency-parity.test.js: the recipe and managed reader agree on 24 actually stored BSON cases in each MongoDB mode, including Decimal128, numeric bounds, NaN and infinities. |
MCP describes version preconditions, treats protected updates as idempotent even with added, and keeps compact { id, version } when it fits | Scalar integer bounds in both directions; explicit selections should include the version; transition hints unchanged | tests/mcp-optimistic-concurrency.test.js; passed in the full database run and packed MCP/SDK consumer checks. |
Verification covers Node 24, MongoDB replica-set and standalone deployments, PostgreSQL, and all five packed runtime and TypeScript consumers. The release workflow also runs the PostgreSQL 15/MongoDB 7, PostgreSQL 16/MongoDB 8 and PostgreSQL 18/MongoDB 8 matrix.
The 3.11.0 baseline run executes all eleven new test files: 449 expected failures and 55 passing checks, with no skips. Of the passing checks, 31 are explicit compatibility pins, 20 exercise existing scalar/storage behavior and four test the race harness. Missing exports and generated fields establish feature boundaries; assertions following those failures are not counted as independently exercised on the baseline. Named-view cases also encounter the absence of registerView(), introduced in 3.12.0. The pipeline and failed-schema diagnostic regressions additionally reproduce their defects on the pre-correction PR source.
Feature tests were checked against 3.11.0 (ca9b7bc); the verification record distinguishes expected feature failures from passing checks of pre-existing behavior.
The files are tests/record-version.test.js, tests/optimistic-concurrency-schema.test.js, tests/optimistic-concurrency-lifecycle.test.js, tests/optimistic-concurrency-visibility.test.js, tests/postgres-optimistic-concurrency.test.js, tests/mcp-optimistic-concurrency.test.js, tests/controlled-race.test.js, tests/integration/optimistic-concurrency-mongodb.test.js, tests/integration/optimistic-concurrency-postgres.test.js, tests/integration/optimistic-concurrency-parity.test.js and tests/integration/postgres-version-column.test.js.
3.14.0: collection frames
This section supersedes the historical “Preserve current cardinality” decision, the earlier one-row-per-matching-child decision and the 3.9.0/3.10.0 joined-scope and scoped-collection decisions, and the 3.11.0 rows saying legacy collection paths keep their 403 and prefixed collection emptiness is permanently invalid. Those historical decisions remain recorded; the marker row also links to its collection-frame exception. The new upgrade notes list all seventeen changes and the additional invalid-input validation-order note.
| Behavior | Decision | Executable evidence |
|---|---|---|
| 1. Lists, counted lists and pages return a parent once even when several children match, instead of repeating it. | Changed | tests/collection-frames.test.js, tests/collection-frames-runtime.test.js, tests/integration/collection-frames-{mongodb,postgres,parity}.test.js |
2. Client filters into scoped collections, and paths into types whose find scopes filter through collections, return rows using correlated scopes instead of FORBIDDEN_FILTER_PATH. Prefixes through embedded lists retain their restrictions; the updated-runtime example is an owner filtering users by barbershops. | Changed | tests/query-emptiness-runtime.test.js, tests/query-path-authorization.test.js, tests/integration/query-path-authorization.test.js, tests/integration/collection-frames-parity.test.js |
| 3. One collection prefix shares a child within one group. Separate groups can match different children, including a server restriction appended as its own group. | Changed | tests/collection-frames.test.js, tests/integration/collection-frames-parity.test.js |
4. A list sort through a collection gives INVALID_FILTER_PATH before middleware or any scope, including count mode and adapters/plugins without the new capability. | Changed | tests/query-plan.test.js, tests/collection-frames-runtime.test.js, tests/integration/query-emptiness-parity.test.js |
5. Outside aggregate joined prefixes J, EQ null, NE and NIN through a collection no longer match a parent without visible children. Trusted conditions use stored children, so the same change in a get_by_id scope changes record admission and required-ID/reference reads. Use collection IS_EMPTY or NONE for absence. | Changed | tests/collection-frames-runtime.test.js, tests/integration/collection-frames-parity.test.js |
6. Inside a client frame, references read through scoped joins; a hidden referenced record reads as missing, which can change NE, NIN and EQ null matches. | Changed | tests/integration/collection-frames-parity.test.js |
7. Aggregate filter-only collection paths stop multiplying facts. Collection prefixes used by groupId or facts keep joins; a remainder beyond those prefixes is a frame anchored on the joined record. | Changed | tests/collection-frames.test.js, tests/integration/collection-frames-{mongodb,postgres,parity}.test.js |
8. maxFrameNesting defaults to 3 along a client or trusted frame chain. maxReferenceFrames remains 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 and where budgets, each measured in compiled units. Excess gives FILTER_DEPTH_EXCEEDED. | Changed | tests/collection-frames.test.js, tests/reference-emptiness-runtime.test.js, tests/collection-frames-runtime.test.js |
9. 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 can now retain their scope in emptiness constructs, explicit groups, conditions inside frames, and collection shorthand outside J. This supersedes the collection part of the 3.11.0 marker row above; a marked non-emptiness reference-only predicate outside a frame still fails with DETACHED_CLIENT_FILTER (500). | Changed | tests/collection-frames-runtime.test.js, tests/reference-emptiness-runtime.test.js |
10. MongoDB joins on aggregate J reached through references or embedded objects no longer match null-connected children when the local key is missing. Direct buildQuery calls also receive collection frames and collection-sort rejection. | Changed | tests/integration/collection-frames-mongodb.test.js, tests/mongodb-query-paths.test.js |
11. MCP input schemas change for every application: they gain QLQuantifier, QLQuantifiedGroup, the ANY/ALL/NONE lists and relation-filter quantifier. | Changed | tests/mcp-text-operators.test.js, tests/mcp-options.test.js, tests/integration/query-text-operators-mcp.test.js |
12. createRuntime() rejects malformed quantifiers, or quantifiers: true without referenceEmptiness: true, with INVALID_QUERY_FEATURES. createQueryPlan() without options now emits quantified nodes for collection filters. | Changed | tests/collection-frames.test.js, tests/query-plan.test.js |
13. Emptiness client markers are issued only when a relation on the absolute chain has a registered find scope. Results stay the same; unscoped constructs need no marker. | Changed | tests/reference-emptiness-runtime.test.js, tests/collection-frames-runtime.test.js |
14. 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 without quantifiers; this supersedes the collection case in the 3.11.0 joined-scope row above, which records INVALID_FILTER_PATH for an emptiness scope prefixed through an embedded list or a collection. | Changed | tests/query-emptiness-runtime.test.js, tests/integration/query-emptiness-parity.test.js |
15. A client path crossing a reference inside an embedded list before or inside a collection frame gives FORBIDDEN_FILTER_PATH when that reference target's scope adds conditions. Empty scopes and trusted paths keep plain vector joins. | Changed | tests/collection-frames-runtime.test.js, tests/integration/collection-frames-parity.test.js |
16. Middleware/direct-call filter groups with unknown own keys now fail with INVALID_FILTER_VALUE; allowed keys are conditions, AND, OR, ANY, ALL, NONE, quantified path, and the reserved internal rewrite keys. Unused reserved keys retain their prior ignored behaviour. | Changed | tests/collection-frames.test.js, tests/query-plan.test.js |
17. Server-written quantifier filters and ANY/ALL/NONE lists, previously ignored or flattened, are honoured by declaring adapters and rejected with UNSUPPORTED_QUERY by others, including final arguments, frame scopes, raw joined scopes and get_by_id scopes. | Changed | tests/collection-frames-runtime.test.js, tests/integration/collection-frames-postgres.test.js |
Already-invalid ordinary paths/values and raw target scopes reject at the mandatory gate, before frame counts, middleware/adapter or prefix restrictions where applicable; 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. Direct MongoDB pathless ordinary relation terms now use the shared Relationship terms require a path diagnostic with the same INVALID_FILTER_PATH/400. Ignored scalar terms remain inspection-only and cannot discharge client markers. | Changed (invalid-input timing/diagnostics) | tests/runtime.test.js, tests/query-plan.test.js, tests/query-emptiness-runtime.test.js, tests/reference-emptiness-runtime.test.js, tests/collection-frames-runtime.test.js, tests/mongodb-query-paths.test.js |
| Public exports, feature flags, plan nodes, recursive inputs and limits are checked in packed consumers; unscoped legacy reads, undeclared-adapter behaviour and batched/single equivalence retain named pins. | Shared verification | scripts/test-packages.js, tests/core-subpath-types.test.js, tests/collection-batching.test.js, compiler suites and the integration parity suites above |
Each collection-frame test from the pre-integration implementation was checked to fail on 3.11.0 (ca9b7bc) for the expected reason, except the regression pins, whose names say so.
Named-view integration preserves separate compiled-unit budgets for client arguments and where, one provenance registry and scoped nested quantified groups. Evidence: tests/register-view-frames.test.js, the named-view cases in tests/integration/collection-frames-parity.test.js, and tests/register-view-mcp.test.js. These integration cases were checked against the pre-fix merged tree; the earlier feature tests retain their recorded 3.11.0 baseline evidence.
3.15.0: controller callbacks after commit
Optional committed controller callbacks extend the lifecycle on both backends. Existing lifecycle hooks remain transactional and supported. See the upgrade notes and controller contract.
| Behavior | Decision | Executable evidence |
|---|---|---|
| Only a confirmed attempt dispatches its queue. Aborted or retried attempts emit nothing; uncertain MongoDB commits wait for confirmation. | Shared (additive) | tests/after-commit-transactions.test.js drives body/commit retries, exhausted attempts and uncertain commits. tests/integration/after-commit.test.js retries after real writes and checks rollback after a hook rejection, NOT_VALID_ID and database unique violations on MongoDB off/transactional integrity and PostgreSQL. |
| Version-checked lifecycle operations notify only when their transaction commits. Stale update/transition/delete attempts emit nothing; a later stale child discards earlier notifications, and retries preserve the submitted version. | Shared with optimistic concurrency | Nine cases in tests/integration/after-commit-occ.test.js, across both MongoDB integrity modes and PostgreSQL, check callback versions, stored rows, partial-child rollback and retry isolation. |
| Nested lifecycle calls join their owner. Registered mutations and SQL's public transaction wrapper cannot dispatch before their body returns or after it rejects. | Shared | Registered-mutation success and rollback cases and joins SQL public transaction ownership without an early dispatch in tests/integration/after-commit.test.js; drains nested joins after commit and cleanup, preserving the result in tests/after-commit-transactions.test.js. |
| Callbacks are awaited serially in lifecycle completion order, with child add/update/delete notifications before their parent. A state action replaces only the parent's update notification with its transition notification. | Shared | The nested completion-order and transition-with-children cases in tests/after-commit.test.js and tests/integration/after-commit.test.js. The latter also checks committed state and children on both databases. |
| Cleanup is attempted before dispatch. A confirmed write still dispatches after cleanup failure while preserving that cleanup error. Callback failure cannot trigger rollback or retry. | Shared | drains a confirmed commit despite cleanup failure, keeping that error and cannot retry or roll back a confirmed write because an effect throws in tests/after-commit-transactions.test.js, for MongoDB and SQL. |
| Callback failures reach the runtime's reporter with the original context. Reporter failures are guarded; later callbacks continue and GraphQL still succeeds. The default logs static metadata only. | Shared | reports callback failures without failing GraphQL or skipping later callbacks and runtime configuration isolation in tests/after-commit.test.js; keeps committed writes successful when callback/reporter fail and continues the queue in tests/integration/after-commit.test.js. |
Native externally owned MongoDB sessions skip callbacks and warn once per runtime/type. SQL continues to reject foreign sessions with INVALID_SESSION. No external commit observation is promised. | Engine-specific ownership, existing session compatibility | skips external owners, warning once per type without mutating context in tests/after-commit.test.js; does not observe external commit or reuse effects on a later transaction in tests/integration/after-commit.test.js; the unchanged foreign/stale-session checks in tests/sql-transactions.test.js. |
Invalid reporter options preserve the previous handler (INVALID_AFTER_COMMIT_OPTIONS, 400). Non-function callbacks fail before writing (INVALID_CONTROLLER, 400); callbacks on an adapter without the queue capability fail explicitly (UNSUPPORTED_AFTER_COMMIT, 500). | Shared | Configuration, invalid-controller and unsupported-adapter cases in tests/after-commit.test.js, at schema creation and operation entry after controller changes. |
Created runtimes expose configureAfterCommit; MongoDB and PostgreSQL also export their singleton function. Shared callback/error/options types are re-exported by both facades and SQL. The MongoDB compatible entry keeps the same function identity. | Additive API and declarations | Runtime export pins and strict TypeScript consumers in scripts/test-packages.js (npm run test:packages), including distinct per-runtime configuration functions. |
Calling configureAfterCommit preserves the ESLint plugin's runtime provenance for subsequent relation and schema checks. | Optional development tooling | Four regression cases in tools/eslint-plugin-simfinity/tests/rules.test.mjs: MongoDB/PostgreSQL fixtures under valid-connection-field and no-schema-cloning. |
These callbacks are best effort: they do not provide durable or exactly-once delivery. An outbox and workers remain necessary when delivery must survive process failure. Runner failure tests use controlled driver failures; the database cases verify the stored rows and notifications separately.
The public runtime suite, tests/after-commit.test.js, was checked to fail on 3.12.0 (7387671) using that revision's implementation: ten cases failed because notifications, configuration or callback validation were absent; the existing lifecycle-hook rejection case passed by design. All eleven cases passed again on the feature branch. The ESLint regression cases were also checked before its one-line fix: all four failed because the expected diagnostic was absent, then passed after the fix. These checks do not claim that unchanged regression tests or every database case must fail on the base.
3.15.0: schema-owned client descriptors
The optional @simtlix/simfinity-descriptor package joins the aligned release set. It describes the exact schema returned by createSchema() without changing the default 3.x introspection behavior. See the upgrade notes and descriptor contract.
| Behavior | Decision | Executable evidence |
|---|---|---|
| Metadata belongs to the exact schema; later registrations and different allowlists cannot contaminate it. | Core captures registrations; the descriptor freezes graph metadata on first use. Cloned/composed schemas are rejected. | tests/client-descriptor.test.js; tests/descriptor-package.test.js. |
| Root input hints describe the generated root inputs, including defaults; nested inverse references retain their contextual input shapes. | Expose rootInputUsage, without redundant wrapper flags or authorization claims. | tests/client-descriptor.test.js; tests/client-descriptor-parity.test.js. |
| Existing introspection clients keep their default metadata; an early opt-out is explicit and irreversible once installed. | The side-effect-free core /introspection subpath rejects late configuration. | Fresh-process tests/introspection-configuration.test.js, including auth and descriptor-first import ordering. |
| HTTP access is explicit and checked before conditional responses. | GET/HEAD only; expected denials stay 401/403, unexpected failures are masked and reported. | tests/client-descriptor-http.test.js; tests/client-descriptor-backends.test.js pins deterministic bodies and ETags. |
| The format is independent of physical database storage and imports no runtime through its HTTP declarations. | Optional sixth package with an exact core dependency and structural HTTP types; no facade re-exports. | Backend fixtures, tests/package-dependencies.test.js, and all six archives plus seven isolated runtime/TypeScript consumers in scripts/test-packages.js. |
3.16.0: lifecycle helpers and generated transactions
Version 3.16.0 adds updateObject(), deleteObject(), transitionObject() and the transaction option of createSchema(). These APIs are not available in 3.15.0. The evidence is the source tests named below; this section reports no run, release or CI result.
| Behavior | Decision | Executable evidence |
|---|---|---|
Helpers and the transaction field run the lifecycle of the generated roots, on MongoDB in referentialIntegrity: 'off' and 'transactional' modes and on PostgreSQL | One lifecycle runner serves the roots, the helpers and the transaction. A helper with a supplied session writes in the caller's transaction and commits nothing; a late failure of the caller rolls back every helper write; nested and state hooks receive the helper's input and context; an optimistic-concurrency conflict keeps the stored version | tests/integration/lifecycle-transactions-parity.test.js (helper cases, one describe per backend); unit coverage in tests/lifecycle-helpers.test.js. |
| Helper input is validated and copied before any hook, read or write | Plain containers with declared fields only, no state or readOnly fields, no singleton-to-list coercion, internal enum values, scalars as primitives, Date and plain data, record keys as strings. { $ne: null } as an id fails with INVALID_INPUT and changes no document on every backend, although the default MongoDB mode would hand it to the driver uncast | tests/operation-input.test.js, tests/lifecycle-helpers.test.js; the { $ne: null } case on the three backends in tests/integration/lifecycle-transactions-parity.test.js. |
| A transaction commits an add, references, an update, a state action and deletes together, or nothing | References $K resolve to the IDs of the current attempt. A late failure leaves every table and collection unchanged; a version conflict at an index keeps the original versions and names that operation; a delete of a missing record returns null and the list continues; a delete of a record that an earlier operation references fails at the delete on PostgreSQL and transactional MongoDB, as the differences below describe | tests/integration/lifecycle-transactions-parity.test.js (transaction cases on MongoDB off, MongoDB transactional and PostgreSQL); schema, reference, execution and limit contracts in tests/transaction-schema.test.js, tests/transaction-references.test.js, tests/transaction-execution.test.js and tests/mutation-limits.test.js. |
| Retries and uncertain commits stay with the adapter | An operation conflict retries the whole list on a fresh copy; an operation that fails on every attempt keeps its position in the final error; a failure of begin or commit carries none, even after an operation failed in an earlier attempt. MongoDB repeats only the commit after an uncertain commit result, as for the roots; PostgreSQL retries confirmed serialization failures and never an unknown commit outcome | tests/integration/lifecycle-transactions-parity.test.js; error identity and positions in tests/transaction-errors.test.js. |
| Model affinity: every model of a transaction must be able to share one session | MongoDB requires one MongoClient, with useDb() siblings counting as one before and after connect(), and refuses two clients before a session opens, in both integrity modes; SQL requires handles of the instance. Core calls the synchronous assertTransactionModels(models) on each invocation before the session opens | tests/mongodb-transaction-affinity.test.js (offline), tests/integration/transaction-affinity-mongodb.test.js, tests/sql-transactions.test.js. |
| Each operation gets the authorization checks of its root | The official auth plugin records an entry on each generated root it wraps; the transaction runs them for every operation, on every attempt, before the operation's middleware. Schema copies, second core copies, unknown protocols and the deprecated middleware are covered | tests/transaction-auth.test.js, tests/transaction-exposure.test.js, tests/transaction-execution.test.js. |
| MCP publishes the transaction field only on request | transactionTools is off by default; the field, and any mutation shaped like it, is never a custom tool; filters, the @oneOf input schema, annotations and compact oversized results are fixed | tests/mcp-transaction.test.js. |
Intentional differences remain in how a supplied session ends. A helper never aborts it itself, and helpers use no savepoints, so a helper or saveObject() that fails inside it may already have written its root record and earlier nested entries there. Errors raised by the database and errors raised by core differ. A database-raised error ends the transaction on its own: on PostgreSQL a failed statement aborts it, so later statements and the commit fail with DATABASE_ERROR, and MongoDB with transactional reference integrity aborts the session on a protected-write error, as it does for saveObject(), so a later write fails with ACTIVE_TRANSACTION_REQUIRED and the commit fails. A core-raised error leaves the session usable on every backend, such as the VERSION_CONFLICT that the pre-read finds for a stale nested child, which transactional MongoDB does not abort either, or a validator or hook error. A caller that catches such an error and commits stores a partial change, so it must end the transaction with the error, by rethrowing it or aborting. Native savepoints do not track the after-commit queue: rolling back lifecycle writes to a savepoint can leave their notifications queued. Let lifecycle failures propagate to the transaction owner. Statement-only savepoints remain usable when they do not undo queued lifecycle writes.
A second intentional difference concerns references inside a transaction mutation. MongoDB with referentialIntegrity: 'off' checks none, so a list that adds a record, adds a parent that references it and then deletes it commits and leaves a parent that references a deleted record. PostgreSQL and MongoDB with transactional reference integrity fail with REFERENCE_CONSTRAINT_VIOLATION (409) at the delete, name its operation and store nothing.
Transactional MongoDB integrity requires one connection for all protected models, so models of several useDb() databases share a transaction only in the default mode.
3.16.0: list operators
This release adds CONTAINS_ALL and IIN to QLOperator, on both backends. The rows below record the contract; see the upgrade notes for the changes applications can observe. The evidence is in the repository's tests/ and scripts/test-packages.js: tests/list-operators.test.js for the enum, the validator's order in both contexts, query plans, the rewrite and the evaluator; tests/list-operators-runtime.test.js for the runtime's gates, trusted constructs, scopes, named views, copies and markers; tests/integration/list-operators-mongodb.test.js and tests/integration/list-operators-postgres.test.js for the compiled forms, the fail-closed plans, the byte limits, explain and EXPLAIN; tests/integration/list-operators-parity.test.js for the rows on both backends; and tests/integration/list-operators-mcp.test.js for the MCP tools. The shared fixture, cases and derived IIN tables are in tests/helpers/list-operators.js.
| Behavior | Decision | Executable evidence |
|---|---|---|
QLOperator gains CONTAINS_ALL and IIN after IS_NOT_EMPTY (SDL, introspection, MCP $defs, and the client descriptor's types.QLOperator.values); both bundled adapters list them explicitly; the 3.7.0 set is unchanged. | Shared (additive) | tests/list-operators.test.js: appends CONTAINS_ALL and then IIN after IS_NOT_EMPTY, with names equal to values, publishes them in the generated SDL and in introspection, and changes no input type, rejects both with UNSUPPORTED_QUERY for the operators of 3.7.0; tests/query-text-operators.test.js: adds the six text operators after the operators of 3.7.0, publishes them in the generated SDL and in introspection; tests/query-emptiness.test.js: adds IS_EMPTY and IS_NOT_EMPTY after the text operators, publishes them in the generated SDL and in introspection; tests/optimistic-concurrency-schema.test.js: pin: a schema without protected types prints byte-identical SDL; tests/client-descriptor.test.js: lists every QLOperator value in enum order, the list operators last, whatever the adapter declares; tests/integration/list-operators-mcp.test.js: publishes CONTAINS_ALL and IIN last in the operator enum of the list and aggregate tools, and states their rules; tests/integration/query-emptiness-mcp.test.js: publishes both operators in the operator enum of the list and aggregate tools, and states their rules; tests/mcp-text-operators.test.js: accepts the list operators and rejects unknown ones in hosts that validate tool input; tests/integration/query-text-operators-mongodb.test.js: the adapter declares every QLOperator value, listed explicitly, and reference emptiness, a copy of the adapter without the declaration gets only the operators of 3.7.0; tests/integration/query-text-operators-postgres.test.js: declares every QLOperator value in the PostgreSQL plugin and binds a frozen copy; tests/integration/query-emptiness-postgres.test.js: declares IS_EMPTY and IS_NOT_EMPTY in the PostgreSQL plugin, after the text operators, and binds them; scripts/test-packages.js (the packed core's QLOperator order and both packed adapters' sorted declarations). |
CONTAINS_ALL: set containment over a scalar list or the values of every item of embedded lists; duplicates and order do not count; missing, null, [] and [null] lists never match (the update-seeded S4, R4 and I5 of the fixture, absent on MongoDB and NULL on PostgreSQL, and I4, I7). | Shared | tests/integration/list-operators-parity.test.js: %s, in every scope shape, listed and counted, on every surface (its CONTAINS_ALL rows and Frames cases), %s, through the Shop.items collection field, batched and one parent at a time; tests/integration/list-operators-mongodb.test.js: CONTAINS_ALL matches lists that hold every value, case-sensitively, whatever their order and duplicates; tests/integration/list-operators-postgres.test.js: runs %s on the server, with the element type of its path; tests/integration/list-operators-mcp.test.js: returns the rows of %s from the list tool, listed and counted, in every scope shape, returns the rows of %s from the aggregate tool; tests/list-operators.test.js: returns the admin keys of %s, matches nothing on missing, null, [] and [null] values; tests/list-operators-runtime.test.js: passes a middleware's CONTAINS_ALL and a find scope's IIN to the adapter, which matches stored values as for IN. |
CONTAINS_ALL values: a non-empty array of non-null scalars without plain objects or holes, cast like IN elements, with IN's two inherited cast divergences (a malformed UUID on an ID leaf is NOT_VALID_ID on PostgreSQL and INVALID_FILTER_VALUE on MongoDB; a number or Boolean on a date leaf is read by MongoDB as epoch milliseconds and rejected by PostgreSQL). | Shared | tests/list-operators.test.js: accepts CONTAINS_ALL values that are non-empty arrays of non-null scalars, and returns them unchanged, rejects other CONTAINS_ALL values with INVALID_FILTER_VALUE and the message of their value rule, rejects a hole in a CONTAINS_ALL or IIN value (%s), pin: IN, NIN and BTW keep skipping holes of sparse arrays, plans CONTAINS_ALL and IIN as predicates with the values as sent: not cast, duplicates kept; tests/list-operators-runtime.test.js: rejects %s with a hole in the final gate, with no adapter call, fails every list read with INVALID_FILTER_VALUE when a %s's CONTAINS_ALL list is empty, and reads with a non-empty one, fails every by-ID read, generated reference fields included, when a get_by_id scope's CONTAINS_ALL list is empty, and reads with a non-empty one, guard: IN [] from a %s returns no rows and no error; tests/integration/list-operators-mongodb.test.js: compiles to $all with the values cast as IN casts them, in order and with duplicates, fails to cast %s %j as IN does, with INVALID_FILTER_VALUE; tests/integration/list-operators-postgres.test.js: rejects invalid CONTAINS_ALL values with core's messages, fails to cast CONTAINS_ALL on %s %j with %s; tests/integration/collection-frames-postgres.test.js: checks native CONTAINS_ALL elements before new joins metadata; tests/integration/list-operators-parity.test.js: %s: the outcome of 9.3 on each backend (its value, cast-failure and the inherited divergence of IN rows), runs 70,001 IIN values on name and 70,001 CONTAINS_ALL values on box.tags alike. |
CONTAINS_ALL paths: INVALID_FILTER_PATH through a collection (J included) or a reference after an embedded list, INVALID_FILTER_OPERATOR on a non-list leaf, after the operator checks. | Shared | tests/list-operators.test.js: accepts every list leaf on %s steps, rejects object ends, collections, references after embedded lists and other leaves on %s steps, rejects QlNote.itemId, an inferred inverse reference, as a leaf that is not a list on %s steps, rejects interface and union carriers in GraphQL contexts, the order of checks (%s), the order of checks on abstract carriers, frame-relative paths (%s), plans CONTAINS_ALL inside an explicit %s group and a quantified field filter, guard: rejects CONTAINS_ALL through an aggregate J prefix with the shorthand reason, without quantifiers, rejects CONTAINS_ALL through a collection with the shorthand reason and its quantified forms as unsupported; tests/collection-frames.test.js: rejects CONTAINS_ALL on a non-list own field and gives the permanent shorthand reason through collections; tests/list-operators-runtime.test.js: rejects %s before middleware (on a declaring adapter without quantifiers), passes CONTAINS_ALL on %s, which crosses no collection, to the adapter; tests/integration/list-operators-mongodb.test.js: rejects %s in buildQuery, buildAggregationQuery and buildFilterGroupMatch, rejects CONTAINS_ALL on a scalar inside a frame body, which compileFrame compiles without validating again, guard: CONTAINS_ALL through a collection keeps the shorthand's INVALID_FILTER_PATH, also on an aggregate J prefix, %s is no list for CONTAINS_ALL, with or without a value, and no String field for IIN; tests/integration/list-operators-postgres.test.js: fails CONTAINS_ALL on %s.%s closed with %s, checks CONTAINS_ALL on the path relative to a frame, on the frame's entity, before any statement; tests/integration/list-operators-parity.test.js: %s: the outcome of 9.3 on each backend (its path and frame-relative rows), rejects CONTAINS_ALL through the J prefix reviews with the shorthand message; tests/integration/list-operators-mcp.test.js: returns INVALID_FILTER_PATH, INVALID_FILTER_OPERATOR and INVALID_FILTER_VALUE tool errors before the adapter runs. |
Trusted conditions that middleware, scopes and direct callers wrote with CONTAINS_ALL or IIN in a form the compilers read failed on 3.14.0 and 3.15.0 with INVALID_FILTER_OPERATOR (Unknown filter operator: …), or with the shorthand's INVALID_FILTER_PATH for CONTAINS_ALL through a collection. Adapters and plugins that do not declare an operator now reject it with UNSUPPORTED_QUERY whatever collection or reference its path crosses, also through a collection; on adapters that declare quantifiers, a path that does not resolve, such as an unknown field, and a path that ends at an object keep their own errors first (INVALID_FILTER_FIELD, INVALID_FILTER_PATH, or MISSING_FILTER_PATH for a one-step object field). Declaring adapters apply the semantics of the operators and their codes: CONTAINS_ALL through a collection keeps INVALID_FILTER_PATH and its message; on a leaf that is not a list it keeps INVALID_FILTER_OPERATOR, with the message CONTAINS_ALL requires a list field, …; through a reference after an embedded list it fails with INVALID_FILTER_PATH; invalid values, and IIN on a field that is not String, fail with INVALID_FILTER_VALUE. In the forms the compilers ignore (an operator beside an object field's terms; the terms of a scalar, scalar-list, interface or union field's filter), a CONTAINS_ALL or IIN that middleware, a find, get_by_id or aggregate scope wrote passed the runtime on 3.14.0 and 3.15.0 and reached the adapter (the bundled compilers and createQueryPlan ignore these forms). Adapters and plugins that do not declare the two names now reject these forms in the runtime with UNSUPPORTED_QUERY and the message The database adapter does not support the <OP> filter operator, used on <Type>.<path>, quantified adapters included, as they already do for undeclared text operators. Declaring adapters, the bundled ones included, still receive these forms and the bundled compilers keep ignoring them; direct createQueryPlan callers are not affected. A joined scope (a target find scope that the runtime prefixes below a client's path into conditions: applyJoinedScopes, prefixScopeArgs) never passed these forms to an adapter: below a single reference it dropped an operator beside terms, so the request ran without it, and scalar terms failed with INVALID_FILTER_PATH, and below a scoped collection that the client's path enters outside a frame (any collection on adapters without quantifiers, and an aggregate's join prefix on every adapter) the request failed with FORBIDDEN_FILTER_PATH; adapters and plugins that do not declare the two names now give UNSUPPORTED_QUERY with the same message for all of these, and declaring ones keep the 3.15.0 outcomes. A target scope that the runtime carries unprefixed in a client's scope marker (the client's frames on quantified adapters, and a client's reference-emptiness condition on every adapter that declares referenceEmptiness) passed these forms to the adapter like any other scope, and adapters and plugins that do not declare the two names now reject them in the same way. On those adapters and plugins a protected type's get_by_id scope that writes these forms also fails the visibility check of a stale or exhausted version: a top-level update, delete or state action, or a nested updated or deleted entry, with a stale version, or an update or state action at MAX_RECORD_VERSION, now fails with UNSUPPORTED_QUERY (400) instead of VERSION_CONFLICT or VERSION_EXHAUSTED (409), also for a caller whom the scope hides, before the validators, hooks and state action that preceded that 409; a matching version runs as before, and declaring ones keep the 409. | Changed | tests/list-operators-runtime.test.js: rejects a client's CONTAINS_ALL through a collection as undeclared, before its path rule (in an adapter with %s of adapters that do not declare CONTAINS_ALL and IIN, on each undeclared shape), rejects a middleware's CONTAINS_ALL through a collection as undeclared, after middleware, before its path rule, rejects a middleware's %s after middleware, with no adapter call, rejects %s's %s in the final gate, with no adapter call, applies the get_by_id scope %s, reads named views whose where uses CONTAINS_ALL and IIN, and validates their values per call, gives each call its own CONTAINS_ALL value array when fields share the variable, gives a middleware's %s beside the terms of %s site A's message, gives a middleware's %s in the terms of %s, which is not an object, site A's message, gives a get_by_id scope's %s on %s site A's message in qlItem, with no adapter call, gives a get_by_id scope's %s on %s site A's message in every generated reference field, with no Item read, gives a find scope's %s on %s site A's message in the read it scopes, with no adapter call, gives an aggregate scope's %s on %s site A's message in the aggregate it scopes, with no adapter call, gives a joined find scope's %s beside the terms of %s site A's message on QlItem, in %s, with no adapter call, gives a joined find scope's %s in the terms of %s, which is not an object, site A's message on QlItem, in %s, with no adapter call, gives a find scope's %s on %s that a client path reads through the scoped collection items site A's message, in %s, with no adapter call, gives a Review find scope's %s on %s that a client path reads through the scoped collection reviews site A's message on QlReview, with no adapter call, gives a find scope's %s on %s that a client frame reads through site A's message on the quantified 18-name adapter, with no adapter call, gives a Shop find scope's %s on %s that a client's reference-emptiness %s reads site A's message on QlShop, on %s, with no adapter call, gives a get_by_id scope's %s in the terms of tenant, which is not an object, site A's message in the visibility check of a stale %s on an adapter without queryFeatures, with no find and no write, guard: the ignored forms keep being ignored on the bundled declaration: %s on %s, guard: a find scope's %s on %s keeps being ignored in the read it scopes on the bundled declaration, guard: an aggregate scope's %s on %s keeps being ignored in the aggregate it scopes on the bundled declaration, guard: a get_by_id scope's %s on %s keeps being ignored in qlItem on the bundled declaration, guard: a get_by_id scope's %s on %s keeps being ignored in generated reference fields on the bundled declaration, guard: a joined find scope's %s beside the terms of %s keeps being ignored, in %s, on %s, guard: a joined find scope's %s in the terms of %s, which is not an object, keeps INVALID_FILTER_PATH, in %s, on %s, guard: a find scope's %s on %s that a client path reads through the scoped collection items keeps FORBIDDEN_FILTER_PATH, in %s, on a declaring adapter without quantifiers, guard: a find scope's %s on %s that a client path reads through the scoped collection items keeps being ignored in the frame, in %s, on the bundled declaration, guard: a Review find scope's %s on %s that a client path reads through the scoped collection reviews keeps FORBIDDEN_FILTER_PATH on a declaring adapter without quantifiers, guard: a Review find scope's %s on %s that a client path reads through the scoped collection reviews keeps being ignored in the frame on the bundled declaration, guard: a find scope's %s on %s that a client frame reads through keeps being ignored on the bundled declaration, guard: a Shop find scope's %s on %s that a client's reference-emptiness %s reads keeps being ignored in its marker, on %s, guard: a get_by_id scope's %s in the terms of tenant keeps the VERSION_CONFLICT of a stale %s, after one find that receives it, on an adapter that declares the 3.7.0 names, CONTAINS_ALL and IIN, guard: a middleware's CONTAINS_ALL through a collection keeps the shorthand's INVALID_FILTER_PATH on the bundled declaration; tests/list-operators.test.js: the order of checks (%s) (with its row an operator list without CONTAINS_ALL rejects reviews.author CONTAINS_ALL ["x"] before its path rule, and the rows that keep a path error first: guard: an operator list without CONTAINS_ALL keeps the object-end rule of lines, guard: an operator list without CONTAINS_ALL keeps the object-end rule of box.shop, guard: an operator list without CONTAINS_ALL keeps the unknown field nope, guard: an operator list without CONTAINS_ALL keeps the traversal error of name.x, guard: an operator list without IIN keeps the object-end rule of shop and guard: an operator list without IIN keeps the unknown field nope), rejects object ends, collections, references after embedded lists and other leaves on %s steps; tests/collection-frames.test.js: rejects CONTAINS_ALL on a non-list own field and gives the permanent shorthand reason through collections. |
A quantified group that trusted code (middleware, scopes, direct createQueryPlan, buildQuery, buildAggregationQuery and buildFilterGroupMatch calls) writes by hand with __implicit: true must, after its __anchor, end at a collection, whatever it crosses first, as the groups the shorthand writes do, or at a reference without crossing a list, as explicit groups must; otherwise it fails with INVALID_FILTER_PATH (400) An implicit quantified path must end at a collection, or at a reference without crossing a list on every adapter, whatever operators it holds, before any compile, except that on adapters without quantifiers the runtime's own checks of the conditions inside it, such as the UNSUPPORTED_QUERY of an undeclared operator other than IS_EMPTY and IS_NOT_EMPTY, come first, as on 3.15.0; the runtime does not check the declaration of IS_EMPTY and IS_NOT_EMPTY inside a group, so in a group that this rule rejects, an undeclared one that its checks do not reject (they do reject emptiness through a reference or collection without referenceEmptiness) now gets this rule's INVALID_FILTER_PATH, where 3.15.0 gave UNSUPPORTED_QUERY (The <OP> filter operator is not supported by this query compiler). On 3.15.0, on adapters with quantifiers, nothing checked such a group: with CONTAINS_ALL or IIN it failed with INVALID_FILTER_OPERATOR; with the other operators, over an embedded list or object MongoDB returned wrong rows (ANY none, ALL and NONE every row) and PostgreSQL failed with a 400, mostly from createQueryPlan; over parts.shop MongoDB compiled a reference frame and PostgreSQL's statement failed with a 500 DATABASE_ERROR; over reviews.item both backends compiled a reference frame, which now fails; adapters without quantifiers rejected such groups with UNSUPPORTED_QUERY (Quantified groups are not supported by this query compiler), except inside a group that also carried __implicit: true, and on those adapters the shapes this rule rejects now give its INVALID_FILTER_PATH in both cases, before the explicit group's UNSUPPORTED_QUERY. Groups that end at a collection, or at a reference with no list before it such as shop, keep their pipelines and statements, except on MongoDB those that cross a collection before their end (next row). The same group's __anchor must have exactly the shape the rewrite writes: in an aggregate, on a group at the root, a non-empty array of single path segments whose steps cross at least one collection, each a J prefix, with embedded lists, embedded objects and references allowed around them, followed by exactly one collection step; otherwise it fails with INVALID_FILTER_PATH (400) An implicit quantified anchor must name the aggregate's join prefixes on every adapter, whatever operators it holds, before any compile, with the same exception and the same order on adapters without quantifiers, where such an undeclared IS_EMPTY or IS_NOT_EMPTY now gets this message, unless the path the group names does not resolve (the anchor's fields followed by the group's path for a non-empty array anchor, the group's path alone for any other anchor), which keeps that path's own error, as on 3.15.0; for example, new Set(['items']) with reviews on Shop gives INVALID_FILTER_FIELD Unknown field reviews. On 3.15.0 no anchor was checked: a collection anchor in a find or count returned duplicate rows on both backends, ['parts'] with shop compiled on MongoDB and failed on PostgreSQL with INVALID_FILTER_FIELD, a Set anchor was read differently by the validator and the compilers, [items] shop compiled on both backends, and a path of more than one step after the anchor compiled on MongoDB, with rows repeated before $group where it crosses a collection or an embedded list, and failed on PostgreSQL with DETACHED_CLIENT_FILTER (500). Anchored groups in the rewrite's shape, those whose anchor crosses an embedded list included, keep their pipelines and statements, whether the rewrite or trusted code writes them. | Changed | tests/list-operators.test.js: rejects a hand-built implicit group that does not end at a collection or at a reference without crossing a list, in both validator contexts and createQueryPlan (%s), accepts a hand-built implicit group that ends at a reference without a list before it or at a collection after a collection (with its guard: rows for EQ and IN), guard: implicit groups the rewrite writes keep validating, rejects a hand-built implicit anchor that is not in the shape the rewrite writes, in both validator contexts and createQueryPlan (%s), rejects a hand-built implicit group whose path crosses an embedded list after its anchor, in both validator contexts and createQueryPlan (%s), pin: a non-array anchor whose bare path does not resolve keeps the resolution's INVALID_FILTER_FIELD; tests/collection-frames.test.js: validates implicit markers inside frames with J empty, rejects non-null J scopes on marked aggregate anchors after all value errors; tests/list-operators-runtime.test.js (in hand-built implicit groups on adapters without quantifiers): gives a find scope's implicit group over %s, on %s, the runtime's checks of its conditions first, then the implicit path rule, gives a find scope's implicit group over %s, on %s, the group's rule before the validator's check of the undeclared emptiness operator, guard: a find scope's implicit group over %s, on %s, keeps its 3.15.0 failure, gives an aggregate scope's implicit group with an empty anchor and CONTAINS_ALL inside, on %s, the runtime's checks of its conditions first, then the anchor rule; tests/integration/list-operators-mongodb.test.js: buildQuery, buildAggregationQuery and buildFilterGroupMatch reject a hand-built implicit group that does not end at a collection or at a reference without crossing a list, with no pipeline, a find scope that writes a hand-built implicit group over an embedded list fails the read with INVALID_FILTER_PATH and no rows, guard: a hand-built implicit group over shop compiles as the explicit ANY group over shop, compiles CONTAINS_ALL and IIN in a hand-built implicit group over shop as the explicit ANY group over shop, guard: the implicit group the rewrite writes for parts.shop.items compiles, compiles IIN through parts.shop.items as the implicit group the rewrite writes, guard: the implicit group the rewrite writes for parts.shop.items reads the items with a part at the shop of the item named one, reads IIN through parts.shop.items as the implicit group the rewrite writes, buildQuery, its count, buildAggregationQuery and buildFilterGroupMatch reject a hand-built implicit anchor that is not in the shape the rewrite writes, with no pipeline, guard: hand-built copies of the anchors the rewrite writes through an embedded list compile as their shorthands; tests/integration/list-operators-postgres.test.js: compileQuery rejects a hand-built implicit group that does not end at a collection or at a reference without crossing a list, before any join, guard: a hand-built implicit group over shop compiles to the statement of the explicit ANY group over shop, compiles CONTAINS_ALL and IIN in a hand-built implicit group over shop to the statement of the explicit ANY group over shop, guard: the implicit group the rewrite writes for parts.shop.items compiles, compiles IIN through parts.shop.items as the implicit group the rewrite writes, a find scope that writes a hand-built implicit group over an embedded list fails the read with INVALID_FILTER_PATH and no rows, guard: the implicit group the rewrite writes for parts.shop.items reads the items with a part at the shop of the item named one, reads IIN through parts.shop.items as the implicit group the rewrite writes, compileQuery rejects a hand-built implicit anchor that is not in the shape the rewrite writes, before any statement, guard: hand-built copies of the anchors the rewrite writes through an embedded list compile to their shorthands' statements. |
MongoDB: a hand-built __implicit group that crosses a collection before its end, outside any anchor (items.reviews on Shop, reviews.item.reviews on Item), compiles inside the self-correlated prefix witness, as PostgreSQL's isolatedPrefix does, and reads each record once, with the rows of the explicit nested groups: on 3.15.0 MongoDB joined the crossed collection into the rows, duplicating records and inflating counts under ANY and failing open under ALL and NONE, and, in an aggregate whose join prefixes include the crossed collection, tested the rest of the path once per joined row, as the anchored group over that prefix does, where PostgreSQL tested it once per record. PostgreSQL, the pipelines the rewrite writes, explicit groups and anchored groups are unchanged. | Changed (MongoDB, trusted code) | tests/integration/list-operators-mongodb.test.js: isolates a collection that a hand-built implicit group crosses before its end in the prefix witness, under %s, guard: groups that cross no collection before their end keep their pipelines, under %s, guard: shorthands through collections and the anchored frame keep their pipelines, reads a hand-built implicit group over items.reviews once per shop, as the explicit nested groups do, guard: the explicit nested groups over items and reviews read the crossed-collection rows, reads a hand-built implicit group over reviews.item.reviews once per item; tests/integration/list-operators-postgres.test.js: guard: a hand-built implicit group over items.reviews with EQ reads the rows of the explicit nested groups (for parity), reads a hand-built implicit group over items.reviews with IIN and CONTAINS_ALL as the explicit nested groups do, guard: a hand-built implicit group over reviews.item.reviews with EQ reads each item once, reads a hand-built implicit group over reviews.item.reviews with IIN and CONTAINS_ALL once per item. |
IIN: the union of IEQ over its values, on String fields and lists only; [] matches nothing. | Shared | tests/integration/list-operators-parity.test.js: returns the same rows for %s, as IEQ returns them, listed and counted, on every surface, returns the same rows for %s through the Shop.items collection field, batched and one parent at a time, %s, in every scope shape, listed and counted, on every surface (its IIN rows and Empty IIN lists in frames cases), %s: the outcome of 9.3 on each backend (its IIN rows); tests/list-operators.test.js: accepts IIN values that are arrays of strings without U+0000 on String fields, the empty array included, rejects IIN on fields that are not String, and other values, with INVALID_FILTER_VALUE; tests/integration/list-operators-mongodb.test.js: IIN matches some value ignoring case, literally; tests/integration/list-operators-postgres.test.js: rejects invalid IIN values. |
IIN folding differs between the backends only where IEQ does (İ, letters newer than MongoDB's Unicode tables). | Shared (accepted differences) | tests/integration/list-operators-parity.test.js: folds case for %s, folds case for %s as a field filter, an AND condition and an OR branch, listed and counted, folds Unicode 16 letters with IIN on PostgreSQL only with an ICU that knows them, never on MongoDB 8.0, returns with IIN, on each backend, the words IEQ returns for each value and the union for several, and differs between the backends only on the accepted words; tests/integration/query-text-operators-parity.test.js: folds case for %s (with the k29 row IEQ "i̇stanbul" that FOLDING_CASES of tests/helpers/query-text-operators.js gains, which pins existing behaviour). |
Both are legacy: group-level joined scopes outside frames, implicit ANY frames for IIN through collections, row filters on J prefixes for IIN. | Shared | tests/list-operators.test.js: plans IIN through a collection as a quantified ANY node, like IN, plans IIN on an aggregate J prefix as a row filter on the joined child, plans IIN below a J prefix as a frame anchored at the prefix, guard: frameCollectionPaths cuts reviews.author IIN, guard: CONTAINS_ALL is never cut, guard: frameUnits counts the IIN frame, counts IIN shorthand frames toward maxFrameNesting on a chain of %s collections; tests/list-operators-runtime.test.js: plans a client IIN through a collection as an implicit ANY frame, and CONTAINS_ALL on a list through embedded lists as a predicate, keeps the legacy join of IN for reviews.author IIN ["ana"], guard: reviews.author IN ["Ana", "ANA"] keeps the legacy join of IN, reads %s (the group-level joined scope of shopTags), marks and fills a client IIN in %s over a scoped type like IN, under %s, reads ANY reviews: item.parts.shop.name IIN ["alpha"] for an admin, gives ANY reviews: item.parts.shop.name IIN ["alpha"] FORBIDDEN_FILTER_PATH under %s, with no adapter call, detaches the client's IIN frame carrier that middleware edits into CONTAINS_ALL on shop.tags, under %j, gives CONTAINS_ALL with a forged __scope outside a frame DETACHED_CLIENT_FILTER, with no adapter call; tests/integration/list-operators-mongodb.test.js: IIN through a collection is an implicit ANY frame, also as a shorthand term, scoped joins inside %s frames apply the target scope before CONTAINS_ALL and IIN read the joined record, a scoped reference inside an embedded list inside a frame fails closed with IIN, before any lookup; tests/integration/list-operators-postgres.test.js: compiles both operators through the scoped joins of a %s frame, the reference join plain where its scope is null; tests/integration/list-operators-parity.test.js: %s, in every scope shape, listed and counted, on every surface (its CONTAINS_ALL through a scoped single reference outside frames, Frames through scoped references and The 403 cases), filters the joined reviews of the J prefix reviews with IIN and groups them under their stored spelling, filters the records by CONTAINS_ALL on their own list in an aggregate grouped by the J prefix reviews, cuts an IIN shorthand below the J prefix items into a frame anchored at items; tests/integration/list-operators-mcp.test.js: filters the joined reviews of the J prefix reviews with IIN, filters by CONTAINS_ALL on the record's own list, and cuts an IIN shorthand below the J prefix items, from the aggregate tools. |
MongoDB: $all with cast values; $in of BSONRegExp values with the byte limit per value. | MongoDB | tests/integration/list-operators-mongodb.test.js: compiles to $all with the values cast as IN casts them, in order and with duplicates, values stay data: $-leading strings are compared, never read as operators, compiles wherever EQ compiles on a list leaf: %s, in every surface, compiles to $in of anchored, escaped BSON regular expressions with the i option, one per value, escapes every regular expression metacharacter, so values never become syntax, compiles wherever IEQ compiles: %s, in every surface, compiles beside CONTAINS_ALL in group conditions by field and path, in OR branches and in nested groups, an ALL frame negates $all inside its sub-pipeline, and a quantified field filter compiles alike, applies MongoDB's 32,764-byte limit to each compiled pattern, which is exactly the pattern sent, the longest IIN pattern runs on the server, and a longer value fails with INVALID_FILTER_VALUE, not a server error, CONTAINS_ALL reads one value's point bounds of a multikey index; IIN reads the whole index and examines every string key; tests/integration/query-text-operators-mongodb.test.js: every declared operator compiles on a String leaf or list. |
PostgreSQL: @> $n::type[], COALESCE only under negation; IIN folds both sides with one array parameter; IIN [] is FALSE. | PostgreSQL | tests/integration/list-operators-postgres.test.js: pins the CONTAINS_ALL statements with their bound values, pins the IIN statements with their bound values, compiles CONTAINS_ALL on %s as the EQ statement with set containment of one bound array, in every surface, compiles IIN on %s as the IEQ statement with the bound values folded by the server, in every surface, keeps CONTAINS_ALL and the scalar IIN two-valued under ALL, with COALESCE only there; tests/integration/collection-frames-postgres.test.js: keeps ALL %s two-valued; tests/integration/query-text-operators-postgres.test.js: folds both sides of the five case-insensitive operators, and only theirs, with caseFoldExpression; tests/integration/list-operators-parity.test.js: %s, in every scope shape, listed and counted, on every surface (its ALL cases over the update-seeded null lists). |
PostgreSQL: a GIN index serves CONTAINS_ALL on root lists and initialization keeps it; IIN uses the IEQ fold btree. | PostgreSQL | tests/integration/list-operators-postgres.test.js: serves CONTAINS_ALL on a root list from a GIN index of the application, which initialization keeps, serves IIN on a String column from %s btree on caseFoldExpression, as IEQ; tests/integration/query-text-operators-postgres.test.js: gives index statements in the queries guide that are on exactly the expression caseFoldExpression writes. |
The case-fold message names IIN. | Changed | tests/integration/query-text-operators-postgres.test.js: marks, unchanged, the errors of statements that fold case, and reports 22P05 and 42704 of those as UNSUPPORTED_QUERY (400), initializes %s and serves every other operation, and fails only the case-insensitive operators, with UNSUPPORTED_QUERY, fails IIN on %s with UNSUPPORTED_QUERY, except IIN [], which matches nothing, and serves CONTAINS_ALL. |
PostgreSQL's compileQuery gives DETACHED_CLIENT_FILTER for a hand-built IEQ, ILIKE, ISTARTS_WITH or IENDS_WITH predicate on a scalar leaf with malformed joins, where 3.14.0 and 3.15.0 threw TypeError: caseFoldExpression expects a non-empty SQL expression string. | Changed (PostgreSQL, direct callers) | tests/integration/collection-frames-postgres.test.js: keeps valid native operands fail-closed with malformed joins %j (its IEQ, ILIKE, ISTARTS_WITH and IENDS_WITH rows), pin: rejects IEQ on a native Int operand with core's field rule, never as a detached filter. |
PostgreSQL's compiler rejects hand-built CONTAINS_ALL paths, object ends, leaves and values with core's codes, before any join, and malformed joins on CONTAINS_ALL, IIN and the case-insensitive text operators with DETACHED_CLIENT_FILTER. | Fail closed | tests/integration/list-operators-postgres.test.js: fails CONTAINS_ALL on %s.%s closed with %s, guard: object ends with a joins member keep core's object-end codes, rejects invalid CONTAINS_ALL values with core's messages, rejects IIN on a field that is not a String, a record identity declared String included, with the message of predicate(), which names no path, rejects invalid IIN values, keeps malformed joins of a CONTAINS_ALL or IIN predicate fail-closed, after its path, leaf and value checks, checks CONTAINS_ALL on the path relative to a frame, on the frame's entity, before any statement; tests/integration/collection-frames-postgres.test.js: keeps valid native operands fail-closed with malformed joins %j (its IIN and CONTAINS_ALL rows), checks native CONTAINS_ALL elements before new joins metadata, rejects IIN on a native Int operand with core's field rule, never as a detached filter. |
Adapters and plugins without the names reject both with UNSUPPORTED_QUERY; names declared early become live; copies of the bundled adapters inherit both. | Default for custom adapters | tests/list-operators.test.js: honors CONTAINS_ALL and IIN declared before this version knew them; tests/query-text-operators.test.js: reads queryFeatures declarations, pin: ignores operator names and future feature fields this version does not know; tests/query-emptiness.test.js: lists the emptiness operators, and keeps the 3.7.0 set apart; tests/list-operators-runtime.test.js: rejects, in %s, a client's %s before middleware (in an adapter with %s of adapters that do not declare CONTAINS_ALL and IIN), rejects a middleware's %s after middleware, with no adapter call, guard: unknown names (SOUNDS_LIKE) keep INVALID_FILTER_OPERATOR on %s, from %s, %s (in a declaring adapter receives CONTAINS_ALL and IIN with the values as sent), a spread copy of createMongoAdapter() receives a client's CONTAINS_ALL and IIN in %s, with the values as sent (in copies of the bundled adapters); tests/integration/query-text-operators-mongodb.test.js: a copy of the adapter without the declaration gets only the operators of 3.7.0; tests/integration/query-text-operators-postgres.test.js: rejects undeclared operators before middleware, and passes declared ones to the adapter, rejects the new operators for a plugin without queryFeatures, also in reads outside the runtime, rejects the new operators for a copy of the plugin that replaces compileQuery, before its compiler runs; tests/integration/list-operators-mcp.test.js: returns UNSUPPORTED_QUERY tool errors without an adapter call for an adapter that declares no queryFeatures. |
A target scope with CONTAINS_ALL that a client path reaches through a reference inside an embedded list or after a J collection, or after any collection on adapters without quantifiers, gives the client INVALID_FILTER_PATH from the final gate; a client path that enters the scoped type directly through such a collection keeps the joined scope's FORBIDDEN_FILTER_PATH (403). | Shared | tests/list-operators-runtime.test.js: gives parts.shop.name EQ "Alpha" under the shopTags scope INVALID_FILTER_PATH naming parts.shop, before the adapter, gives an aggregate on the J prefix reviews with reviews.item.name EQ "one" under the itemTags scope INVALID_FILTER_PATH, before the adapter, gives a client path through reviews.item INVALID_FILTER_PATH under the itemTags scope, before the adapter, reads the same path under the itemTags scope on a quantified adapter, which carries the scope in the frame, guard: a find scope's %s on %s that a client path reads through the scoped collection items keeps FORBIDDEN_FILTER_PATH, in %s, on a declaring adapter without quantifiers, guard: a Review find scope's %s on %s that a client path reads through the scoped collection reviews keeps FORBIDDEN_FILTER_PATH on a declaring adapter without quantifiers; tests/integration/list-operators-parity.test.js: gives parts.shop.name EQ "Alpha" under the shopTags scope INVALID_FILTER_PATH naming parts.shop, with no adapter read, gives an aggregate on the J prefix reviews with reviews.item.name EQ "one" under the itemTags scope INVALID_FILTER_PATH, with no adapter read, %s, in every scope shape, listed and counted, on every surface (its Scopes cases), applies the get_by_id scope %s to qlItem(id) alike, reads the named views qlItemsWithTags and qlItemsNamed alike, and rejects an empty CONTAINS_ALL list per call. |
A record identity declared String: IIN follows IEQ (core accepts it; MongoDB returns no rows, PostgreSQL's compiler gives INVALID_FILTER_VALUE), as decided for this release; documented for the text operators and IIN. | Shared (inherited divergence) | tests/list-operators.test.js: accepts IIN on Label.id and on Item.label.id, the empty list included, pin: accepts IEQ on Label.id and on Item.label.id; tests/integration/list-operators-mongodb.test.js: a record identity declared String compiles IIN on _id, which a generated ObjectId key never matches, pin: a record identity declared String compiles IEQ on _id; tests/integration/list-operators-postgres.test.js: rejects IIN on a field that is not a String, a record identity declared String included, with the message of predicate(), which names no path, pin: IEQ on a record identity declared String fails in the compiler with IEQ requires a String field; tests/integration/list-operators-parity.test.js: the inherited divergence of IEQ: %s returns no rows on MongoDB and fails on PostgreSQL, pin: the inherited divergence of IEQ: %s returns no rows on MongoDB and fails on PostgreSQL. |
| MCP texts and TypeScript types name both operators. | Shared | tests/mcp-text-operators.test.js: names every operator in the QLOperator description, with the literal and case rules of the text operators, names every operator in the operator list of the list and aggregate tool descriptions, accepts the list operators and rejects unknown ones in hosts that validate tool input, says that IS_EMPTY and IS_NOT_EMPTY take no value, test the field itself without a path, and test references and collections where the adapter supports it; tests/integration/list-operators-mcp.test.js: publishes CONTAINS_ALL and IIN last in the operator enum of the list and aggregate tools, and states their rules, runs CONTAINS_ALL and IIN calls from an SDK client that validates tool input and output; scripts/test-packages.js (the packed consumers, with the Equals check of the QueryOperator union and the packed core's validateTextListFilterValue('IIN', [], 'String')). |
Each new test was checked to fail on 3.15.0 (7f12322) for the expected reason, except the pins and guards whose names say so, and the k29 FOLDING_CASES row, which pins existing behaviour.
3.16.0: case-insensitive filters that MongoDB cannot compile
Ignoring case, MongoDB compiles each letter that has other case forms to a class of them, so a case-insensitive text filter value within the 32,764 bytes checked before the query runs can still be too large to compile. See the upgrade notes and text operators.
| Behavior | Decision | Executable evidence |
|---|---|---|
A value of ILIKE, ISTARTS_WITH, IENDS_WITH or IEQ whose regular expression the server cannot compile ignoring case (Location51091), such as 22,000 letters k or s, fails with INVALID_FILTER_VALUE (400) and Case-insensitive text filter value is too long: MongoDB cannot compile its regular expression when ignoring case, because letters such as k and s match more than one character, instead of INTERNAL_SERVER_ERROR (500): in list reads, through references, with pagination.count, in scoped by-ID reads, aggregate filters and collection fields, read one parent at a time and batched, where the failed batch is no longer reported as an adapter failure. The driver error is the cause. An IEQ value of 32,761 letters a still runs. | Fixed; MongoDB only | a case-insensitive value whose regular expression the server cannot compile fails with INVALID_FILTER_VALUE, not a server error on the replica set and the standalone server in tests/integration/query-text-operators-mongodb.test.js. |
| Only that server error is mapped, and only for a command with a case-insensitive pattern: a case-sensitive pattern, which the byte limit bounds, and other server errors keep the driver's error. | Fixed; MongoDB only | map the server rejecting a case-insensitive regular expression to INVALID_FILTER_VALUE, and no other error in MongoDB text operator compilation, for the adapter's find, count, aggregate and findChildren. |
Version 3.18.0 embedded-frame coverage: query-element-frames, query-element-frame-runtime, element-frames-mongodb, element-frames-postgres, integration/element-frames-postgres and integration/element-frames-parity verify containment, both PostgreSQL layouts, MongoDB replica/standalone, scopes, counts, aggregates, child batching and MCP. Earlier version-specific rejection rows above describe their historical releases; the 3.18.0 extension supersedes embedded-list rejection when elementFrames is declared.