Skip to content

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

Plugin authoring for AI agents ​

Use this guide when implementing a reusable extension for @simtlix/simfinity-js. It describes existing integration points and their boundaries; it does not introduce a new plugin API. Read the implementation in the target checkout before changing behavior. When code and documentation disagree, reproduce the behavior and report the discrepancy instead of silently assuming either is correct.

Establish the contract first ​

Record the requested behavior, affected entities and operations, supported execution paths, trusted identity source, and failure behavior in a short implementation note. Infer routine choices from the application; ask only about missing decisions that materially change behavior.

Simfinity currently exports createAuthPlugin, envelopCountPlugin, and apolloCountPlugin through simfinity.plugins. There is no general plugin-object installer, automatic controller composition, dependency ordering, or uninstall lifecycle. simfinity.use() accepts middleware functions, not Envelop plugin objects. Do not invent APIs such as registerPlugin() or advertise an installer as a built-in API.

Choose the smallest existing integration point:

RequirementIntegrationBoundary
GraphQL validation, execution, context, response metadataEnvelop/Yoga hooks; a separate adapter for ApolloServer-specific lifecycle; not automatically executed by standalone MCP
Field authorization or field result transformationIn-place resolver wrappers, usually installed by onSchemaChangePreserve schema identity, resolver behavior, and GraphQL output types
Prepare or reject generated operationssimfinity.use(middleware)Runs before database execution; not an execution wrapper
Restrict generated readsType extensions.scopeDoes not authorize writes
Validate or enrich persistenceController passed to connect()Runs within the mutation transaction, before commit
Inspect, reject, or wrap an MCP tool calltoolMiddlewareSeparate contract from global Simfinity middleware

Use middleware, controllers, query scope, authorization, and the MCP reference for complete signatures. Use the Series Sample Project for application structure.

Source map for repository agents ​

Consult these repository-relative files; locate functions by name rather than relying on fixed line numbers:

FileContracts to inspect
packages/core/src/plugins.jsExisting server adapters and exports
packages/core/src/auth/index.jscreateAuthPlugin, configuration validation, permission resolution
packages/core/src/runtime.js and the selected adapter under packages/mongodb/src/ or packages/postgres/src/use, executeMiddleware, executeScope, connect, generateModel, saveObject, transaction and controller implementation
packages/mcp/src/index.jsnormalizeSchemaPlugins, applySchemaPlugins, normalizeToolMiddleware, normalizeLimits, validateDefaultPagination, resolveExecution, prepareMCPTools, composeToolMiddleware, createCallTool, execution modes and result limits
packages/*/types/ and packages/mongodb/src/**/*.d.tsExisting public declarations that may need updating, including the declarations of MongoDB's legacy src/ deep imports
tests/Existing regression and integration fixtures
.cursor/rules/Architecture, coding, auth, extensions, testing, and documentation rules

For external plugin packages, inspect the installed Simfinity version and its matching source. Verify the installed server's hook signatures against its official documentation. Do not assume Apollo and Envelop callbacks are interchangeable or copy a mocked callback payload as proof of runtime compatibility.

GraphQL resolver and server plugins ​

  1. Wrap existing resolvers on the supplied schema. Never apply graphql-middleware's applyMiddleware or @graphql-tools/utils's mapSchema to a Simfinity schema. Type cloning can duplicate its globally injected introspection metadata types.
  2. Capture the previous resolver and delegate to it, using GraphQL's defaultFieldResolver when no explicit resolver exists. Preserve parent, args, request context, info, and any receiver the original resolver relies on. Generated list resolvers report MCP pagination counts through parent or info.rootValue, so a wrapper that drops both writes the count onto the caller's context and the MCP tool returns no total; see counted lists. Await asynchronous checks and results; propagate failures deliberately.
  3. Restrict wrapping to the intended types and fields. Skip introspection types. Respect nullability and scalar serialization when transforming a result; masking a non-null field as null can null out its parent through GraphQL error propagation.
  4. Make repeated application safe. Schemas built from the same types, such as a toConfig() copy or a second createSchema() call, share their field objects, so a per-schema guard still wraps a shared field again. The auth plugin records the wrapper it installed on each field in a closure-local WeakMap and skips fields that still have it. Its wrappers pass through when another auth plugin instance, but not this one, processed the executing info.schema, so separate instances do not combine their rules; a schema no instance processed keeps every wrapper's rules.
  5. Declare installation order where it matters, especially authorization, cache lookup, result masking, and instrumentation. Test their composition. Do not allow a cached response to bypass the caller's access policy.
  6. Keep request identity, timing, and intermediate results in request-local state. Do not store a current user or tenant in a plugin factory closure or shared schema metadata.
  7. Validate configuration at creation time. Distinguish absent configuration from explicitly invalid values, especially permission rules. Authorization checks must not grant access on errors or malformed configuration.
  8. Execution results may contain both data and errors. Handle streaming/async iterable results if the supported host exposes them; otherwise declare the supported execution mode. Preserve unrelated response extensions.

