@simtlix/simfinity-core
v3.7.0
Published
Driver-free GraphQL runtime and model metadata for Simfinity
Readme
@simtlix/simfinity-core
Driver-free GraphQL runtime, model metadata, and shared GraphQL helpers for Simfinity. It requires GraphQL 16 and Node.js 18.18 or later, and does not import Mongoose, MongoDB, PostgreSQL, or MCP packages.
All Simfinity packages are released together. Install from npm and keep their versions aligned. Source: simtlix/simfinity.js.
For relational storage, @simtlix/simfinity-sql builds on core and delegates physical database behavior to a plugin. PostgreSQL is the first supported plugin; its createPostgres facade remains available. See the SQL plugin guide.
Runtime
createRuntime(adapter) creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
connect,addNoEndpointType, andcreateSchemafor registration and schema creation;getModel,getType,getInputType, andgetRegistrationsfor runtime inspection;use,registerMutation, andsaveObjectfor middleware and write orchestration;preventCreatingCollectionto pass validation-only storage setup to an adapter;configureQueryLimitsandconfigureMutationLimitsfor the process-wide page-size and nested-operation limits.
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. DatabaseAdapter, Runtime, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define getByIds(model, ids, { context }), which returns the records it finds in getById shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by castId, splitting batches at maxPageSize, and reading an ID with getById when a batch read fails or when castId would normalize or reject it. castId should throw a NOT_VALID_ID (400) SimfinityError for a malformed value and must not create a new identifier. An adapter can define readByIdIsInert(model), returning true when a getById of that model has no side effects and update() and delete() reject the same malformed IDs with NOT_VALID_ID (400) and return null for a missing record; the runtime then skips the reads before deletes without an onDelete hook and before updates that merge no embedded value and have neither onUpdating nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without find, findOne, findOneAndUpdate or findOneAndDelete middleware and without application _id setters or overrides of the statics those reads and writes call. When getById rejects a stored reference value that way, including one that castId rejected first, the generated reference field fails with an InternalServerError (500), since the client did not send the value. update() resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its _id, else id, else the requested ID. An adapter that hydrates records can define readEmbeddedValue(value): every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored null reads as null. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as null an object that holds no data when a required member would read as null, not counting id, readOnly, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored _id as id; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define rawEmbeddedValue(value), which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
An adapter can also define findChildrenBatch(model, type, connectionField, parentIds, args, { context }), the batch form of findChildren for generated collection fields, and a synchronous batchesChildren(model, type, connectionField) predicate. The runtime calls both as adapter methods and checks them on every read. When the method is missing or the predicate returns false, each field calls findChildren at once with the arguments object it built, as before. Otherwise, within one request (a context object), the runtime groups the collection reads of one related type and connection storage field whose effective arguments (after middleware, the scope and joined scopes) hold the same plain data and are requested in the same tick, that is before the promise jobs queued so far have run. It calls the method with 2 to 100 distinct parent IDs, each a raw ID that castId accepts without changing its string form, and with a copy of the arguments that the method may change; a group with one distinct parent calls findChildren. Return one array per parent ID, in order, each holding exactly the records, order and page that findChildren returns for that parent; records should show their parent under the connection field, or not show one. Fields of one parent share its records, each with its own array. After a rejection, or a result of another shape or with records of another parent, each field reads alone with findChildren; failures other than a SimfinityError, and results of another shape, are logged once per type. Pairing rule: the batch must reproduce this.findChildren, so batchesChildren must return false when this.findChildren is not the adapter's own function, as with a spread copy or an Object.assign override that changes how children are read. The bundled adapters follow this rule: both define the two members only with batchedCollections: true: the SQL adapter when the schema is prepared and its plugin's compileChildrenQuery is paired with its compileQuery, and the MongoDB adapter at creation. To opt out on one adapter instance, set adapter.findChildrenBatch = undefined (deleting it does nothing for a class whose method is on the prototype), or make batchesChildren return false. A copy that overrides getById still inherits the base getByIds, as before.
Entity types, those registered with connect() and the targets of non-embedded relations, resolve id to _id, falling back to id, whatever the createSchema() allowlists include; types used only as embedded values keep their declared id. Embedded copies of an entity type resolve the same way, so they return their declared id unless the adapter stores an _id for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. createSchema() marks these registrations with storedIdentity: true before calling adapter.prepare().
When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws TYPE_BOUND_TO_OTHER_RUNTIME (409). createSchema() also rejects a type with a relation field copied, by toConfig() or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a toConfig() copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
Registration and createSchema() validate extensions.scope (INVALID_SCOPE) and reject list or embedded relations to unregistered types (UNREGISTERED_RELATION_TARGET); use() rejects non-functions (INVALID_MIDDLEWARE). Before adapter.prepare(), createSchema() rejects a writable non-embedded list relation without a non-empty child connectionField whose resolver it would generate (INVALID_MODEL, 400). A collection without connectionField that has an application resolver, or is readOnly, only logs a Configuration issue warning, unless it is both, and nested writes through it fail with INVALID_MODEL (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in prepare(). Fields named like generated query arguments (sort, pagination, AND, OR, and aggregation on aggregate endpoints) also log a warning once per runtime. Generated inputs that would have no fields are left out with a Configuration issue warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws INVALID_MODEL (400) when generated mutations reach its owner. An application type named FieldExtensionsType or RelationType makes Simfinity rename its introspection metadata types to SimfinityFieldExtensionsType and SimfinityRelationType when it is in the process's first Simfinity schema. That schema fixes the names: in a later createSchema(), an application type with one of the current names, the original ones or the fallback names after a rename, throws RESERVED_TYPE_NAME (500), before adapter.prepare() when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
Generated and custom mutations run through adapter.withTransaction. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define stateValue when its persisted enum representation differs from GraphQL enum names.
Importing the runtime installs Simfinity's __Field.extensions introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
Shared metadata and helpers
describeModels([{ gqltype, endpoint }]) discovers the persistent entity graph from GraphQLObjectType definitions and extensions.relation. Types registered with endpoint: false are still included when referenced. Reciprocal one-to-many definitions resolve to one child reference; explicit linking entities represent many-to-many relationships.
createQueryPlan and resolveModelPath turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
The package also exports the auth, validators, scalars, and plugins helper namespaces alongside createValidatedScalar, SimfinityError, InternalServerError, UnauthenticatedError and ForbiddenError (the same classes as auth.UnauthenticatedError and auth.ForbiddenError), buildErrorFormatter, configureQueryLimits, configureMutationLimits, QLOperator, QLSort, and QLValue. buildErrorFormatter returns a GraphQLError whose originalError is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
The same helpers are available from the @simtlix/simfinity-core/auth, /auth/errors, /auth/expressions, /auth/rules, /plugins, /scalars and /validators subpaths, each with TypeScript declarations whose types match the root namespaces. moduleResolution node16, nodenext and bundler read them through the export map, and node10 through typesVersions. The internal/* subpaths are not public API and have no declarations.
