@simtlix/simfinity-js 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4323 +1,48 @@
1
- ![Simfinity.js — Define once. Build beyond. GraphQL, MongoDB, PostgreSQL, and optional MCP.](.github/assets/readme-cover.png)
1
+ # @simtlix/simfinity-js
2
2
 
3
- # Simfinity.js
4
-
5
- A Node.js framework that turns GraphQL object types into generated queries, mutations, relationships, and database storage. Use the established MongoDB/Mongoose facade or the PostgreSQL 15+ facade with real tables and foreign keys.
6
-
7
- Read the [documentation website](https://simtlix.github.io/simfinity.js/): start with the [quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html), explore the [guides](https://simtlix.github.io/simfinity.js/guide/schema.html), or consult the [API reference](https://simtlix.github.io/simfinity.js/reference/api.html). The website source is in [`docs/`](docs/).
8
-
9
- Run the documentation website locally with Node.js 22+:
10
-
11
- ```sh
12
- npm run docs:install
13
- npm run docs:dev
14
- ```
15
-
16
- For builds and hosting, see the [website maintainer guide](docs/.vitepress/README.md). The website documents the current source; some older examples later in this README retain historical conventions.
17
-
18
- > **Documentation for both databases:** The [public website](https://simtlix.github.io/simfinity.js/guide/databases.html) now covers MongoDB and PostgreSQL, including shared APIs, relationships, generated FKs, scopes, and MCP. Both adapters are available in **v3.2.0** on npm. Follow the quick starts for installation, or download the runnable starters and verified release archives.
19
-
20
- ## 📑 Table of Contents
21
-
22
- - [Features](#-features)
23
- - [Installation](#-installation)
24
- - [PostgreSQL support](#postgresql-support)
25
- - [Quick Start](#-quick-start)
26
- - [Core Concepts](#-core-concepts)
27
- - [Connecting Models](#connecting-models)
28
- - [Creating Schemas](#creating-schemas)
29
- - [Global Configuration](#global-configuration)
30
- - [Basic Usage](#-basic-usage)
31
- - [Automatic Query Generation](#automatic-query-generation)
32
- - [Automatic Mutation Generation](#automatic-mutation-generation)
33
- - [Filtering and Querying](#filtering-and-querying)
34
- - [Collection Field Filtering](#collection-field-filtering)
35
- - [Relationships](#-relationships)
36
- - [Defining Relationships](#defining-relationships)
37
- - [Auto-Generated Resolve Methods](#auto-generated-resolve-methods)
38
- - [Adding Types Without Endpoints](#adding-types-without-endpoints)
39
- - [Embedded vs Referenced Relationships](#embedded-vs-referenced-relationships)
40
- - [Querying Relationships](#querying-relationships)
41
- - [Validations](#-validations)
42
- - [Field-Level Validations](#field-level-validations)
43
- - [Type-Level Validations](#type-level-validations)
44
- - [Custom Validated Scalar Types](#custom-validated-scalar-types)
45
- - [Custom Error Classes](#custom-error-classes)
46
- - [State Machines](#-state-machines)
47
- - [Controllers & Lifecycle Hooks](#️-controllers--lifecycle-hooks)
48
- - [Hook Parameters](#hook-parameters)
49
- - [Query Scope](#-query-scope)
50
- - [Overview](#overview)
51
- - [Defining Scope](#defining-scope)
52
- - [Scope for Find Operations](#scope-for-find-operations)
53
- - [Scope for Aggregate Operations](#scope-for-aggregate-operations)
54
- - [Scope for Get By ID Operations](#scope-for-get-by-id-operations)
55
- - [Scope Function Parameters](#scope-function-parameters)
56
- - [Authorization](#-authorization)
57
- - [Quick Start](#quick-start-1)
58
- - [Permission Schema](#permission-schema)
59
- - [Rule Helpers](#rule-helpers)
60
- - [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
61
- - [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
62
- - [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
63
- - [Middlewares](#-middlewares)
64
- - [Adding Middlewares](#adding-middlewares)
65
- - [Middleware Parameters](#middleware-parameters)
66
- - [Common Use Cases](#common-use-cases)
67
- - [Advanced Features](#-advanced-features)
68
- - [Field Extensions](#field-extensions)
69
- - [Custom Mutations](#custom-mutations)
70
- - [Working with Existing Mongoose Models](#working-with-existing-mongoose-models)
71
- - [Programmatic Data Access](#programmatic-data-access)
72
- - [MCP Generation](#-mcp-generation)
73
- - [Generating tool definitions](#generating-tool-definitions)
74
- - [Standalone MCP server (stdio)](#standalone-mcp-server-stdio)
75
- - [HTTP MCP endpoint (alongside GraphQL)](#http-mcp-endpoint-alongside-graphql)
76
- - [Options](#options)
77
- - [Selecting and naming tools](#selecting-and-naming-tools)
78
- - [Customizing tools](#customizing-tools)
79
- - [Limits](#limits)
80
- - [Tool middleware](#tool-middleware)
81
- - [Remote execution](#remote-execution)
82
- - [Tool results and errors](#tool-results-and-errors)
83
- - [Authentication](#authentication)
84
- - [Aggregation Queries](#-aggregation-queries)
85
- - [Complete Example](#-complete-example)
86
- - [Resources](#-resources)
87
- - [License](#-license)
88
- - [Contributing](#-contributing)
89
-
90
- ## ✨ Features
91
-
92
- - **Automatic Schema Generation**: Define your object model, and Simfinity.js generates all queries and mutations
93
- - **MongoDB or PostgreSQL**: Choose the database facade once during application startup
94
- - **Powerful Querying**: Typed filters, nested paths, pagination, sorting, and aggregations across the supported contract
95
- - **Aggregation Queries**: Built-in support for GROUP BY queries with aggregation operations (SUM, COUNT, AVG, MIN, MAX)
96
- - **Auto-Generated Resolvers**: Automatically generates resolve methods for relationship fields
97
- - **Automatic Index Creation**: Automatically creates MongoDB indexes for all ObjectId fields, including nested embedded objects and relationship fields
98
- - **Business Logic**: Implement business logic and domain validations declaratively
99
- - **State Machines**: Built-in support for declarative state machine workflows
100
- - **Lifecycle Hooks**: Controller methods for granular control over operations
101
- - **Custom Validation**: Field-level and type-level custom validations
102
- - **Relationship Management**: Support for embedded and referenced relationships
103
- - **Authorization**: Production-grade GraphQL authorization with RBAC/ABAC, function-based rules, declarative policy expressions, and native Envelop/Yoga plugin support
104
-
105
- ## 📦 Installation
106
-
107
- ```bash
108
- npm install mongoose@^8.16.2 graphql@^16.11.0 @simtlix/simfinity-js@3.2.0
109
- ```
110
-
111
- **Prerequisites**: Simfinity.js requires `mongoose` and `graphql` as peer dependencies.
112
-
113
- ## PostgreSQL support
114
-
115
- Version 3.2.0 releases `@simtlix/simfinity-core`, `@simtlix/simfinity-mcp`, `@simtlix/simfinity-postgres`, and the root MongoDB facade in lockstep. Install the selected adapter from npm; shared dependencies resolve automatically. PostgreSQL runs the shared GraphQL query/mutation engine, including scopes, controllers, validators, state transitions and nested writes. It generates and validates tables, indexes, and **real foreign keys**, including inverse relations, explicit many-to-many linking entities, and references inside embedded objects. PostgreSQL installation does not pull Mongoose, MongoDB, or MCP dependencies.
116
-
117
- Enum filters resolve member names first, then declared internal values by strict equality, on both backends. For example, with `ONE: { value: 'TWO' }` and `TWO: { value: 'two' }`, filter `"TWO"` selects member `TWO`. Numeric internal values require numbers, not numeric strings. This applies to scalar lists, embedded/reference leaves and state filters across EQ, NE, LT, LTE, GT, GTE, BTW, IN and NIN. LIKE accepts string fields only. PostgreSQL writes and state guards continue to use internal enum values.
118
-
119
- The shared runtime preserves the v3.1 contract: generated relationships run target middleware/scopes with protected identity and parent filters; nested mutations enforce child middleware and persisted ownership; standalone `saveObject()` wraps the complete workflow in a transaction. Accepted empty strings are preserved. Both facades expose `configureQueryLimits()` for bounded pagination.
120
-
121
- Choose the backend at application setup. The existing package continues to use MongoDB; PostgreSQL uses `createPostgres({ pool, schema })`, the same `connect(null, Type, ...)`/`createSchema()` signatures, and an awaited `initializeDatabase()` before serving requests. See the canonical [PostgreSQL quick start](docs/guide/postgresql.md), detailed [storage reference](docs/postgresql.md), and [compatibility contract](docs/compatibility.md) before adopting this version.
122
-
123
- Both database facades expose the same `auth`, `validators`, `scalars`, and `plugins` helper objects. PostgreSQL keeps MCP optional; install the database-independent integration and its transport SDK only when needed:
124
-
125
- ```sh
126
- npm install @simtlix/simfinity-mcp@3.2.0 @modelcontextprotocol/sdk@^1.13.0
127
- ```
128
-
129
- Import `generateMCPTools`, `createMCPServer`, or the transport helpers from `@simtlix/simfinity-mcp` and pass the schema returned by `createPostgres().createSchema()`.
130
-
131
- Compatibility tests run the same GraphQL schemas and query corpus against both databases. IDs use UUIDs on PostgreSQL, native model/session APIs differ, and some mappings remain explicitly unsupported, including whole embedded-object uniqueness and whole-object sorting/grouping. Nested embedded scalar-list filters/sorts, ragged array group keys and array aggregate facts now match the supported Mongo query contract. Native embedded writes retain explicit nulls, apply descendant list defaults and minimize empty inline objects; historical Date parameters use UTC. PostgreSQL enforces scalar/list multikey uniqueness within embedded trees through typed owner-key tables, validates JSONB embedded shapes, and checks owned marker/row consistency with deferred database triggers. MongoDB fixes also cover list nullability wrappers, scalar-ID inverse relations, inverse aggregation paths, embedded array replacement after clearing, and isolation of mutation input across transaction retries. Existing API signatures remain unchanged.
132
-
133
- ## 🚀 Quick Start
134
-
135
- The [complete quick start](docs/guide/getting-started.md) includes MongoDB setup, installation, and working create, read, update, and delete operations. Simfinity uses ES modules and named exports.
3
+ MongoDB/Mongoose adapter and compatibility facade for Simfinity. This package generates GraphQL queries, mutations, relationships and models from `GraphQLObjectType` definitions using the shared Simfinity runtime.
136
4
 
137
5
  ```sh
138
- npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.16.2 graphql-yoga@^5
6
+ npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.16.2
139
7
  ```
140
8
 
141
- Set `"type": "module"` in your application's `package.json`, then create `server.js`:
142
-
143
9
  ```javascript
144
- import { createServer } from 'node:http';
145
- import { GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
146
- import { createYoga } from 'graphql-yoga';
147
10
  import mongoose from 'mongoose';
148
11
  import * as simfinity from '@simtlix/simfinity-js';
149
12
 
150
- const SerieType = new GraphQLObjectType({
151
- name: 'Serie',
152
- fields: {
153
- id: { type: GraphQLID },
154
- name: { type: new GraphQLNonNull(GraphQLString) },
155
- },
156
- });
157
-
158
- await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/series?replicaSet=rs0&directConnection=true');
13
+ await mongoose.connect(process.env.MONGODB_URI);
14
+ // Register your GraphQLObjectType definitions before creating the schema.
159
15
  simfinity.connect(null, SerieType, 'serie', 'series');
160
16
  const schema = simfinity.createSchema();
161
- const yoga = createYoga({ schema });
162
- createServer(yoga).listen(4000, '127.0.0.1', () => {
163
- console.log('GraphQL ready at http://127.0.0.1:4000/graphql');
164
- });
165
- ```
166
-
167
- Generated mutations require a MongoDB replica set or sharded cluster. Follow the [local MongoDB setup](docs/guide/getting-started.md#2-start-mongodb), then run `node server.js` and open `http://127.0.0.1:4000/graphql`:
168
-
169
- ```graphql
170
- mutation {
171
- addserie(input: { name: "The Expanse" }) {
172
- id
173
- name
174
- }
175
- }
176
- ```
177
-
178
- ```graphql
179
- query {
180
- series {
181
- id
182
- name
183
- }
184
- }
185
- ```
186
-
187
- For a full working application, see the [Series Sample Project](https://github.com/simtlix/series-sample), with relationships, state machines, controllers, and authorization.
188
-
189
- ## 🔧 Core Concepts
190
-
191
- ### Connecting Models
192
-
193
- The `simfinity.connect()` method links your GraphQL types to Simfinity's automatic schema generation:
194
-
195
- ```javascript
196
- simfinity.connect(
197
- mongooseModel, // Optional: Custom Mongoose model (null for auto-generation)
198
- graphQLType, // Required: Your GraphQLObjectType
199
- singularEndpointName, // Required: Singular name for mutations (e.g., 'book')
200
- pluralEndpointName, // Required: Plural name for queries (e.g., 'books')
201
- controller, // Optional: Controller with lifecycle hooks
202
- onModelCreated, // Optional: Callback when Mongoose model is created
203
- stateMachine // Optional: State machine configuration
204
- );
205
- ```
206
-
207
- ### Creating Schemas
208
-
209
- Generate your complete GraphQL schema with optional type filtering:
210
-
211
- ```javascript
212
- const schema = simfinity.createSchema(
213
- includedQueryTypes, // Optional: Array of types to include in queries
214
- includedMutationTypes, // Optional: Array of types to include in mutations
215
- includedCustomMutations // Optional: Array of custom mutations to include
216
- );
217
- ```
218
-
219
- Importing Simfinity initializes its global `__Field.extensions` introspection field safely whether GraphQL's fields have already been materialized or a schema already exists. Repeated module evaluation with the same GraphQL peer reuses the existing extension field and metadata types. Application field metadata is preserved.
220
-
221
- The extension remains shared by every schema using that GraphQL peer; Simfinity's type and middleware registries belong to each runtime instance. Schemas constructed after import include `FieldExtensionsType` and `RelationType` in their type maps. Earlier schemas keep their original type maps: ordinary operations and direct metadata selections work, but named fragments on the new metadata types require a schema constructed after import.
222
-
223
- Schema-cloning tools remain unsupported. In a process that has imported Simfinity, `buildClientSchema()` on a post-import schema's introspection result can also fail with duplicate metadata type names. Use Envelop plugins and in-place resolver wrapping; see the [introspection metadata reference](docs/reference/extensions.md).
224
-
225
- ### Global Configuration
226
-
227
- ```javascript
228
- // Prevent automatic MongoDB collection creation (useful for testing)
229
- simfinity.preventCreatingCollection(true);
230
- ```
231
-
232
- ## 📋 Basic Usage
233
-
234
- ### Automatic Query Generation
235
-
236
- Simfinity automatically generates queries for each connected type:
237
-
238
- ```javascript
239
- // For a BookType, you get:
240
- // - book(id: ID): Book - Get single book by ID
241
- // - books(...filters): [Book] - Get filtered list of books
242
- ```
243
-
244
- ### Automatic Mutation Generation
245
-
246
- Simfinity automatically generates mutations for each connected type:
247
-
248
- ```javascript
249
- // For a BookType, you get:
250
- // - addBook(input: BookInput): Book
251
- // - updateBook(input: BookInputForUpdate): Book
252
- // - deleteBook(id: ID): Book
253
- ```
254
-
255
- Creation inputs retain required scalar, enum, embedded-object, and list fields. Update inputs remove the outer non-null wrapper so fields can be omitted; the entity `id` remains required for `GraphQLID` and `GraphQLID!`. Other ID fields remain optional on update. List item nullability is preserved:
256
-
257
- | Output field | Create input | Update input |
258
- | --- | --- | --- |
259
- | `[String]` | `[String]` | `[String]` |
260
- | `[String]!` | `[String]!` | `[String]` |
261
- | `[String!]` | `[String!]` | `[String!]` |
262
- | `[String!]!` | `[String!]!` | `[String!]` |
263
-
264
- The same wrapper handling applies to enum lists, supported custom scalar lists, and embedded lists (using their nested input types). Referenced collections keep the `added` / `updated` / `deleted` input object: a required collection requires that object on create, and non-null object items make `added` and `updated` items non-null. Nullable collection-operation items are ignored. Use `deleted` to delete children.
265
-
266
- Empty strings, `false`, `0`, and empty arrays are persisted after validators accept them. Omitting an update field leaves its value unchanged. Explicit `null` clears a nullable scalar, embedded field, or single-object reference; references clear the actual stored `connectionField`, or the GraphQL field name when no override is configured. Multiple nullable fields can be cleared in one update. An explicit `null` for an originally non-null field leaves its stored value unchanged; use an update validator to reject that input if needed.
267
-
268
- ### Filtering and Querying
269
-
270
- Every relationship or embedded `terms` entry is combined with AND, including repeated paths such as `age GTE 18` and `age LTE 30`. These conditions remain ANDed with top-level logical groups and scope filters. Filter and list-sort paths must resolve to declared scalar or enum fields. `id` paths refer to the stored `_id`; comparisons use the connected Mongoose model's schema, so supplied string or numeric ID models retain their identifier representation.
271
-
272
- Filter values must use the field's JSON scalar type. `IN` and `NIN` require flat lists, `BTW` requires exactly two non-null bounds, and `LIKE` requires a string search fragment. Literal objects, nested lists, null list elements, invalid operators, unknown paths, and malformed groups are rejected with a structured 400 error instead of being ignored. Explicit `null` remains supported by `EQ` and `NE`. Enum names or declared internal enum values are converted to the stored representation; state-machine `state` filters preserve stored state names. Date filters convert valid date values without mutating the query input. Validated string scalars support partial `LIKE` searches, and range filters use their base scalar type rather than the field's create/update validation constraints.
273
-
274
- Query with powerful filtering options:
275
-
276
- ```graphql
277
- query {
278
- books(
279
- title: { operator: LIKE, value: "Galaxy" }
280
- author: { operator: EQ, value: "Douglas Adams" }
281
- pagination: { page: 1, size: 10, count: true }
282
- sort: { terms: [{ field: "title", order: ASC }] }
283
- ) {
284
- id
285
- title
286
- author
287
- }
288
- }
289
- ```
290
-
291
- #### Available Operators
292
-
293
- - `EQ` - Equal
294
- - `NE` - Not equal
295
- - `GT` - Greater than
296
- - `LT` - Less than
297
- - `GTE` - Greater than or equal
298
- - `LTE` - Less than or equal
299
- - `LIKE` - Pattern matching
300
- - `IN` - In array
301
- - `NIN` - Not in array
302
- - `BTW` - Between two values
303
-
304
- ### Logical Filters (AND / OR)
305
-
306
- By default, all field-level filters are combined with implicit AND logic. For complex conditions requiring OR logic or nested combinations, use the `AND` and `OR` query arguments.
307
-
308
- #### Simple OR
309
-
310
- Return books in either the Sci-Fi or Fantasy category:
311
-
312
- ```graphql
313
- query {
314
- books(
315
- OR: [
316
- { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
317
- { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
318
- ]
319
- ) {
320
- id
321
- title
322
- category
323
- }
324
- }
325
- ```
326
-
327
- #### Flat Filters Combined with OR
328
-
329
- Flat field filters are always ANDed at the top level, making them ideal for scope/security conditions that cannot be bypassed by user OR logic:
330
-
331
- ```graphql
332
- query {
333
- books(
334
- rating: { operator: GTE, value: 7.0 }
335
- OR: [
336
- { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
337
- { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
338
- ]
339
- ) {
340
- id
341
- title
342
- }
343
- }
344
- ```
345
-
346
- This translates to: `rating >= 7.0 AND (category = "Sci-Fi" OR category = "Fantasy")`.
347
-
348
- #### Nested AND inside OR
349
-
350
- ```graphql
351
- query {
352
- books(
353
- OR: [
354
- { AND: [
355
- { conditions: [{ field: "rating", operator: GTE, value: 9.0 }] }
356
- { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
357
- ]}
358
- { AND: [
359
- { conditions: [{ field: "rating", operator: GTE, value: 8.0 }] }
360
- { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
361
- ]}
362
- ]
363
- ) {
364
- id
365
- title
366
- }
367
- }
368
- ```
369
-
370
- This translates to: `(rating >= 9.0 AND category = "Sci-Fi") OR (rating >= 8.0 AND category = "Fantasy")`.
371
-
372
- #### Filtering on Relationships within AND/OR
373
-
374
- Use the `path` parameter to filter on related entity fields:
375
-
376
- ```graphql
377
- query {
378
- books(
379
- OR: [
380
- { conditions: [{ field: "author", path: "name", operator: LIKE, value: "Adams" }] }
381
- { conditions: [{ field: "author", path: "name", operator: LIKE, value: "Pratchett" }] }
382
- ]
383
- ) {
384
- id
385
- title
386
- author { name }
387
- }
388
- }
389
- ```
390
-
391
- As an equivalent shorthand, you can pass the full dotted path in `field` and omit `path`:
392
-
393
- ```graphql
394
- query {
395
- books(
396
- OR: [
397
- { conditions: [{ field: "author.name", operator: LIKE, value: "Adams" }] }
398
- { conditions: [{ field: "author.name", operator: LIKE, value: "Pratchett" }] }
399
- ]
400
- ) {
401
- id
402
- title
403
- author { name }
404
- }
405
- }
406
- ```
407
-
408
- Both forms produce the same query. Multi-segment paths work too — e.g. `field: "author.country.code"` is equivalent to `field: "author", path: "country.code"`. Supplying both a dotted `field` and a separate `path` is rejected as ambiguous.
409
-
410
- #### Mixing Flat Filters with AND/OR
411
-
412
- You can freely combine the existing flat filter syntax with AND/OR groups. Flat filters and AND groups are all ANDed together at the top level:
413
-
414
- ```graphql
415
- query {
416
- books(
417
- rating: { operator: GTE, value: 7.0 }
418
- author: { terms: [{ path: "country", operator: EQ, value: "UK" }] }
419
- OR: [
420
- { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
421
- { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
422
- ]
423
- ) {
424
- id
425
- title
426
- rating
427
- category
428
- author { name country }
429
- }
430
- }
431
- ```
432
-
433
- This translates to: `rating >= 7.0 AND author.country = "UK" AND (category = "Sci-Fi" OR category = "Fantasy")`. The flat field filters (`rating`, `author`) use the existing syntax while the OR group uses the new `QLFilterGroup` syntax.
434
-
435
- You can also combine flat filters with explicit AND groups for more complex logic:
436
-
437
- ```graphql
438
- query {
439
- books(
440
- rating: { operator: GTE, value: 5.0 }
441
- AND: [
442
- {
443
- OR: [
444
- { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
445
- { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
446
- ]
447
- }
448
- {
449
- OR: [
450
- { conditions: [{ field: "author", path: "country", operator: EQ, value: "UK" }] }
451
- { conditions: [{ field: "author", path: "country", operator: EQ, value: "US" }] }
452
- ]
453
- }
454
- ]
455
- ) {
456
- id
457
- title
458
- }
459
- }
460
- ```
461
-
462
- This translates to: `rating >= 5.0 AND (category = "Sci-Fi" OR category = "Fantasy") AND (author.country = "UK" OR author.country = "US")`.
463
-
464
- #### Collection Filtering with AND/OR
465
-
466
- AND/OR filters are also available on collection fields (one-to-many relationships). The auto-generated resolvers for collection fields support the same `AND` and `OR` arguments:
467
-
468
- ```graphql
469
- query {
470
- series {
471
- seasons(
472
- OR: [
473
- { conditions: [{ field: "year", operator: EQ, value: 2020 }] }
474
- { conditions: [{ field: "year", operator: EQ, value: 2021 }] }
475
- ]
476
- ) {
477
- number
478
- year
479
- }
480
- }
481
- }
482
- ```
483
-
484
- You can mix flat collection filters with AND/OR:
485
-
486
- ```graphql
487
- query {
488
- series {
489
- seasons(
490
- number: { operator: GT, value: 1 }
491
- OR: [
492
- { conditions: [{ field: "year", operator: EQ, value: 2020 }] }
493
- {
494
- AND: [
495
- { conditions: [{ field: "year", operator: GTE, value: 2022 }] }
496
- { conditions: [
497
- # Equivalent to: { field: "episodes", path: "name", ... }
498
- { field: "episodes.name", operator: LIKE, value: "Final" }
499
- ] }
500
- ]
501
- }
502
- ]
503
- ) {
504
- number
505
- year
506
- episodes { name }
507
- }
508
- }
509
- }
510
- ```
511
-
512
- This translates to: `number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.name LIKE "Final"))`.
513
-
514
- #### Filter Types Reference
515
-
516
- | Type | Fields | Description |
517
- |------|--------|-------------|
518
- | `QLFilterGroup` | `AND: [QLFilterGroup]`, `OR: [QLFilterGroup]`, `conditions: [QLFilterCondition]` | Recursive logical group |
519
- | `QLFilterCondition` | `field: String!`, `operator: QLOperator`, `value: QLValue`, `path: String` | Individual filter condition |
520
-
521
- - `field` identifies the entity field by name (e.g., `"title"`, `"author"`). For object/relationship fields you can also pass the full dotted path here as a shorthand (e.g., `"author.name"`, `"author.country.name"`)
522
- - `path` is required for object/relationship fields when `field` is just the top-level name (e.g., `"name"`, `"country.name"`). Omit `path` if you used the dotted-`field` shorthand. Supplying both is an error.
523
- - Multiple `conditions` in the same group are combined with AND
524
- - Maximum nesting depth: 5 levels
525
-
526
- ### Collection Field Filtering
527
-
528
- Simfinity.js now supports filtering collection fields (one-to-many relationships) using the same powerful query format. This allows you to filter related objects directly within your GraphQL queries.
529
-
530
- #### Basic Collection Filtering
531
-
532
- Filter collection fields using the same operators and format as main queries:
533
-
534
- ```graphql
535
- query {
536
- series {
537
- seasons(number: { operator: EQ, value: 1 }) {
538
- number
539
- id
540
- year
541
- }
542
- }
543
- }
544
- ```
545
-
546
- #### Advanced Collection Filtering
547
-
548
- You can use complex filtering with nested object properties:
549
-
550
- ```graphql
551
- query {
552
- series {
553
- seasons(
554
- year: { operator: GTE, value: 2020 }
555
- episodes: {
556
- terms: [
557
- {
558
- path: "name",
559
- operator: LIKE,
560
- value: "Pilot"
561
- }
562
- ]
563
- }
564
- ) {
565
- number
566
- year
567
- episodes {
568
- name
569
- date
570
- }
571
- }
572
- }
573
- }
574
- ```
575
-
576
- #### Collection Filtering with Multiple Conditions
577
-
578
- Combine multiple filter conditions for collection fields:
579
-
580
- ```graphql
581
- query {
582
- series {
583
- seasons(
584
- number: { operator: GT, value: 1 }
585
- year: { operator: BTW, value: [2015, 2023] }
586
- ) {
587
- number
588
- year
589
- state
590
- }
591
- }
592
- }
593
- ```
594
-
595
- #### Nested Collection Filtering
596
-
597
- Filter deeply nested collections using dot notation:
598
-
599
- ```graphql
600
- query {
601
- series {
602
- seasons(
603
- episodes: {
604
- terms: [
605
- {
606
- path: "name",
607
- operator: LIKE,
608
- value: "Final"
609
- }
610
- ]
611
- }
612
- ) {
613
- number
614
- episodes {
615
- name
616
- date
617
- }
618
- }
619
- }
620
- }
621
- ```
622
-
623
- #### Collection Filtering with Array Operations
624
-
625
- Use array operations for collection fields:
626
-
627
- ```graphql
628
- query {
629
- series {
630
- seasons(
631
- categories: { operator: IN, value: ["Drama", "Crime"] }
632
- ) {
633
- number
634
- categories
635
- }
636
- }
637
- }
638
- ```
639
-
640
- **Note**: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (`EQ`, `NE`, `GT`, `LT`, `GTE`, `LTE`, `LIKE`, `IN`, `NIN`, `BTW`) work with collection fields.
641
-
642
- ## 🔗 Relationships
643
-
644
- ### Defining Relationships
645
-
646
- Use the `extensions.relation` field to define relationships between types:
647
-
648
- ```javascript
649
- const AuthorType = new GraphQLObjectType({
650
- name: 'Author',
651
- fields: () => ({
652
- id: { type: new GraphQLNonNull(GraphQLID) },
653
- name: { type: new GraphQLNonNull(GraphQLString) },
654
- books: {
655
- type: new GraphQLList(BookType),
656
- extensions: {
657
- relation: {
658
- connectionField: 'author',
659
- displayField: 'title'
660
- },
661
- },
662
- // resolve method automatically generated! 🎉
663
- },
664
- }),
665
- });
666
-
667
- const BookType = new GraphQLObjectType({
668
- name: 'Book',
669
- fields: () => ({
670
- id: { type: new GraphQLNonNull(GraphQLID) },
671
- title: { type: new GraphQLNonNull(GraphQLString) },
672
- author: {
673
- type: AuthorType,
674
- extensions: {
675
- relation: {
676
- displayField: 'name'
677
- },
678
- },
679
- // resolve method automatically generated! 🎉
680
- },
681
- }),
682
- });
683
- ```
684
-
685
- ### Relationship Configuration
686
-
687
- - `connectionField`: **(Required for referenced collections)** The child field linking back to the parent. For a single-object reference, this optionally overrides the stored ObjectId field; when omitted, model generation, writes, clears, and resolvers use the GraphQL field name.
688
- - `displayField`: **(Optional)** Field to use for display in UI components
689
- - `embedded`: **(Optional)** Whether the relation is embedded (default: false)
690
-
691
- ### Auto-Generated Resolve Methods
692
-
693
- 🎉 **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
694
-
695
- #### Before (Manual Resolvers)
696
-
697
- ```javascript
698
- const BookType = new GraphQLObjectType({
699
- name: 'Book',
700
- fields: () => ({
701
- id: { type: new GraphQLNonNull(GraphQLID) },
702
- title: { type: new GraphQLNonNull(GraphQLString) },
703
- author: {
704
- type: AuthorType,
705
- extensions: {
706
- relation: {
707
- displayField: 'name'
708
- },
709
- },
710
- // You had to manually write this
711
- resolve(parent) {
712
- return simfinity.getModel(AuthorType).findById(parent.author);
713
- }
714
- },
715
- comments: {
716
- type: new GraphQLList(CommentType),
717
- extensions: {
718
- relation: {
719
- connectionField: 'bookId',
720
- displayField: 'text'
721
- },
722
- },
723
- // You had to manually write this too
724
- resolve(parent) {
725
- return simfinity.getModel(CommentType).find({ bookId: parent.id });
726
- }
727
- }
728
- }),
729
- });
730
- ```
731
-
732
- #### After (Auto-Generated Resolvers)
733
-
734
- ```javascript
735
- const BookType = new GraphQLObjectType({
736
- name: 'Book',
737
- fields: () => ({
738
- id: { type: new GraphQLNonNull(GraphQLID) },
739
- title: { type: new GraphQLNonNull(GraphQLString) },
740
- author: {
741
- type: AuthorType,
742
- extensions: {
743
- relation: {
744
- displayField: 'name'
745
- },
746
- },
747
- // resolve method automatically generated! 🎉
748
- },
749
- comments: {
750
- type: new GraphQLList(CommentType),
751
- extensions: {
752
- relation: {
753
- connectionField: 'bookId',
754
- displayField: 'text'
755
- },
756
- },
757
- // resolve method automatically generated! 🎉
758
- }
759
- }),
760
- });
761
- ```
762
-
763
- #### How It Works
764
-
765
- - **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField`
766
- - **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
767
- - **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
768
- - **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
769
- - **Type Safety**: Clear error messages if related types aren't properly connected
770
-
771
- #### Connect Your Types
772
-
773
- ```javascript
774
- // Connect all your types to Simfinity
775
- simfinity.connect(null, AuthorType, 'author', 'authors');
776
- simfinity.connect(null, BookType, 'book', 'books');
777
- simfinity.connect(null, CommentType, 'comment', 'comments');
778
-
779
- // Or use addNoEndpointType for types that don't need direct queries/mutations
780
- simfinity.addNoEndpointType(AuthorType);
781
- ```
782
-
783
- That's it! All relationship resolvers are automatically generated when you connect your types.
784
-
785
- ### Adding Types Without Endpoints
786
-
787
- Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations:
788
-
789
- ```javascript
790
- simfinity.addNoEndpointType(TypeName);
791
- ```
792
-
793
- **When to use `addNoEndpointType()` vs `connect()`:**
794
-
795
- | Method | Use Case | Creates Endpoints | Use Example |
796
- |--------|----------|-------------------|-------------|
797
- | `connect()` | Types that need CRUD operations | ✅ Yes | User, Product, Order |
798
- | `addNoEndpointType()` | Types only used in relationships | ❌ No | Address, Settings, Director |
799
-
800
- #### Perfect Example: TV Series with Embedded Director
801
-
802
- From the [series-sample](https://github.com/simtlix/series-sample) project:
803
-
804
- ```javascript
805
- // Director type - Used only as embedded data, no direct API access needed
806
- const directorType = new GraphQLObjectType({
807
- name: 'director',
808
- fields: () => ({
809
- id: { type: GraphQLID },
810
- name: { type: new GraphQLNonNull(GraphQLString) },
811
- country: { type: GraphQLString }
812
- })
813
- });
814
-
815
- // Add to schema WITHOUT creating endpoints
816
- simfinity.addNoEndpointType(directorType);
817
-
818
- // Serie type - Has its own endpoints and embeds director data
819
- const serieType = new GraphQLObjectType({
820
- name: 'serie',
821
- fields: () => ({
822
- id: { type: GraphQLID },
823
- name: { type: new GraphQLNonNull(GraphQLString) },
824
- categories: { type: new GraphQLList(GraphQLString) },
825
- director: {
826
- type: new GraphQLNonNull(directorType),
827
- extensions: {
828
- relation: {
829
- embedded: true, // Director data stored within serie document
830
- displayField: 'name'
831
- }
832
- }
833
- }
834
- })
835
- });
836
-
837
- // Create full CRUD endpoints for series
838
- simfinity.connect(null, serieType, 'serie', 'series');
839
- ```
840
-
841
- **Result:**
842
- - ✅ `addserie`, `updateserie`, `deleteserie` mutations available
843
- - ✅ `serie`, `series` queries available
844
- - ❌ No `adddirector`, `director`, `directors` endpoints (director is embedded)
845
-
846
- **Usage:**
847
- ```graphql
848
- mutation {
849
- addserie(input: {
850
- name: "Breaking Bad"
851
- categories: ["crime", "drama", "thriller"]
852
- director: {
853
- name: "Vince Gilligan"
854
- country: "United States"
855
- }
856
- }) {
857
- id
858
- name
859
- director {
860
- name
861
- country
862
- }
863
- }
864
- }
865
- ```
866
-
867
- #### When to Use Each Approach
868
-
869
- **Use `addNoEndpointType()` for:**
870
- - Simple data objects with few fields
871
- - Data that doesn't need CRUD operations
872
- - Objects that belong to a single parent (1:1 relationships)
873
- - Configuration or settings objects
874
- - **Examples**: Address, Director info, Product specifications
875
-
876
- **Use `connect()` for:**
877
- - Complex entities that need their own endpoints
878
- - Data that needs CRUD operations
879
- - Objects shared between multiple parents (many:many relationships)
880
- - Objects with business logic (controllers, state machines)
881
- - **Examples**: User, Product, Order, Season, Episode
882
-
883
- ### Embedded vs Referenced Relationships
884
-
885
- **Referenced Relationships** (default):
886
- ```javascript
887
- // Stores author ID in the book document
888
- author: {
889
- type: AuthorType,
890
- extensions: {
891
- relation: {
892
- // connectionField not needed for single object relationships
893
- embedded: false // This is the default
894
- }
895
- }
896
- }
897
- ```
898
-
899
- **Embedded Relationships**:
900
- ```javascript
901
- // Stores the full publisher object in the book document
902
- publisher: {
903
- type: PublisherType,
904
- extensions: {
905
- relation: {
906
- embedded: true
907
- }
908
- }
909
- }
910
- ```
911
-
912
- ### Querying Relationships
913
-
914
- Query nested relationships with dot notation:
915
-
916
- ```graphql
917
- query {
918
- books(author: {
919
- terms: [
920
- {
921
- path: "country.name",
922
- operator: EQ,
923
- value: "England"
924
- }
925
- ]
926
- }) {
927
- id
928
- title
929
- author {
930
- name
931
- country {
932
- name
933
- }
934
- }
935
- }
936
- }
937
- ```
938
-
939
- ### Creating Objects with Relationships
940
-
941
- **Link to existing objects:**
942
- ```graphql
943
- mutation {
944
- addBook(input: {
945
- title: "New Book"
946
- author: {
947
- id: "existing_author_id"
948
- }
949
- }) {
950
- id
951
- title
952
- author {
953
- name
954
- }
955
- }
956
- }
957
- ```
958
-
959
- **Create embedded objects:**
960
- ```graphql
961
- mutation {
962
- addBook(input: {
963
- title: "New Book"
964
- publisher: {
965
- name: "Penguin Books"
966
- location: "London"
967
- }
968
- }) {
969
- id
970
- title
971
- publisher {
972
- name
973
- location
974
- }
975
- }
976
- }
977
- ```
978
-
979
- ### Collection Fields
980
-
981
- Work with arrays of related objects:
982
-
983
- ```graphql
984
- mutation {
985
- updateBook(input: {
986
- id: "book_id"
987
- reviews: {
988
- added: [
989
- { rating: 5, comment: "Amazing!" }
990
- { rating: 4, comment: "Good read" }
991
- ]
992
- updated: [
993
- { id: "review_id", rating: 3 }
994
- ]
995
- deleted: ["review_id_to_delete"]
996
- }
997
- }) {
998
- id
999
- title
1000
- reviews {
1001
- rating
1002
- comment
1003
- }
1004
- }
1005
- }
1006
- ```
1007
-
1008
- For non-embedded collections, each `added`, `updated`, or `deleted` child runs the target type's global middleware with the request context. The operation and argument shapes match root mutations: `save`/`update` receive `{ input }`, and `delete` receives `{ id }`. Updated and deleted children must already belong to the current parent in the transaction; a missing child raises `NOT_VALID_ID`, and a different parent raises `FORBIDDEN`. Nested updates cannot move a child between parents. A rejection aborts the parent mutation and its child changes.
1009
-
1010
- ## ✅ Validations
1011
-
1012
- ### Declarative Validation Helpers
1013
-
1014
- Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
1015
-
1016
- #### Using Validators
1017
-
1018
- ```javascript
1019
- const { validators } = require('@simtlix/simfinity-js');
1020
-
1021
- const PersonType = new GraphQLObjectType({
1022
- name: 'Person',
1023
- fields: () => ({
1024
- id: { type: GraphQLID },
1025
- name: {
1026
- type: GraphQLString,
1027
- extensions: {
1028
- validations: validators.stringLength('Name', 2, 100)
1029
- }
1030
- },
1031
- email: {
1032
- type: GraphQLString,
1033
- extensions: {
1034
- validations: validators.email()
1035
- }
1036
- },
1037
- website: {
1038
- type: GraphQLString,
1039
- extensions: {
1040
- validations: validators.url()
1041
- }
1042
- },
1043
- age: {
1044
- type: GraphQLInt,
1045
- extensions: {
1046
- validations: validators.numberRange('Age', 0, 120)
1047
- }
1048
- },
1049
- price: {
1050
- type: GraphQLFloat,
1051
- extensions: {
1052
- validations: validators.positive('Price')
1053
- }
1054
- }
1055
- })
1056
- });
1057
- ```
1058
-
1059
- #### Available Validators
1060
-
1061
- **String Validators:**
1062
- - `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
1063
- - `validators.maxLength(name, max)` - Validates maximum string length
1064
- - `validators.pattern(name, regex, message)` - Validates against a regex pattern
1065
- - `validators.email()` - Validates email format
1066
- - `validators.url()` - Validates URL format
1067
-
1068
- **Number Validators:**
1069
- - `validators.numberRange(name, min, max)` - Validates number range
1070
- - `validators.positive(name)` - Ensures number is positive
1071
-
1072
- **Array Validators:**
1073
- - `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
1074
-
1075
- **Date Validators:**
1076
- - `validators.dateFormat(name, format)` - Validates date format
1077
- - `validators.futureDate(name)` - Ensures date is in the future
1078
-
1079
- #### Validator Features
1080
-
1081
- - **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
1082
- - **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
1083
- - **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
1084
-
1085
- #### Example: Multiple Validators
1086
-
1087
- ```javascript
1088
- const ProductType = new GraphQLObjectType({
1089
- name: 'Product',
1090
- fields: () => ({
1091
- id: { type: GraphQLID },
1092
- name: {
1093
- type: GraphQLString,
1094
- extensions: {
1095
- validations: validators.stringLength('Product Name', 3, 200)
1096
- }
1097
- },
1098
- sku: {
1099
- type: GraphQLString,
1100
- extensions: {
1101
- validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
1102
- }
1103
- },
1104
- price: {
1105
- type: GraphQLFloat,
1106
- extensions: {
1107
- validations: validators.positive('Price')
1108
- }
1109
- },
1110
- tags: {
1111
- type: new GraphQLList(GraphQLString),
1112
- extensions: {
1113
- validations: validators.arrayLength('Tags', 10)
1114
- }
1115
- }
1116
- })
1117
- });
1118
- ```
1119
-
1120
- ### Field-Level Validations (Manual)
1121
-
1122
- For custom validation logic, you can still write manual validators:
1123
-
1124
- ```javascript
1125
- const { SimfinityError } = require('@simtlix/simfinity-js');
1126
-
1127
- const validateAge = {
1128
- validate: async (typeName, fieldName, value, session) => {
1129
- if (value < 0 || value > 120) {
1130
- throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
1131
- }
1132
- }
1133
- };
1134
-
1135
- const PersonType = new GraphQLObjectType({
1136
- name: 'Person',
1137
- fields: () => ({
1138
- id: { type: GraphQLID },
1139
- name: {
1140
- type: GraphQLString,
1141
- extensions: {
1142
- validations: {
1143
- save: [{
1144
- validate: async (typeName, fieldName, value, session) => {
1145
- if (!value || value.length < 2) {
1146
- throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
1147
- }
1148
- }
1149
- }],
1150
- update: [{
1151
- validate: async (typeName, fieldName, value, session) => {
1152
- if (value && value.length < 2) {
1153
- throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
1154
- }
1155
- }
1156
- }]
1157
- }
1158
- }
1159
- },
1160
- age: {
1161
- type: GraphQLInt,
1162
- extensions: {
1163
- validations: {
1164
- save: [validateAge],
1165
- update: [validateAge]
1166
- }
1167
- }
1168
- }
1169
- })
1170
- });
1171
- ```
1172
-
1173
- ### Type-Level Validations
1174
-
1175
- Validate objects as a whole:
1176
-
1177
- ```javascript
1178
- const orderValidator = {
1179
- validate: async (typeName, args, modelArgs, session) => {
1180
- // Cross-field validation
1181
- if (modelArgs.deliveryDate < modelArgs.orderDate) {
1182
- throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
1183
- }
1184
-
1185
- // Business rule validation
1186
- if (modelArgs.items.length === 0) {
1187
- throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
1188
- }
1189
- }
1190
- };
1191
-
1192
- const OrderType = new GraphQLObjectType({
1193
- name: 'Order',
1194
- extensions: {
1195
- validations: {
1196
- save: [orderValidator],
1197
- update: [orderValidator]
1198
- }
1199
- },
1200
- fields: () => ({
1201
- // ... fields
1202
- })
1203
- });
1204
17
  ```
1205
18
 
1206
- ### Custom Validated Scalar Types
1207
-
1208
- Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
19
+ Use a MongoDB replica set or sharded cluster for transactional mutations. The library requires Node.js >=18.18.0 and GraphQL 16.
1209
20
 
1210
- #### Pre-built Scalars
21
+ ## Optional transactional reference integrity
1211
22
 
1212
- Simfinity.js provides ready-to-use validated scalars for common patterns:
23
+ Choose the mode once when creating an adapter; the default remains `'off'`:
1213
24
 
1214
25
  ```javascript
1215
- const { scalars } = require('@simtlix/simfinity-js');
26
+ import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
1216
27
 
1217
- const UserType = new GraphQLObjectType({
1218
- name: 'User',
1219
- fields: () => ({
1220
- id: { type: GraphQLID },
1221
- email: { type: scalars.EmailScalar }, // Type name: Email_String
1222
- website: { type: scalars.URLScalar }, // Type name: URL_String
1223
- age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
1224
- price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
1225
- }),
1226
- });
28
+ const adapter = createMongoAdapter({ referentialIntegrity: 'transactional' });
29
+ const simfinity = createRuntime(adapter);
30
+ await mongoose.connect(process.env.MONGODB_URI);
31
+ // Register every application type on this runtime.
32
+ simfinity.connect(null, SerieType, 'serie', 'series');
33
+ const schema = simfinity.createSchema();
34
+ await adapter.initialize();
1227
35
  ```
1228
36
 
1229
- **Available Pre-built Scalars:**
1230
- - `scalars.EmailScalar` - Validates email format (`Email_String`)
1231
- - `scalars.URLScalar` - Validates URL format (`URL_String`)
1232
- - `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
1233
- - `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
37
+ Initialization checks transaction support and audits existing references. Protected mutations reject nonexistent references, including embedded paths, inferred/private inverse keys and explicit link entities. Deleting a referenced target is restricted. Target-document writes coordinate concurrent creation/deletion; a violation rolls back the whole mutation with `REFERENCE_CONSTRAINT_VIOLATION` (409).
1234
38
 
1235
- #### Factory Functions for Custom Scalars
39
+ Owned transactions use snapshot reads and majority commits. Supplied sessions need the same options; a reference violation or guarded-write error aborts even a supplied transaction. The reserved `_simfinityReferenceLock` storage field is excluded from GraphQL. All writers must use the same complete registry and mode. Direct Mongoose/driver writes bypass this protection; this is not a native MongoDB FK. Read the [full setup, concurrency and migration contract](https://simtlix.github.io/simfinity.js/guide/mongodb-integrity.html).
1236
40
 
1237
- Create custom validated scalars with parameters:
1238
-
1239
- ```javascript
1240
- const { scalars } = require('@simtlix/simfinity-js');
1241
-
1242
- // Create a bounded string scalar (name length between 2-100 characters)
1243
- const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
1244
-
1245
- // Create a bounded integer scalar (age between 0-120)
1246
- const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
1247
-
1248
- // Create a bounded float scalar (rating between 0-10)
1249
- const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
1250
-
1251
- // Create a pattern-based string scalar (phone number format)
1252
- const PhoneScalar = scalars.createPatternStringScalar(
1253
- 'Phone',
1254
- /^\+?[\d\s\-()]+$/,
1255
- 'Invalid phone number format'
1256
- );
1257
-
1258
- // Use in your types
1259
- const PersonType = new GraphQLObjectType({
1260
- name: 'Person',
1261
- fields: () => ({
1262
- id: { type: GraphQLID },
1263
- name: { type: NameScalar }, // Type name: Name_String
1264
- age: { type: AgeScalar }, // Type name: Age_Int
1265
- rating: { type: RatingScalar }, // Type name: Rating_Float
1266
- phone: { type: PhoneScalar } // Type name: Phone_String
1267
- }),
1268
- });
1269
- ```
1270
-
1271
- **Available Factory Functions:**
1272
- - `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
1273
- - `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
1274
- - `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
1275
- - `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
1276
-
1277
- #### Creating Custom Scalars Manually
1278
-
1279
- You can also create custom scalars using `createValidatedScalar` directly:
1280
-
1281
- ```javascript
1282
- const { GraphQLString, GraphQLInt } = require('graphql');
1283
- const { createValidatedScalar } = require('@simtlix/simfinity-js');
1284
-
1285
- // Email scalar with validation (generates type name: Email_String)
1286
- const EmailScalar = createValidatedScalar(
1287
- 'Email',
1288
- 'A valid email address',
1289
- GraphQLString,
1290
- (value) => {
1291
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1292
- if (!emailRegex.test(value)) {
1293
- throw new Error('Invalid email format');
1294
- }
1295
- }
1296
- );
1297
-
1298
- // Positive integer scalar (generates type name: PositiveInt_Int)
1299
- const PositiveIntScalar = createValidatedScalar(
1300
- 'PositiveInt',
1301
- 'A positive integer',
1302
- GraphQLInt,
1303
- (value) => {
1304
- if (value <= 0) {
1305
- throw new Error('Value must be positive');
1306
- }
1307
- }
1308
- );
1309
-
1310
- // Use in your types
1311
- const UserType = new GraphQLObjectType({
1312
- name: 'User',
1313
- fields: () => ({
1314
- id: { type: GraphQLID },
1315
- email: { type: EmailScalar }, // Type name: Email_String
1316
- age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
1317
- }),
1318
- });
1319
- ```
1320
-
1321
- ### Custom Error Classes
1322
-
1323
- Create domain-specific error classes:
1324
-
1325
- ```javascript
1326
- const { SimfinityError } = require('@simtlix/simfinity-js');
1327
-
1328
- // Business logic error
1329
- class BusinessError extends SimfinityError {
1330
- constructor(message) {
1331
- super(message, 'BUSINESS_ERROR', 400);
1332
- }
1333
- }
1334
-
1335
- // Authorization error
1336
- class AuthorizationError extends SimfinityError {
1337
- constructor(message) {
1338
- super(message, 'UNAUTHORIZED', 401);
1339
- }
1340
- }
1341
-
1342
- // Not found error
1343
- class NotFoundError extends SimfinityError {
1344
- constructor(message) {
1345
- super(message, 'NOT_FOUND', 404);
1346
- }
1347
- }
1348
- ```
1349
-
1350
- ## 🔄 State Machines
1351
-
1352
- Implement declarative state machine workflows:
1353
-
1354
- ### 1. Define States
1355
-
1356
- ```javascript
1357
- const { GraphQLEnumType } = require('graphql');
1358
-
1359
- const OrderState = new GraphQLEnumType({
1360
- name: 'OrderState',
1361
- values: {
1362
- PENDING: { value: 'PENDING' },
1363
- PROCESSING: { value: 'PROCESSING' },
1364
- SHIPPED: { value: 'SHIPPED' },
1365
- DELIVERED: { value: 'DELIVERED' },
1366
- CANCELLED: { value: 'CANCELLED' }
1367
- }
1368
- });
1369
- ```
1370
-
1371
- ### 2. Define Type with State Field
1372
-
1373
- ```javascript
1374
- const OrderType = new GraphQLObjectType({
1375
- name: 'Order',
1376
- fields: () => ({
1377
- id: { type: GraphQLID },
1378
- customer: { type: GraphQLString },
1379
- state: { type: OrderState }
1380
- })
1381
- });
1382
- ```
1383
-
1384
- ### 3. Configure State Machine
1385
-
1386
- ```javascript
1387
- const stateMachine = {
1388
- initialState: { name: 'PENDING', value: 'PENDING' },
1389
- actions: {
1390
- process: {
1391
- from: { name: 'PENDING', value: 'PENDING' },
1392
- to: { name: 'PROCESSING', value: 'PROCESSING' },
1393
- description: 'Process the order',
1394
- action: async (args, session) => {
1395
- // Business logic for processing
1396
- console.log(`Processing order ${args.id}`);
1397
- // You can perform additional operations here
1398
- }
1399
- },
1400
- ship: {
1401
- from: { name: 'PROCESSING', value: 'PROCESSING' },
1402
- to: { name: 'SHIPPED', value: 'SHIPPED' },
1403
- description: 'Ship the order',
1404
- action: async (args, session) => {
1405
- // Business logic for shipping
1406
- console.log(`Shipping order ${args.id}`);
1407
- }
1408
- },
1409
- deliver: {
1410
- from: { name: 'SHIPPED', value: 'SHIPPED' },
1411
- to: { name: 'DELIVERED', value: 'DELIVERED' },
1412
- description: 'Mark as delivered'
1413
- },
1414
- cancel: {
1415
- from: { name: 'PENDING', value: 'PENDING' },
1416
- to: { name: 'CANCELLED', value: 'CANCELLED' },
1417
- description: 'Cancel the order'
1418
- }
1419
- }
1420
- };
1421
- ```
1422
-
1423
- ### 4. Connect with State Machine
1424
-
1425
- ```javascript
1426
- simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
1427
- ```
1428
-
1429
- ### 5. Use State Machine Mutations
1430
-
1431
- The state machine automatically generates mutations for each action:
1432
-
1433
- ```graphql
1434
- mutation {
1435
- process_order(input: {
1436
- id: "order_id"
1437
- }) {
1438
- id
1439
- state
1440
- customer
1441
- }
1442
- }
1443
- ```
1444
-
1445
- **Important Notes**:
1446
- - The `state` field is automatically read-only and managed by the state machine
1447
- - State transitions are only allowed based on the defined actions
1448
- - Business logic in the `action` function is executed during transitions
1449
- - Invalid transitions throw errors automatically
1450
-
1451
- ## 🎛️ Controllers & Lifecycle Hooks
1452
-
1453
- Controllers provide fine-grained control over operations with lifecycle hooks:
1454
-
1455
- ```javascript
1456
- const bookController = {
1457
- onSaving: async (doc, args, session, context) => {
1458
- // Before saving - doc is a Mongoose document
1459
- if (!doc.title || doc.title.trim().length === 0) {
1460
- throw new Error('Book title cannot be empty');
1461
- }
1462
- // Access user from context to set owner
1463
- if (context && context.user) {
1464
- doc.owner = context.user.id;
1465
- }
1466
- console.log(`Creating book: ${doc.title}`);
1467
- },
1468
-
1469
- onSaved: async (doc, args, session, context) => {
1470
- // After saving - doc is a plain object
1471
- console.log(`Book saved: ${doc._id}`);
1472
- // Use context.user for transactional audit/outbox records on this session
1473
- },
1474
-
1475
- onUpdating: async (id, doc, session, context) => {
1476
- // Before updating - doc contains only changed fields
1477
- // Validate user has permission to update
1478
- if (context && context.user && context.user.role !== 'admin') {
1479
- throw new simfinity.SimfinityError('Only admins can update books', 'FORBIDDEN', 403);
1480
- }
1481
- console.log(`Updating book ${id}`);
1482
- },
1483
-
1484
- onUpdated: async (doc, session, context) => {
1485
- // After updating - doc is the updated document
1486
- console.log(`Book updated: ${doc.title}`);
1487
- },
1488
-
1489
- onDelete: async (doc, session, context) => {
1490
- // Before deleting - doc is the document to be deleted
1491
- // Validate user has permission to delete
1492
- if (context && context.user && context.user.role !== 'admin') {
1493
- throw new simfinity.SimfinityError('Only admins can delete books', 'FORBIDDEN', 403);
1494
- }
1495
- console.log(`Deleting book: ${doc.title}`);
1496
- }
1497
- };
1498
-
1499
- // Connect with controller
1500
- simfinity.connect(null, BookType, 'book', 'books', bookController);
1501
- ```
1502
-
1503
- ### Hook Parameters
1504
-
1505
- **`onSaving(doc, args, session, context)`**:
1506
- - `doc`: Mongoose Document instance (not yet saved)
1507
- - `args`: Raw GraphQL mutation input
1508
- - `session`: Mongoose session for transaction
1509
- - `context`: GraphQL context object (includes request info, user data, etc.)
1510
-
1511
- **`onSaved(doc, args, session, context)`**:
1512
- - `doc`: Plain object of saved document
1513
- - `args`: Raw GraphQL mutation input
1514
- - `session`: Mongoose session for transaction
1515
- - `context`: GraphQL context object (includes request info, user data, etc.)
1516
-
1517
- **`onUpdating(id, doc, session, context)`**:
1518
- - `id`: Document ID being updated
1519
- - `doc`: Plain object with only changed fields
1520
- - `session`: Mongoose session for transaction
1521
- - `context`: GraphQL context object (includes request info, user data, etc.)
1522
-
1523
- **`onUpdated(doc, session, context)`**:
1524
- - `doc`: Full updated Mongoose document, after the parent update and nested collection writes have completed
1525
- - `session`: Mongoose session for transaction
1526
- - `context`: GraphQL context object (includes request info, user data, etc.)
1527
-
1528
- **`onDelete(doc, session, context)`**:
1529
- - `doc`: Plain object of document to be deleted
1530
- - `session`: Mongoose session for transaction
1531
- - `context`: GraphQL context object (includes request info, user data, etc.)
1532
-
1533
- The parent update executes before nested child operations. An update with no matching parent throws `NOT_VALID_ID` (404), without running child operations or `onUpdated`.
1534
-
1535
- Lifecycle hooks, including `onSaved` and `onUpdated`, run before the transaction commits. They can run again after a transient transaction failure, with up to five retries after the first attempt. Keep additional database writes on the supplied session. For email or webhooks, write an outbox record in that transaction and deliver it after commit; external calls inside hooks cannot be rolled back. An uncertain commit result retries only the commit, up to five times, without repeating hooks. If the result remains uncertain, the error is returned and the database may already have committed; reconcile the outcome before repeating the operation.
1536
-
1537
- ### Using Context in Controllers
1538
-
1539
- The `context` parameter provides access to the GraphQL request context, which typically includes user information, request metadata, and other application-specific data. This is particularly useful for:
1540
-
1541
- - **Setting ownership**: Automatically assign the current user as the owner of new entities
1542
- - **Authorization checks**: Validate user permissions before allowing operations
1543
- - **Audit logging**: Track who performed which operations
1544
- - **User-specific business logic**: Apply different logic based on user roles or attributes
1545
-
1546
- **Example: Setting Owner on Creation**
1547
-
1548
- ```javascript
1549
- const documentController = {
1550
- onSaving: async (doc, args, session, context) => {
1551
- // Automatically set the owner to the current user
1552
- if (context && context.user) {
1553
- doc.owner = context.user.id;
1554
- }
1555
- }
1556
- };
1557
- ```
1558
-
1559
- **Example: Role-Based Authorization**
1560
-
1561
- ```javascript
1562
- const adminOnlyController = {
1563
- onUpdating: async (id, doc, session, context) => {
1564
- if (!context || !context.user || context.user.role !== 'admin') {
1565
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1566
- }
1567
- },
1568
-
1569
- onDelete: async (doc, session, context) => {
1570
- if (!context || !context.user || context.user.role !== 'admin') {
1571
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1572
- }
1573
- }
1574
- };
1575
- ```
1576
-
1577
- **Note**: When using `saveObject` programmatically (outside of GraphQL), the `context` parameter is optional and may be `undefined`. Always check for context existence before accessing its properties.
1578
-
1579
- ## 🔒 Query Scope
1580
-
1581
- ### Overview
1582
-
1583
- Query scope allows you to automatically modify query arguments based on context (e.g., user permissions). This enables automatic filtering so that users can only see documents they're authorized to access. Scope functions are executed after middleware and before query execution, allowing you to append filter conditions to queries and aggregations.
1584
-
1585
- ### Defining Scope
1586
-
1587
- Define scope in the type extensions, similar to how validations are defined:
1588
-
1589
- ```javascript
1590
- const EpisodeType = new GraphQLObjectType({
1591
- name: 'episode',
1592
- extensions: {
1593
- validations: {
1594
- create: [validateEpisodeFields],
1595
- update: [validateEpisodeBusinessRules]
1596
- },
1597
- scope: {
1598
- find: async ({ type, args, operation, context }) => {
1599
- // Modify args in place to add filter conditions
1600
- args.owner = {
1601
- terms: [
1602
- {
1603
- path: 'id',
1604
- operator: 'EQ',
1605
- value: context.user.id
1606
- }
1607
- ]
1608
- };
1609
- },
1610
- aggregate: async ({ type, args, operation, context }) => {
1611
- // Apply same scope to aggregate queries
1612
- args.owner = {
1613
- terms: [
1614
- {
1615
- path: 'id',
1616
- operator: 'EQ',
1617
- value: context.user.id
1618
- }
1619
- ]
1620
- };
1621
- },
1622
- get_by_id: async ({ type, args, operation, context }) => {
1623
- // For get_by_id, scope is automatically merged with id filter
1624
- args.owner = {
1625
- terms: [
1626
- {
1627
- path: 'id',
1628
- operator: 'EQ',
1629
- value: context.user.id
1630
- }
1631
- ]
1632
- };
1633
- }
1634
- }
1635
- },
1636
- fields: () => ({
1637
- id: { type: GraphQLID },
1638
- name: { type: GraphQLString },
1639
- owner: {
1640
- type: new GraphQLNonNull(simfinity.getType('user')),
1641
- extensions: {
1642
- relation: {
1643
- connectionField: 'owner',
1644
- displayField: 'name'
1645
- }
1646
- }
1647
- }
1648
- })
1649
- });
1650
- ```
1651
-
1652
- ### Scope for Find Operations
1653
-
1654
- Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results:
1655
-
1656
- ```javascript
1657
- const DocumentType = new GraphQLObjectType({
1658
- name: 'Document',
1659
- extensions: {
1660
- scope: {
1661
- find: async ({ type, args, operation, context }) => {
1662
- // Only show documents owned by the current user
1663
- args.owner = {
1664
- terms: [
1665
- {
1666
- path: 'id',
1667
- operator: 'EQ',
1668
- value: context.user.id
1669
- }
1670
- ]
1671
- };
1672
- }
1673
- }
1674
- },
1675
- fields: () => ({
1676
- id: { type: GraphQLID },
1677
- title: { type: GraphQLString },
1678
- owner: {
1679
- type: new GraphQLNonNull(simfinity.getType('user')),
1680
- extensions: {
1681
- relation: {
1682
- connectionField: 'owner',
1683
- displayField: 'name'
1684
- }
1685
- }
1686
- }
1687
- })
1688
- });
1689
- ```
1690
-
1691
- **Result**: All `documents` queries will automatically filter to only return documents where `owner.id` equals `context.user.id`.
1692
-
1693
- ### Scope for Aggregate Operations
1694
-
1695
- Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope:
1696
-
1697
- ```javascript
1698
- const OrderType = new GraphQLObjectType({
1699
- name: 'Order',
1700
- extensions: {
1701
- scope: {
1702
- aggregate: async ({ type, args, operation, context }) => {
1703
- // Only aggregate orders for the current user's organization
1704
- args.organization = {
1705
- terms: [
1706
- {
1707
- path: 'id',
1708
- operator: 'EQ',
1709
- value: context.user.organizationId
1710
- }
1711
- ]
1712
- };
1713
- }
1714
- }
1715
- },
1716
- fields: () => ({
1717
- // ... fields
1718
- })
1719
- });
1720
- ```
1721
-
1722
- **Result**: All `orders_aggregate` queries will automatically filter to only aggregate orders from the user's organization.
1723
-
1724
- ### Scope for Get By ID Operations
1725
-
1726
- For `get_by_id` operations, scope functions modify a temporary query arguments object that includes the id filter. The system automatically combines the id filter with scope filters:
1727
-
1728
- ```javascript
1729
- const PrivateDocumentType = new GraphQLObjectType({
1730
- name: 'PrivateDocument',
1731
- extensions: {
1732
- scope: {
1733
- get_by_id: async ({ type, args, operation, context }) => {
1734
- // Ensure user can only access their own documents
1735
- args.owner = {
1736
- terms: [
1737
- {
1738
- path: 'id',
1739
- operator: 'EQ',
1740
- value: context.user.id
1741
- }
1742
- ]
1743
- };
1744
- }
1745
- }
1746
- },
1747
- fields: () => ({
1748
- // ... fields
1749
- })
1750
- });
1751
- ```
1752
-
1753
- **Result**: When querying `privatedocument(id: "some_id")`, the system will:
1754
- 1. Create a query that includes both the id filter and the owner scope filter
1755
- 2. Only return the document if it matches both conditions
1756
- 3. Return `null` if the document exists but doesn't match the scope
1757
-
1758
- ### Scope Function Parameters
1759
-
1760
- Scope functions receive the same parameters as middleware for consistency:
1761
-
1762
- ```javascript
1763
- {
1764
- type, // Type information (model, gqltype, controller, etc.)
1765
- args, // GraphQL arguments passed to the operation (modify this object)
1766
- operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
1767
- context // GraphQL context object (includes request info, user data, etc.)
1768
- }
1769
- ```
1770
-
1771
- ### Filter Structure
1772
-
1773
- When modifying `args` in scope functions, use the appropriate filter structure:
1774
-
1775
- **For scalar fields:**
1776
- ```javascript
1777
- args.fieldName = {
1778
- operator: 'EQ',
1779
- value: 'someValue'
1780
- };
1781
- ```
1782
-
1783
- **For object/relation fields (QLTypeFilterExpression):**
1784
- ```javascript
1785
- args.relationField = {
1786
- terms: [
1787
- {
1788
- path: 'fieldName',
1789
- operator: 'EQ',
1790
- value: 'someValue'
1791
- }
1792
- ]
1793
- };
1794
- ```
1795
-
1796
- ### Complete Example
1797
-
1798
- Here's a complete example showing scope for all query operations:
1799
-
1800
- ```javascript
1801
- const EpisodeType = new GraphQLObjectType({
1802
- name: 'episode',
1803
- extensions: {
1804
- validations: {
1805
- save: [validateEpisodeFields],
1806
- update: [validateEpisodeBusinessRules]
1807
- },
1808
- scope: {
1809
- find: async ({ type, args, operation, context }) => {
1810
- // Only show episodes from seasons the user has access to
1811
- args.season = {
1812
- terms: [
1813
- {
1814
- path: 'owner.id',
1815
- operator: 'EQ',
1816
- value: context.user.id
1817
- }
1818
- ]
1819
- };
1820
- },
1821
- aggregate: async ({ type, args, operation, context }) => {
1822
- // Apply same scope to aggregations
1823
- args.season = {
1824
- terms: [
1825
- {
1826
- path: 'owner.id',
1827
- operator: 'EQ',
1828
- value: context.user.id
1829
- }
1830
- ]
1831
- };
1832
- },
1833
- get_by_id: async ({ type, args, operation, context }) => {
1834
- // Ensure user can only access their own episodes
1835
- args.owner = {
1836
- terms: [
1837
- {
1838
- path: 'id',
1839
- operator: 'EQ',
1840
- value: context.user.id
1841
- }
1842
- ]
1843
- };
1844
- }
1845
- }
1846
- },
1847
- fields: () => ({
1848
- id: { type: GraphQLID },
1849
- number: { type: GraphQLInt },
1850
- name: { type: GraphQLString },
1851
- season: {
1852
- type: new GraphQLNonNull(simfinity.getType('season')),
1853
- extensions: {
1854
- relation: {
1855
- connectionField: 'season',
1856
- displayField: 'number'
1857
- }
1858
- }
1859
- },
1860
- owner: {
1861
- type: new GraphQLNonNull(simfinity.getType('user')),
1862
- extensions: {
1863
- relation: {
1864
- connectionField: 'owner',
1865
- displayField: 'name'
1866
- }
1867
- }
1868
- }
1869
- })
1870
- });
1871
- ```
1872
-
1873
- ### Important Notes
1874
-
1875
- - **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
1876
- - **Modify Args In Place**: Scope functions should modify the `args` object directly
1877
- - **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
1878
- - **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
1879
- - **Generated Relationships**: Non-embedded single relations run the target type's `get_by_id` middleware and scope; collection relations run its `find` middleware and scope with the same request context. A separate database predicate preserves the referenced ID or parent connection even when filters are changed, including by scope functions. A scoped-out single relation returns `null`; a collection omits scoped-out children.
1880
- - **Custom Resolvers and Writes**: Existing relationship resolvers remain unchanged. Query scope does not authorize mutations; use global middleware or controller checks for write permissions, including permissions on nested children.
1881
- - **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
1882
- - **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
1883
-
1884
- ### Use Cases
1885
-
1886
- - **Multi-tenancy**: Filter documents by organization or tenant
1887
- - **User-specific data**: Only show documents owned by the current user
1888
- - **Role-based access**: Filter based on user roles or permissions
1889
- - **Department/Team scoping**: Show only data relevant to user's department
1890
- - **Geographic scoping**: Filter by user's location or region
1891
-
1892
- ## 🔐 Authorization
1893
-
1894
- Simfinity.js provides production-grade centralized GraphQL authorization supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies. It ships as a native Envelop plugin for GraphQL Yoga (recommended) and also supports the legacy graphql-middleware approach.
1895
-
1896
- ### Quick Start
1897
-
1898
- ```javascript
1899
- const { auth } = require('@simtlix/simfinity-js');
1900
- const { createYoga } = require('graphql-yoga');
1901
-
1902
- const { createAuthPlugin, requireAuth, requireRole } = auth;
1903
-
1904
- // Define your permission schema
1905
- // Query/Mutation names match the ones generated by simfinity.connect()
1906
- const permissions = {
1907
- Query: {
1908
- series: requireAuth(),
1909
- seasons: requireAuth(),
1910
- },
1911
- Mutation: {
1912
- deleteserie: requireRole('admin'),
1913
- deletestar: requireRole('admin'),
1914
- },
1915
- serie: {
1916
- '*': requireAuth(), // Wildcard: all fields require auth
1917
- },
1918
- };
1919
-
1920
- // Create the Envelop auth plugin and pass it to your server
1921
- const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
1922
- const yoga = createYoga({ schema, plugins: [authPlugin] });
1923
- ```
1924
-
1925
- ### Permission Schema
1926
-
1927
- The permission schema defines authorization rules per type and field:
1928
-
1929
- ```javascript
1930
- const permissions = {
1931
- // Operation types (Query, Mutation, Subscription)
1932
- Query: {
1933
- fieldName: ruleOrRules,
1934
- },
1935
-
1936
- // Object types
1937
- TypeName: {
1938
- '*': wildcardRule, // Applies to all fields unless overridden
1939
- fieldName: specificRule, // Overrides wildcard for this field
1940
- },
1941
- };
1942
- ```
1943
-
1944
- **Resolution Order:**
1945
- 1. Check exact field rule: `permissions[TypeName][fieldName]`
1946
- 2. Fallback to wildcard: `permissions[TypeName]['*']`
1947
- 3. Apply default policy (ALLOW or DENY)
1948
-
1949
- **Rule Types:**
1950
- - **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
1951
- - **Nonempty array of rules**: All rules must pass (AND logic); functions, expressions, and nested nonempty arrays can be mixed
1952
- - **Policy expression**: JSON AST object or boolean literal (see below)
1953
-
1954
- Permission maps must be plain objects (including objects with a null prototype). Only their own type and field entries are used. The default policy applies only when an exact field entry and wildcard are both absent. `defaultPolicy` must be exactly `'ALLOW'` or `'DENY'`; omitting it defaults to `'DENY'`.
1955
-
1956
- `createAuthPlugin`, `createAuthMiddleware`, and `createFieldMiddleware` throw `TypeError` when configured with an invalid rule, empty rule array, malformed policy expression, permission map, or default policy. A configured `null`, `undefined`, or unknown object never falls back to `ALLOW`. Use `allow()` or `true` to grant access explicitly, or omit the entry to use the default policy.
1957
-
1958
- **Rule Semantics:**
1959
- - `return true` or `return void` → allow
1960
- - `return false` → deny
1961
- - `throw Error` → deny with error
1962
-
1963
- Only `true` and `undefined` allow access. Other results, including `null`, `0`, an empty string, objects, and other truthy values, deny access. This contract is identical for direct rules, `composeRules`, `anyRule`, and `createRule`, including async results. `composeRules`, `anyRule`, and `createRule` require function arguments. `anyRule` may continue after a denial or error to a different granting rule.
1964
-
1965
- ### Rule Helpers
1966
-
1967
- Simfinity.js provides reusable rule builders:
1968
-
1969
- ```javascript
1970
- const { auth } = require('@simtlix/simfinity-js');
1971
-
1972
- const {
1973
- resolvePath, // Utility to resolve dotted paths in objects
1974
- requireAuth, // Requires ctx.user to exist
1975
- requireRole, // Requires specific role(s)
1976
- requirePermission, // Requires specific permission(s)
1977
- composeRules, // Combine rules (AND logic)
1978
- anyRule, // Combine rules (OR logic)
1979
- isOwner, // Check resource ownership
1980
- allow, // Always allow
1981
- deny, // Always deny
1982
- createRule, // Create custom rule
1983
- } = auth;
1984
- ```
1985
-
1986
- #### requireAuth(userPath?)
1987
-
1988
- Requires the user to be authenticated. Supports custom user paths in context:
1989
-
1990
- ```javascript
1991
- const permissions = {
1992
- Query: {
1993
- // Default: checks ctx.user
1994
- me: requireAuth(),
1995
-
1996
- // Custom path: checks ctx.auth.currentUser
1997
- profile: requireAuth('auth.currentUser'),
1998
-
1999
- // Deep path: checks ctx.session.data.user
2000
- settings: requireAuth('session.data.user'),
2001
- },
2002
- };
2003
- ```
2004
-
2005
- #### requireRole(role, options?)
2006
-
2007
- Requires the user to have a specific role. Supports custom paths:
2008
-
2009
- The required role must be a nonempty string or nonempty array of nonempty strings; invalid configuration throws `TypeError`. The user's single role value must match an allowed string exactly.
2010
-
2011
- ```javascript
2012
- const permissions = {
2013
- Query: {
2014
- // Default: checks ctx.user.role
2015
- adminDashboard: requireRole('ADMIN'),
2016
- modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
2017
-
2018
- // Custom paths: checks ctx.auth.user.profile.role
2019
- superAdmin: requireRole('SUPER_ADMIN', {
2020
- userPath: 'auth.user',
2021
- rolePath: 'profile.role',
2022
- }),
2023
- },
2024
- };
2025
- ```
2026
-
2027
- #### requirePermission(permission, options?)
2028
-
2029
- Requires the user to have specific permission(s). Supports custom paths:
2030
-
2031
- The required permission must be a nonempty string or nonempty array of nonempty strings; invalid configuration throws `TypeError`. The user's claim must be an array of nonempty strings. Entries match exactly; only a standalone `'*'` entry grants every permission. A claim such as `'posts:read'` must be supplied as `['posts:read']`. String claims, substrings, and embedded wildcard characters do not grant access.
2032
-
2033
- ```javascript
2034
- const permissions = {
2035
- Mutation: {
2036
- // Default: checks ctx.user.permissions
2037
- deletePost: requirePermission('posts:delete'),
2038
- manageUsers: requirePermission(['users:read', 'users:write']), // All required
2039
-
2040
- // Custom paths: checks ctx.session.user.access.grants
2041
- admin: requirePermission('admin:all', {
2042
- userPath: 'session.user',
2043
- permissionsPath: 'access.grants',
2044
- }),
2045
- },
2046
- };
2047
- ```
2048
-
2049
- #### composeRules(...rules)
2050
-
2051
- Combines multiple rules with AND logic (all must pass):
2052
-
2053
- ```javascript
2054
- const permissions = {
2055
- Mutation: {
2056
- updatePost: composeRules(
2057
- requireAuth(),
2058
- requireRole('EDITOR'),
2059
- async (post, args, ctx) => post.authorId === ctx.user.id,
2060
- ),
2061
- },
2062
- };
2063
- ```
2064
-
2065
- #### anyRule(...rules)
2066
-
2067
- Combines multiple rules with OR logic (any must pass):
2068
-
2069
- ```javascript
2070
- const permissions = {
2071
- Post: {
2072
- content: anyRule(
2073
- requireRole('ADMIN'),
2074
- async (post, args, ctx) => post.authorId === ctx.user.id,
2075
- ),
2076
- },
2077
- };
2078
- ```
2079
-
2080
- #### isOwner(ownerField, userIdField)
2081
-
2082
- Checks if the authenticated user owns the resource:
2083
-
2084
- IDs must be nonempty strings, finite numbers (including zero), or Mongoose `ObjectId` instances. Numbers compare by their string representation, and ObjectIds compare by hexadecimal value. Missing/null IDs, empty strings, arrays, booleans, and arbitrary objects deny access. Use the path or extractor arguments to obtain a supported ID from a custom identity object.
2085
-
2086
- ```javascript
2087
- const permissions = {
2088
- Post: {
2089
- '*': composeRules(
2090
- requireAuth(),
2091
- isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
2092
- ),
2093
- },
2094
- };
2095
- ```
2096
-
2097
- ### Policy Expressions (JSON AST)
2098
-
2099
- For declarative rules, use JSON AST policy expressions:
2100
-
2101
- ```javascript
2102
- const permissions = {
2103
- Post: {
2104
- content: {
2105
- anyOf: [
2106
- { eq: [{ ref: 'parent.published' }, true] },
2107
- { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
2108
- ],
2109
- },
2110
- },
2111
- };
2112
- ```
2113
-
2114
- **Supported Operators:**
2115
-
2116
- | Operator | Description | Example |
2117
- |----------|-------------|---------|
2118
- | `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
2119
- | `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
2120
- | `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
2121
- | `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
2122
- | `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
2123
-
2124
- **References:**
2125
-
2126
- Use `{ ref: 'path' }` to reference values:
2127
- - `parent.*` - Parent resolver result (the object being resolved)
2128
- - `args.*` - GraphQL arguments
2129
- - `ctx.*` - GraphQL context
2130
-
2131
- **Security:**
2132
- - Only `parent`, `args`, and `ctx` roots are allowed
2133
- - Reference paths support root values, dotted fields, document getters, and numeric array indices. Empty path segments and `__proto__`, `prototype`, or `constructor` segments are invalid
2134
- - `eq` and `in` require exactly two operands; `in` requires an array or a reference resolving to an array. `allOf` and `anyOf` require arrays of valid expressions; `not` requires a valid expression
2135
- - Unknown operators or malformed nested expressions invalidate the entire configured policy, even inside an otherwise granting `anyOf`
2136
- - Factories reject malformed expressions with `TypeError`; `isPolicyExpression` validates the complete AST, and direct `evaluateExpression` calls return `false` for malformed ASTs
2137
- - Missing references and invalid runtime membership operands cannot grant access, including under `not` or repeated negation. They propagate through `allOf` and implicit AND. A separate valid `anyOf` branch can still grant access, such as a published post without an authenticated user
2138
- - Explicit `null`, `false`, `0`, and empty-string comparison values remain valid; equality is strict. Use `requireAuth()` or `isOwner()` for identity checks rather than treating two explicit null IDs as ownership
2139
- - Boolean expressions and logical identities remain valid: `{ allOf: [] }` is true, `{ anyOf: [] }` is false, and `{ not: false }` is true. Multiple operator keys form an implicit AND
2140
- - No `eval()` or `Function()` - pure object traversal
2141
-
2142
- ### Integration with GraphQL Yoga / Envelop
2143
-
2144
- The recommended way to use the auth system is via the Envelop plugin, which works natively with GraphQL Yoga and any Envelop-based server. The plugin wraps resolvers in-place without rebuilding the schema, avoiding compatibility issues.
2145
-
2146
- ```javascript
2147
- const { createYoga } = require('graphql-yoga');
2148
- const { createServer } = require('http');
2149
- const simfinity = require('@simtlix/simfinity-js');
2150
-
2151
- const { auth } = simfinity;
2152
- const { createAuthPlugin, requireAuth, requireRole, composeRules, isOwner, deny } = auth;
2153
-
2154
- // Define your types and connect them
2155
- simfinity.connect(null, SerieType, 'serie', 'series');
2156
- simfinity.connect(null, SeasonType, 'season', 'seasons');
2157
- simfinity.connect(null, StarType, 'star', 'stars');
2158
-
2159
- // Create base schema
2160
- const schema = simfinity.createSchema();
2161
-
2162
- // Define permissions
2163
- // Query/Mutation names match the ones generated by simfinity.connect()
2164
- const permissions = {
2165
- Query: {
2166
- series: requireAuth(),
2167
- seasons: requireAuth(),
2168
- stars: requireAuth(),
2169
- },
2170
- Mutation: {
2171
- addserie: requireAuth(),
2172
- updateserie: composeRules(requireAuth(), isOwner('createdBy')),
2173
- deleteserie: requireRole('admin'),
2174
- deletestar: requireRole('admin'),
2175
- },
2176
- serie: {
2177
- '*': requireAuth(),
2178
- },
2179
- season: {
2180
- '*': requireAuth(),
2181
- },
2182
- };
2183
-
2184
- // Create auth plugin
2185
- const authPlugin = createAuthPlugin(permissions, {
2186
- defaultPolicy: 'ALLOW',
2187
- debug: false,
2188
- });
2189
-
2190
- // Setup Yoga with the auth plugin
2191
- const yoga = createYoga({
2192
- schema,
2193
- plugins: [authPlugin],
2194
- context: (req) => ({
2195
- user: req.user, // Set by your authentication layer
2196
- }),
2197
- });
2198
-
2199
- const server = createServer(yoga);
2200
- server.listen(4000);
2201
- ```
2202
-
2203
- ### Legacy: Integration with graphql-middleware
2204
-
2205
- > **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
2206
- > which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
2207
- > Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
2208
-
2209
- ```javascript
2210
- const { applyMiddleware } = require('graphql-middleware');
2211
- const simfinity = require('@simtlix/simfinity-js');
2212
-
2213
- const { auth } = simfinity;
2214
- const { createAuthMiddleware, requireAuth, requireRole } = auth;
2215
-
2216
- const baseSchema = simfinity.createSchema();
2217
-
2218
- const authMiddleware = createAuthMiddleware(permissions, {
2219
- defaultPolicy: 'DENY',
2220
- });
2221
-
2222
- const schema = applyMiddleware(baseSchema, authMiddleware);
2223
- ```
2224
-
2225
- ### Plugin / Middleware Options
2226
-
2227
- ```javascript
2228
- const plugin = createAuthPlugin(permissions, {
2229
- defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
2230
- debug: false, // Enable debug logging
2231
- });
2232
- ```
2233
-
2234
- | Option | Type | Default | Description |
2235
- |--------|------|---------|-------------|
2236
- | `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
2237
- | `debug` | `boolean` | `false` | Log authorization decisions |
2238
-
2239
- ### Error Handling
2240
-
2241
- The auth middleware uses Simfinity error classes:
2242
-
2243
- ```javascript
2244
- const { auth } = require('@simtlix/simfinity-js');
2245
-
2246
- const { UnauthenticatedError, ForbiddenError } = auth;
2247
-
2248
- // UnauthenticatedError: code 'UNAUTHENTICATED', status 401
2249
- // ForbiddenError: code 'FORBIDDEN', status 403
2250
- ```
2251
-
2252
- Custom error handling in rules:
2253
-
2254
- ```javascript
2255
- const permissions = {
2256
- Mutation: {
2257
- deleteserie: async (parent, args, ctx) => {
2258
- if (!ctx.user) {
2259
- throw new auth.UnauthenticatedError('Please log in');
2260
- }
2261
- if (ctx.user.role !== 'admin') {
2262
- throw new auth.ForbiddenError('Only admins can delete series');
2263
- }
2264
- return true;
2265
- },
2266
- },
2267
- };
2268
- ```
2269
-
2270
- ### Best Practices
2271
-
2272
- 1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
2273
- 2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
2274
- 3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
2275
- 4. **Fail closed**: Custom rules should deny on unexpected conditions
2276
- 5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
2277
- 6. **Test thoroughly**: Auth rules are critical - test all scenarios
2278
-
2279
- ## 🔧 Middlewares
2280
-
2281
- Middlewares provide a powerful way to intercept and process all GraphQL operations before they execute. Use them for cross-cutting concerns like authentication, logging, validation, and performance monitoring.
2282
-
2283
- ### Adding Middlewares
2284
-
2285
- Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered:
2286
-
2287
- ```javascript
2288
- // Basic logging middleware
2289
- simfinity.use((params, next) => {
2290
- console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
2291
- next();
2292
- });
2293
- ```
2294
-
2295
- ### Middleware Parameters
2296
-
2297
- Each middleware receives a `params` object containing:
2298
-
2299
- Generated non-embedded relationship reads and nested collection mutations also invoke middleware for the related type. Middleware and scope callbacks are awaited. Nested child operations use the same argument shapes and request context as their root equivalents; review middleware that previously assumed it ran only once per root operation.
2300
-
2301
- ```javascript
2302
- simfinity.use((params, next) => {
2303
- // params object contains:
2304
- const {
2305
- type, // Type information (model, gqltype, controller, etc.)
2306
- args, // GraphQL arguments passed to the operation
2307
- operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
2308
- context, // GraphQL context object (includes request info, user data, etc.)
2309
- actionName, // For state machine actions (only present for state_changed operations)
2310
- actionField, // State machine action details (only present for state_changed operations)
2311
- entry // Custom mutation name (only present for custom_mutation operations)
2312
- } = params;
2313
-
2314
- // Always call next() to continue the middleware chain
2315
- next();
2316
- });
2317
- ```
2318
-
2319
- ### Common Use Cases
2320
-
2321
- #### 1. Authentication & Authorization
2322
-
2323
- ```javascript
2324
- simfinity.use((params, next) => {
2325
- const { context, operation, type } = params;
2326
-
2327
- // Skip authentication for read operations
2328
- if (operation === 'get_by_id' || operation === 'find') {
2329
- return next();
2330
- }
2331
-
2332
- // Check if user is authenticated
2333
- if (!context.user) {
2334
- throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
2335
- }
2336
-
2337
- // Check permissions for specific types
2338
- if (type?.name === 'User' && context.user.role !== 'admin') {
2339
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
2340
- }
2341
-
2342
- next();
2343
- });
2344
- ```
2345
-
2346
- #### 2. Request Logging & Monitoring
2347
-
2348
- ```javascript
2349
- simfinity.use((params, next) => {
2350
- const { operation, type, args, context } = params;
2351
- const startTime = Date.now();
2352
-
2353
- console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
2354
-
2355
- // Continue with the operation
2356
- next();
2357
-
2358
- const duration = Date.now() - startTime;
2359
- console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
2360
- });
2361
- ```
2362
-
2363
- #### 3. Input Validation & Sanitization
2364
-
2365
- ```javascript
2366
- simfinity.use((params, next) => {
2367
- const { operation, args, type } = params;
2368
-
2369
- // Validate input for save operations
2370
- if (operation === 'save' && args.input) {
2371
- // Trim string fields
2372
- Object.keys(args.input).forEach(key => {
2373
- if (typeof args.input[key] === 'string') {
2374
- args.input[key] = args.input[key].trim();
2375
- }
2376
- });
2377
-
2378
- // Validate required business rules
2379
- if (type?.name === 'Book' && args.input.title && args.input.title.length < 3) {
2380
- throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
2381
- }
2382
- }
2383
-
2384
- next();
2385
- });
2386
- ```
2387
-
2388
- #### 4. Rate Limiting
2389
-
2390
- ```javascript
2391
- const requestCounts = new Map();
2392
-
2393
- simfinity.use((params, next) => {
2394
- const { context, operation } = params;
2395
- const userId = context.user?.id || context.ip;
2396
- const now = Date.now();
2397
- const windowMs = 60000; // 1 minute
2398
- const maxRequests = 100;
2399
-
2400
- // Only apply rate limiting to mutations
2401
- if (operation === 'save' || operation === 'update' || operation === 'delete') {
2402
- const userRequests = requestCounts.get(userId) || [];
2403
- const recentRequests = userRequests.filter(time => now - time < windowMs);
2404
-
2405
- if (recentRequests.length >= maxRequests) {
2406
- throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
2407
- }
2408
-
2409
- recentRequests.push(now);
2410
- requestCounts.set(userId, recentRequests);
2411
- }
2412
-
2413
- next();
2414
- });
2415
- ```
2416
-
2417
- #### 5. Audit Trail
2418
-
2419
- ```javascript
2420
- simfinity.use((params, next) => {
2421
- const { operation, type, args, context } = params;
2422
-
2423
- // Log all mutations for audit purposes
2424
- if (operation === 'save' || operation === 'update' || operation === 'delete') {
2425
- const auditEntry = {
2426
- timestamp: new Date(),
2427
- user: context.user?.id,
2428
- operation,
2429
- type: type?.name,
2430
- entityId: args.id || 'new',
2431
- data: operation === 'delete' ? null : args.input,
2432
- ip: context.ip,
2433
- userAgent: context.userAgent
2434
- };
2435
-
2436
- // Save to audit log (could be database, file, or external service)
2437
- console.log('AUDIT:', JSON.stringify(auditEntry));
2438
- }
2439
-
2440
- next();
2441
- });
2442
- ```
2443
-
2444
- ### Multiple Middlewares
2445
-
2446
- Middlewares execute in registration order. Each middleware must call `next()` to continue the chain:
2447
-
2448
- ```javascript
2449
- // Middleware 1: Authentication
2450
- simfinity.use((params, next) => {
2451
- console.log('1. Checking authentication...');
2452
- // Authentication logic here
2453
- next(); // Continue to next middleware
2454
- });
2455
-
2456
- // Middleware 2: Authorization
2457
- simfinity.use((params, next) => {
2458
- console.log('2. Checking permissions...');
2459
- // Authorization logic here
2460
- next(); // Continue to next middleware
2461
- });
2462
-
2463
- // Middleware 3: Logging
2464
- simfinity.use((params, next) => {
2465
- console.log('3. Logging request...');
2466
- // Logging logic here
2467
- next(); // Continue to GraphQL operation
2468
- });
2469
- ```
2470
-
2471
- ### Error Handling in Middlewares
2472
-
2473
- Middlewares can throw errors to stop the operation:
2474
-
2475
- ```javascript
2476
- simfinity.use((params, next) => {
2477
- const { context, operation } = params;
2478
-
2479
- try {
2480
- // Validation logic
2481
- if (!context.user && operation !== 'find') {
2482
- throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
2483
- }
2484
-
2485
- next(); // Continue only if validation passes
2486
- } catch (error) {
2487
- // Error automatically bubbles up to GraphQL error handling
2488
- throw error;
2489
- }
2490
- });
2491
- ```
2492
-
2493
- ### Conditional Middleware Execution
2494
-
2495
- Execute middleware logic conditionally based on operation type or context:
2496
-
2497
- ```javascript
2498
- simfinity.use((params, next) => {
2499
- const { operation, type, context } = params;
2500
-
2501
- // Only apply to specific types
2502
- if (type?.name === 'SensitiveData') {
2503
- // Special handling for sensitive data
2504
- if (!context.user?.hasHighSecurity) {
2505
- throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
2506
- }
2507
- }
2508
-
2509
- // Only apply to mutation operations
2510
- if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
2511
- // Mutation-specific logic
2512
- console.log(`Mutation ${operation} executing...`);
2513
- }
2514
-
2515
- next();
2516
- });
2517
- ```
2518
-
2519
- ### Best Practices
2520
-
2521
- 1. **Always call `next()`**: Failing to call `next()` will hang the request
2522
- 2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
2523
- 3. **Keep middlewares focused**: Each middleware should handle one concern
2524
- 4. **Order matters**: Register middlewares in logical order (auth → validation → logging)
2525
- 5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
2526
- 6. **Use context wisely**: Store request-specific data in the GraphQL context object
2527
-
2528
- ## 🔧 Advanced Features
2529
-
2530
- ### Field Extensions
2531
-
2532
- Control field behavior with extensions:
2533
-
2534
- ```javascript
2535
- const BookType = new GraphQLObjectType({
2536
- name: 'Book',
2537
- fields: () => ({
2538
- id: { type: GraphQLID },
2539
- title: {
2540
- type: GraphQLString,
2541
- extensions: {
2542
- unique: true, // Creates unique index in MongoDB
2543
- readOnly: true // Excludes from input types
2544
- }
2545
- },
2546
- isbn: {
2547
- type: GraphQLString,
2548
- extensions: {
2549
- unique: true
2550
- }
2551
- }
2552
- })
2553
- });
2554
- ```
2555
-
2556
- ### Custom Mutations
2557
-
2558
- Register custom mutations beyond the automatic CRUD operations:
2559
-
2560
- ```javascript
2561
- simfinity.registerMutation(
2562
- 'sendBookNotification',
2563
- 'Send notification about a book',
2564
- BookNotificationInput, // Input type
2565
- NotificationResult, // Output type
2566
- async (args, session) => {
2567
- // Custom business logic
2568
- const book = await BookModel.findById(args.bookId);
2569
- // Send notification logic here
2570
- return { success: true, message: 'Notification sent' };
2571
- }
2572
- );
2573
- ```
2574
-
2575
- ### Adding Types Without Endpoints
2576
-
2577
- Include types in the schema without generating endpoints. See the [detailed guide on addNoEndpointType()](#adding-types-without-endpoints) for when and how to use this pattern:
2578
-
2579
- ```javascript
2580
- // This type can be used in relationships but won't have queries/mutations
2581
- simfinity.addNoEndpointType(AddressType);
2582
- ```
2583
-
2584
- ### Working with Existing Mongoose Models
2585
-
2586
- Use your existing Mongoose models:
2587
-
2588
- ```javascript
2589
- const mongoose = require('mongoose');
2590
-
2591
- const BookSchema = new mongoose.Schema({
2592
- title: String,
2593
- author: String,
2594
- publishedDate: Date
2595
- });
2596
-
2597
- const BookModel = mongoose.model('Book', BookSchema);
2598
-
2599
- // Use existing model
2600
- simfinity.connect(BookModel, BookType, 'book', 'books');
2601
- ```
2602
-
2603
- ### Programmatic Data Access
2604
-
2605
- Access data programmatically outside of GraphQL:
2606
-
2607
- ```javascript
2608
- // Save an object programmatically
2609
- const newBook = await simfinity.saveObject('Book', {
2610
- title: 'New Book',
2611
- author: 'Author Name'
2612
- }, session);
2613
-
2614
- // Get the Mongoose model for a type
2615
- const BookModel = simfinity.getModel(BookType);
2616
- const books = await BookModel.find({ author: 'Douglas Adams' });
2617
-
2618
- // Get the GraphQL type definition by name
2619
- const UserType = simfinity.getType('User');
2620
- console.log(UserType.name); // 'User'
2621
- console.log(UserType.getFields()); // Access GraphQL fields
2622
-
2623
- // Get the input type for a GraphQL type
2624
- const BookInput = simfinity.getInputType(BookType);
2625
- ```
2626
-
2627
- ## 🤖 MCP Generation
2628
-
2629
- Simfinity can automatically expose every generated GraphQL operation as a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) tool. For each connected type you get one MCP tool per generated query and mutation — exactly mirroring the GraphQL surface:
2630
-
2631
- | GraphQL operation | MCP tool name |
2632
- | --- | --- |
2633
- | Single-entity query | `book` |
2634
- | List query | `books` |
2635
- | Aggregate query | `books_aggregate` |
2636
- | Add mutation | `addbook` |
2637
- | Update mutation | `updatebook` |
2638
- | Delete mutation | `deletebook` |
2639
- | State-machine action | `publish_book` |
2640
- | Custom mutation | _the registered name_ |
2641
-
2642
- Each generated tool follows MCP best practices so an agent can use it without extra context:
2643
-
2644
- - **`description`**: reuses the GraphQL type/field descriptions and adds an actionable summary. List and aggregate tools spell out the available filter operators (`EQ, NE, LT, LTE, GT, GTE, IN, NIN, BTW, LIKE`), `AND`/`OR` groups, pagination and sorting; aggregate tools additionally document the `SUM/COUNT/AVG/MIN/MAX` operations and the `aggregation` argument.
2645
- - **`title`**: a human-readable label (e.g. `List Book`, `Create Book`, `Aggregate Book`).
2646
- - **`inputSchema`**: JSON Schema derived from the GraphQL arguments (scalars, enums, input objects, lists, non-null wrappers, and recursive filter types such as `QLFilterGroup` via `$defs`/`$ref`). Nullable arguments, input fields and list items accept explicit `null`; non-null positions reject it. Nullable references use `anyOf` with a null alternative. GraphQL type and field descriptions are propagated, with curated descriptions for synthetic types. `Date`/`DateTime`/`Time` scalars use the matching JSON Schema `format`. Defaulted arguments are optional; representable defaults appear in the schema and generated GraphQL variables, with enum member names rather than internal values. Omitted opaque scalar defaults that have no GraphQL literal representation use the field's original default.
2647
- - **`outputSchema`**: JSON Schema describing the returned data (mirroring the auto-generated selection set), with type/field descriptions. Every nullable GraphQL position also accepts `null` (e.g. `type: ['string', 'null']`; enums get `null` appended), so validating MCP clients accept the `null`s GraphQL legitimately returns in `structuredContent` (get-by-id misses, unset optional fields).
2648
- - **`annotations`**: behavioral hints — queries are `readOnlyHint: true`, `delete*` is `destructiveHint: true`, `update*` is `idempotentHint: true`. Generated CRUD mutations are recognized by the placeholder descriptions Simfinity stamps on them, so a custom mutation that happens to be named `updateReport` (and carries its own description) keeps that description and gets no inferred hints.
2649
-
2650
- On execution, each tool returns both a serialized JSON `text` content block and a machine-readable `structuredContent` (the GraphQL `data`) that conforms to the `outputSchema`. When the caller requests `pagination: { count: true }` on a **list** tool, the total record count is delivered in the tool result `_meta.count` (aggregate tools ignore the flag — their resolver never computes a total).
2651
-
2652
- > The MCP transports require the optional `@modelcontextprotocol/sdk` dependency (`^1.13.0` or newer). Install it with `npm install @modelcontextprotocol/sdk@^1.13.0`. `generateMCPTools` works without it. SDK load problems raise distinct error codes: `MCP_SDK_NOT_INSTALLED` (not installed), `MCP_SDK_INCOMPATIBLE` (installed but too old to provide the requested transport — upgrade it), `MCP_SDK_LOAD_FAILED` (any other import failure).
2653
-
2654
- ### Installation
2655
-
2656
- ```bash
2657
- npm install @modelcontextprotocol/sdk@^1.13.0
2658
- ```
2659
-
2660
- ### Generating tool definitions
2661
-
2662
- `generateMCPTools(schema, options)` returns the tool list, a `callTool` executor and a `getOperation` inspector. It does not require the SDK and is handy for inspection or custom wiring:
2663
-
2664
- ```javascript
2665
- import * as simfinity from '@simtlix/simfinity-js';
2666
-
2667
- const schema = simfinity.createSchema();
2668
- const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema);
2669
-
2670
- // tools: [{ name, title, description, inputSchema, outputSchema, annotations, kind }, ...]
2671
- const result = await callTool('addbook', { input: { title: 'Dune' } });
2672
- // result: { content: [{ type: 'text', text: '...JSON...' }],
2673
- // structuredContent: { addbook: { ... } }, isError: false }
2674
-
2675
- // getOperation returns the prebuilt GraphQL document a tool executes
2676
- console.log(getOperation('books'));
2677
- // query booksOperation($id: QLFilter, $pagination: QLPagination, ...) {
2678
- // books(id: $id, pagination: $pagination, ...) { id title ... }
2679
- // }
2680
- ```
2681
-
2682
- Both `callTool` and `getOperation` throw a `SimfinityError` with code `MCP_TOOL_NOT_FOUND` for unknown tool names.
2683
-
2684
- ### Standalone MCP server (stdio)
2685
-
2686
- Use stdio for a standalone executable consumed by clients like Cursor or Claude Desktop:
2687
-
2688
- ```javascript
2689
- import * as simfinity from '@simtlix/simfinity-js';
2690
-
2691
- const schema = simfinity.createSchema();
2692
- await simfinity.startStdioMCPServer(schema, {
2693
- serverName: 'my-api-mcp',
2694
- context: { user: { id: 'system' } },
2695
- });
2696
- ```
2697
-
2698
- ### HTTP MCP endpoint (alongside GraphQL)
2699
-
2700
- Mount an MCP endpoint (Streamable HTTP) next to your existing GraphQL server:
2701
-
2702
- ```javascript
2703
- import express from 'express';
2704
- import * as simfinity from '@simtlix/simfinity-js';
2705
-
2706
- const schema = simfinity.createSchema();
2707
- const app = express();
2708
- app.use(express.json());
2709
-
2710
- const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
2711
- // In HTTP mode the context factory receives the Express `req`,
2712
- // so you can authenticate per request (e.g. from the Authorization header).
2713
- context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
2714
- });
2715
- app.post('/mcp', mcpHandler);
2716
- ```
2717
-
2718
- For deployments reachable from a browser (especially localhost), enable the SDK's DNS-rebinding protection via `transportOptions`, and observe failures with `onError`:
2719
-
2720
- ```javascript
2721
- const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
2722
- context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
2723
- // Spread into the SDK's StreamableHTTPServerTransport options
2724
- transportOptions: {
2725
- enableDnsRebindingProtection: true,
2726
- allowedHosts: ['127.0.0.1:3000', 'localhost:3000'],
2727
- allowedOrigins: ['http://localhost:3000'],
2728
- },
2729
- onError: (err, req, res) => console.error('MCP request failed', err),
2730
- });
2731
- ```
2732
-
2733
- The handler catches all errors itself: `onError` is for logging/metrics, and a JSON-RPC internal error (HTTP 500) is sent automatically when response headers were not already written.
2734
-
2735
- ### Options
2736
-
2737
- | Option | Default | Description |
2738
- | --- | --- | --- |
2739
- | `execution` | `{ mode: 'in-process' }` | `in-process` runs against the in-memory `schema`. `remote` issues HTTP GraphQL requests: `{ mode: 'remote', endpoint, headers, timeoutMs, retry: { attempts, backoffMs } }`. See [Remote execution](#remote-execution). |
2740
- | `context` | `{}` | GraphQL context value, or a factory. For in-process/stdio it is called as `(extra) => context`; for the HTTP handler it is called as `(req, extra) => context` so you can authenticate per request. Passed through to execution so `scope` functions and auth rules work (they read `context.user`). |
2741
- | `include` / `exclude` | — | String or array: raw GraphQL field names, prefixed tool names, or the categories `'query'` / `'mutation'`. `exclude` wins over `include`. |
2742
- | `includeTypes` / `excludeTypes` | — | String or array of entity (return) type names — one entry covers every tool for that entity (`'Book'` matches `book`, `books`, `books_aggregate`, `addbook`, ...). `excludeTypes` wins. |
2743
- | `selectionDepth` | `1` | Nesting depth for the auto-generated output selection set (and the mirrored `outputSchema`). |
2744
- | `includeId` | `true` | Select a scalar or enum `id` as a fallback when nothing else is selectable; otherwise use `__typename`. |
2745
- | `toolNamePrefix` | — | Prefix prepended to every published tool name (e.g. `'catalog_'`). Must match `/^[a-zA-Z0-9_-]+$/`; the resulting names must match `/^[a-zA-Z0-9_-]{1,128}$/` (`MCP_INVALID_TOOL_NAME` otherwise). |
2746
- | `toolOverrides` | `{}` | Per-tool overrides keyed by tool (or unprefixed field) name: `{ description, title, annotations, selectionDepth, includeId, selection }`. See [Customizing tools](#customizing-tools). |
2747
- | `toolMiddleware` | — | Array of koa-style `async (call, next)` functions run around every tool call. See [Tool middleware](#tool-middleware). |
2748
- | `limits` | — | Guardrails: `{ maxPageSize, defaultPagination, maxResultBytes }`. See [Limits](#limits). |
2749
- | `schemaPlugins` | — | Envelop-style plugins whose `onSchemaChange` hook is applied once before serving (in-process execution only). See [Authentication](#authentication). |
2750
- | `serverName` / `serverVersion` | `'simfinity-mcp'` / `'1.0.0'` | Identity reported by the MCP server. |
2751
- | `transportOptions` | — | `createHTTPMCPHandler` only: stateless SDK transport options (e.g. `enableDnsRebindingProtection`, `allowedHosts`, `allowedOrigins`). Setting `sessionIdGenerator`, `onsessioninitialized`, `onsessionclosed` or `eventStore` raises `MCP_INVALID_TRANSPORT_OPTIONS` at setup. |
2752
- | `onError` | — | `createHTTPMCPHandler` only: `(err, req, res)` callback invoked when a request fails; the handler still responds with a JSON-RPC internal error (HTTP 500) if headers were not sent. |
2753
-
2754
- Selection sets always fall back to `id` and then `__typename` when nothing else is selectable (interface/union return types select `__typename`), so the generated documents are always statically valid — and the output schemas mirror the same fallbacks.
2755
-
2756
- ### Selecting and naming tools
2757
-
2758
- Trim and namespace the published surface with `include`/`exclude`, `includeTypes`/`excludeTypes` and `toolNamePrefix`:
2759
-
2760
- ```javascript
2761
- const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema, {
2762
- exclude: 'mutation', // read-only server
2763
- excludeTypes: 'AuditLog', // hide every tool whose entity type is AuditLog
2764
- toolNamePrefix: 'catalog_', // books -> catalog_books
2765
- });
2766
-
2767
- // callTool/getOperation use the published (prefixed) name;
2768
- // the underlying GraphQL document still uses the raw field name.
2769
- console.log(getOperation('catalog_books'));
2770
- // query booksOperation(...) { books(...) { ... } }
2771
- ```
2772
-
2773
- - `include`/`exclude` entries match the raw GraphQL field name, the prefixed tool name, or the categories `'query'` / `'mutation'`. `exclude` always wins over `include`.
2774
- - `includeTypes`/`excludeTypes` match the entity (return) type. Aggregate queries resolve their entity through the sibling list field (`books_aggregate` → `books`), so `excludeTypes: 'Book'` also hides the aggregate tool. With an `includeTypes` allowlist, tools without an object return type are dropped too.
2775
- - Duplicate tool names (e.g. a Query field and a Mutation field sharing a name) are not allowed by MCP: the first tool wins and the later one is skipped with a `console.warn` — rename one of the GraphQL fields or use `include`/`exclude`.
2776
-
2777
- ### Customizing tools
2778
-
2779
- `toolOverrides` tunes individual tools without touching the schema. Keys accept either the published tool name or the unprefixed field name:
2780
-
2781
- ```javascript
2782
- const { tools } = simfinity.generateMCPTools(schema, {
2783
- toolOverrides: {
2784
- books: {
2785
- title: 'Search books',
2786
- description: 'Search the book catalog. Filter by title, author or publication year.',
2787
- selectionDepth: 2, // deeper selection set for this tool only
2788
- },
2789
- publish_book: {
2790
- annotations: { idempotentHint: true }, // merged over the generated annotations
2791
- },
2792
- book: {
2793
- selection: 'id title author { name }', // explicit selection set
2794
- },
2795
- },
2796
- });
2797
- ```
2798
-
2799
- `selection` accepts `'id title'` or `'{ id title }'` and replaces the auto-generated selection set. When it is set, the `outputSchema` is **omitted** for that tool — it can no longer be guaranteed to match the returned data, and publishing a wrong schema would make validating clients reject valid results.
2800
-
2801
- ### Limits
2802
-
2803
- `limits` protects agents (and your database) from oversized requests and responses. Violations come back as regular `isError` tool results, not exceptions:
2804
-
2805
- ```javascript
2806
- const { callTool } = simfinity.generateMCPTools(schema, {
2807
- limits: {
2808
- maxPageSize: 100, // MCP_PAGE_SIZE_EXCEEDED above this
2809
- defaultPagination: { page: 1, size: 20 }, // injected when the caller sends none
2810
- maxResultBytes: 262144, // MCP_RESULT_TOO_LARGE above this
2811
- },
2812
- });
2813
- ```
2814
-
2815
- - `maxPageSize`: rejects calls whose `pagination.size` exceeds the cap.
2816
- - `defaultPagination`: injected only for tools that have a `pagination` argument, and only when the caller did not send one. `page` and `size` are both required (QLPagination declares them non-null), and `size` must not exceed `maxPageSize` — that misconfiguration throws `MCP_INVALID_LIMITS` at setup.
2817
- - `maxResultBytes`: caps the UTF-8 byte length of the pretty-printed logical payload: `data` on success, or `{ errors, data? }` on failure. Oversized errors and partial data are replaced by `MCP_RESULT_TOO_LARGE`. This diagnostic and pagination-limit diagnostics are exempt from the cap, so even a tiny cap can return an actionable error. The cap excludes MCP envelope overhead, the duplicate `structuredContent`, and middleware-created results; it runs after execution and does not limit database work or remote download size.
2818
-
2819
- ### Tool middleware
2820
-
2821
- `toolMiddleware` wraps every tool execution with koa-style `(call, next)` functions — for logging, metrics, argument rewriting or policy checks. `call` is `{ name, args, extra, kind, operation }`:
2822
-
2823
- ```javascript
2824
- const { callTool } = simfinity.generateMCPTools(schema, {
2825
- toolMiddleware: [
2826
- async (call, next) => {
2827
- const started = Date.now();
2828
- const result = await next();
2829
- console.log(`[mcp] ${call.name} (${call.kind}) took ${Date.now() - started}ms`);
2830
- return result;
2831
- },
2832
- async (call, next) => {
2833
- if (call.kind === 'mutation' && process.env.MCP_READ_ONLY === 'true') {
2834
- // Short-circuit: return a result without calling next()
2835
- return {
2836
- content: [{ type: 'text', text: 'Mutations are disabled on this server.' }],
2837
- isError: true,
2838
- };
2839
- }
2840
- return next();
2841
- },
2842
- ],
2843
- });
2844
- ```
2845
-
2846
- Middleware can mutate `call.args` before calling `next()`, short-circuit by returning a result, or throw. Calling `next()` twice rejects with `MCP_MIDDLEWARE_ERROR`.
2847
-
2848
- ### Remote execution
2849
-
2850
- `execution.mode: 'remote'` turns the MCP server into a thin proxy that POSTs each operation to an existing GraphQL endpoint. Harden it with `timeoutMs` and `retry`:
2851
-
2852
- ```javascript
2853
- const { callTool } = simfinity.generateMCPTools(schema, {
2854
- execution: {
2855
- mode: 'remote',
2856
- endpoint: 'https://api.example.com/graphql',
2857
- headers: { Authorization: `Bearer ${token}` },
2858
- timeoutMs: 10000, // abort requests slower than 10s
2859
- retry: { attempts: 2, backoffMs: 250 }, // up to 2 extra attempts, linear backoff
2860
- },
2861
- });
2862
- ```
2863
-
2864
- - `timeoutMs` aborts slow attempts, including reading the response body (`AbortSignal.timeout`); the timeout signal is combined with the MCP client's cancellation signal. Client cancellation rejects during either fetch or body reading and is never retried.
2865
- - `retry` re-issues failed **queries only — never mutations** — after network errors/timeouts, HTTP 5xx and 429 responses. Backoff is linear: the first retry waits `backoffMs`, the second `2 × backoffMs`, and so on (default `backoffMs`: 250).
2866
- - Transport failures surface as `isError` tool results carrying GraphQL-shaped errors with the codes `MCP_REMOTE_HTTP_ERROR` (non-2xx response without a valid GraphQL body, includes `extensions.status`), `MCP_REMOTE_REQUEST_FAILED` (network error/timeout, including body-read failures) and `MCP_REMOTE_INVALID_RESPONSE` (non-JSON body or invalid GraphQL response shape). A valid body has object `data` or a nonempty `errors` array with string messages; `data: null` requires errors. Invalid successful HTTP responses are not retried. Client cancellation rejects instead of returning an error tool result.
2867
- - If the remote response carries a numeric `extensions.count` (e.g. exposed via `simfinity.plugins.envelopCountPlugin()`), it is surfaced as `_meta.count` on the tool result.
2868
-
2869
- `schemaPlugins` is not applied in remote mode — the remote GraphQL server enforces its own plugins.
2870
-
2871
- ### Tool results and errors
2872
-
2873
- Successful calls return `{ content: [textJSON], structuredContent: data, isError: false }`. Failures and edge cases behave as follows:
2874
-
2875
- - **Total count**: with `pagination: { count: true }` on a **list** tool, the count lands in `_meta.count` on the tool result — in-process it is read back from a per-call context layer (Simfinity's find resolver writes it there; counts never leak across calls), in remote mode from the response `extensions.count`. Aggregate tools never deliver a count.
2876
- - **Execution errors**: `isError: true` with a `text` payload of `{ errors, data? }` — partial data is preserved alongside errors when it fits `maxResultBytes`. `structuredContent` is omitted on errors.
2877
- - **Remote transport failures** and **limit violations** are also `isError` results (see [Remote execution](#remote-execution) and [Limits](#limits) for the codes).
2878
- - **Unknown tool names** are the exception: `callTool` and `getOperation` throw `MCP_TOOL_NOT_FOUND`. An aborted `extra.signal` throws `MCP_CALL_CANCELLED` before execution, including cancellation while awaiting an asynchronous context factory. This prevents starting the GraphQL operation after cancellation; it does not undo or cancel database work already started.
2879
-
2880
- ### Authentication
2881
-
2882
- The MCP tools execute the same resolvers as GraphQL, so the GraphQL `context` flows through unchanged: pass credentials via `options.context` (a value or a factory). [Query scope](#-query-scope) functions read the context on every execution and need no extra wiring.
2883
-
2884
- Field-level rules from [`createAuthPlugin`](#-authorization) are different. The auth plugin is an Envelop plugin that wraps resolvers from its `onSchemaChange` hook — a hook that only fires when an Envelop/Yoga server boots. A standalone MCP server executes operations with bare `graphql()`, so without help the hook never fires and the field-level rules are silently skipped. Pass the plugin via `schemaPlugins` to get auth parity: every plugin's `onSchemaChange` is invoked once before serving (in-process execution only):
2885
-
2886
- ```javascript
2887
- import * as simfinity from '@simtlix/simfinity-js';
2888
-
2889
- const { createAuthPlugin, requireAuth, requireRole } = simfinity.auth;
2890
-
2891
- // Permissions are keyed by the schema's type names. Simfinity names its
2892
- // roots `RootQueryType` and `Mutation`.
2893
- const permissions = {
2894
- RootQueryType: { books: requireAuth() },
2895
- Mutation: { deletebook: requireRole('admin') },
2896
- };
2897
-
2898
- const schema = simfinity.createSchema();
2899
- await simfinity.startStdioMCPServer(schema, {
2900
- // requireRole reads `user.role` by default (see rolePath to customize).
2901
- context: { user: { id: 'agent', role: 'admin' } },
2902
- schemaPlugins: [createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' })],
2903
- });
2904
- ```
2905
-
2906
- Mounting the MCP handler next to a running Envelop/Yoga server that shares the same `schema` object also works: Yoga fires `onSchemaChange` at startup and `createAuthPlugin` wraps the resolvers in place, so the MCP tools execute the already-wrapped schema.
2907
-
2908
- For per-request HTTP auth, use a function `context`: the HTTP handler invokes it as `(req, extra) => context`, so you can read the `Authorization` header from the Express `req` and map it to `context.user` exactly like your GraphQL server does.
2909
-
2910
- ## 📊 Aggregation Queries
2911
-
2912
- Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
2913
-
2914
- ### Overview
2915
-
2916
- For each entity type registered with `connect()`, an additional aggregation endpoint is automatically generated with the format `{entityname}_aggregate`.
2917
-
2918
- ### Features
2919
-
2920
- - **Group By**: Group results by any field (direct or related entity field path)
2921
- - **Aggregation Operations**: SUM, COUNT, AVG, MIN, MAX
2922
- - **Filtering**: Use the same filter parameters as regular queries
2923
- - **Sorting**: Sort by groupId or any calculated fact (metrics), with support for multiple sort fields
2924
- - **Pagination**: Use the same pagination parameters as regular queries
2925
- - **Related Entity Fields**: Group by or aggregate on fields from related entities using dot notation
2926
-
2927
- ### GraphQL Types
2928
-
2929
- #### QLAggregationOperation (Enum)
2930
- - `SUM`: Sum of numeric values
2931
- - `COUNT`: Count of records
2932
- - `AVG`: Average of numeric values
2933
- - `MIN`: Minimum value
2934
- - `MAX`: Maximum value
2935
-
2936
- #### QLTypeAggregationFact (Input)
2937
- ```graphql
2938
- input QLTypeAggregationFact {
2939
- operation: QLAggregationOperation!
2940
- factName: String!
2941
- path: String!
2942
- }
2943
- ```
2944
-
2945
- #### QLTypeAggregationExpression (Input)
2946
- ```graphql
2947
- input QLTypeAggregationExpression {
2948
- groupId: String!
2949
- facts: [QLTypeAggregationFact!]!
2950
- }
2951
- ```
2952
-
2953
- #### QLTypeAggregationResult (Output)
2954
- ```graphql
2955
- type QLTypeAggregationResult {
2956
- groupId: JSON
2957
- facts: JSON
2958
- }
2959
- ```
2960
-
2961
- ### Quick Examples
2962
-
2963
- #### Simple Group By
2964
- ```graphql
2965
- query {
2966
- series_aggregate(
2967
- aggregation: {
2968
- groupId: "category"
2969
- facts: [
2970
- { operation: COUNT, factName: "total", path: "id" }
2971
- ]
2972
- }
2973
- ) {
2974
- groupId
2975
- facts
2976
- }
2977
- }
2978
- ```
2979
-
2980
- #### Group By Related Entity
2981
- ```graphql
2982
- query {
2983
- series_aggregate(
2984
- aggregation: {
2985
- groupId: "country.name"
2986
- facts: [
2987
- { operation: COUNT, factName: "count", path: "id" }
2988
- { operation: AVG, factName: "avgRating", path: "rating" }
2989
- ]
2990
- }
2991
- ) {
2992
- groupId
2993
- facts
2994
- }
2995
- }
2996
- ```
2997
-
2998
- #### Multiple Aggregation Facts
2999
- ```graphql
3000
- query {
3001
- series_aggregate(
3002
- aggregation: {
3003
- groupId: "category"
3004
- facts: [
3005
- { operation: COUNT, factName: "total", path: "id" }
3006
- { operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
3007
- { operation: AVG, factName: "avgRating", path: "rating" }
3008
- { operation: MIN, factName: "minRating", path: "rating" }
3009
- { operation: MAX, factName: "maxRating", path: "rating" }
3010
- ]
3011
- }
3012
- ) {
3013
- groupId
3014
- facts
3015
- }
3016
- }
3017
- ```
3018
-
3019
- #### With Filtering
3020
- ```graphql
3021
- query {
3022
- series_aggregate(
3023
- rating: { operator: GTE, value: 8.0 }
3024
- aggregation: {
3025
- groupId: "category"
3026
- facts: [
3027
- { operation: COUNT, factName: "highRated", path: "id" }
3028
- ]
3029
- }
3030
- ) {
3031
- groupId
3032
- facts
3033
- }
3034
- }
3035
- ```
3036
-
3037
- #### Sorting by Multiple Fields
3038
- ```graphql
3039
- query {
3040
- series_aggregate(
3041
- sort: {
3042
- terms: [
3043
- { field: "total", order: "DESC" }, # Sort by count first
3044
- { field: "groupId", order: "ASC" } # Then by name
3045
- ]
3046
- }
3047
- aggregation: {
3048
- groupId: "category"
3049
- facts: [
3050
- { operation: COUNT, factName: "total", path: "id" }
3051
- { operation: AVG, factName: "avgRating", path: "rating" }
3052
- ]
3053
- }
3054
- ) {
3055
- groupId
3056
- facts
3057
- }
3058
- }
3059
- ```
3060
-
3061
- #### With Pagination (Top 5)
3062
- ```graphql
3063
- query {
3064
- series_aggregate(
3065
- sort: {
3066
- terms: [{ field: "total", order: "DESC" }]
3067
- }
3068
- pagination: {
3069
- page: 1
3070
- size: 5
3071
- }
3072
- aggregation: {
3073
- groupId: "category"
3074
- facts: [
3075
- { operation: COUNT, factName: "total", path: "id" }
3076
- ]
3077
- }
3078
- ) {
3079
- groupId
3080
- facts
3081
- }
3082
- }
3083
- ```
3084
-
3085
- ### Field Path Resolution
3086
-
3087
- The `groupId` and `path` parameters support:
3088
-
3089
- 1. **Direct Fields**: Simple field names from the entity
3090
- - Example: `"category"`, `"rating"`, `"id"`
3091
-
3092
- 2. **Related Entity Fields**: Dot notation for fields in related entities
3093
- - Example: `"country.name"`, `"studio.foundedYear"`
3094
-
3095
- 3. **Nested Related Entities**: Multiple levels of relationships
3096
- - Example: `"country.region.name"`
3097
-
3098
- ### Sorting Options
3099
-
3100
- - Sort by **groupId** or **any fact name**
3101
- - **Multiple sort fields supported** - results are sorted by the first field, then by the second field for ties, etc.
3102
- - Set the `field` parameter to:
3103
- - `"groupId"` to sort by the grouping field
3104
- - Any fact name (e.g., `"avgRating"`, `"total"`) to sort by that calculated metric
3105
- - The `order` parameter (ASC/DESC) determines the sort direction for each field
3106
- - If a field doesn't match groupId or any fact name, it defaults to groupId
3107
- - If no sort is specified, defaults to sorting by groupId ascending
3108
-
3109
- ### Pagination Notes
3110
-
3111
- - Explicit `page` and `size` must be positive safe integers, and `size` must fit the configured maximum (1000 by default). Invalid input produces `INVALID_PAGINATION` with status 400.
3112
- - The `count` parameter is **ignored** for aggregation queries
3113
- - Pagination is applied **after** grouping and sorting
3114
- - Aggregation queries remain unbounded when pagination is omitted; they do not inherit the list default limit.
3115
-
3116
- ### MongoDB Translation
3117
-
3118
- Aggregation queries are translated to efficient MongoDB aggregation pipelines:
3119
-
3120
- 1. **$lookup**: Joins with related entity collections
3121
- 2. **$unwind**: Flattens joined arrays
3122
- 3. **$match**: Applies filters (before grouping)
3123
- 4. **$group**: Groups by the specified field with aggregation operations
3124
- 5. **$project**: Formats final output with groupId and facts fields
3125
- 6. **$sort**: Sorts results by groupId or facts (with multiple fields support)
3126
- 7. **$limit** / **$skip**: Applied for pagination (after sorting)
3127
-
3128
- ### Result Structure
3129
-
3130
- Results are returned in a consistent format:
3131
- ```json
3132
- {
3133
- "groupId": <value>,
3134
- "facts": {
3135
- "factName1": <calculated_value>,
3136
- "factName2": <calculated_value>
3137
- }
3138
- }
3139
- ```
3140
-
3141
- For complete documentation with more examples, see [AGGREGATION_EXAMPLE.md](./AGGREGATION_EXAMPLE.md) and [AGGREGATION_CHANGES_SUMMARY.md](./AGGREGATION_CHANGES_SUMMARY.md).
3142
-
3143
- ## 📚 Complete Example
3144
-
3145
- Here's a complete bookstore example with relationships, validations, and state machines:
3146
-
3147
- ```javascript
3148
- const express = require('express');
3149
- const { graphqlHTTP } = require('express-graphql');
3150
- const mongoose = require('mongoose');
3151
- const {
3152
- GraphQLObjectType,
3153
- GraphQLString,
3154
- GraphQLNonNull,
3155
- GraphQLID,
3156
- GraphQLList,
3157
- GraphQLInt,
3158
- GraphQLEnumType
3159
- } = require('graphql');
3160
- const simfinity = require('@simtlix/simfinity-js');
3161
-
3162
- // Connect to MongoDB
3163
- mongoose.connect('mongodb://localhost:27017/bookstore', {
3164
- useNewUrlParser: true,
3165
- useUnifiedTopology: true,
3166
- });
3167
-
3168
- // Define Types
3169
- const AuthorType = new GraphQLObjectType({
3170
- name: 'Author',
3171
- fields: () => ({
3172
- id: { type: new GraphQLNonNull(GraphQLID) },
3173
- name: { type: new GraphQLNonNull(GraphQLString) },
3174
- email: { type: GraphQLString },
3175
- books: {
3176
- type: new GraphQLList(BookType),
3177
- extensions: {
3178
- relation: {
3179
- connectionField: 'author',
3180
- displayField: 'title'
3181
- },
3182
- },
3183
- resolve(parent) {
3184
- return simfinity.getModel(BookType).find({ author: parent.id });
3185
- }
3186
- },
3187
- }),
3188
- });
3189
-
3190
- const BookType = new GraphQLObjectType({
3191
- name: 'Book',
3192
- fields: () => ({
3193
- id: { type: new GraphQLNonNull(GraphQLID) },
3194
- title: {
3195
- type: new GraphQLNonNull(GraphQLString),
3196
- extensions: {
3197
- validations: {
3198
- save: [{
3199
- validate: async (typeName, fieldName, value, session) => {
3200
- if (!value || value.length < 2) {
3201
- throw new simfinity.SimfinityError('Title must be at least 2 characters', 'VALIDATION_ERROR', 400);
3202
- }
3203
- }
3204
- }]
3205
- }
3206
- }
3207
- },
3208
- pages: { type: GraphQLInt },
3209
- author: {
3210
- type: AuthorType,
3211
- extensions: {
3212
- relation: {
3213
- displayField: 'name'
3214
- },
3215
- },
3216
- resolve(parent) {
3217
- return simfinity.getModel(AuthorType).findById(parent.author);
3218
- }
3219
- },
3220
- }),
3221
- });
3222
-
3223
- // Define Controllers
3224
- const bookController = {
3225
- onSaving: async (doc, args, session) => {
3226
- console.log(`Creating book: ${doc.title}`);
3227
- },
3228
-
3229
- onSaved: async (doc, args, session) => {
3230
- console.log(`Book saved: ${doc.title}`);
3231
- }
3232
- };
3233
-
3234
- // Connect Types
3235
- simfinity.connect(null, AuthorType, 'author', 'authors');
3236
- simfinity.connect(null, BookType, 'book', 'books', bookController);
3237
-
3238
- // Create Schema
3239
- const schema = simfinity.createSchema();
3240
-
3241
- // Setup Express Server
3242
- const app = express();
3243
-
3244
- app.use('/graphql', graphqlHTTP({
3245
- schema,
3246
- graphiql: true,
3247
- formatError: simfinity.buildErrorFormatter((err) => {
3248
- console.log(err);
3249
- })
3250
- }));
3251
-
3252
- app.listen(4000, () => {
3253
- console.log('Bookstore API running on http://localhost:4000/graphql');
3254
- });
3255
- ```
3256
-
3257
- ## 🔗 Resources
3258
-
3259
- - **[Series Sample Project](https://github.com/simtlix/series-sample)** - A complete TV series microservice built with Simfinity.js demonstrating types, relationships, state machines, controllers, and authorization
3260
- - **[Samples Repository](https://github.com/simtlix/simfinity.js-samples)** - Complete examples and use cases
3261
- - **[MongoDB Query Language](https://docs.mongodb.com/manual/tutorial/query-documents/)** - Learn about MongoDB querying
3262
- - **[GraphQL Documentation](https://graphql.org/learn/)** - Learn about GraphQL
3263
-
3264
- ## 📄 License
3265
-
3266
- Apache-2.0 License - see the [LICENSE](LICENSE) file for details.
3267
-
3268
- ## 🤝 Contributing
3269
-
3270
- Contributions are welcome! Please feel free to submit a Pull Request.
3271
-
3272
- ---
3273
-
3274
- *Built with ❤️ by [Simtlix](https://github.com/simtlix)*
3275
-
3276
-
3277
- ## 📚 Query Examples from Series-Sample
3278
-
3279
- Here are some practical GraphQL query examples from the series-sample project, showcasing how to use simfinity.js effectively:
3280
-
3281
- ### 1. Series with Directors from a Specific Country
3282
-
3283
- Find all series that have directors from the United States:
3284
-
3285
- ```graphql
3286
- query {
3287
- series(director: {
3288
- terms: [
3289
- {
3290
- path: "country",
3291
- operator: EQ,
3292
- value: "United States"
3293
- }
3294
- ]
3295
- }) {
3296
- id
3297
- name
3298
- categories
3299
- director {
3300
- name
3301
- country
3302
- }
3303
- }
3304
- }
3305
- ```
3306
-
3307
- ### 2. Series with a Specific Episode Name
3308
-
3309
- Find series that contain an episode with the name "Pilot":
3310
-
3311
- ```graphql
3312
- query {
3313
- series(
3314
- seasons: {
3315
- terms: [
3316
- {
3317
- path: "episodes.name",
3318
- operator: EQ,
3319
- value: "Pilot"
3320
- }
3321
- ]
3322
- }
3323
- ) {
3324
- id
3325
- name
3326
- seasons {
3327
- number
3328
- episodes {
3329
- number
3330
- name
3331
- date
3332
- }
3333
- }
3334
- }
3335
- }
3336
- ```
3337
-
3338
- ### 3. Series with a Particular Star
3339
-
3340
- Find series that feature "Bryan Cranston":
3341
-
3342
- ```graphql
3343
- query {
3344
- assignedStarsAndSeries(star: {
3345
- terms: [
3346
- {
3347
- path: "name",
3348
- operator: EQ,
3349
- value: "Bryan Cranston"
3350
- }
3351
- ]
3352
- }) {
3353
- id
3354
- star {
3355
- name
3356
- }
3357
- serie {
3358
- id
3359
- name
3360
- categories
3361
- director {
3362
- name
3363
- country
3364
- }
3365
- }
3366
- }
3367
- }
3368
- ```
3369
-
3370
- ### 4. Seasons from Series with Directors from a Given Country
3371
-
3372
- Find all seasons that belong to series directed by someone from the United States:
3373
-
3374
- ```graphql
3375
- query {
3376
- seasons(serie: {
3377
- terms: [
3378
- {
3379
- path: "director.country",
3380
- operator: EQ,
3381
- value: "United States"
3382
- }
3383
- ]
3384
- }) {
3385
- id
3386
- number
3387
- year
3388
- state
3389
- serie {
3390
- name
3391
- categories
3392
- director {
3393
- name
3394
- country
3395
- }
3396
- }
3397
- episodes {
3398
- number
3399
- name
3400
- date
3401
- }
3402
- }
3403
- }
3404
- ```
3405
-
3406
- ### 5. Combining Scalar and ObjectType Filters
3407
-
3408
- Find series named "Breaking Bad" that have at least one season with number 1:
3409
-
3410
- ```graphql
3411
- query {
3412
- series(
3413
- name: {
3414
- operator: EQ,
3415
- value: "Breaking Bad"
3416
- }
3417
- seasons: {
3418
- terms: [
3419
- {
3420
- path: "number",
3421
- operator: EQ,
3422
- value: 1
3423
- }
3424
- ]
3425
- }
3426
- ) {
3427
- id
3428
- name
3429
- director {
3430
- name
3431
- country
3432
- }
3433
- seasons {
3434
- number
3435
- episodes {
3436
- name
3437
- }
3438
- }
3439
- }
3440
- }
3441
- ```
3442
-
3443
- ### 6. Complex Nested Queries
3444
-
3445
- Get complete information for a specific series:
3446
-
3447
- ```graphql
3448
- query {
3449
- series(name: {
3450
- operator: EQ,
3451
- value: "Breaking Bad"
3452
- }) {
3453
- id
3454
- name
3455
- categories
3456
- director {
3457
- name
3458
- country
3459
- }
3460
- seasons {
3461
- number
3462
- year
3463
- state
3464
- episodes {
3465
- number
3466
- name
3467
- date
3468
- }
3469
- }
3470
- }
3471
- }
3472
- ```
3473
-
3474
- ### 7. Episodes from a Specific Season and Series
3475
-
3476
- Find all episodes from Season 1 of Breaking Bad:
3477
-
3478
- ```graphql
3479
- query {
3480
- episodes(season: {
3481
- terms: [
3482
- {
3483
- path: "number",
3484
- operator: EQ,
3485
- value: 1
3486
- },
3487
- {
3488
- path: "serie.name",
3489
- operator: EQ,
3490
- value: "Breaking Bad"
3491
- }
3492
- ]
3493
- }) {
3494
- id
3495
- number
3496
- name
3497
- date
3498
- season {
3499
- number
3500
- serie {
3501
- name
3502
- }
3503
- }
3504
- }
3505
- }
3506
- ```
3507
-
3508
- ### 8. Series by Category
3509
-
3510
- Find all crime series:
3511
-
3512
- ```graphql
3513
- query {
3514
- series(categories: {
3515
- operator: EQ,
3516
- value: "Crime"
3517
- }) {
3518
- id
3519
- name
3520
- categories
3521
- director {
3522
- name
3523
- country
3524
- }
3525
- }
3526
- }
3527
- ```
3528
-
3529
- ### 9. Search by Partial Episode Name
3530
-
3531
- Find episodes containing "Fire" in the name:
3532
-
3533
- ```graphql
3534
- query {
3535
- episodes(name: {
3536
- operator: LIKE,
3537
- value: "Fire"
3538
- }) {
3539
- id
3540
- number
3541
- name
3542
- date
3543
- season {
3544
- number
3545
- serie {
3546
- name
3547
- }
3548
- }
3549
- }
3550
- }
3551
- ```
3552
-
3553
- ### 10. Pagination
3554
-
3555
- Simfinity.js supports built-in pagination with optional total count:
3556
-
3557
- Configure the process-wide maximum once during application startup:
3558
-
3559
- ```javascript
3560
- import * as simfinity from '@simtlix/simfinity-js';
3561
-
3562
- simfinity.configureQueryLimits({ maxPageSize: 500 });
3563
- ```
3564
-
3565
- The default maximum is **1000**. Explicit `page` and `size` must be positive safe integers, `size` must not exceed the maximum, and the computed skip must remain a safe integer. Invalid pagination throws `INVALID_PAGINATION` (400); invalid configuration throws `INVALID_QUERY_LIMITS` (400). With no pagination, a list returns at most `Math.min(100, maxPageSize)` records. Any positive safe-integer maximum is allowed, including values below 100. Call `configureQueryLimits()` to restore the default maximum. This deliberately tightens previously unbounded explicit page sizes; applications needing larger pages can configure an appropriate maximum. Unpaginated aggregate queries retain their existing behavior.
3566
-
3567
- ```graphql
3568
- query {
3569
- series(
3570
- categories: {
3571
- operator: EQ,
3572
- value: "Crime"
3573
- }
3574
- pagination: {
3575
- page: 1,
3576
- size: 2,
3577
- count: true
3578
- }
3579
- ) {
3580
- id
3581
- name
3582
- categories
3583
- director {
3584
- name
3585
- country
3586
- }
3587
- }
3588
- }
3589
- ```
3590
-
3591
- #### Pagination Parameters:
3592
- - **page**: Page number (starts at 1, not 0)
3593
- - **size**: Number of items per page
3594
- - **count**: Optional boolean - if `true`, returns total count of matching records
3595
-
3596
- #### Getting Total Count:
3597
- When `count: true` is specified, the total count is available in the response extensions. You need to configure a plugin to expose it. Simfinity.js provides utility plugins for both Apollo Server and Envelop:
3598
-
3599
- ```javascript
3600
- const simfinity = require('@simtlix/simfinity-js');
3601
-
3602
- // For Envelop
3603
- const getEnveloped = envelop({
3604
- plugins: [
3605
- useSchema(schema),
3606
- simfinity.plugins.envelopCountPlugin(),
3607
- ],
3608
- });
3609
-
3610
- // For Apollo Server
3611
- const server = new ApolloServer({
3612
- schema,
3613
- plugins: [
3614
- simfinity.plugins.apolloCountPlugin(),
3615
- ],
3616
- });
3617
- ```
3618
-
3619
- See the [Plugins for Count in Extensions](#-plugins-for-count-in-extensions) section for complete examples.
3620
-
3621
- #### Example Response:
3622
- ```json
3623
- {
3624
- "data": {
3625
- "series": [
3626
- {
3627
- "id": "1",
3628
- "name": "Breaking Bad",
3629
- "categories": ["Crime", "Drama"],
3630
- "director": {
3631
- "name": "Vince Gilligan",
3632
- "country": "United States"
3633
- }
3634
- },
3635
- {
3636
- "id": "2",
3637
- "name": "Better Call Saul",
3638
- "categories": ["Crime", "Drama"],
3639
- "director": {
3640
- "name": "Vince Gilligan",
3641
- "country": "United States"
3642
- }
3643
- }
3644
- ]
3645
- },
3646
- "extensions": {
3647
- "count": 15
3648
- }
3649
- }
3650
- ```
3651
-
3652
- ### 11. Sorting
3653
-
3654
- List sort fields must resolve to declared scalar or enum paths, and every term requires `ASC` or `DESC`. Root `id` and related `author.id` sort by their stored `_id` paths. Embedded paths remain dotted, and repeated sort terms through one relationship reuse the same lookup. Supply at least one term; invalid sort input is rejected before executing the list query.
3655
-
3656
- Simfinity.js supports sorting with multiple fields and sort orders:
3657
-
3658
- ```graphql
3659
- query {
3660
- series(
3661
- categories: { operator: EQ, value: "Crime" }
3662
- pagination: { page: 1, size: 5, count: true }
3663
- sort: {
3664
- terms: [
3665
- {
3666
- field: "name",
3667
- order: DESC
3668
- }
3669
- ]
3670
- }
3671
- ) {
3672
- id
3673
- name
3674
- categories
3675
- director {
3676
- name
3677
- country
3678
- }
3679
- }
3680
- }
3681
- ```
3682
-
3683
- #### Sorting Parameters:
3684
- - **sort**: Contains sorting configuration
3685
- - **terms**: Array of sort criteria (allows multiple sort fields)
3686
- - **field**: The field name to sort by
3687
- - **order**: Sort order - `ASC` (ascending) or `DESC` (descending)
3688
-
3689
- #### Sorting by Nested Fields:
3690
- You can sort by fields from related/nested objects using dot notation:
3691
-
3692
- ```graphql
3693
- query {
3694
- series(
3695
- categories: { operator: EQ, value: "Drama" }
3696
- pagination: { page: 1, size: 5, count: true }
3697
- sort: {
3698
- terms: [
3699
- {
3700
- field: "director.name",
3701
- order: DESC
3702
- }
3703
- ]
3704
- }
3705
- ) {
3706
- id
3707
- name
3708
- categories
3709
- director {
3710
- name
3711
- country
3712
- }
3713
- }
3714
- }
3715
- ```
3716
-
3717
- #### Multiple Sort Fields:
3718
- You can sort by multiple fields with different orders:
3719
-
3720
- ```graphql
3721
- query {
3722
- series(
3723
- sort: {
3724
- terms: [
3725
- { field: "director.country", order: ASC },
3726
- { field: "name", order: DESC }
3727
- ]
3728
- }
3729
- ) {
3730
- id
3731
- name
3732
- director {
3733
- name
3734
- country
3735
- }
3736
- }
3737
- }
3738
- ```
3739
-
3740
- #### Combining Features:
3741
- The example above demonstrates combining **filtering**, **pagination**, and **sorting** in a single query - a common pattern for data tables and lists with full functionality.
3742
-
3743
- ### 12. Series Released in a Specific Year Range
3744
-
3745
- Find series with seasons released between 2010-2015:
3746
-
3747
- ```graphql
3748
- query {
3749
- seasons(year: {
3750
- operator: BETWEEN,
3751
- value: [2010, 2015]
3752
- }) {
3753
- id
3754
- number
3755
- year
3756
- serie {
3757
- name
3758
- director {
3759
- name
3760
- country
3761
- }
3762
- }
3763
- }
3764
- }
3765
- ```
3766
-
3767
-
3768
- ## 🔄 State Machine Example from Series-Sample
3769
-
3770
- Simfinity.js provides built-in state machine support for managing entity lifecycles. Here's an example of how a state machine is implemented in the Season entity from the series-sample project.
3771
-
3772
- ### State Machine Configuration
3773
-
3774
- State machines require **GraphQL Enum Types** to define states and proper state references:
3775
-
3776
- **Step 1: Define the GraphQL Enum Type**
3777
-
3778
- ```javascript
3779
- const { GraphQLEnumType } = require('graphql');
3780
-
3781
- const seasonState = new GraphQLEnumType({
3782
- name: 'seasonState',
3783
- values: {
3784
- SCHEDULED: { value: 'SCHEDULED' },
3785
- ACTIVE: { value: 'ACTIVE' },
3786
- FINISHED: { value: 'FINISHED' }
3787
- }
3788
- });
3789
- ```
3790
-
3791
- **Step 2: Use Enum in GraphQL Object Type**
3792
-
3793
- ```javascript
3794
- const seasonType = new GraphQLObjectType({
3795
- name: 'season',
3796
- fields: () => ({
3797
- id: { type: GraphQLID },
3798
- number: { type: GraphQLInt },
3799
- year: { type: GraphQLInt },
3800
- state: { type: seasonState }, // ← Use the enum type
3801
- // ... other fields
3802
- })
3803
- });
3804
- ```
3805
-
3806
- **Step 3: Define State Machine with Enum Values**
3807
-
3808
- ```javascript
3809
- const stateMachine = {
3810
- initialState: seasonState.getValue('SCHEDULED'),
3811
- actions: {
3812
- activate: {
3813
- from: seasonState.getValue('SCHEDULED'),
3814
- to: seasonState.getValue('ACTIVE'),
3815
- action: async (params) => {
3816
- console.log('Season activated:', JSON.stringify(params));
3817
- }
3818
- },
3819
- finalize: {
3820
- from: seasonState.getValue('ACTIVE'),
3821
- to: seasonState.getValue('FINISHED'),
3822
- action: async (params) => {
3823
- console.log('Season finalized:', JSON.stringify(params));
3824
- }
3825
- }
3826
- }
3827
- };
3828
-
3829
- // Connect type with state machine
3830
- simfinity.connect(null, seasonType, 'season', 'seasons', null, null, stateMachine);
3831
- ```
3832
-
3833
- ### Season States
3834
-
3835
- The Season entity has three states:
3836
-
3837
- 1. **SCHEDULED** - Initial state when season is created
3838
- 2. **ACTIVE** - Season is currently airing
3839
- 3. **FINISHED** - Season has completed airing
3840
-
3841
- ### State Transitions
3842
-
3843
- **Available transitions:**
3844
- - `activate`: SCHEDULED → ACTIVE
3845
- - `finalize`: ACTIVE → FINISHED
3846
-
3847
- ### State Machine Mutations
3848
-
3849
- Simfinity.js automatically generates state transition mutations:
3850
-
3851
- ```graphql
3852
- # Activate a scheduled season
3853
- mutation {
3854
- activateseason(id: "season_id_here") {
3855
- id
3856
- number
3857
- year
3858
- state
3859
- serie {
3860
- name
3861
- }
3862
- }
3863
- }
3864
- ```
3865
-
3866
- ```graphql
3867
- # Finalize an active season
3868
- mutation {
3869
- finalizeseason(id: "season_id_here") {
3870
- id
3871
- number
3872
- year
3873
- state
3874
- serie {
3875
- name
3876
- }
3877
- }
3878
- }
3879
- ```
3880
-
3881
- ### State Machine Features
3882
-
3883
- **Validation:**
3884
- - Only valid transitions are allowed
3885
- - Attempting invalid transitions returns an error
3886
- - State field is read-only (managed by state machine)
3887
-
3888
- **Custom Actions:**
3889
- - Each transition can execute custom business logic
3890
- - Actions receive parameters including entity data
3891
- - Actions can perform side effects (logging, notifications, etc.)
3892
-
3893
- **Query by State:**
3894
- ```graphql
3895
- query {
3896
- seasons(state: {
3897
- operator: EQ,
3898
- value: ACTIVE
3899
- }) {
3900
- id
3901
- number
3902
- year
3903
- state
3904
- serie {
3905
- name
3906
- }
3907
- }
3908
- }
3909
- ```
3910
-
3911
- ### State Machine Best Practices
3912
-
3913
- 1. **GraphQL Enum Types**: Always define states as GraphQL enums for type safety
3914
- 2. **getValue() Method**: Use `enumType.getValue('VALUE')` for state machine configuration
3915
- 3. **Initial State**: Define clear initial state using enum values
3916
- 4. **Linear Flows**: Design logical progression (SCHEDULED → ACTIVE → FINISHED)
3917
- 5. **Type Safety**: GraphQL enums provide validation and autocomplete
3918
- 6. **Actions**: Implement side effects in transition actions
3919
- 7. **Error Handling**: Handle transition failures gracefully
3920
-
3921
- ### Key Implementation Points
3922
-
3923
- - **Enum Definition**: States must be defined as `GraphQLEnumType`
3924
- - **Type Reference**: Use the enum type in your GraphQL object: `state: { type: seasonState }`
3925
- - **State Machine Values**: Reference enum values with `seasonState.getValue('STATE_NAME')`
3926
- - **Automatic Validation**: GraphQL validates state values against the enum
3927
- - **IDE Support**: Enum values provide autocomplete and type checking
3928
-
3929
- ### Example Workflow
3930
-
3931
- ```graphql
3932
- # 1. Create season (automatically SCHEDULED)
3933
- mutation {
3934
- addseason(input: {
3935
- number: 6
3936
- year: 2024
3937
- serie: "series_id_here"
3938
- }) {
3939
- id
3940
- state # Will be "SCHEDULED"
3941
- }
3942
- }
3943
-
3944
- # 2. Activate season when airing begins
3945
- mutation {
3946
- activateseason(id: "season_id_here") {
3947
- id
3948
- state # Will be "ACTIVE"
3949
- }
3950
- }
3951
-
3952
- # 3. Finalize season when completed
3953
- mutation {
3954
- finalizeseason(id: "season_id_here") {
3955
- id
3956
- state # Will be "FINISHED"
3957
- }
3958
- }
3959
- ```
3960
-
3961
-
3962
- ## 📦 Plugins for Count in Extensions
3963
-
3964
- To include the total count in the extensions of your GraphQL response, Simfinity.js provides utility plugins for both Apollo Server and Envelop. This is particularly useful for pagination and analytics.
3965
-
3966
- ### Envelop Plugin
3967
-
3968
- Use `simfinity.plugins.envelopCountPlugin()` to add count to extensions when using Envelop:
3969
-
3970
- ```javascript
3971
- const { envelop, useSchema } = require('@envelop/core');
3972
- const { makeExecutableSchema } = require('@graphql-tools/schema');
3973
- const simfinity = require('@simtlix/simfinity-js');
3974
-
3975
- const schema = makeExecutableSchema({
3976
- typeDefs,
3977
- resolvers,
3978
- });
3979
-
3980
- const getEnveloped = envelop({
3981
- plugins: [
3982
- useSchema(schema),
3983
- simfinity.plugins.envelopCountPlugin(), // Add the count plugin here
3984
- ],
3985
- });
3986
-
3987
- // Use getEnveloped in your server setup
3988
- ```
3989
-
3990
- ### Apollo Server Plugin
3991
-
3992
- Use `simfinity.plugins.apolloCountPlugin()` to add count to extensions when using Apollo Server:
3993
-
3994
- ```javascript
3995
- const { ApolloServer } = require('apollo-server-express');
3996
- const simfinity = require('@simtlix/simfinity-js');
3997
-
3998
- const server = new ApolloServer({
3999
- schema,
4000
- plugins: [
4001
- simfinity.plugins.apolloCountPlugin(), // Add the count plugin here
4002
- ],
4003
- context: ({ req }) => {
4004
- // Your context setup
4005
- return {
4006
- user: req.user,
4007
- // count will be automatically added to extensions if present in context
4008
- };
4009
- },
4010
- });
4011
- ```
4012
-
4013
- ### How to Use
4014
-
4015
- 1. **Import the Plugin**: Use `simfinity.plugins.envelopCountPlugin()` or `simfinity.plugins.apolloCountPlugin()` depending on your GraphQL server.
4016
- 2. **Configure Context**: Ensure that your context includes the count value when executing queries (Simfinity.js automatically sets `context.count` when `count: true` is specified in pagination).
4017
- 3. **Access Count**: The count will be available in the `extensions` field of the GraphQL response.
4018
-
4019
- ### Example Response
4020
-
4021
- When the plugin is correctly set up, your GraphQL response will include the count in the extensions:
4022
-
4023
- ```json
4024
- {
4025
- "data": {
4026
- "series": [
4027
- {
4028
- "id": "1",
4029
- "name": "Breaking Bad",
4030
- "categories": ["Crime", "Drama"],
4031
- "director": {
4032
- "name": "Vince Gilligan",
4033
- "country": "United States"
4034
- }
4035
- }
4036
- ]
4037
- },
4038
- "extensions": {
4039
- "count": 15
4040
- }
4041
- }
4042
- ```
4043
-
4044
- This setup allows you to efficiently manage and display pagination information in your GraphQL applications.
4045
-
4046
- ## 📖 API Reference
4047
-
4048
- Simfinity.js provides several utility methods for programmatic access to your GraphQL types and data:
4049
-
4050
- ### `getType(typeName)`
4051
-
4052
- Retrieves a GraphQL type definition from the internal types registry.
4053
-
4054
- **Parameters:**
4055
- - `typeName` (string | GraphQLObjectType): The name of the type or a GraphQL type object
4056
-
4057
- **Returns:**
4058
- - `GraphQLObjectType | null`: The GraphQL type definition, or null if not found
4059
-
4060
- **Examples:**
4061
-
4062
- ```javascript
4063
- import { getType } from '@simtlix/simfinity-js';
4064
-
4065
- // Get type by string name
4066
- const UserType = getType('User');
4067
- if (UserType) {
4068
- console.log(UserType.name); // 'User'
4069
-
4070
- // Access field definitions
4071
- const fields = UserType.getFields();
4072
- console.log(Object.keys(fields)); // ['id', 'name', 'email', ...]
4073
-
4074
- // Check specific field
4075
- const nameField = fields.name;
4076
- console.log(nameField.type); // GraphQLString
4077
- }
4078
-
4079
- // Get type by GraphQL type object
4080
- const BookType = getType(SomeBookType);
4081
-
4082
- // Safe access - returns null if not found
4083
- const nonExistentType = getType('NonExistent');
4084
- console.log(nonExistentType); // null
4085
- ```
4086
-
4087
- **Use Cases:**
4088
- - **Type introspection**: Examine type definitions programmatically
4089
- - **Dynamic schema analysis**: Build tools that analyze your GraphQL schema
4090
- - **Runtime type checking**: Validate types exist before operations
4091
- - **Admin interfaces**: Build dynamic forms based on type definitions
4092
- - **Circular reference resolution**: Prevent import cycles when types reference each other
4093
-
4094
- ### Preventing Circular References with `getType`
4095
-
4096
- When you have types that reference each other (like User and Group), using `getType` prevents circular import issues:
4097
-
4098
- ```javascript
4099
- import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
4100
- import { getType } from '@simtlix/simfinity-js';
4101
-
4102
- // User type that references Group
4103
- const UserType = new GraphQLObjectType({
4104
- name: 'User',
4105
- fields: () => ({
4106
- id: { type: GraphQLID },
4107
- name: { type: GraphQLString },
4108
- email: { type: GraphQLString },
4109
-
4110
- // Reference Group type by name to avoid circular imports
4111
- groups: {
4112
- type: new GraphQLList(() => getType('Group')), // Use getType instead of direct import
4113
- extensions: {
4114
- relation: {
4115
- connectionField: 'members',
4116
- displayField: 'name'
4117
- }
4118
- }
4119
- },
4120
-
4121
- // Single group reference
4122
- primaryGroup: {
4123
- type: () => getType('Group'), // Lazy evaluation with getType
4124
- extensions: {
4125
- relation: {
4126
- connectionField: 'primaryGroupId',
4127
- displayField: 'name'
4128
- }
4129
- }
4130
- }
4131
- })
4132
- });
4133
-
4134
- // Group type that references User
4135
- const GroupType = new GraphQLObjectType({
4136
- name: 'Group',
4137
- fields: () => ({
4138
- id: { type: GraphQLID },
4139
- name: { type: GraphQLString },
4140
- description: { type: GraphQLString },
4141
-
4142
- // Reference User type by name to avoid circular imports
4143
- members: {
4144
- type: new GraphQLList(() => getType('User')), // Use getType instead of direct import
4145
- extensions: {
4146
- relation: {
4147
- connectionField: 'groups',
4148
- displayField: 'name'
4149
- }
4150
- }
4151
- },
4152
-
4153
- // Single user reference (admin)
4154
- admin: {
4155
- type: () => getType('User'), // Lazy evaluation with getType
4156
- extensions: {
4157
- relation: {
4158
- connectionField: 'adminId',
4159
- displayField: 'name'
4160
- }
4161
- }
4162
- }
4163
- })
4164
- });
4165
-
4166
- // Register types with simfinity
4167
- simfinity.connect(null, UserType, 'user', 'users');
4168
- simfinity.connect(null, GroupType, 'group', 'groups');
4169
-
4170
- // Create schema - resolvers will be auto-generated for all relationships
4171
- const schema = simfinity.createSchema();
4172
- ```
4173
-
4174
- **Benefits of this approach:**
4175
-
4176
- 1. **🔄 No Circular Imports**: Each file can import `getType` without importing other type definitions
4177
- 2. **⚡ Lazy Resolution**: Types are resolved at schema creation time when all types are registered
4178
- 3. **🛡️ Type Safety**: Still maintains GraphQL type checking and validation
4179
- 4. **🧹 Clean Architecture**: Separates type definitions from type relationships
4180
- 5. **📦 Better Modularity**: Each type can be in its own file without import dependencies
4181
-
4182
- **File Structure Example:**
4183
-
4184
- ```
4185
- types/
4186
- ├── User.js // Defines UserType using getType('Group')
4187
- ├── Group.js // Defines GroupType using getType('User')
4188
- └── index.js // Registers all types and creates schema
4189
- ```
4190
-
4191
- ```javascript
4192
- // types/User.js
4193
- import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
4194
- import { getType } from '@simtlix/simfinity-js';
4195
-
4196
- export const UserType = new GraphQLObjectType({
4197
- name: 'User',
4198
- fields: () => ({
4199
- id: { type: GraphQLID },
4200
- name: { type: GraphQLString },
4201
- groups: {
4202
- type: new GraphQLList(() => getType('Group')),
4203
- extensions: { relation: { connectionField: 'members' } }
4204
- }
4205
- })
4206
- });
4207
-
4208
- // types/Group.js
4209
- import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
4210
- import { getType } from '@simtlix/simfinity-js';
4211
-
4212
- export const GroupType = new GraphQLObjectType({
4213
- name: 'Group',
4214
- fields: () => ({
4215
- id: { type: GraphQLID },
4216
- name: { type: GraphQLString },
4217
- members: {
4218
- type: new GraphQLList(() => getType('User')),
4219
- extensions: { relation: { connectionField: 'groups' } }
4220
- }
4221
- })
4222
- });
4223
-
4224
- // types/index.js
4225
- import { UserType } from './User.js';
4226
- import { GroupType } from './Group.js';
4227
- import simfinity from '@simtlix/simfinity-js';
4228
-
4229
- // Register all types
4230
- simfinity.connect(null, UserType, 'user', 'users');
4231
- simfinity.connect(null, GroupType, 'group', 'groups');
4232
-
4233
- // Create schema with auto-generated resolvers
4234
- export const schema = simfinity.createSchema();
4235
- ```
4236
-
4237
- ### `getModel(gqltype)`
4238
-
4239
- Retrieves the Mongoose model associated with a GraphQL type.
4240
-
4241
- **Parameters:**
4242
- - `gqltype` (GraphQLObjectType): The GraphQL type object
4243
-
4244
- **Returns:**
4245
- - `MongooseModel`: The associated Mongoose model
4246
-
4247
- **Example:**
4248
-
4249
- ```javascript
4250
- const BookModel = simfinity.getModel(BookType);
4251
- const books = await BookModel.find({ author: 'Douglas Adams' });
4252
- ```
4253
-
4254
- ### `getInputType(type)`
4255
-
4256
- Retrieves the input type for mutations associated with a GraphQL type.
4257
-
4258
- **Parameters:**
4259
- - `type` (GraphQLObjectType): The GraphQL type object
4260
-
4261
- **Returns:**
4262
- - `GraphQLInputObjectType`: The input type for mutations
4263
-
4264
- **Example:**
4265
-
4266
- ```javascript
4267
- const BookInput = simfinity.getInputType(BookType);
4268
- console.log(BookInput.getFields()); // Input fields for mutations
4269
- ```
4270
-
4271
- ### `saveObject(typeName, args, session?, context?)`
4272
-
4273
- Programmatically save an object through the creation pipeline, including validation, controllers, and nested writes.
4274
-
4275
- **Parameters:**
4276
- - `typeName` (string): The name of the GraphQL type
4277
- - `args` (object): The data to save
4278
- - `session` (MongooseSession, optional): Caller-owned session with an active transaction, from the same MongoDB client as the registered model
4279
- - `context` (object, optional): GraphQL context object (includes request info, user data, etc.)
4280
-
4281
- **Returns:**
4282
- - `Promise<object>`: The saved object
4283
-
4284
- Without a session, `saveObject()` starts a transaction on the registered model's connection, commits the parent and nested writes together, and awaits session cleanup. The same bounded transaction and commit retries described above apply. This requires a transaction-capable MongoDB deployment, such as a replica set.
4285
-
4286
- With a session, it participates in the caller's active transaction. It never starts, commits, aborts, retries, or ends that caller-owned transaction/session; the caller must handle failures and finish the transaction. An inactive supplied session is rejected with `ACTIVE_TRANSACTION_REQUIRED` (400) before writes. Pass the provided session when calling from a controller or custom mutation so the writes share its transaction. Direct calls bypass GraphQL input coercion, field authorization, and global middleware.
4287
-
4288
- **Example:**
4289
-
4290
- ```javascript
4291
- const newBook = await simfinity.saveObject('Book', {
4292
- title: 'New Book',
4293
- author: 'Author Name'
4294
- }, session, context);
4295
-
4296
- // Without context (context will be undefined in controller hooks)
4297
- const newBook = await simfinity.saveObject('Book', {
4298
- title: 'New Book',
4299
- author: 'Author Name'
4300
- }, session);
4301
- ```
4302
-
4303
- **Note**: When `context` is not provided, it will be `undefined` in controller hooks. This is acceptable for programmatic usage where context may not be available.
4304
-
4305
- ### `createSchema(includedQueryTypes?, includedMutationTypes?, includedCustomMutations?)`
4306
-
4307
- Creates the final GraphQL schema with all connected types.
4308
-
4309
- **Parameters:**
4310
- - `includedQueryTypes` (array, optional): Limit query types to include
4311
- - `includedMutationTypes` (array, optional): Limit mutation types to include
4312
- - `includedCustomMutations` (array, optional): Limit custom mutations to include
4313
-
4314
- **Returns:**
4315
- - `GraphQLSchema`: The complete GraphQL schema
4316
-
4317
- **Example:**
4318
-
4319
- ```javascript
4320
- const schema = simfinity.createSchema();
4321
- ```
41
+ The source now lives in `packages/mongodb` in the monorepo. Its public npm name remains `@simtlix/simfinity-js`; existing imports, including paths under `@simtlix/simfinity-js/src/`, retain their layout. Shared helpers, error identities and MCP compatibility exports are preserved. Internal Simfinity dependencies use the same exact release version.
4322
42
 
4323
- *Built with ❤️ by [Simtlix](https://github.com/simtlix)*
43
+ - [MongoDB quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html)
44
+ - [API reference](https://simtlix.github.io/simfinity.js/reference/api.html)
45
+ - [Database comparison](https://simtlix.github.io/simfinity.js/guide/databases.html)
46
+ - [MCP integration](https://simtlix.github.io/simfinity.js/guide/mcp.html)
47
+ - [Complete library documentation](https://github.com/simtlix/simfinity.js#readme)
48
+ - [Aggregation examples](./AGGREGATION_EXAMPLE.md)