Authentication is supplied by the application. createAuthPlugin evaluates permissions against context; it does not authenticate tokens. Root mutation permissions do not automatically authorize nested child mutations or direct model access.

Global middleware and scopes ​

Register global middleware once at application startup. Type and middleware registries belong to each runtime instance; the MongoDB and PostgreSQL module facades each use one default instance. GraphQL type objects bind to the first runtime that generates their relation resolvers, or that reaches them with an unresolved relation field, so a second runtime needs its own type objects, not toConfig() copies made afterwards (TYPE_BOUND_TO_OTHER_RUNTIME). Query limits, mutation limits and the introspection extension are process-wide. Do not register middleware on every request.

For simfinity.use(), await next() advances only the middleware chain. The database resolver runs after the chain returns. Code after next() is still pre-execution; omitting next() skips remaining middleware but does not cancel the resolver. Throw to reject an operation. use() rejects non-functions with INVALID_MIDDLEWARE. The runtime runs the rest of the chain at most once, awaits it even when next() is not awaited, and cancels the operation when it fails, even if a middleware catches the error. A next() called after the middleware has finished, such as from a timer, does nothing, so the remaining middleware is skipped. Do not implement response caching, after-save events, or complete operation timing with this hook.

Mutate the existing args or context object where the contract permits it. Generated list, count, aggregate and collection-relationship reads use the post-middleware params.args for the scope and the adapter, so replacing it takes effect there. Mutations, nested collection writes and get_by_id reads keep the resolver's own reference and ignore a replaced params.args. Client query paths are checked before middleware runs; paths that middleware or scopes add are trusted. Generated save/update middleware receives { input }; delete receives { id }. Custom mutation metadata differs from entity operations, so do not assume type is always present.

Scopes mutate query arguments; their return value is not a database filter. extensions.scope must be a plain object of find, get_by_id and aggregate functions; other shapes fail with INVALID_SCOPE at registration, at createSchema(), and on reads after a later change. Preserve normalized ID filters for get_by_id. Generated non-embedded relationship reads apply the target type's middleware and scope, with a separate predicate enforcing the persisted relationship. Embedded fields, custom resolvers, and direct Mongoose calls do not inherit that coverage.

For tenant isolation, derive the tenant from trusted context, set server-owned fields during creation, scope reads, and authorize updates, deletes, state actions, and nested writes separately. extensions.readOnly removes fields from generated inputs; it is not a universal security boundary for programmatic access. Do not trust a client-supplied tenant or assume a parent permission grants child permission.

Controllers, models, and transaction boundaries ​

The controller is the fifth argument to connect(model, gqltype, singular, plural, controller, onModelCreated, stateMachine). Supply it before schema generation. If an application already has a controller, explicitly compose hooks in a documented order rather than overwriting it. Await each hook and preserve its mutations and failures.

HookArgumentsTiming
onSavingdocument, args, session, contextBefore parent insertion
onSaveddocument, args, session, contextAfter parent save and collection inputs
onUpdatingid, changes, session, contextBefore parent update
onUpdateddocument, session, contextAfter parent update and collection inputs
onDeletedocument, session, contextBefore physical deletion

