agentleFS
simtlixsimtlix/simfinity.js/.cursor/rules/simfinity-core-functions.mdc

simfinity.js / rules

Cursor rule11 starsChanged 17 days agoApache-2.0View on GitHub

Documents the critical internal functions and pipelines in the Simfinity core engine

Files
simfinity-core-functions.mdc
---
description: Documents the critical internal functions and pipelines in the Simfinity core engine
globs: packages/core/src/**, packages/mongodb/src/**, packages/postgres/src/**
alwaysApply: false
---

# Core Functions

The shared engine now lives in `packages/core/src/runtime.js`; `packages/mongodb/src/index.js` is the Mongo facade. Mongo-specific model, pipeline and transaction implementations moved to `packages/mongodb/src/mongo/`. SQL provides the relational lifecycle adapter in `packages/sql/src/adapter.js`; `packages/postgres/src/index.js` binds the PostgreSQL plugin through `createSQL({ plugin: postgresPlugin(options) })`, with driver-free query plans in core. Function descriptions below retain the legacy public API unless a backend-specific distinction is called out in `docs/postgresql.md`.

## Registration Phase

### `connect(model, gqltype, simpleEntityEndpointName, listEntitiesEndpointName, controller, onModelCreated, stateMachine)`

Registers a GraphQL type for full CRUD endpoint generation. The `model` param is typically `null` (generated later by `createSchema`). Stores metadata in `typesDict` and `typesDictForUpdate`.

### `addNoEndpointType(gqltype)`

Registers a supporting type without query/mutation endpoints. During schema construction the runtime marks referenced supporting types that need an adapter model. Embedded storage is determined by the selected adapter.

### `registerMutation(name, description, inputModel, outputModel, callback)`

Registers a custom mutation outside the standard CRUD pattern.

## Schema Build Phase (`createSchema()`)

Called after all `connect()`/`addNoEndpointType()` calls. Executes in order:

0. **Validation before any model is built** -- `validateScope()` for every registration (`INVALID_SCOPE`), then `collectSchemaOutputTypes()` gathers object/interface/union-member types reachable from registered types and custom mutation outputs (following fields, union members and `getInterfaces()`, like `GraphQLSchema`), `assertNotBoundElsewhere()` rejects any type bound or reserved by another runtime, and `assertFieldsNotBoundElsewhere()` rejects any field copied (by `toConfig()` or a field spread made after another runtime built its schema) with a resolver that runtime generated or the auth plugin's wrapper of it, or with an untagged resolver and that runtime's extensions object, all with `TYPE_BOUND_TO_OTHER_RUNTIME` (409). Fields without a resolver are skipped and later get this runtime's resolver; interface fields are skipped, because graphql-js never runs their resolvers. `connect()` and `addNoEndpointType()` run the type-level check and scope validation first, so a rejected registration is not kept.
1. **`assertCollectionConnections()`**, **`markReferencedTypesForModelGeneration()`** and **`adapter.prepare()`** -- `assertCollectionConnections()` checks every non-embedded list relation to a `GraphQLObjectType` without a non-empty string `connectionField` (`isUnlinkedCollection`): writable with a generated or no resolver throws `SimfinityError(\`${Type}.${field} requires a child connectionField\`, 'INVALID_MODEL', 400)`; writable with an application resolver, or `readOnly` without one, logs `Configuration issue: …; nested writes through it are rejected` once per runtime (`warnOnce`); `readOnly` with an application resolver is silent. There is no implicit many-to-many rejection in core. `markReferencedTypesForModelGeneration()` marks supporting models and returns the set of relation targets; every registration (and its `typesDictForUpdate` copy) then gets `storedIdentity = endpoint === true || referenced`. `adapter.prepare()` prepares database metadata: PostgreSQL validates registrations and describes storage (describeModels still rejects every collection without `connectionField`); MongoDB adds a private ObjectId connection field to a generated child model whenever `normalizeConnectionField(itemType, connectionField).graphqlFieldName` is null, including chained and self-referencing collections whose child has a collection with the same `connectionField`. Between `assertCollectionConnections()` and `markReferencedTypesForModelGeneration()`, `assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes, includedCustomMutations))` throws `RESERVED_TYPE_NAME` (500) when the field metadata names are fixed and one of those types (included endpoint types and the input and output types of included registered mutations) or a type they reach has a current metadata type name. It calls `fieldMetadataClash(roots, { inputFields: false })`, so `findSchemaType` follows only what `collectSchemaOutputTypes` already materialized: object and interface fields, interfaces, union members and argument types by name. An input object, a registered mutation's `inputModel` included, is matched by its own name and its `getFields()` is never called, because its fields thunk may call `getInputType()`, which returns `undefined` until `buildPendingInputTypes` runs, and graphql-js caches the result. A clash inside an input object's fields is left to `constructSchema()`; see step 5.
2. **`adapter.createModel()`** -- creates missing endpoint/supporting models. MongoDB uses `createMongoModel()` in `packages/mongodb/src/mongo/models.js`, with schema/index generation. Generated models still declare `{ id: 1 }` on the top-level `id`, which no generated or MCP path uses (the record key is `_id`); apps remove it from a `connect()` type with `onModelCreated: (model) => model.schema.removeIndex({ id: 1 })` (`Top-level id index opt-out` in `tests/objectid-indexes.test.js`). Keep the declaration in 3.x; dropping it by default, with the stored `id` path and the duplicate `$set.id`, is a 4.0 item. Its `generateSchemaDefinition()` throws `SimfinityError(\`${Type}.${field} cannot be stored on MongoDB: ${reason}\`, 'INVALID_MODEL', 400)`, `Type` being the declaring (possibly embedded) type, at every depth before the Mongoose model is built: for an embedded object or list whose name is `in Object.prototype` (Mongoose cannot build the nested path, and the driver's `fieldsAsRaw: {}` lookup returns such arrays' items as raw BSON buffers), and for any stored field that adds a `constructor` path, including a reference whose `connectionField` is `constructor` (Mongoose drops it from every write). Supplied models are not checked. `generateSchemaDefinition(gqlType, nested = false, ancestors = [], path = gqlType.name)` also calls `assertNoEmbeddedCycle(gqlType, entryType, ancestors, fieldPath)` for each embedded object and list before recursing with `[...ancestors, gqlType]` and `${path}.${field}`: a type that embeds itself throws `SimfinityError('A type cannot have a field of its same type and embedded', 'INVALID_MODEL', 400)`, and an embedded type that is an ancestor on the current path throws `SimfinityError(\`Embedded cycle at ${fieldPath}\`, 'INVALID_MODEL', 400)`. The path starts at the type whose model is generated (`A.b.a`; in default mode that can be an embedded `addNoEndpointType()` type with `needsModel`, such as `B.a.b`). Ancestors are tracked per path, so sibling and nested reuse of a type still builds, and a `readOnly` link does not break a cycle. Only generated models are walked: in default mode a cycle that only supplied models reach is not checked and fails with `INPUT_TYPE_UNRESOLVED`, or builds with a `readOnly` link, as before; `describeModels` in transactional mode and PostgreSQL rejects it from every entity. The private connection field the adapter adds to a child model after its definition is built is checked in `adapter.prepare()`: a collection whose `connectionField` is `constructor` on a child without that field throws the same `INVALID_MODEL` naming `Child.constructor`. PostgreSQL creates native handles rather than Mongoose models.
3. **`installIdResolver()`**, **`autoGenerateResolvers()`** and **`installEmbeddedValueResolvers()`** -- for every `storedIdentity` registration, whatever the allowlists include, the type object is marked with `markStoredIdentity()` (internal relation-storage module) and an `id` field without a resolver gets `markGenerated((parent) => parent._id ?? parent.id)`; embedded-only types keep graphql's default `parent.id`. Only when `typeof adapter.readEmbeddedValue === 'function'`, every nullable, singular embedded object field without a resolver, on registered types and on the embedded types they reach (cycle-guarded), gets the shared `readEmbeddedField` resolver (`markGenerated`): it reads the value through `readOwnField`, which resolves like graphql's `defaultFieldResolver` (a method on the parent, such as a custom mutation result's, is still called with the parent as receiver) but reads the property once, and passes the result to `adapter.readEmbeddedValue`, after a thenable settles (`isThenable`) or synchronously otherwise, then through `readEmbeddedObject(value, getNamedType(info.returnType))`. That returns the value untouched, reading no member, unless the type is a `GraphQLObjectType` with a member that counts (`hasRequiredMember`/`countsAsRequired`: a non-null member other than `id`, `extensions.readOnly` members, members with an application resolver (`hasApplicationResolver`), interface and union members, single or list (`isAbstractOutputType(unwrapListAndNonNull(member.type))`, which `buildInputType` leaves out) and referenced collections). It then reads the stored members, never the value's getters or virtuals: `storedMembersOf(value)` requires `isRecordObject(value)` (a plain or null-prototype object, or an object with `toObject()` such as a Mongoose document or nested path; never a thenable, array, Date, ObjectId or other class instance) and passes it through the optional `adapter.rawEmbeddedValue` (MongoDB: a nested path's raw value through its `toJSON` without a receiver, a subdocument's `_doc`, a `mongoose.Types.Map`'s `Object.fromEntries`; but the value unchanged, so data, when its schema declares a getter, a virtual other than the automatic `id` (`isAutomaticIdVirtual`), an alias or (on a subdocument's own schema) any method on a member at any depth inside it, or when a nested path's `$__schema` is not a `mongoose.Schema` of this Mongoose copy, through subdocument, document-array, map-value and array-item schema types: `declaresComputedMembers(schema, nestedPath)`/`declaresComputedValues(mapValueType)` read schema metadata only (`paths[*].getters`, `virtuals`, `aliases`), cycle-guarded and cached per schema and nested path in a WeakMap; a nested path's own path comes from its document's `$__.getters` cache (`nestedPathOf`), else the whole schema); a null/undefined result is `{}`, a plain object is read, anything else returns null and counts as data. `storedMemberValue` reads own data properties only (`ownStoredValue`; an accessor is `PRESENT`, never called), by storage name, referenced collections as undefined, and `id` as `_id ?? id` (the generated entity resolver and Mongoose's `id` virtual). It returns `null` when both hold on those stored members: `holdsNoData` (every member is null/undefined, an empty list, or a singular embedded object whose stored members hold no data; thenables, accessors and any other value are data, and an ancestors guard makes a self-containing value data); and `missesRequiredMember` (a member that counts is null/undefined, or a non-null singular embedded object's stored members miss one; thenables, accessors and values `storedMembersOf` does not read count as present, as on writes in `completeEmbeddedValue`). Any error while reading (an adapter hook, a proxy) returns the value unchanged. Mongoose minimizes an empty object that a create writes for a required embedded member (`geo: {}`), so such an object that holds nothing else reads as `null` too, except in by-ID reads of nested paths (generated models), which render `{}`; this is documented, not fixed, because storage cannot tell it from an omitted object. The rule ignores the selection and depends only on the value and its type: the hook reads no data, so these resolvers are not claimed and do not bind their types, and every runtime that shares the type, with or without the hook, reads the same. After those, `installObjectMemberResolvers()` gives every output field named `in Object.prototype` (`constructor`, `toString`, `valueOf`, ...) without a resolver, on registered types and the embedded types they reach, skipping references and collections, the shared `readOwnField` resolver (`markGenerated`, binds no type): it returns `null` when the value is identical to the `Object.prototype` member (`isObjectMember`) and otherwise resolves as `defaultFieldResolver` does, reading the property once so a getter runs once; `readEmbeddedField` reads through it too. `autoGenerateResolvers()` generates non-embedded relationship resolvers when none exists. Single references use `resolveById` with target `get_by_id` middleware/scope, reading `ownValue(parent, connectionField)` and, only when that is `null`/`undefined`, `ownValue(parent, fieldName)` (a function under the field name, such as the class a MongoDB document inherits `constructor` from, is ignored) and resolving any ID except `null`/`undefined`/`''` (so `0` resolves); they await it and `storedReferenceError()` turns a `NOT_VALID_ID` (400) `SimfinityError` into `InternalServerError(\`${Type}.${field} stores an invalid identifier\`, error)` (`INTERNAL_SERVER_ERROR`, 500, like every `InternalServerError`), because a stored ID is a data error, not a bad request (other errors, including `NOT_VALID_ID` 404, pass through); collections use target `find` middleware/scope and `adapter.findChildren` with `parent._id ?? parent.id`, keeping the `connectionField || fieldName` read fallback. Both await callbacks with the request context and enforce independent identity/parent predicates. Existing resolvers are preserved; `assertFieldsNotBoundElsewhere()` runs first, so a resolver copied from another runtime is rejected rather than kept. `claimRelationField()` gives each field a fresh null-prototype extensions object and records it and the generated resolver in `relationFieldOwners`, and each generated resolver binds its owner type to this runtime in `runtimeBoundTypes`; an unregistered list target throws `UNREGISTERED_RELATION_TARGET` via `requireRegisteredType()` (also used by `graphQLListInputType` and embedded singles in `buildInputType`).
4. **`buildRootQuery()`** -- creates the root Query type with: `{singular}(id)` single-entity query, `{plural}(filters, pagination, sort)` list query, `{plural}_aggregate(filters, aggregation)` aggregation query. `createArgsForQuery(gqltype, omit = null)` builds field filter args (skipping a collection's back-reference `omit`) and then the control args `pagination`, `sort`, `AND`, `OR`; it warns once per runtime for fields with those names (except `omit`), and `buildRootQuery()` warns for a field named `aggregation` on a type with an aggregate endpoint (`Configuration issue: ${Type}.${name} …`). It never throws for them. It emits a field filter only for scalar, enum, object and list fields (`let filterType = null; … if (filterType) argsObject[name] = { type: filterType }`): a single interface or union field, nullable or non-null, gets none; lists of them keep `QLTypeFilterExpression`, which accepts only `null`, because that argument has always existed. The list field carries `extensions.simfinityQuery = { typeName, operation: 'find', countSink: true }`; with `pagination.count`, `canReportCount(parent, info, context)` runs before the queries: without a count sink (`countSinkFor(parent, info)`: a function under the non-exported `Symbol.for('simfinity.countSink')` on `parent`, the root value, which three-argument resolver wrappers still forward, then on `info.rootValue`) and with a context that is not an object or function, the count query is skipped and the rows returned. `reportCount(parent, info, context, count)` returns whether the count was stored: through the sink, or with `Reflect.set(context, 'count', count)`, where the Envelop/Apollo count plugins read it. A refused write (frozen, sealed or read-only `count`) logs one `warnOnce(runtimeIdentity, 'count-context', …)` and the rows are returned. `buildRootQuery(name, includedTypes, aggregationResultType = QLTypeAggregationResult)`: the `_aggregate` result type is `QLTypeAggregationResult` over Simfinity's `JSON` scalar (`createAggregationResultType(GraphQLJSON)`).
5. **`buildMutation()`** -- first resolves deferred inputs through `buildPendingInputTypes(waitingInputType, includedMutationTypes)`, then creates `add{singular}(input)`, `update{singular}(input)`, `delete{singular}(id)` and `{actionName}_{singular}(input)`. A GraphQL input object needs a field, so `add{singular}` exists only when `hasInputFields(type.inputType)`, otherwise `warnOnce(gqltype, 'emptyCreateInput', …)`; `update{singular}` and the state actions are skipped with `warnOnce(gqltype, 'emptyUpdateInput', …)` when the update input has no fields (no writable fields and no writable id); `delete{singular}` always exists. The warnings start with `withoutWritableFields(type)`, which names a non-readOnly `state` managed by a state machine (with `{ noWritableId: true }` it appends `and no writable id`, after a comma when the state clause is present), and the state-machine add warning says a registered mutation must set the initial state (`saveObject()` sets it). Each generated mutation field, including registered custom mutations, carries `extensions.simfinityMutation` (`{ typeName, operation }` with `operations.SAVE`/`UPDATE`/`DELETE`; `{ typeName, operation: 'state_changed', action, from, to }`; `{ operation: 'custom_mutation' }`), which MCP reads; do not reuse `simfinityQuery` for mutations or get-by-id fields, since auth reads it. `createSchema()` builds the query root, then `applicationResolvedFields`, then the mutation root into a local; when `findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult)` (module level, walking fields, args, interfaces, union members and input fields) finds an application `GraphQLScalarType` named `JSON`, it rebuilds the query root with `aggregationResultTypeFor(thatScalar)` (a per-runtime cached `QLTypeAggregationResult` over that scalar). Any other type named `JSON` keeps graphql-js's duplicate-name error. The schema is built by `constructSchema(query, mutation)`: `fieldMetadataClash([query, mutation])` finds an application type named like a current metadata type (`fieldMetadataTypes()`, through `findSchemaType` skipping the metadata type itself). When the names are fixed (`fieldMetadataNamesFixed()`: a `Symbol.for('simfinity.fieldMetadataNamesFixed')` flag on the shared `__Field.extensions` field, so every module evaluation sees it) it throws `reservedTypeNameError` (`RESERVED_TYPE_NAME`, 500), a backstop for types nested in input objects' fields and types that only generated types reach. `findSchemaType(roots, name, skip, { inputFields = true })` reads input-object fields only with `inputFields` true; the pre-check in step 1 passes `false`. Otherwise a clash picks the first free `SimfinityFieldExtensionsType{n}`/`SimfinityRelationType{n}` pair (no suffix, then 2, 3, …) and calls `renameFieldMetadataTypes(...)`, which swaps `field.type` and returns a restore closure; `new GraphQLSchema` runs in try/catch, and a throw restores the previous type objects (identity preserved) and rethrows. Only after construction succeeds does it call `fixFieldMetadataNames()` and, after a rename, log one `Configuration issue` warning, so every successful Simfinity schema fixes the names, renamed or not.
6. **Reservation** -- after `new GraphQLSchema`, reachable object types that still have a non-embedded relation field without a resolver are bound to this runtime, so another runtime cannot later generate resolvers this schema executes.

PostgreSQL requires `await initializeDatabase({ mode: 'create' })` or `mode: 'validate'` after `createSchema()` and before operations. Preparing a GraphQL schema does not itself execute PostgreSQL DDL. Configuration is bound once and registrations freeze at preparation.

## Auto-Generated Endpoint Naming

- **Single entity query**: `simpleEntityEndpointName` (e.g., `serie`)
- **List query**: `listEntitiesEndpointName` (e.g., `series`)
- **Add mutation**: `add` + `simpleEntityEndpointName` (e.g., `addserie`)
- **Update mutation**: `update` + `simpleEntityEndpointName` (e.g., `updateserie`)
- **Delete mutation**: `delete` + `simpleEntityEndpointName` (e.g., `deleteserie`)
- **State action**: `{actionName}_{simpleEntityEndpointName}` (e.g., `approve_serie`)

Names preserve the strings supplied by the consumer in `connect()`; the documented examples use lowercase. The generated root query type is `RootQueryType`, and the mutation type is `Mutation` (relevant to authorization permission maps).

## Input Type Generation (`buildInputType()`)

Creates two input types per registered type:

- `{Type}Input` -- for `add` mutations (excludes `id`, readOnly fields, state-machine-managed `state`)
- `{Type}InputForUpdate` -- for `update` mutations (all fields optional except `id` which is required)

Handles: scalars, enums, relation fields (`IdInputType` for refs, nested input for embedded), `GraphQLList` fields, self-referencing collections. An `isAbstractOutputType` branch (before the list branch) leaves interface and union fields, single or list, out of generated inputs and calls `warnOnce(gqltype, 'abstract:<field>', …)`; readOnly fields never reach it. A field whose input cannot be built yet (an embedded type without its input, or a list `graphQLListInputType` returns `null` for, such as a list of lists) does not stop `buildInputType`: it checks every field and returns `{ unresolvedFields: [{ field, waitsFor? }] }`, never `null`. `waitsFor` is the pending embedded type (single or list item); it is absent for a list with no generated input. The `does not define extensions.relation` warning fires only while no field has failed yet, so it logs as often as the earlier early return did. `buildPendingInputTypes` keeps the type waiting, and when a pass makes no progress throws `INPUT_TYPE_UNRESOLVED` (500) with `unresolvedInputTypesMessage()`: every unbuildable field by type, the causes (fields without `waitsFor`, and waiting fields on a wait cycle, that is embedded types that contain each other) with the readOnly advice, and the other waiting fields listed apart as `T.f waits for U`. Keep the code, the status and the `Could not build input types for: <types>. Check for circular or misconfigured relations` prefix.

`buildInputType(gqltype, reachable)` decides embedded create and update inputs separately, since a GraphQL input object needs a field: an embedded single or list whose embedded input has no fields (`hasInputFields`; `graphQLListInputType` returns the `NO_INPUT_FIELDS` sentinel for such a list) is left out of that input of the owner, with `warnEmptyEmbeddedInputs` (`warnOnce(gqltype, 'emptyEmbeddedInput:<field>', …)`), so an embedded type whose writable fields hold only ids stays in update inputs only. That create-only case warns `… is left out of generated create inputs, because the create input of its embedded type <E> would have no fields (create inputs leave out ids), …, while generated updates still can; …` without the readOnly advice; only when both inputs are empty does the warning advise marking the field readOnly. A NonNull single, or a list whose outer type is NonNull, without a create input throws `requiredEmbeddedWithoutInputError` (`INVALID_MODEL`, 400, `<T>.<f> is non-null but its embedded type <E> has no writable fields, …`) when `reachable` (`typesWithReachableInputs(includedMutationTypes)`, computed once in `buildPendingInputTypes`) has the owner. Otherwise the omission is returned as `requiredWithoutCreateInput` and stored in the `requiredFieldsWithoutCreateInput` WeakMap keyed by registration, which `buildPendingInputTypes` re-checks first for inputs that an earlier schema built and this one reaches, so an app whose generated mutations do not reach the owner keeps starting.

Referenced collection inputs (`createOneToManyInputType(name, prefix, itemType, connection, itemNonNull, optionalFields, owner)`):

- A collection of another type is `OneToMany<Type>A<field>` / `OneToMany<Type>U<field>`. Its `added` items are `<Type>A<Item>InputFor<ConnectionField>` / `<Type>U<Item>InputFor<ConnectionField>` (`createTypeWithExcludedField`: the item's creation input without the back-reference), or the item's `<Item>Input` without a `connectionField`; `updated` items are `<Item>InputForUpdate`; `deleted` is always `[ID]` (null IDs are skipped).
- A self-referencing collection is `OneToManyA<field>` / `OneToManyU<field>`, with items `A<Type>InputFor<ConnectionField>` / `U<Type>InputFor<ConnectionField>`. The A item gets the type's self collections as optional fields (`optionalFields`, outer non-null removed); the U item keeps them as declared. Self collection input fields carry `fieldEntry.description`.
- Item inputs are resolved in fields thunks (`getInputType(itemType)`, `typesDictForUpdate.types[item].inputType`), so referenced collections never wait for their item's input and cycles across types build; there is no `_fields` patching. Only embedded lists wait for `registration.inputType`.
- The fields thunk leaves out `added` or `updated` when its item input has no fields (`hasInputFields`), with `warnOnce(itemType, 'emptyItemInput:<owner>:<op>', …)`, `owner` naming the collection as `Type.field`; `deleted` is always present. A required collection keeps its NonNull operation input on create, which may then hold only `updated` and `deleted` (clients send `{}`).
- `added` inputs are shared per generated name through `excludedFieldInputTypes`, reused only when itemType, excluded field and `optionalFields` match, so sibling collections through one `connectionField` share one input and a real name clash still fails.
- `claimSelfCollectionNames()` runs first in `buildPendingInputTypes(waitingInputType, includedMutationTypes)`, over unbuilt types; `typesWithReachableInputs(includedMutationTypes)` gives the types this build reaches (included endpoint types, then through non-readOnly lists and embedded fields). `selfCollectionNameHolders` records, per field name, every type built with `OneToManyA|U<field>`, reachable or not. A reachable type is qualified only when a holder other than itself is still the registered type (`typesDict.types[holder.name]?.gqltype === holder`) and reachable in this build; otherwise it becomes another holder and keeps `OneToManyA|U<field>`. So the first registered reachable type of a build claims the names, and a type registered again under the same name, or registered after an earlier `createSchema()` and built by one that reaches no holder, keeps the legacy names with no warning, as on 3.5.7. A qualified type goes to `qualifiedSelfCollections` and gets `OneToMany<Type>A|U<field>`, with one `warnOnce(gqltype, 'selfCollectionName:<field>', …)` naming it and the holder. Unreachable types keep the legacy names. Names are fixed when a type's inputs are first built. Qualify only when a current holder is reachable: otherwise a schema that builds on 3.5.7 gets renamed inputs (`self-referencing collections registered after a schema` in `tests/collection-inputs.test.js`).

Preserves outer non-null wrappers for creation and removes only the outer wrapper for updates. List item nullability is retained in scalar/enum/custom scalar lists, embedded inputs, and referenced collection `added`/`updated` items. Entity `id` is required on update for both `GraphQLID` and `GraphQLID!`; other ID fields are optional. Unwrap non-null wrappers before detecting lists, and unwrap list items before looking up their model/type or generating relation resolvers and filters.

## Query Pipeline

The shared runtime runs middleware and scopes before calling adapter reads. MongoDB's compatibility `buildQuery(args, gqltype)` in `packages/mongodb/src/mongo/queries.js` returns an aggregation pipeline. PostgreSQL uses the typed plans in `packages/core/src/query-plan.js` and compiles parameterized SQL in `packages/postgres/src/query/`; raw MongoDB pipelines are not a portable API.

MongoDB pipeline helpers:

- `resolveQueryPath()` resolves declared filter/list-sort paths and Mongoose schema paths together, maps `id` through `identifierLeaf()`, and collects required lookups. `id` is `_id` at the root and right after a `$lookup`; inside an embedded object at schema path `p` it is `_id` only when the schema has `${p}._id` and either the embedded type object is marked as an entity (`hasStoredIdentity()`, which any runtime's `createSchema()` sets through `markStoredIdentity()`, so the rule follows the type object across runtimes, like the id resolver) or the schema has no `${p}.id`; otherwise, and without a schema, it is `id`. Relation storage names come from `@simtlix/simfinity-core/internal/relation-storage`. A `GraphQLID` filter value without a schema path is cast with `castObjectId` (invalid values give `INVALID_FILTER_VALUE`). `buildQueryTerms()` preserves every scalar/enum condition and relationship term, including repeated leaves. Values are validated and cast against the real schema, with state-machine state-name storage preserved.
- `buildFilterGroupMatch()` -- recursively processes `QLFilterGroup` (AND/OR) into nested MongoDB `$and`/`$or` match expressions. Reuses `buildQueryTerms()` for each leaf condition. Deduplicates `$lookup` stages via shared `aggregationsIncluded` dict. Max depth: 5.
- `buildSortClause()` uses `resolveQueryPath()` and deduplicates lookups for related sort terms.
- `configureQueryLimits({ maxPageSize })` sets a process-wide positive safe-integer maximum (default 1000); `configureMutationLimits({ maxNestedOperations })` in `mutation-limits.js` is its process-wide mutation counterpart. `paginationStages()` validates explicit page/size/skip, applies `min(100, maxPageSize)` to unpaged lists, and leaves unpaged aggregates unbounded.
- `QLValue.parseLiteral(ast, variables)` preserves explicit null and flat-list elements. It resolves variables inside list literals: an unset one reads as `null` (`Object.hasOwn`), and validation passes no variables. It reads enum literals as their names. It still rejects object literals and nested lists. Query validation rejects null list elements and invalid scalar/operator/group shapes.
- Sort: `$sort` from `QLSortExpression` input
- AND/OR: optional `AND`/`OR` arguments containing `QLFilterGroup` trees. Flat field filters and scope-injected filters are always ANDed at the top level (cannot be bypassed by user OR). Top-level arguments validate only `AND`/`OR` as lists; `conditions` is a list key only inside groups. `aggregation` is a control key only in aggregate mode (`collectFiltersAndLookups(..., operation = 'find')`, `createQueryPlan`/`collectQueryPathEntries` in `query-plan.js`); on find and count it is a field filter, so auth path checks and `queryable` apply to it.
- Joined scopes (`prefixScopeArgs`) are find scopes: `aggregation` becomes a prefixed condition; `sort` and `pagination` are validated for shape only by `assertScopeControlArg()` (non-empty terms with a string `field` and `ASC`/`DESC` order, else `INVALID_SORT`; `paginationStages()`, else `INVALID_PAGINATION`; both 400) and then ignored; sort field names are not resolved.

`buildAggregationQuery()` extends this with `$group` for aggregation operations (SUM, COUNT, AVG, MIN, MAX). PostgreSQL implements the supported filter/sort/pagination/aggregation contract through its compiler, preserving root multiplicity across collection joins. Both adapters share query-limit configuration and typed enum normalization; enum names resolve before strict internal values. Do not reuse query normalization for persisted writes/state guards.

## Mutation Pipeline

The runtime delegates generated mutations, custom mutations and programmatic `saveObject()` calls to `adapter.withTransaction()`. For non-delete operations, `executeOperation()` (and `saveObject()`) first runs `assertCollectionWrites()`, which rejects nested `added`/`updated`/`deleted` items with a non-null entry through a collection without `connectionField` (`INVALID_MODEL`, 500, `${Type}.${field} cannot store nested items because it has no connectionField`) before anything is written; `executeItemFunction()` repeats the check for items middleware adds later. `executeOperation()` also runs `assertNestedOperations()` when `maxNestedOperations` is set: after root middleware and before the first input copy/the transaction, it counts `added`/`updated`/`deleted` entries (nulls included) of non-embedded object collections across all levels, recursing into `added`/`updated` items, and throws `NESTED_OPERATIONS_EXCEEDED` (400). Root deletes, `saveObject()` and `registerMutation` callbacks are not capped. Field/type validators, hooks, nested writes and state transitions remain in core. Both adapters preserve active caller-owned sessions. `attemptInputs(input, type)` gives each attempt of `executeRegisteredMutation()` and `executeOperation()` its own typed copy of the input: the first copy is taken before the transaction starts and handed out once (then released), and a retry copies the request input again, which no attempt receives; deletes pass `args` unchanged, and `saveObject()` keeps one snapshot because its caller owns the input.

MongoDB transaction handling in `packages/mongodb/src/mongo/transactions.js` (`withMongoTransaction`), which the off-mode adapter wraps in `withOwnedTransaction` (`packages/mongodb/src/mongo/adapter.js`):

1. Without a session, owns a session + transaction on the registered model's connection. Custom mutations pass no model: in off mode `withOwnedTransaction` passes a supplied session or model through and otherwise asks `sessionModel()`, which keeps `mongoose.connection` unless it has no client (`getClient()`), no model is compiled on it or on any of its `useDb()` descendants (`defaultClientHasModels()` is false: it walks `otherDbs` from `mongoose.connection` at any depth with a `Set`, since a child's `otherDbs` also lists its parent, and checks `hasModels` on each connection, generated or application models alike; a `useDb(name, { noListener: true })` child is not in its parent's `otherDbs`, so neither it nor its descendants are seen, and native `mongoose.connection.collection()` writes compile no model) and every registered model with a `.db` (`binding.getRegistrations`, stored in `bind()`) is off the default connection on one MongoClient (`clientOf`, so `useDb()` children count as their parent); it then returns the first such model, whose connection opens the session. An unbound adapter keeps `mongoose.connection`; transactional mode uses the first protected model. A supplied session must have an active transaction; the caller owns its lifecycle and retries. Opt-in transactional Mongo reference integrity additionally requires snapshot/majority options and aborts supplied sessions on guarded-write errors, violations or ambiguous update results.
2. Calls the appropriate handler: `onSaveObject`, `onUpdateSubject`, `onDelete`, `onStateChanged`
3. Commits on success, aborts active transactions on write/hook errors, and awaits owned session cleanup. Abort/cleanup errors do not replace an earlier operation error.
4. Retries the transaction on `TransientTransactionError`, up to five retries after the first attempt, each after a full-jitter wait of `Math.random() * 10 * 2 ** attempt` ms that starts after the abort. An exhausted `WriteConflict` (code 112 or codeName `WriteConflict`) becomes `SimfinityError('Concurrent write could not be completed', 'TRANSACTION_RETRY_EXCEEDED', 409)` with `cause`/`getCause()`; other exhausted transient errors are rethrown raw, and borrowed sessions are never retried or mapped. `UnknownTransactionCommitResult` retries only commit, up to five retries, never repeating the body for an uncertain result. Expired commits are not retried; terminal uncertainty is surfaced without replaying writes.

SQL transaction handling in `packages/sql/src/transactions.js` orchestrates sessions and retries. `withTransaction` retries up to five times the errors the driver's `isRetryable()` accepts, only after a confirmed ROLLBACK, each after a full-jitter wait of `Math.random() * 10 * 2 ** retry` ms that starts at the top of the next attempt, once the failed attempt's `driver.release()` has been awaited; the PostgreSQL plugin borrows a `pg` client, begins repeatable-read isolation and classifies confirmed serialization/deadlock aborts (`isRetryableDatabaseError`). Its `commit` rejects with a non-retryable `SimfinityError('Transaction was rolled back because one of its statements failed', 'DATABASE_ERROR', 500)` when the `COMMIT` result reports `command: 'ROLLBACK'`, because PostgreSQL turns the COMMIT of a transaction with a caught statement error into a rollback without an error; like the other PostgreSQL `DATABASE_ERROR`s it has a non-enumerable `cause` (`new Error('COMMIT reported ROLLBACK')`, no SQL) and `getCause()`. A result without `command`, from a pool wrapper returning `{ rows, rowCount }`, resolves as in 3.5.4; the declarations make `command` optional. A commit rejection that `isRetryable()` accepts is retried like any other confirmed abort. `packages/sql/src/errors.js` (not exported) classifies by origin: `callDriver()` marks failures of `acquire`/`begin`/`query`/`commit` in a WeakSet, keeping their identity (a non-object rejection becomes `new Error('Database driver failed', { cause })`); `isRetryableFailure()` asks the plugin only about objects; `normalizeFailure()` returns non-objects and `SimfinityError`s unchanged, otherwise calls `driver.normalizeError()`, and masks a marked failure that the plugin returned unchanged as `DATABASE_ERROR` (500) with a non-enumerable `cause` plus `getCause()`. Body errors that are not PostgreSQL errors (`isPostgresError`: five-character SQLSTATE plus `severity`) therefore propagate unchanged; `session.query()` keeps throwing `INVALID_SESSION` synchronously and rejects with the raw driver error, so savepoints work. Sessions and native model handles are adapter-specific; do not accept a Mongoose model/session as a PostgreSQL handle. Only for plugins that advertise the `singleStatementReads` capability (PostgreSQL does), the adapter's `read()` helper sends sessionless, unlocked `getById`, `getByIds`, `find` and `findChildren` of entities without owned tables (`snapshotEntities`, computed in `prepare()`) through `withConnection(body)`: `acquire`, body, `release`, no `begin`/`commit`, with the same `retryDelay` before each of up to five retries of `isRetryableFailure()` errors and the same `normalizeFailure()`. A failure that `isRetryableFailure()` rejects first runs `driver.rollback(client)` as a probe, and a probe failure (`failure ?? new Error('ROLLBACK failed')`) is passed as `release(client, probeError)`, so a dead `pg` client is destroyed instead of returning to the pool; confirmed aborts, retried or exhausted, skip the probe, and the body's normalized error is always the one thrown (`tests/integration/postgres-connection-loss.test.js`). It routes on the raw `model?.name` and validates with `nameOf` inside the body, so error precedence is unchanged; reads of owned entities, locking reads and reads in a session keep `withTransaction`. The `{ client }` handle is tracked in a WeakSet that only `clientOf()` (used by `query()`) accepts while the body runs; `requireSession()` and `withTransaction()` reject it with `INVALID_SESSION`. `records.js` queues owned rows depth-first (`createRowQueue`) and sends each run of one table and column set as one `compileRecordBatch` statement within `maxRecordBatchValues`, flushing before another table, before `deleteOwned` and before rethrowing a validation error; it batches only when the plugin has both members and `createRecordStore`'s fifth argument does not set `multiRowInserts: false`, and sends a run of one row as `insert`.

`onUpdateSubject()` reads the stored embedded values (projected, `lock: true`) only when `readsStoredEmbedded()` says so: always when it merges a singular embedded object or writes a non-empty embedded list; never when `recordChecked` is set (`onStateChanged()` and nested updates pass it, since they already read the record); otherwise for `onUpdating`, nested collection items, or when `readByIdIsInert(Model)` is not `true` (`adapter.readByIdIsInert(Model) === true`: PostgreSQL always, MongoDB `readsByIdAreInert(Model)`: no `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` hooks, no application `_id` setters, and Mongoose's own `BY_ID_STATICS` (`Model[name] === mongoose.Model[name]`), since update and delete hooks and overridden statics run before Mongoose casts the ID; custom adapters without it keep the read). `onDelete(Model, controller, id, session, context, current)` reads only for `controller.onDelete` or a read that is not inert, and uses a nested child's ownership read passed as `current`. Do not remove the remaining reads: they make a malformed ID fail before `onUpdating`, lock the PostgreSQL parent row before child writes, and run supplied models' `findOne` guards before their write hooks (`tests/mutation-reads.test.js`, `tests/integration/mutation-reads.test.js`). `onUpdateSubject()` awaits the parent update before collection writes and `onUpdated`. Missing parents throw `NOT_VALID_ID` (404) before child writes or `onUpdated`; the hook receives the updated adapter record. All hooks still run before commit and must tolerate transaction retries; external effects are not rolled back.

`materializeModel()` transforms GraphQL input args into adapter records, running field-level and type-level validators. The adapters handle encoding, persistence and the internal `$unset` update representation.

`materializeModel()`, `completeEmbeddedValue()` and the `onUpdateSubject()` embedded merge read input and stored values through `ownValue()`, so an absent field named like an `Object.prototype` member is never materialized from the inherited member (identity-based: Mongoose nested paths return the member from their own accessors). On CREATE, `materializeModel()` stores `[]` for such a list (scalar or embedded, at every depth, not a collection) that is undefined or null, because Mongoose skips its array default for a path it finds the inherited member under; SQL stores `[]` for every list. Only `undefined` and `null` skip value materialization; accepted empty strings, zero, false, and empty arrays must survive. Nullable embedded list items retain `null`; nullable referenced-collection operation items are ignored. Single-object references use `getFieldStorageName()` (`relation.connectionField || fieldName`) for both writes and nullable update `$unset`. Inside a singular embedded update patch, `clearNullEmbeddedMembers()` turns an explicit `null` for a nullable member into a stored `null` (under its storage name) or `[]` for lists; null for a NonNull member keeps the stored value. Merge all null clears into one `$unset` without clearing originally non-null fields. Validators continue receiving the original value and are awaited.

`executeItemFunction()` runs target child middleware with root-shaped arguments (`{ input }` for save/update, `{ id }` for delete). Update/delete ownership is checked from the persisted child inside the transaction after middleware selects the effective ID. Ownership compares the persisted child link with the parent's stored key: `result._id ?? result.id ?? args.id` from `adapter.update()` in `onUpdateSubject()`, or `newObject._id` on save; the requested `args.id` is used only when the update result has neither `_id` nor `id` (guarded by tests/runtime.test.js); `linkToParent` writes that key, and executeItemFunction's parameter is `parentKey`. Missing children raise `NOT_VALID_ID` (404), malformed IDs `NOT_VALID_ID` (400) from the adapter cast; a different parent raises `ForbiddenError('Child does not belong to this parent')` (`FORBIDDEN`, 403, from `./auth/errors.js`). For a delete, it passes that child (read with `{ plain: true, lock: true }`) to `onDelete()`, which then does not read it again; a nested update passes `recordChecked`. Child save/update handlers reapply the required parent link after pre-write controller hooks. Child failures abort the parent transaction. Query scopes do not authorize writes. `saveObject()` runs no middleware for its root record, but nested children run theirs with the `context` passed to `saveObject()` (possibly `undefined`).

## Middleware and Scope

- `use(middleware)` -- validates the function (`INVALID_MIDDLEWARE`, 500) and pushes it to the runtime instance's `middlewares` array. `executeMiddleware()` is an index-based chain: calling `next()` more than once runs the rest of the chain once and returns the same promise; `next()` attaches a rejection handler as soon as it starts the rest, which is always awaited after the middleware settles (even when `next()` is not), so its rejection cancels the operation, even if a middleware catches it, and never becomes an unhandled rejection. A `next()` called after the middleware settled is a no-op returning a resolved promise, so later middleware never runs after the operation. Omitting `next()` skips only the remaining middleware.
- `validateScope()` / `getScope()` -- `extensions.scope` must be a plain or null-prototype object whose own string keys (`Object.getOwnPropertyNames`, so non-enumerable keys too) are `find`, `get_by_id` or `aggregate` functions; a key set to `undefined` is rejected; anything else throws `INVALID_SCOPE` (500). `getScope()` revalidates on every call, so `executeScope()`, joined scopes in `inspectClientPaths()` and `resolveById()` fail closed when a scope is changed after `createSchema()`. An omitted key means no scope for that operation.
- `resolveById()` -- shared root and generated single-relation read pipeline; generated relations supply an immutable target ID independently of mutable middleware/scope arguments. The selected adapter validates/casts required predicates using its storage metadata.
- `loadChildren()` -- the generated collection read, called after the field's middleware, scope and joined scopes. Checked on every call, never cached: `typeof adapter.findChildrenBatch === 'function'`, an object context, and `adapter.batchesChildren?.(model, type, storageField) !== false` (called as a method; a throw counts as false and warns once per type unless it is a `SimfinityError`). Any no, a parent without a `childParentKey()` (null/undefined, an object whose `String()` is `Object.prototype.toString`, an ID `castId` rejects or normalizes), args without a `childArgsKey()` (plain data only, key within `CHILD_ARGS_KEY_BUDGET`, 64 KiB) or pagination `paginationStages(…, true)` rejects (or a `clonePlainArgs` throw for a new group) returns a synchronous `findChildren` with `params.args` itself and no extra promise job; only the method, context and predicate declines also make no `castId` call (`childParentKey()` calls `castId` for any ID other than null/undefined or an uninformative object, and the args and pagination checks run after it). Otherwise groups live in `childBatches` (WeakMap context → type → `JSON.stringify(storageFieldName) + argsKey`); only the group's first read clones the args (`clonePlainArgs`), later reads compute only the key, and the group flushes after `Promise.resolve().then(() => process.nextTick(…))`, as references do. Every grouped read waits for that flush and gets a clone, even alone: args that differ from every other read's (a per-call middleware value) make a one-read group, not an at-once read. `flushChildren()` chunks `CHILD_BATCH_SIZE` (100) distinct parents; a chunk of one parent calls `findChildren`, otherwise `findChildrenBatch(model, type, storageField, rawParentIds, clone, { context })` (no session). Every adapter call gets a fresh clone. Duplicates share records with their own arrays (`settleChildren`). A rejection, throw or bad shape (not an array, wrong length, non-array entry, a record that `belongsToParent()` rejects) sends every waiter to `readEachChildAlone()`: its own promise job, raw parent ID and clone. Warn once per type for non-`SimfinityError` throws and shapes; an internal error rejects every unsettled waiter. Bundled adapters' `batchesChildren` return false while `this.findChildren` is not their own.

## Introspection Monkey-Patch

Initialization adds `extensions` to `__Field.getFields()` once per shared GraphQL peer. It supports already materialized fields and reuses the extension field and its `FieldExtensionsType` / `RelationType` identities on repeated module evaluation. `createFieldMetadataType(extensionsName, relationName)` builds the pair; `renameFieldMetadataTypes()` replaces `field.type` in place on the shared extensions field (whose identity never changes, so other evaluations and the auth plugin's `isSimfinityMetadataType` follow it) and returns a restore closure; `fixFieldMetadataNames()` sets the `Symbol.for('simfinity.fieldMetadataNamesFixed')` flag on that field. Only `constructSchema()` in `runtime.js` renames, on a clash in the process's first Simfinity schema. This exposes relation metadata, read-only flags, and state machine flags without replacing application metadata.

The extension remains global. Newly constructed schemas collect the metadata types; schemas built before import retain their original type maps. **Never rebuild the schema** with tools that clone types (e.g., `mapSchema` from `@graphql-tools/utils`). A `buildClientSchema()` roundtrip of a post-import schema in the Simfinity process can likewise create duplicate metadata type names. See the architecture rule for import-order limits.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.