Create args is the inner mutation input. onSaved receives a plain parent snapshot; onUpdated receives the updated Mongoose document. Update changes is materialized input, not a full previous or current record, and may include $unset. onDelete may receive null. Read previous values explicitly, in the supplied session, when an audit feature needs them.

All these hooks execute before transaction commit. Nested collection writes use the same session and invoke the child controller. A failure later in the mutation can roll back earlier hook writes.

  • Pass the supplied session to every related database operation and await that work before the hook returns. Do not commit, abort, end, or replace a caller-owned session. A SQL session rejects statements with INVALID_SESSION once its transaction callback settles.
  • saveObject() with an active supplied session joins that transaction. Without one, it owns a separate transaction. It runs the creation pipeline but skips GraphQL coercion, field authorization and the root record's global middleware; nested non-embedded collection children still run their target type's middleware with the supplied context (possibly undefined). Direct Mongoose access also bypasses Simfinity controllers and validation.
  • Transient transaction errors can retry the transaction body and its hooks. Unknown commit results retry only the commit. If commit uncertainty remains, an error does not prove that nothing was persisted. Design retry-sensitive work and client retries accordingly.
  • For email, webhooks, or indexing, persist an outbox event with the entity change and process it after commit. The external processor needs idempotency and retry handling; an outbox alone does not guarantee exactly-once delivery.
  • There is no general after-commit controller hook, onDeleted hook, or controller return value that substitutes a delete. Soft deletion requires an explicit alternative mutation or a deliberate core change.
  • onModelCreated runs after mongoose.model() and is not awaited. Do not treat it as an asynchronous pre-compilation Mongoose schema-plugin hook. Verify model customization timing before proposing Mongoose middleware installation there.

MCP integration ​

State support separately for Yoga/Envelop, Apollo, standalone in-process MCP, remote MCP, and direct programmatic calls. A plugin working in one path is not evidence for the others.

In-process MCP uses bare graphql(). schemaPlugins invokes only onSchemaChange, after validating every entry (MCP_INVALID_SCHEMA_PLUGIN). Validation reads onSchemaChange directly and inspects every other property, including then, through its descriptor, so no other getter runs. On a plugin without a function onSchemaChange, it rejects a method or getter whose name starts with onSchema in any letter case or is at most two edits from onSchemaChange, such as onSchemaChanged or onSchemChange, as a misspelling; onSchema* helpers next to a working onSchemaChange are accepted. replaceSchema accepts only the same schema; any other schema throws MCP_UNSUPPORTED_SCHEMA_REPLACEMENT. A returned promise is awaited before tools run, but Envelop and Yoga do not await onSchemaChange, so keep installation synchronous for plugins you share with them. MCP does not run the full Envelop lifecycle; a plugin with other hooks gets a one-time warning naming them. Reuse the same auth plugin instance when sharing a schema already wrapped by Yoga.

MCP toolMiddleware receives { name, args, extra, kind, operation }. kind is query/mutation metadata; operation is the generated GraphQL document string, not Simfinity's save/find operation enum. It can replace call.args, return a complete tool result without delegation, or wrap real execution with return await next(). Calling next() twice or returning undefined is rejected. A synchronous throw rejects the previous middleware's next() promise, as an asynchronous one does; a rejected next() promise that no middleware awaits or returns is dropped. An undeclared argument key that the caller sent and that is still in call.args at execution is rejected with MCP_UNKNOWN_ARGUMENT without running the tool, so delete a custom key you consume before next(); keys the middleware adds pass through. Hosts that validate tool input against the closed inputSchema reject undeclared keys before they reach middleware, and there is no option to declare extra keys. A single function is a one-element stack; a non-array value, a non-function entry or an empty slot in a sparse array is rejected at setup with MCP_INVALID_MIDDLEWARE, and the stack is copied at setup.

The configured GraphQL context factory executes inside the terminal executor, after tool middleware begins. Do not assume middleware receives call.context or a precomputed authenticated user. Establish a trusted identity source through the application's transport/authentication integration. Treat extra according to that integration, not as automatically authenticated caller data.

In remote mode, local schema plugins and local GraphQL context are not applied. The remote server enforces its own plugins. Configured execution.headers are not an automatic forwarding mechanism for incoming credentials. Do not reuse one user's credentials across tenants or claim dynamic credential propagation without implementing it.

Preserve the MCP result contract, including content, isError, optional structuredContent, and metadata. structuredContent is the GraphQL data plus the totalCount or truncated keys MCP adds next to the root field. GraphQL errors can arrive as isError: true results rather than thrown exceptions. An oversized mutation result that succeeded is isError: false with _meta.truncated, although its full data was omitted. Inspect both when measuring success or caching. Middleware-created results bypass the terminal result-size cap; enforce appropriate limits on results you create or enlarge, and keep text and structured representations consistent.

Cancellation checks prevent some work from starting; they do not roll back completed writes. Do not implement blanket retries around mutations. Remote transport retries are query-only, and authorization failures must not become successful fallback responses.

Packaging and public API ​

Prefer a small factory with explicit options and adapters over import-time registration. Keep application models, credentials, transports, and external clients injectable. Do not rely on private Simfinity registries or GraphQL internals.

For an external npm package, declare compatible peers for the runtimes it actually shares and verify the supported versions; avoid bundling another GraphQL copy. For a built-in feature, update the appropriate exports and public declarations. Keep ES module imports at the top and follow repository style. Use stable domain error codes without exposing credentials or sensitive inputs.

Document installation order, complete application wiring, supported modes, configuration defaults, failure behavior, and known limitations. Distinguish a proposed new core contract from functionality already available. A plugin must not silently change global behavior just because it was imported.

Verify behavior, not implementation shape ​

Select checks according to the extension's actual risk. Do not add arbitrary tests for runtime version strings, file existence, or export shape as substitutes for behavior. For documentation-only changes, check links and the documentation build.

Extension behaviorRequired material scenarios
Resolver wrapping or authorizationReal GraphQL execution; allow/deny/error paths; default and custom resolvers; repeated installation; composition order; schema introspection
Tenant or ownership restrictionsSeparate users/tenants; list, ID, aggregation, relations; nested writes; spoofed ownership input; explicit direct-access boundaries
Persistence or auditActual MongoDB replica-set transaction; rollback after a later failure; same-session writes; relevant retry paths; nested operations
Caching or request stateConcurrent distinct identities; permission isolation; partial errors; invalidation and rollback behavior
MCPReal generated tool execution in every claimed mode; error results and thrown errors; short-circuit and delegation; context timing; relevant cancellation/limit behavior
Server lifecycle hooksIntegration with the actual supported server/runtime, not only manually invoking hooks with fabricated payloads

Reuse existing fixtures. Run npm run lint and npm test for implementation changes. Database integration suites require an appropriate MongoDB replica set; existing transaction tests use SIMFINITY_TEST_MONGODB_URI. Report skipped checks explicitly. A green run with skipped integration tests does not verify transactional behavior. Build docs with npm run docs:build when changing documentation.

Reusable agent task ​

Copy this task and fill in the feature-specific details:

text
Implement [extension name] for @simtlix/simfinity-js.
Behavior: [observable behavior and failure policy].
Entities and operations: [scope, including relevant nested operations].
Execution modes: [GraphQL host, MCP mode, programmatic access if required].
Identity and external dependencies: [trusted sources and injected clients].
Packaging: [application module, external package, or built-in feature].

Read AGENTS.md, docs/guide/plugin-authoring.md, and the matching source/rules.
Trace each affected execution path before selecting hooks. Use existing public
contracts; identify any core change explicitly. Preserve existing controllers,
resolvers, transaction ownership, and request isolation. Do not infer security
coverage across adapters or access paths.

Implement the smallest complete solution with installation examples and public
types where applicable. Verify observable behavior using the actual execution
paths, including the material negative and concurrency/transaction scenarios.
Report what changed, evidence, skipped checks, and remaining limitations.
Keep public artifacts in English and free of private paths or incidental metadata.

When a requested behavior cannot be implemented through current hooks, explain the specific missing contract and propose the smallest change. Do not hide unsupported behavior behind a plugin-shaped API.

Open source. Released under the Apache 2.0 License.