@simtlix/simfinity-js 2.4.1 → 2.4.4

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
@@ -16,31 +16,20 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
16
16
  - [Automatic Mutation Generation](#automatic-mutation-generation)
17
17
  - [Filtering and Querying](#filtering-and-querying)
18
18
  - [Collection Field Filtering](#collection-field-filtering)
19
- - [Middlewares](#-middlewares)
20
- - [Adding Middlewares](#adding-middlewares)
21
- - [Middleware Parameters](#middleware-parameters)
22
- - [Common Use Cases](#common-use-cases)
23
- - [Authorization](#-authorization)
24
- - [Quick Start](#quick-start-1)
25
- - [Permission Schema](#permission-schema)
26
- - [Rule Helpers](#rule-helpers)
27
- - [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
28
- - [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
29
- - [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
30
19
  - [Relationships](#-relationships)
31
20
  - [Defining Relationships](#defining-relationships)
32
21
  - [Auto-Generated Resolve Methods](#auto-generated-resolve-methods)
33
22
  - [Adding Types Without Endpoints](#adding-types-without-endpoints)
34
23
  - [Embedded vs Referenced Relationships](#embedded-vs-referenced-relationships)
35
24
  - [Querying Relationships](#querying-relationships)
36
- - [Controllers & Lifecycle Hooks](#️-controllers--lifecycle-hooks)
37
- - [Hook Parameters](#hook-parameters)
38
- - [State Machines](#-state-machines)
39
25
  - [Validations](#-validations)
40
26
  - [Field-Level Validations](#field-level-validations)
41
27
  - [Type-Level Validations](#type-level-validations)
42
28
  - [Custom Validated Scalar Types](#custom-validated-scalar-types)
43
29
  - [Custom Error Classes](#custom-error-classes)
30
+ - [State Machines](#-state-machines)
31
+ - [Controllers & Lifecycle Hooks](#️-controllers--lifecycle-hooks)
32
+ - [Hook Parameters](#hook-parameters)
44
33
  - [Query Scope](#-query-scope)
45
34
  - [Overview](#overview)
46
35
  - [Defining Scope](#defining-scope)
@@ -48,6 +37,17 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
48
37
  - [Scope for Aggregate Operations](#scope-for-aggregate-operations)
49
38
  - [Scope for Get By ID Operations](#scope-for-get-by-id-operations)
50
39
  - [Scope Function Parameters](#scope-function-parameters)
40
+ - [Authorization](#-authorization)
41
+ - [Quick Start](#quick-start-1)
42
+ - [Permission Schema](#permission-schema)
43
+ - [Rule Helpers](#rule-helpers)
44
+ - [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
45
+ - [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
46
+ - [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
47
+ - [Middlewares](#-middlewares)
48
+ - [Adding Middlewares](#adding-middlewares)
49
+ - [Middleware Parameters](#middleware-parameters)
50
+ - [Common Use Cases](#common-use-cases)
51
51
  - [Advanced Features](#-advanced-features)
52
52
  - [Field Extensions](#field-extensions)
53
53
  - [Custom Mutations](#custom-mutations)
@@ -58,10 +58,6 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
58
58
  - [Resources](#-resources)
59
59
  - [License](#-license)
60
60
  - [Contributing](#-contributing)
61
- - [Query Examples from Series-Sample](#-query-examples-from-series-sample)
62
- - [State Machine Example from Series-Sample](#-state-machine-example-from-series-sample)
63
- - [Plugins for Count in Extensions](#-plugins-for-count-in-extensions)
64
- - [API Reference](#-api-reference)
65
61
 
66
62
  ## ✨ Features
67
63
 
@@ -175,6 +171,8 @@ query {
175
171
  }
176
172
  ```
177
173
 
174
+ > For a full working application, see the [Series Sample Project](https://github.com/simtlix/series-sample) -- a complete TV series microservice with types, relationships, state machines, controllers, and authorization.
175
+
178
176
  ## 🔧 Core Concepts
179
177
 
180
178
  ### Connecting Models
@@ -383,1005 +381,813 @@ query {
383
381
 
384
382
  **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.
385
383
 
386
- ## 🔧 Middlewares
387
-
388
- 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.
384
+ ## 🔗 Relationships
389
385
 
390
- ### Adding Middlewares
386
+ ### Defining Relationships
391
387
 
392
- Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered:
388
+ Use the `extensions.relation` field to define relationships between types:
393
389
 
394
390
  ```javascript
395
- // Basic logging middleware
396
- simfinity.use((params, next) => {
397
- console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
398
- next();
391
+ const AuthorType = new GraphQLObjectType({
392
+ name: 'Author',
393
+ fields: () => ({
394
+ id: { type: new GraphQLNonNull(GraphQLID) },
395
+ name: { type: new GraphQLNonNull(GraphQLString) },
396
+ books: {
397
+ type: new GraphQLList(BookType),
398
+ extensions: {
399
+ relation: {
400
+ connectionField: 'author',
401
+ displayField: 'title'
402
+ },
403
+ },
404
+ // resolve method automatically generated! 🎉
405
+ },
406
+ }),
399
407
  });
400
- ```
401
-
402
- ### Middleware Parameters
403
408
 
404
- Each middleware receives a `params` object containing:
405
-
406
- ```javascript
407
- simfinity.use((params, next) => {
408
- // params object contains:
409
- const {
410
- type, // Type information (model, gqltype, controller, etc.)
411
- args, // GraphQL arguments passed to the operation
412
- operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
413
- context, // GraphQL context object (includes request info, user data, etc.)
414
- actionName, // For state machine actions (only present for state_changed operations)
415
- actionField, // State machine action details (only present for state_changed operations)
416
- entry // Custom mutation name (only present for custom_mutation operations)
417
- } = params;
418
-
419
- // Always call next() to continue the middleware chain
420
- next();
409
+ const BookType = new GraphQLObjectType({
410
+ name: 'Book',
411
+ fields: () => ({
412
+ id: { type: new GraphQLNonNull(GraphQLID) },
413
+ title: { type: new GraphQLNonNull(GraphQLString) },
414
+ author: {
415
+ type: AuthorType,
416
+ extensions: {
417
+ relation: {
418
+ displayField: 'name'
419
+ },
420
+ },
421
+ // resolve method automatically generated! 🎉
422
+ },
423
+ }),
421
424
  });
422
425
  ```
423
426
 
424
- ### Common Use Cases
427
+ ### Relationship Configuration
425
428
 
426
- #### 1. Authentication & Authorization
429
+ - `connectionField`: **(Required for collections)** The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from the GraphQL field name.
430
+ - `displayField`: **(Optional)** Field to use for display in UI components
431
+ - `embedded`: **(Optional)** Whether the relation is embedded (default: false)
427
432
 
428
- ```javascript
429
- simfinity.use((params, next) => {
430
- const { context, operation, type } = params;
431
-
432
- // Skip authentication for read operations
433
- if (operation === 'get_by_id' || operation === 'find') {
434
- return next();
435
- }
436
-
437
- // Check if user is authenticated
438
- if (!context.user) {
439
- throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
440
- }
441
-
442
- // Check permissions for specific types
443
- if (type?.name === 'User' && context.user.role !== 'admin') {
444
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
445
- }
446
-
447
- next();
448
- });
449
- ```
433
+ ### Auto-Generated Resolve Methods
450
434
 
451
- #### 2. Request Logging & Monitoring
435
+ 🎉 **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
436
+
437
+ #### Before (Manual Resolvers)
452
438
 
453
439
  ```javascript
454
- simfinity.use((params, next) => {
455
- const { operation, type, args, context } = params;
456
- const startTime = Date.now();
457
-
458
- console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
459
-
460
- // Continue with the operation
461
- next();
462
-
463
- const duration = Date.now() - startTime;
464
- console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
440
+ const BookType = new GraphQLObjectType({
441
+ name: 'Book',
442
+ fields: () => ({
443
+ id: { type: new GraphQLNonNull(GraphQLID) },
444
+ title: { type: new GraphQLNonNull(GraphQLString) },
445
+ author: {
446
+ type: AuthorType,
447
+ extensions: {
448
+ relation: {
449
+ displayField: 'name'
450
+ },
451
+ },
452
+ // You had to manually write this
453
+ resolve(parent) {
454
+ return simfinity.getModel(AuthorType).findById(parent.author);
455
+ }
456
+ },
457
+ comments: {
458
+ type: new GraphQLList(CommentType),
459
+ extensions: {
460
+ relation: {
461
+ connectionField: 'bookId',
462
+ displayField: 'text'
463
+ },
464
+ },
465
+ // You had to manually write this too
466
+ resolve(parent) {
467
+ return simfinity.getModel(CommentType).find({ bookId: parent.id });
468
+ }
469
+ }
470
+ }),
465
471
  });
466
472
  ```
467
473
 
468
- #### 3. Input Validation & Sanitization
474
+ #### After (Auto-Generated Resolvers)
469
475
 
470
476
  ```javascript
471
- simfinity.use((params, next) => {
472
- const { operation, args, type } = params;
473
-
474
- // Validate input for save operations
475
- if (operation === 'save' && args.input) {
476
- // Trim string fields
477
- Object.keys(args.input).forEach(key => {
478
- if (typeof args.input[key] === 'string') {
479
- args.input[key] = args.input[key].trim();
480
- }
481
- });
482
-
483
- // Validate required business rules
484
- if (type?.name === 'Book' && args.input.title && args.input.title.length < 3) {
485
- throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
477
+ const BookType = new GraphQLObjectType({
478
+ name: 'Book',
479
+ fields: () => ({
480
+ id: { type: new GraphQLNonNull(GraphQLID) },
481
+ title: { type: new GraphQLNonNull(GraphQLString) },
482
+ author: {
483
+ type: AuthorType,
484
+ extensions: {
485
+ relation: {
486
+ displayField: 'name'
487
+ },
488
+ },
489
+ // resolve method automatically generated! 🎉
490
+ },
491
+ comments: {
492
+ type: new GraphQLList(CommentType),
493
+ extensions: {
494
+ relation: {
495
+ connectionField: 'bookId',
496
+ displayField: 'text'
497
+ },
498
+ },
499
+ // resolve method automatically generated! 🎉
486
500
  }
487
- }
488
-
489
- next();
501
+ }),
490
502
  });
491
503
  ```
492
504
 
493
- #### 4. Rate Limiting
505
+ #### How It Works
506
+
507
+ - **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField`
508
+ - **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
509
+ - **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
510
+ - **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
511
+ - **Type Safety**: Clear error messages if related types aren't properly connected
512
+
513
+ #### Connect Your Types
494
514
 
495
515
  ```javascript
496
- const requestCounts = new Map();
516
+ // Connect all your types to Simfinity
517
+ simfinity.connect(null, AuthorType, 'author', 'authors');
518
+ simfinity.connect(null, BookType, 'book', 'books');
519
+ simfinity.connect(null, CommentType, 'comment', 'comments');
497
520
 
498
- simfinity.use((params, next) => {
499
- const { context, operation } = params;
500
- const userId = context.user?.id || context.ip;
501
- const now = Date.now();
502
- const windowMs = 60000; // 1 minute
503
- const maxRequests = 100;
504
-
505
- // Only apply rate limiting to mutations
506
- if (operation === 'save' || operation === 'update' || operation === 'delete') {
507
- const userRequests = requestCounts.get(userId) || [];
508
- const recentRequests = userRequests.filter(time => now - time < windowMs);
509
-
510
- if (recentRequests.length >= maxRequests) {
511
- throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
512
- }
513
-
514
- recentRequests.push(now);
515
- requestCounts.set(userId, recentRequests);
516
- }
517
-
518
- next();
519
- });
521
+ // Or use addNoEndpointType for types that don't need direct queries/mutations
522
+ simfinity.addNoEndpointType(AuthorType);
520
523
  ```
521
524
 
522
- #### 5. Audit Trail
525
+ That's it! All relationship resolvers are automatically generated when you connect your types.
526
+
527
+ ### Adding Types Without Endpoints
528
+
529
+ Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations:
523
530
 
524
531
  ```javascript
525
- simfinity.use((params, next) => {
526
- const { operation, type, args, context } = params;
527
-
528
- // Log all mutations for audit purposes
529
- if (operation === 'save' || operation === 'update' || operation === 'delete') {
530
- const auditEntry = {
531
- timestamp: new Date(),
532
- user: context.user?.id,
533
- operation,
534
- type: type?.name,
535
- entityId: args.id || 'new',
536
- data: operation === 'delete' ? null : args.input,
537
- ip: context.ip,
538
- userAgent: context.userAgent
539
- };
540
-
541
- // Save to audit log (could be database, file, or external service)
542
- console.log('AUDIT:', JSON.stringify(auditEntry));
543
- }
544
-
545
- next();
546
- });
532
+ simfinity.addNoEndpointType(TypeName);
547
533
  ```
548
534
 
549
- ### Multiple Middlewares
535
+ **When to use `addNoEndpointType()` vs `connect()`:**
550
536
 
551
- Middlewares execute in registration order. Each middleware must call `next()` to continue the chain:
537
+ | Method | Use Case | Creates Endpoints | Use Example |
538
+ |--------|----------|-------------------|-------------|
539
+ | `connect()` | Types that need CRUD operations | ✅ Yes | User, Product, Order |
540
+ | `addNoEndpointType()` | Types only used in relationships | ❌ No | Address, Settings, Director |
552
541
 
553
- ```javascript
554
- // Middleware 1: Authentication
555
- simfinity.use((params, next) => {
556
- console.log('1. Checking authentication...');
557
- // Authentication logic here
558
- next(); // Continue to next middleware
559
- });
542
+ #### Perfect Example: TV Series with Embedded Director
560
543
 
561
- // Middleware 2: Authorization
562
- simfinity.use((params, next) => {
563
- console.log('2. Checking permissions...');
564
- // Authorization logic here
565
- next(); // Continue to next middleware
566
- });
544
+ From the [series-sample](https://github.com/simtlix/series-sample) project:
567
545
 
568
- // Middleware 3: Logging
569
- simfinity.use((params, next) => {
570
- console.log('3. Logging request...');
571
- // Logging logic here
572
- next(); // Continue to GraphQL operation
546
+ ```javascript
547
+ // Director type - Used only as embedded data, no direct API access needed
548
+ const directorType = new GraphQLObjectType({
549
+ name: 'director',
550
+ fields: () => ({
551
+ id: { type: GraphQLID },
552
+ name: { type: new GraphQLNonNull(GraphQLString) },
553
+ country: { type: GraphQLString }
554
+ })
573
555
  });
574
- ```
575
556
 
576
- ### Error Handling in Middlewares
577
-
578
- Middlewares can throw errors to stop the operation:
557
+ // Add to schema WITHOUT creating endpoints
558
+ simfinity.addNoEndpointType(directorType);
579
559
 
580
- ```javascript
581
- simfinity.use((params, next) => {
582
- const { context, operation } = params;
583
-
584
- try {
585
- // Validation logic
586
- if (!context.user && operation !== 'find') {
587
- throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
560
+ // Serie type - Has its own endpoints and embeds director data
561
+ const serieType = new GraphQLObjectType({
562
+ name: 'serie',
563
+ fields: () => ({
564
+ id: { type: GraphQLID },
565
+ name: { type: new GraphQLNonNull(GraphQLString) },
566
+ categories: { type: new GraphQLList(GraphQLString) },
567
+ director: {
568
+ type: new GraphQLNonNull(directorType),
569
+ extensions: {
570
+ relation: {
571
+ embedded: true, // Director data stored within serie document
572
+ displayField: 'name'
573
+ }
574
+ }
588
575
  }
589
-
590
- next(); // Continue only if validation passes
591
- } catch (error) {
592
- // Error automatically bubbles up to GraphQL error handling
593
- throw error;
594
- }
576
+ })
595
577
  });
596
- ```
597
578
 
598
- ### Conditional Middleware Execution
579
+ // Create full CRUD endpoints for series
580
+ simfinity.connect(null, serieType, 'serie', 'series');
581
+ ```
599
582
 
600
- Execute middleware logic conditionally based on operation type or context:
583
+ **Result:**
584
+ - ✅ `addserie`, `updateserie`, `deleteserie` mutations available
585
+ - ✅ `serie`, `series` queries available
586
+ - ❌ No `adddirector`, `director`, `directors` endpoints (director is embedded)
601
587
 
602
- ```javascript
603
- simfinity.use((params, next) => {
604
- const { operation, type, context } = params;
605
-
606
- // Only apply to specific types
607
- if (type?.name === 'SensitiveData') {
608
- // Special handling for sensitive data
609
- if (!context.user?.hasHighSecurity) {
610
- throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
588
+ **Usage:**
589
+ ```graphql
590
+ mutation {
591
+ addserie(input: {
592
+ name: "Breaking Bad"
593
+ categories: ["crime", "drama", "thriller"]
594
+ director: {
595
+ name: "Vince Gilligan"
596
+ country: "United States"
597
+ }
598
+ }) {
599
+ id
600
+ name
601
+ director {
602
+ name
603
+ country
611
604
  }
612
605
  }
613
-
614
- // Only apply to mutation operations
615
- if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
616
- // Mutation-specific logic
617
- console.log(`Mutation ${operation} executing...`);
618
- }
619
-
620
- next();
621
- });
606
+ }
622
607
  ```
623
608
 
624
- ### Best Practices
625
-
626
- 1. **Always call `next()`**: Failing to call `next()` will hang the request
627
- 2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
628
- 3. **Keep middlewares focused**: Each middleware should handle one concern
629
- 4. **Order matters**: Register middlewares in logical order (auth → validation → logging)
630
- 5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
631
- 6. **Use context wisely**: Store request-specific data in the GraphQL context object
609
+ #### When to Use Each Approach
632
610
 
633
- ## 🔐 Authorization
611
+ **Use `addNoEndpointType()` for:**
612
+ - Simple data objects with few fields
613
+ - Data that doesn't need CRUD operations
614
+ - Objects that belong to a single parent (1:1 relationships)
615
+ - Configuration or settings objects
616
+ - **Examples**: Address, Director info, Product specifications
634
617
 
635
- 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.
618
+ **Use `connect()` for:**
619
+ - Complex entities that need their own endpoints
620
+ - Data that needs CRUD operations
621
+ - Objects shared between multiple parents (many:many relationships)
622
+ - Objects with business logic (controllers, state machines)
623
+ - **Examples**: User, Product, Order, Season, Episode
636
624
 
637
- ### Quick Start
625
+ ### Embedded vs Referenced Relationships
638
626
 
627
+ **Referenced Relationships** (default):
639
628
  ```javascript
640
- const { auth } = require('@simtlix/simfinity-js');
641
- const { createYoga } = require('graphql-yoga');
642
-
643
- const { createAuthPlugin, requireAuth, requireRole } = auth;
644
-
645
- // Define your permission schema
646
- const permissions = {
647
- Query: {
648
- users: requireAuth(),
649
- adminDashboard: requireRole('ADMIN'),
650
- },
651
- Mutation: {
652
- publishPost: requireRole('EDITOR'),
653
- },
654
- User: {
655
- '*': requireAuth(), // Wildcard: all fields require auth
656
- email: requireRole('ADMIN'), // Override: email requires ADMIN role
657
- },
658
- Post: {
659
- '*': requireAuth(),
660
- content: async (post, _args, ctx) => {
661
- // Custom logic: allow if published OR if author
662
- if (post.published) return true;
663
- if (post.authorId === ctx.user?.id) return true;
664
- return false;
665
- },
666
- },
667
- };
629
+ // Stores author ID in the book document
630
+ author: {
631
+ type: AuthorType,
632
+ extensions: {
633
+ relation: {
634
+ // connectionField not needed for single object relationships
635
+ embedded: false // This is the default
636
+ }
637
+ }
638
+ }
639
+ ```
668
640
 
669
- // Create the Envelop auth plugin and pass it to your server
670
- const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
671
- const yoga = createYoga({ schema, plugins: [authPlugin] });
641
+ **Embedded Relationships**:
642
+ ```javascript
643
+ // Stores the full publisher object in the book document
644
+ publisher: {
645
+ type: PublisherType,
646
+ extensions: {
647
+ relation: {
648
+ embedded: true
649
+ }
650
+ }
651
+ }
672
652
  ```
673
653
 
674
- ### Permission Schema
654
+ ### Querying Relationships
675
655
 
676
- The permission schema defines authorization rules per type and field:
656
+ Query nested relationships with dot notation:
677
657
 
678
- ```javascript
679
- const permissions = {
680
- // Operation types (Query, Mutation, Subscription)
681
- Query: {
682
- fieldName: ruleOrRules,
683
- },
684
-
685
- // Object types
686
- TypeName: {
687
- '*': wildcardRule, // Applies to all fields unless overridden
688
- fieldName: specificRule, // Overrides wildcard for this field
689
- },
690
- };
658
+ ```graphql
659
+ query {
660
+ books(author: {
661
+ terms: [
662
+ {
663
+ path: "country.name",
664
+ operator: EQ,
665
+ value: "England"
666
+ }
667
+ ]
668
+ }) {
669
+ id
670
+ title
671
+ author {
672
+ name
673
+ country {
674
+ name
675
+ }
676
+ }
677
+ }
678
+ }
691
679
  ```
692
680
 
693
- **Resolution Order:**
694
- 1. Check exact field rule: `permissions[TypeName][fieldName]`
695
- 2. Fallback to wildcard: `permissions[TypeName]['*']`
696
- 3. Apply default policy (ALLOW or DENY)
681
+ ### Creating Objects with Relationships
697
682
 
698
- **Rule Types:**
699
- - **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
700
- - **Array of functions**: All rules must pass (AND logic)
701
- - **Policy expression**: JSON AST object (see below)
702
-
703
- **Rule Semantics:**
704
- - `return true` or `return void` → allow
705
- - `return false` → deny
706
- - `throw Error` → deny with error
707
-
708
- ### Rule Helpers
709
-
710
- Simfinity.js provides reusable rule builders:
711
-
712
- ```javascript
713
- const { auth } = require('@simtlix/simfinity-js');
714
-
715
- const {
716
- resolvePath, // Utility to resolve dotted paths in objects
717
- requireAuth, // Requires ctx.user to exist
718
- requireRole, // Requires specific role(s)
719
- requirePermission, // Requires specific permission(s)
720
- composeRules, // Combine rules (AND logic)
721
- anyRule, // Combine rules (OR logic)
722
- isOwner, // Check resource ownership
723
- allow, // Always allow
724
- deny, // Always deny
725
- createRule, // Create custom rule
726
- } = auth;
727
- ```
728
-
729
- #### requireAuth(userPath?)
730
-
731
- Requires the user to be authenticated. Supports custom user paths in context:
732
-
733
- ```javascript
734
- const permissions = {
735
- Query: {
736
- // Default: checks ctx.user
737
- me: requireAuth(),
738
-
739
- // Custom path: checks ctx.auth.currentUser
740
- profile: requireAuth('auth.currentUser'),
741
-
742
- // Deep path: checks ctx.session.data.user
743
- settings: requireAuth('session.data.user'),
744
- },
745
- };
746
- ```
747
-
748
- #### requireRole(role, options?)
749
-
750
- Requires the user to have a specific role. Supports custom paths:
751
-
752
- ```javascript
753
- const permissions = {
754
- Query: {
755
- // Default: checks ctx.user.role
756
- adminDashboard: requireRole('ADMIN'),
757
- modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
758
-
759
- // Custom paths: checks ctx.auth.user.profile.role
760
- superAdmin: requireRole('SUPER_ADMIN', {
761
- userPath: 'auth.user',
762
- rolePath: 'profile.role',
763
- }),
764
- },
765
- };
683
+ **Link to existing objects:**
684
+ ```graphql
685
+ mutation {
686
+ addBook(input: {
687
+ title: "New Book"
688
+ author: {
689
+ id: "existing_author_id"
690
+ }
691
+ }) {
692
+ id
693
+ title
694
+ author {
695
+ name
696
+ }
697
+ }
698
+ }
766
699
  ```
767
700
 
768
- #### requirePermission(permission, options?)
769
-
770
- Requires the user to have specific permission(s). Supports custom paths:
771
-
772
- ```javascript
773
- const permissions = {
774
- Mutation: {
775
- // Default: checks ctx.user.permissions
776
- deletePost: requirePermission('posts:delete'),
777
- manageUsers: requirePermission(['users:read', 'users:write']), // All required
778
-
779
- // Custom paths: checks ctx.session.user.access.grants
780
- admin: requirePermission('admin:all', {
781
- userPath: 'session.user',
782
- permissionsPath: 'access.grants',
783
- }),
784
- },
785
- };
701
+ **Create embedded objects:**
702
+ ```graphql
703
+ mutation {
704
+ addBook(input: {
705
+ title: "New Book"
706
+ publisher: {
707
+ name: "Penguin Books"
708
+ location: "London"
709
+ }
710
+ }) {
711
+ id
712
+ title
713
+ publisher {
714
+ name
715
+ location
716
+ }
717
+ }
718
+ }
786
719
  ```
787
720
 
788
- #### composeRules(...rules)
721
+ ### Collection Fields
789
722
 
790
- Combines multiple rules with AND logic (all must pass):
723
+ Work with arrays of related objects:
791
724
 
792
- ```javascript
793
- const permissions = {
794
- Mutation: {
795
- updatePost: composeRules(
796
- requireAuth(),
797
- requireRole('EDITOR'),
798
- async (post, args, ctx) => post.authorId === ctx.user.id,
799
- ),
800
- },
801
- };
725
+ ```graphql
726
+ mutation {
727
+ updateBook(input: {
728
+ id: "book_id"
729
+ reviews: {
730
+ added: [
731
+ { rating: 5, comment: "Amazing!" }
732
+ { rating: 4, comment: "Good read" }
733
+ ]
734
+ updated: [
735
+ { id: "review_id", rating: 3 }
736
+ ]
737
+ deleted: ["review_id_to_delete"]
738
+ }
739
+ }) {
740
+ id
741
+ title
742
+ reviews {
743
+ rating
744
+ comment
745
+ }
746
+ }
747
+ }
802
748
  ```
803
749
 
804
- #### anyRule(...rules)
805
-
806
- Combines multiple rules with OR logic (any must pass):
750
+ ## ✅ Validations
807
751
 
808
- ```javascript
809
- const permissions = {
810
- Post: {
811
- content: anyRule(
812
- requireRole('ADMIN'),
813
- async (post, args, ctx) => post.authorId === ctx.user.id,
814
- ),
815
- },
816
- };
817
- ```
752
+ ### Declarative Validation Helpers
818
753
 
819
- #### isOwner(ownerField, userIdField)
754
+ Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
820
755
 
821
- Checks if the authenticated user owns the resource:
756
+ #### Using Validators
822
757
 
823
758
  ```javascript
824
- const permissions = {
825
- Post: {
826
- '*': composeRules(
827
- requireAuth(),
828
- isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
829
- ),
830
- },
831
- };
832
- ```
833
-
834
- ### Policy Expressions (JSON AST)
835
-
836
- For declarative rules, use JSON AST policy expressions:
759
+ const { validators } = require('@simtlix/simfinity-js');
837
760
 
838
- ```javascript
839
- const permissions = {
840
- Post: {
841
- content: {
842
- anyOf: [
843
- { eq: [{ ref: 'parent.published' }, true] },
844
- { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
845
- ],
761
+ const PersonType = new GraphQLObjectType({
762
+ name: 'Person',
763
+ fields: () => ({
764
+ id: { type: GraphQLID },
765
+ name: {
766
+ type: GraphQLString,
767
+ extensions: {
768
+ validations: validators.stringLength('Name', 2, 100)
769
+ }
846
770
  },
847
- },
848
- };
849
- ```
850
-
851
- **Supported Operators:**
852
-
853
- | Operator | Description | Example |
854
- |----------|-------------|---------|
855
- | `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
856
- | `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
857
- | `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
858
- | `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
859
- | `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
860
-
861
- **References:**
862
-
863
- Use `{ ref: 'path' }` to reference values:
864
- - `parent.*` - Parent resolver result (the object being resolved)
865
- - `args.*` - GraphQL arguments
866
- - `ctx.*` - GraphQL context
867
-
868
- **Security:**
869
- - Only `parent`, `args`, and `ctx` roots are allowed
870
- - Unknown operators fail closed (deny)
871
- - No `eval()` or `Function()` - pure object traversal
872
-
873
- ### Integration with GraphQL Yoga / Envelop
874
-
875
- 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.
876
-
877
- ```javascript
878
- const { createYoga } = require('graphql-yoga');
879
- const { createServer } = require('http');
880
- const simfinity = require('@simtlix/simfinity-js');
881
-
882
- const { auth } = simfinity;
883
- const { createAuthPlugin, requireAuth, requireRole, requirePermission } = auth;
884
-
885
- // Define your types and connect them
886
- simfinity.connect(null, UserType, 'user', 'users');
887
- simfinity.connect(null, PostType, 'post', 'posts');
888
-
889
- // Create base schema
890
- const schema = simfinity.createSchema();
891
-
892
- // Define permissions
893
- const permissions = {
894
- Query: {
895
- users: requireAuth(),
896
- user: requireAuth(),
897
- posts: requireAuth(),
898
- post: requireAuth(),
899
- },
900
- Mutation: {
901
- adduser: requireRole('ADMIN'),
902
- updateuser: requireRole('ADMIN'),
903
- deleteuser: requireRole('ADMIN'),
904
- addpost: requireAuth(),
905
- updatepost: composeRules(requireAuth(), isOwner('authorId')),
906
- deletepost: requireRole('ADMIN'),
907
- },
908
- User: {
909
- '*': requireAuth(),
910
- email: requireRole('ADMIN'),
911
- password: deny('Password field is not accessible'),
912
- },
913
- Post: {
914
- '*': requireAuth(),
915
- content: {
916
- anyOf: [
917
- { eq: [{ ref: 'parent.published' }, true] },
918
- { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
919
- ],
771
+ email: {
772
+ type: GraphQLString,
773
+ extensions: {
774
+ validations: validators.email()
775
+ }
920
776
  },
921
- },
922
- };
923
-
924
- // Create auth plugin
925
- const authPlugin = createAuthPlugin(permissions, {
926
- defaultPolicy: 'DENY',
927
- debug: false,
928
- });
929
-
930
- // Setup Yoga with the auth plugin
931
- const yoga = createYoga({
932
- schema,
933
- plugins: [authPlugin],
934
- context: (req) => ({
935
- user: req.user, // Set by your authentication layer
936
- }),
937
- });
938
-
939
- const server = createServer(yoga);
940
- server.listen(4000);
941
- ```
942
-
943
- ### Legacy: Integration with graphql-middleware
944
-
945
- > **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
946
- > which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
947
- > Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
948
-
949
- ```javascript
950
- const { applyMiddleware } = require('graphql-middleware');
951
- const simfinity = require('@simtlix/simfinity-js');
952
-
953
- const { auth } = simfinity;
954
- const { createAuthMiddleware, requireAuth, requireRole } = auth;
955
-
956
- const baseSchema = simfinity.createSchema();
957
-
958
- const authMiddleware = createAuthMiddleware(permissions, {
959
- defaultPolicy: 'DENY',
777
+ website: {
778
+ type: GraphQLString,
779
+ extensions: {
780
+ validations: validators.url()
781
+ }
782
+ },
783
+ age: {
784
+ type: GraphQLInt,
785
+ extensions: {
786
+ validations: validators.numberRange('Age', 0, 120)
787
+ }
788
+ },
789
+ price: {
790
+ type: GraphQLFloat,
791
+ extensions: {
792
+ validations: validators.positive('Price')
793
+ }
794
+ }
795
+ })
960
796
  });
961
-
962
- const schema = applyMiddleware(baseSchema, authMiddleware);
963
797
  ```
964
798
 
965
- ### Plugin / Middleware Options
966
-
967
- ```javascript
968
- const plugin = createAuthPlugin(permissions, {
969
- defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
970
- debug: false, // Enable debug logging
971
- });
972
- ```
799
+ #### Available Validators
973
800
 
974
- | Option | Type | Default | Description |
975
- |--------|------|---------|-------------|
976
- | `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
977
- | `debug` | `boolean` | `false` | Log authorization decisions |
801
+ **String Validators:**
802
+ - `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
803
+ - `validators.maxLength(name, max)` - Validates maximum string length
804
+ - `validators.pattern(name, regex, message)` - Validates against a regex pattern
805
+ - `validators.email()` - Validates email format
806
+ - `validators.url()` - Validates URL format
978
807
 
979
- ### Error Handling
808
+ **Number Validators:**
809
+ - `validators.numberRange(name, min, max)` - Validates number range
810
+ - `validators.positive(name)` - Ensures number is positive
980
811
 
981
- The auth middleware uses Simfinity error classes:
812
+ **Array Validators:**
813
+ - `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
982
814
 
983
- ```javascript
984
- const { auth } = require('@simtlix/simfinity-js');
815
+ **Date Validators:**
816
+ - `validators.dateFormat(name, format)` - Validates date format
817
+ - `validators.futureDate(name)` - Ensures date is in the future
985
818
 
986
- const { UnauthenticatedError, ForbiddenError } = auth;
819
+ #### Validator Features
987
820
 
988
- // UnauthenticatedError: code 'UNAUTHENTICATED', status 401
989
- // ForbiddenError: code 'FORBIDDEN', status 403
990
- ```
821
+ - **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
822
+ - **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
823
+ - **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
991
824
 
992
- Custom error handling in rules:
825
+ #### Example: Multiple Validators
993
826
 
994
827
  ```javascript
995
- const permissions = {
996
- Mutation: {
997
- deleteAccount: async (parent, args, ctx) => {
998
- if (!ctx.user) {
999
- throw new auth.UnauthenticatedError('Please log in');
1000
- }
1001
- if (ctx.user.role !== 'ADMIN' && ctx.user.id !== args.id) {
1002
- throw new auth.ForbiddenError('Cannot delete other users');
828
+ const ProductType = new GraphQLObjectType({
829
+ name: 'Product',
830
+ fields: () => ({
831
+ id: { type: GraphQLID },
832
+ name: {
833
+ type: GraphQLString,
834
+ extensions: {
835
+ validations: validators.stringLength('Product Name', 3, 200)
1003
836
  }
1004
- return true;
1005
837
  },
1006
- },
1007
- };
1008
- ```
1009
-
1010
- ### Best Practices
1011
-
1012
- 1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
1013
- 2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
1014
- 3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
1015
- 4. **Fail closed**: Custom rules should deny on unexpected conditions
1016
- 5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
1017
- 6. **Test thoroughly**: Auth rules are critical - test all scenarios
1018
-
1019
- ## 🔗 Relationships
1020
-
1021
- ### Defining Relationships
1022
-
1023
- Use the `extensions.relation` field to define relationships between types:
1024
-
1025
- ```javascript
1026
- const AuthorType = new GraphQLObjectType({
1027
- name: 'Author',
1028
- fields: () => ({
1029
- id: { type: new GraphQLNonNull(GraphQLID) },
1030
- name: { type: new GraphQLNonNull(GraphQLString) },
1031
- books: {
1032
- type: new GraphQLList(BookType),
838
+ sku: {
839
+ type: GraphQLString,
1033
840
  extensions: {
1034
- relation: {
1035
- connectionField: 'author',
1036
- displayField: 'title'
1037
- },
1038
- },
1039
- // resolve method automatically generated! 🎉
841
+ validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
842
+ }
1040
843
  },
1041
- }),
1042
- });
1043
-
1044
- const BookType = new GraphQLObjectType({
1045
- name: 'Book',
1046
- fields: () => ({
1047
- id: { type: new GraphQLNonNull(GraphQLID) },
1048
- title: { type: new GraphQLNonNull(GraphQLString) },
1049
- author: {
1050
- type: AuthorType,
844
+ price: {
845
+ type: GraphQLFloat,
1051
846
  extensions: {
1052
- relation: {
1053
- displayField: 'name'
1054
- },
1055
- },
1056
- // resolve method automatically generated! 🎉
847
+ validations: validators.positive('Price')
848
+ }
1057
849
  },
1058
- }),
850
+ tags: {
851
+ type: new GraphQLList(GraphQLString),
852
+ extensions: {
853
+ validations: validators.arrayLength('Tags', 10)
854
+ }
855
+ }
856
+ })
1059
857
  });
1060
858
  ```
1061
859
 
1062
- ### Relationship Configuration
1063
-
1064
- - `connectionField`: **(Required for collections)** The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from the GraphQL field name.
1065
- - `displayField`: **(Optional)** Field to use for display in UI components
1066
- - `embedded`: **(Optional)** Whether the relation is embedded (default: false)
860
+ ### Field-Level Validations (Manual)
1067
861
 
1068
- ### Auto-Generated Resolve Methods
862
+ For custom validation logic, you can still write manual validators:
1069
863
 
1070
- 🎉 **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
864
+ ```javascript
865
+ const { SimfinityError } = require('@simtlix/simfinity-js');
1071
866
 
1072
- #### Before (Manual Resolvers)
867
+ const validateAge = {
868
+ validate: async (typeName, fieldName, value, session) => {
869
+ if (value < 0 || value > 120) {
870
+ throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
871
+ }
872
+ }
873
+ };
1073
874
 
1074
- ```javascript
1075
- const BookType = new GraphQLObjectType({
1076
- name: 'Book',
875
+ const PersonType = new GraphQLObjectType({
876
+ name: 'Person',
1077
877
  fields: () => ({
1078
- id: { type: new GraphQLNonNull(GraphQLID) },
1079
- title: { type: new GraphQLNonNull(GraphQLString) },
1080
- author: {
1081
- type: AuthorType,
878
+ id: { type: GraphQLID },
879
+ name: {
880
+ type: GraphQLString,
1082
881
  extensions: {
1083
- relation: {
1084
- displayField: 'name'
1085
- },
1086
- },
1087
- // You had to manually write this
1088
- resolve(parent) {
1089
- return simfinity.getModel(AuthorType).findById(parent.author);
882
+ validations: {
883
+ save: [{
884
+ validate: async (typeName, fieldName, value, session) => {
885
+ if (!value || value.length < 2) {
886
+ throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
887
+ }
888
+ }
889
+ }],
890
+ update: [{
891
+ validate: async (typeName, fieldName, value, session) => {
892
+ if (value && value.length < 2) {
893
+ throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
894
+ }
895
+ }
896
+ }]
897
+ }
1090
898
  }
1091
899
  },
1092
- comments: {
1093
- type: new GraphQLList(CommentType),
900
+ age: {
901
+ type: GraphQLInt,
1094
902
  extensions: {
1095
- relation: {
1096
- connectionField: 'bookId',
1097
- displayField: 'text'
1098
- },
1099
- },
1100
- // You had to manually write this too
1101
- resolve(parent) {
1102
- return simfinity.getModel(CommentType).find({ bookId: parent.id });
903
+ validations: {
904
+ save: [validateAge],
905
+ update: [validateAge]
906
+ }
1103
907
  }
1104
908
  }
1105
- }),
909
+ })
1106
910
  });
1107
911
  ```
1108
912
 
1109
- #### After (Auto-Generated Resolvers)
913
+ ### Type-Level Validations
914
+
915
+ Validate objects as a whole:
1110
916
 
1111
917
  ```javascript
1112
- const BookType = new GraphQLObjectType({
1113
- name: 'Book',
1114
- fields: () => ({
1115
- id: { type: new GraphQLNonNull(GraphQLID) },
1116
- title: { type: new GraphQLNonNull(GraphQLString) },
1117
- author: {
1118
- type: AuthorType,
1119
- extensions: {
1120
- relation: {
1121
- displayField: 'name'
1122
- },
1123
- },
1124
- // resolve method automatically generated! 🎉
1125
- },
1126
- comments: {
1127
- type: new GraphQLList(CommentType),
1128
- extensions: {
1129
- relation: {
1130
- connectionField: 'bookId',
1131
- displayField: 'text'
1132
- },
1133
- },
1134
- // resolve method automatically generated! 🎉
918
+ const orderValidator = {
919
+ validate: async (typeName, args, modelArgs, session) => {
920
+ // Cross-field validation
921
+ if (modelArgs.deliveryDate < modelArgs.orderDate) {
922
+ throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
1135
923
  }
1136
- }),
924
+
925
+ // Business rule validation
926
+ if (modelArgs.items.length === 0) {
927
+ throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
928
+ }
929
+ }
930
+ };
931
+
932
+ const OrderType = new GraphQLObjectType({
933
+ name: 'Order',
934
+ extensions: {
935
+ validations: {
936
+ save: [orderValidator],
937
+ update: [orderValidator]
938
+ }
939
+ },
940
+ fields: () => ({
941
+ // ... fields
942
+ })
1137
943
  });
1138
944
  ```
1139
945
 
1140
- #### How It Works
946
+ ### Custom Validated Scalar Types
1141
947
 
1142
- - **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField`
1143
- - **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
1144
- - **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
1145
- - **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
1146
- - **Type Safety**: Clear error messages if related types aren't properly connected
948
+ Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
1147
949
 
1148
- #### Connect Your Types
950
+ #### Pre-built Scalars
951
+
952
+ Simfinity.js provides ready-to-use validated scalars for common patterns:
1149
953
 
1150
954
  ```javascript
1151
- // Connect all your types to Simfinity
1152
- simfinity.connect(null, AuthorType, 'author', 'authors');
1153
- simfinity.connect(null, BookType, 'book', 'books');
1154
- simfinity.connect(null, CommentType, 'comment', 'comments');
955
+ const { scalars } = require('@simtlix/simfinity-js');
1155
956
 
1156
- // Or use addNoEndpointType for types that don't need direct queries/mutations
1157
- simfinity.addNoEndpointType(AuthorType);
957
+ const UserType = new GraphQLObjectType({
958
+ name: 'User',
959
+ fields: () => ({
960
+ id: { type: GraphQLID },
961
+ email: { type: scalars.EmailScalar }, // Type name: Email_String
962
+ website: { type: scalars.URLScalar }, // Type name: URL_String
963
+ age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
964
+ price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
965
+ }),
966
+ });
1158
967
  ```
1159
968
 
1160
- That's it! All relationship resolvers are automatically generated when you connect your types.
969
+ **Available Pre-built Scalars:**
970
+ - `scalars.EmailScalar` - Validates email format (`Email_String`)
971
+ - `scalars.URLScalar` - Validates URL format (`URL_String`)
972
+ - `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
973
+ - `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
1161
974
 
1162
- ### Adding Types Without Endpoints
975
+ #### Factory Functions for Custom Scalars
1163
976
 
1164
- Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations:
977
+ Create custom validated scalars with parameters:
1165
978
 
1166
979
  ```javascript
1167
- simfinity.addNoEndpointType(TypeName);
1168
- ```
980
+ const { scalars } = require('@simtlix/simfinity-js');
1169
981
 
1170
- **When to use `addNoEndpointType()` vs `connect()`:**
982
+ // Create a bounded string scalar (name length between 2-100 characters)
983
+ const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
1171
984
 
1172
- | Method | Use Case | Creates Endpoints | Use Example |
1173
- |--------|----------|-------------------|-------------|
1174
- | `connect()` | Types that need CRUD operations | ✅ Yes | User, Product, Order |
1175
- | `addNoEndpointType()` | Types only used in relationships | ❌ No | Address, Settings, Director |
985
+ // Create a bounded integer scalar (age between 0-120)
986
+ const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
1176
987
 
1177
- #### Perfect Example: TV Series with Embedded Director
988
+ // Create a bounded float scalar (rating between 0-10)
989
+ const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
1178
990
 
1179
- From the [series-sample](https://github.com/simtlix/series-sample) project:
991
+ // Create a pattern-based string scalar (phone number format)
992
+ const PhoneScalar = scalars.createPatternStringScalar(
993
+ 'Phone',
994
+ /^\+?[\d\s\-()]+$/,
995
+ 'Invalid phone number format'
996
+ );
1180
997
 
1181
- ```javascript
1182
- // Director type - Used only as embedded data, no direct API access needed
1183
- const directorType = new GraphQLObjectType({
1184
- name: 'director',
998
+ // Use in your types
999
+ const PersonType = new GraphQLObjectType({
1000
+ name: 'Person',
1185
1001
  fields: () => ({
1186
1002
  id: { type: GraphQLID },
1187
- name: { type: new GraphQLNonNull(GraphQLString) },
1188
- country: { type: GraphQLString }
1189
- })
1003
+ name: { type: NameScalar }, // Type name: Name_String
1004
+ age: { type: AgeScalar }, // Type name: Age_Int
1005
+ rating: { type: RatingScalar }, // Type name: Rating_Float
1006
+ phone: { type: PhoneScalar } // Type name: Phone_String
1007
+ }),
1190
1008
  });
1009
+ ```
1191
1010
 
1192
- // Add to schema WITHOUT creating endpoints
1193
- simfinity.addNoEndpointType(directorType);
1011
+ **Available Factory Functions:**
1012
+ - `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
1013
+ - `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
1014
+ - `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
1015
+ - `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
1194
1016
 
1195
- // Serie type - Has its own endpoints and embeds director data
1196
- const serieType = new GraphQLObjectType({
1197
- name: 'serie',
1198
- fields: () => ({
1199
- id: { type: GraphQLID },
1200
- name: { type: new GraphQLNonNull(GraphQLString) },
1201
- categories: { type: new GraphQLList(GraphQLString) },
1202
- director: {
1203
- type: new GraphQLNonNull(directorType),
1204
- extensions: {
1205
- relation: {
1206
- embedded: true, // Director data stored within serie document
1207
- displayField: 'name'
1208
- }
1209
- }
1210
- }
1211
- })
1212
- });
1017
+ #### Creating Custom Scalars Manually
1213
1018
 
1214
- // Create full CRUD endpoints for series
1215
- simfinity.connect(null, serieType, 'serie', 'series');
1216
- ```
1019
+ You can also create custom scalars using `createValidatedScalar` directly:
1217
1020
 
1218
- **Result:**
1219
- - `addserie`, `updateserie`, `deleteserie` mutations available
1220
- - `serie`, `series` queries available
1221
- - ❌ No `adddirector`, `director`, `directors` endpoints (director is embedded)
1021
+ ```javascript
1022
+ const { GraphQLString, GraphQLInt } = require('graphql');
1023
+ const { createValidatedScalar } = require('@simtlix/simfinity-js');
1222
1024
 
1223
- **Usage:**
1224
- ```graphql
1225
- mutation {
1226
- addserie(input: {
1227
- name: "Breaking Bad"
1228
- categories: ["crime", "drama", "thriller"]
1229
- director: {
1230
- name: "Vince Gilligan"
1231
- country: "United States"
1025
+ // Email scalar with validation (generates type name: Email_String)
1026
+ const EmailScalar = createValidatedScalar(
1027
+ 'Email',
1028
+ 'A valid email address',
1029
+ GraphQLString,
1030
+ (value) => {
1031
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1032
+ if (!emailRegex.test(value)) {
1033
+ throw new Error('Invalid email format');
1232
1034
  }
1233
- }) {
1234
- id
1235
- name
1236
- director {
1237
- name
1238
- country
1035
+ }
1036
+ );
1037
+
1038
+ // Positive integer scalar (generates type name: PositiveInt_Int)
1039
+ const PositiveIntScalar = createValidatedScalar(
1040
+ 'PositiveInt',
1041
+ 'A positive integer',
1042
+ GraphQLInt,
1043
+ (value) => {
1044
+ if (value <= 0) {
1045
+ throw new Error('Value must be positive');
1239
1046
  }
1240
1047
  }
1241
- }
1048
+ );
1049
+
1050
+ // Use in your types
1051
+ const UserType = new GraphQLObjectType({
1052
+ name: 'User',
1053
+ fields: () => ({
1054
+ id: { type: GraphQLID },
1055
+ email: { type: EmailScalar }, // Type name: Email_String
1056
+ age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
1057
+ }),
1058
+ });
1242
1059
  ```
1243
1060
 
1244
- #### When to Use Each Approach
1061
+ ### Custom Error Classes
1245
1062
 
1246
- **Use `addNoEndpointType()` for:**
1247
- - Simple data objects with few fields
1248
- - Data that doesn't need CRUD operations
1249
- - Objects that belong to a single parent (1:1 relationships)
1250
- - Configuration or settings objects
1251
- - **Examples**: Address, Director info, Product specifications
1063
+ Create domain-specific error classes:
1252
1064
 
1253
- **Use `connect()` for:**
1254
- - Complex entities that need their own endpoints
1255
- - Data that needs CRUD operations
1256
- - Objects shared between multiple parents (many:many relationships)
1257
- - Objects with business logic (controllers, state machines)
1258
- - **Examples**: User, Product, Order, Season, Episode
1065
+ ```javascript
1066
+ const { SimfinityError } = require('@simtlix/simfinity-js');
1259
1067
 
1260
- ### Embedded vs Referenced Relationships
1068
+ // Business logic error
1069
+ class BusinessError extends SimfinityError {
1070
+ constructor(message) {
1071
+ super(message, 'BUSINESS_ERROR', 400);
1072
+ }
1073
+ }
1261
1074
 
1262
- **Referenced Relationships** (default):
1263
- ```javascript
1264
- // Stores author ID in the book document
1265
- author: {
1266
- type: AuthorType,
1267
- extensions: {
1268
- relation: {
1269
- // connectionField not needed for single object relationships
1270
- embedded: false // This is the default
1271
- }
1075
+ // Authorization error
1076
+ class AuthorizationError extends SimfinityError {
1077
+ constructor(message) {
1078
+ super(message, 'UNAUTHORIZED', 401);
1272
1079
  }
1273
1080
  }
1274
- ```
1275
1081
 
1276
- **Embedded Relationships**:
1277
- ```javascript
1278
- // Stores the full publisher object in the book document
1279
- publisher: {
1280
- type: PublisherType,
1281
- extensions: {
1282
- relation: {
1283
- embedded: true
1284
- }
1082
+ // Not found error
1083
+ class NotFoundError extends SimfinityError {
1084
+ constructor(message) {
1085
+ super(message, 'NOT_FOUND', 404);
1285
1086
  }
1286
1087
  }
1287
1088
  ```
1288
1089
 
1289
- ### Querying Relationships
1090
+ ## 🔄 State Machines
1290
1091
 
1291
- Query nested relationships with dot notation:
1092
+ Implement declarative state machine workflows:
1292
1093
 
1293
- ```graphql
1294
- query {
1295
- books(author: {
1296
- terms: [
1297
- {
1298
- path: "country.name",
1299
- operator: EQ,
1300
- value: "England"
1301
- }
1302
- ]
1303
- }) {
1304
- id
1305
- title
1306
- author {
1307
- name
1308
- country {
1309
- name
1310
- }
1311
- }
1094
+ ### 1. Define States
1095
+
1096
+ ```javascript
1097
+ const { GraphQLEnumType } = require('graphql');
1098
+
1099
+ const OrderState = new GraphQLEnumType({
1100
+ name: 'OrderState',
1101
+ values: {
1102
+ PENDING: { value: 'PENDING' },
1103
+ PROCESSING: { value: 'PROCESSING' },
1104
+ SHIPPED: { value: 'SHIPPED' },
1105
+ DELIVERED: { value: 'DELIVERED' },
1106
+ CANCELLED: { value: 'CANCELLED' }
1312
1107
  }
1313
- }
1108
+ });
1314
1109
  ```
1315
1110
 
1316
- ### Creating Objects with Relationships
1111
+ ### 2. Define Type with State Field
1317
1112
 
1318
- **Link to existing objects:**
1319
- ```graphql
1320
- mutation {
1321
- addBook(input: {
1322
- title: "New Book"
1323
- author: {
1324
- id: "existing_author_id"
1325
- }
1326
- }) {
1327
- id
1328
- title
1329
- author {
1330
- name
1331
- }
1332
- }
1333
- }
1113
+ ```javascript
1114
+ const OrderType = new GraphQLObjectType({
1115
+ name: 'Order',
1116
+ fields: () => ({
1117
+ id: { type: GraphQLID },
1118
+ customer: { type: GraphQLString },
1119
+ state: { type: OrderState }
1120
+ })
1121
+ });
1334
1122
  ```
1335
1123
 
1336
- **Create embedded objects:**
1337
- ```graphql
1338
- mutation {
1339
- addBook(input: {
1340
- title: "New Book"
1341
- publisher: {
1342
- name: "Penguin Books"
1343
- location: "London"
1344
- }
1345
- }) {
1346
- id
1347
- title
1348
- publisher {
1349
- name
1350
- location
1124
+ ### 3. Configure State Machine
1125
+
1126
+ ```javascript
1127
+ const stateMachine = {
1128
+ initialState: { name: 'PENDING', value: 'PENDING' },
1129
+ actions: {
1130
+ process: {
1131
+ from: { name: 'PENDING', value: 'PENDING' },
1132
+ to: { name: 'PROCESSING', value: 'PROCESSING' },
1133
+ description: 'Process the order',
1134
+ action: async (args, session) => {
1135
+ // Business logic for processing
1136
+ console.log(`Processing order ${args.id}`);
1137
+ // You can perform additional operations here
1138
+ }
1139
+ },
1140
+ ship: {
1141
+ from: { name: 'PROCESSING', value: 'PROCESSING' },
1142
+ to: { name: 'SHIPPED', value: 'SHIPPED' },
1143
+ description: 'Ship the order',
1144
+ action: async (args, session) => {
1145
+ // Business logic for shipping
1146
+ console.log(`Shipping order ${args.id}`);
1147
+ }
1148
+ },
1149
+ deliver: {
1150
+ from: { name: 'SHIPPED', value: 'SHIPPED' },
1151
+ to: { name: 'DELIVERED', value: 'DELIVERED' },
1152
+ description: 'Mark as delivered'
1153
+ },
1154
+ cancel: {
1155
+ from: { name: 'PENDING', value: 'PENDING' },
1156
+ to: { name: 'CANCELLED', value: 'CANCELLED' },
1157
+ description: 'Cancel the order'
1351
1158
  }
1352
1159
  }
1353
- }
1160
+ };
1354
1161
  ```
1355
1162
 
1356
- ### Collection Fields
1163
+ ### 4. Connect with State Machine
1357
1164
 
1358
- Work with arrays of related objects:
1165
+ ```javascript
1166
+ simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
1167
+ ```
1168
+
1169
+ ### 5. Use State Machine Mutations
1170
+
1171
+ The state machine automatically generates mutations for each action:
1359
1172
 
1360
1173
  ```graphql
1361
1174
  mutation {
1362
- updateBook(input: {
1363
- id: "book_id"
1364
- reviews: {
1365
- added: [
1366
- { rating: 5, comment: "Amazing!" }
1367
- { rating: 4, comment: "Good read" }
1368
- ]
1369
- updated: [
1370
- { id: "review_id", rating: 3 }
1371
- ]
1372
- deleted: ["review_id_to_delete"]
1373
- }
1175
+ process_order(input: {
1176
+ id: "order_id"
1374
1177
  }) {
1375
1178
  id
1376
- title
1377
- reviews {
1378
- rating
1379
- comment
1380
- }
1179
+ state
1180
+ customer
1381
1181
  }
1382
1182
  }
1383
1183
  ```
1384
1184
 
1185
+ **Important Notes**:
1186
+ - The `state` field is automatically read-only and managed by the state machine
1187
+ - State transitions are only allowed based on the defined actions
1188
+ - Business logic in the `action` function is executed during transitions
1189
+ - Invalid transitions throw errors automatically
1190
+
1385
1191
  ## 🎛️ Controllers & Lifecycle Hooks
1386
1192
 
1387
1193
  Controllers provide fine-grained control over operations with lifecycle hooks:
@@ -1482,781 +1288,956 @@ const documentController = {
1482
1288
  if (context && context.user) {
1483
1289
  doc.owner = context.user.id;
1484
1290
  }
1485
- }
1486
- };
1291
+ }
1292
+ };
1293
+ ```
1294
+
1295
+ **Example: Role-Based Authorization**
1296
+
1297
+ ```javascript
1298
+ const adminOnlyController = {
1299
+ onUpdating: async (id, doc, session, context) => {
1300
+ if (!context || !context.user || context.user.role !== 'admin') {
1301
+ throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1302
+ }
1303
+ },
1304
+
1305
+ onDelete: async (doc, session, context) => {
1306
+ if (!context || !context.user || context.user.role !== 'admin') {
1307
+ throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1308
+ }
1309
+ }
1310
+ };
1311
+ ```
1312
+
1313
+ **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.
1314
+
1315
+ ## 🔒 Query Scope
1316
+
1317
+ ### Overview
1318
+
1319
+ 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.
1320
+
1321
+ ### Defining Scope
1322
+
1323
+ Define scope in the type extensions, similar to how validations are defined:
1324
+
1325
+ ```javascript
1326
+ const EpisodeType = new GraphQLObjectType({
1327
+ name: 'episode',
1328
+ extensions: {
1329
+ validations: {
1330
+ create: [validateEpisodeFields],
1331
+ update: [validateEpisodeBusinessRules]
1332
+ },
1333
+ scope: {
1334
+ find: async ({ type, args, operation, context }) => {
1335
+ // Modify args in place to add filter conditions
1336
+ args.owner = {
1337
+ terms: [
1338
+ {
1339
+ path: 'id',
1340
+ operator: 'EQ',
1341
+ value: context.user.id
1342
+ }
1343
+ ]
1344
+ };
1345
+ },
1346
+ aggregate: async ({ type, args, operation, context }) => {
1347
+ // Apply same scope to aggregate queries
1348
+ args.owner = {
1349
+ terms: [
1350
+ {
1351
+ path: 'id',
1352
+ operator: 'EQ',
1353
+ value: context.user.id
1354
+ }
1355
+ ]
1356
+ };
1357
+ },
1358
+ get_by_id: async ({ type, args, operation, context }) => {
1359
+ // For get_by_id, scope is automatically merged with id filter
1360
+ args.owner = {
1361
+ terms: [
1362
+ {
1363
+ path: 'id',
1364
+ operator: 'EQ',
1365
+ value: context.user.id
1366
+ }
1367
+ ]
1368
+ };
1369
+ }
1370
+ }
1371
+ },
1372
+ fields: () => ({
1373
+ id: { type: GraphQLID },
1374
+ name: { type: GraphQLString },
1375
+ owner: {
1376
+ type: new GraphQLNonNull(simfinity.getType('user')),
1377
+ extensions: {
1378
+ relation: {
1379
+ connectionField: 'owner',
1380
+ displayField: 'name'
1381
+ }
1382
+ }
1383
+ }
1384
+ })
1385
+ });
1487
1386
  ```
1488
1387
 
1489
- **Example: Role-Based Authorization**
1388
+ ### Scope for Find Operations
1389
+
1390
+ Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results:
1490
1391
 
1491
1392
  ```javascript
1492
- const adminOnlyController = {
1493
- onUpdating: async (id, doc, session, context) => {
1494
- if (!context || !context.user || context.user.role !== 'admin') {
1495
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1393
+ const DocumentType = new GraphQLObjectType({
1394
+ name: 'Document',
1395
+ extensions: {
1396
+ scope: {
1397
+ find: async ({ type, args, operation, context }) => {
1398
+ // Only show documents owned by the current user
1399
+ args.owner = {
1400
+ terms: [
1401
+ {
1402
+ path: 'id',
1403
+ operator: 'EQ',
1404
+ value: context.user.id
1405
+ }
1406
+ ]
1407
+ };
1408
+ }
1496
1409
  }
1497
1410
  },
1498
-
1499
- onDelete: async (doc, session, context) => {
1500
- if (!context || !context.user || context.user.role !== 'admin') {
1501
- throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
1411
+ fields: () => ({
1412
+ id: { type: GraphQLID },
1413
+ title: { type: GraphQLString },
1414
+ owner: {
1415
+ type: new GraphQLNonNull(simfinity.getType('user')),
1416
+ extensions: {
1417
+ relation: {
1418
+ connectionField: 'owner',
1419
+ displayField: 'name'
1420
+ }
1421
+ }
1502
1422
  }
1503
- }
1504
- };
1423
+ })
1424
+ });
1505
1425
  ```
1506
1426
 
1507
- **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.
1508
-
1509
- ## 🔄 State Machines
1510
-
1511
- Implement declarative state machine workflows:
1512
-
1513
- ### 1. Define States
1514
-
1515
- ```javascript
1516
- const { GraphQLEnumType } = require('graphql');
1427
+ **Result**: All `documents` queries will automatically filter to only return documents where `owner.id` equals `context.user.id`.
1517
1428
 
1518
- const OrderState = new GraphQLEnumType({
1519
- name: 'OrderState',
1520
- values: {
1521
- PENDING: { value: 'PENDING' },
1522
- PROCESSING: { value: 'PROCESSING' },
1523
- SHIPPED: { value: 'SHIPPED' },
1524
- DELIVERED: { value: 'DELIVERED' },
1525
- CANCELLED: { value: 'CANCELLED' }
1526
- }
1527
- });
1528
- ```
1429
+ ### Scope for Aggregate Operations
1529
1430
 
1530
- ### 2. Define Type with State Field
1431
+ Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope:
1531
1432
 
1532
1433
  ```javascript
1533
1434
  const OrderType = new GraphQLObjectType({
1534
1435
  name: 'Order',
1436
+ extensions: {
1437
+ scope: {
1438
+ aggregate: async ({ type, args, operation, context }) => {
1439
+ // Only aggregate orders for the current user's organization
1440
+ args.organization = {
1441
+ terms: [
1442
+ {
1443
+ path: 'id',
1444
+ operator: 'EQ',
1445
+ value: context.user.organizationId
1446
+ }
1447
+ ]
1448
+ };
1449
+ }
1450
+ }
1451
+ },
1535
1452
  fields: () => ({
1536
- id: { type: GraphQLID },
1537
- customer: { type: GraphQLString },
1538
- state: { type: OrderState }
1453
+ // ... fields
1539
1454
  })
1540
1455
  });
1541
1456
  ```
1542
1457
 
1543
- ### 3. Configure State Machine
1458
+ **Result**: All `orders_aggregate` queries will automatically filter to only aggregate orders from the user's organization.
1459
+
1460
+ ### Scope for Get By ID Operations
1461
+
1462
+ 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:
1544
1463
 
1545
1464
  ```javascript
1546
- const stateMachine = {
1547
- initialState: { name: 'PENDING', value: 'PENDING' },
1548
- actions: {
1549
- process: {
1550
- from: { name: 'PENDING', value: 'PENDING' },
1551
- to: { name: 'PROCESSING', value: 'PROCESSING' },
1552
- description: 'Process the order',
1553
- action: async (args, session) => {
1554
- // Business logic for processing
1555
- console.log(`Processing order ${args.id}`);
1556
- // You can perform additional operations here
1557
- }
1558
- },
1559
- ship: {
1560
- from: { name: 'PROCESSING', value: 'PROCESSING' },
1561
- to: { name: 'SHIPPED', value: 'SHIPPED' },
1562
- description: 'Ship the order',
1563
- action: async (args, session) => {
1564
- // Business logic for shipping
1565
- console.log(`Shipping order ${args.id}`);
1465
+ const PrivateDocumentType = new GraphQLObjectType({
1466
+ name: 'PrivateDocument',
1467
+ extensions: {
1468
+ scope: {
1469
+ get_by_id: async ({ type, args, operation, context }) => {
1470
+ // Ensure user can only access their own documents
1471
+ args.owner = {
1472
+ terms: [
1473
+ {
1474
+ path: 'id',
1475
+ operator: 'EQ',
1476
+ value: context.user.id
1477
+ }
1478
+ ]
1479
+ };
1566
1480
  }
1567
- },
1568
- deliver: {
1569
- from: { name: 'SHIPPED', value: 'SHIPPED' },
1570
- to: { name: 'DELIVERED', value: 'DELIVERED' },
1571
- description: 'Mark as delivered'
1572
- },
1573
- cancel: {
1574
- from: { name: 'PENDING', value: 'PENDING' },
1575
- to: { name: 'CANCELLED', value: 'CANCELLED' },
1576
- description: 'Cancel the order'
1577
1481
  }
1578
- }
1579
- };
1482
+ },
1483
+ fields: () => ({
1484
+ // ... fields
1485
+ })
1486
+ });
1580
1487
  ```
1581
1488
 
1582
- ### 4. Connect with State Machine
1583
-
1584
- ```javascript
1585
- simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
1586
- ```
1489
+ **Result**: When querying `privatedocument(id: "some_id")`, the system will:
1490
+ 1. Create a query that includes both the id filter and the owner scope filter
1491
+ 2. Only return the document if it matches both conditions
1492
+ 3. Return `null` if the document exists but doesn't match the scope
1587
1493
 
1588
- ### 5. Use State Machine Mutations
1494
+ ### Scope Function Parameters
1589
1495
 
1590
- The state machine automatically generates mutations for each action:
1496
+ Scope functions receive the same parameters as middleware for consistency:
1591
1497
 
1592
- ```graphql
1593
- mutation {
1594
- process_order(input: {
1595
- id: "order_id"
1596
- }) {
1597
- id
1598
- state
1599
- customer
1600
- }
1498
+ ```javascript
1499
+ {
1500
+ type, // Type information (model, gqltype, controller, etc.)
1501
+ args, // GraphQL arguments passed to the operation (modify this object)
1502
+ operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
1503
+ context // GraphQL context object (includes request info, user data, etc.)
1601
1504
  }
1602
1505
  ```
1603
1506
 
1604
- **Important Notes**:
1605
- - The `state` field is automatically read-only and managed by the state machine
1606
- - State transitions are only allowed based on the defined actions
1607
- - Business logic in the `action` function is executed during transitions
1608
- - Invalid transitions throw errors automatically
1507
+ ### Filter Structure
1609
1508
 
1610
- ## Validations
1509
+ When modifying `args` in scope functions, use the appropriate filter structure:
1611
1510
 
1612
- ### Declarative Validation Helpers
1511
+ **For scalar fields:**
1512
+ ```javascript
1513
+ args.fieldName = {
1514
+ operator: 'EQ',
1515
+ value: 'someValue'
1516
+ };
1517
+ ```
1613
1518
 
1614
- Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
1519
+ **For object/relation fields (QLTypeFilterExpression):**
1520
+ ```javascript
1521
+ args.relationField = {
1522
+ terms: [
1523
+ {
1524
+ path: 'fieldName',
1525
+ operator: 'EQ',
1526
+ value: 'someValue'
1527
+ }
1528
+ ]
1529
+ };
1530
+ ```
1615
1531
 
1616
- #### Using Validators
1532
+ ### Complete Example
1617
1533
 
1618
- ```javascript
1619
- const { validators } = require('@simtlix/simfinity-js');
1534
+ Here's a complete example showing scope for all query operations:
1620
1535
 
1621
- const PersonType = new GraphQLObjectType({
1622
- name: 'Person',
1536
+ ```javascript
1537
+ const EpisodeType = new GraphQLObjectType({
1538
+ name: 'episode',
1539
+ extensions: {
1540
+ validations: {
1541
+ save: [validateEpisodeFields],
1542
+ update: [validateEpisodeBusinessRules]
1543
+ },
1544
+ scope: {
1545
+ find: async ({ type, args, operation, context }) => {
1546
+ // Only show episodes from seasons the user has access to
1547
+ args.season = {
1548
+ terms: [
1549
+ {
1550
+ path: 'owner.id',
1551
+ operator: 'EQ',
1552
+ value: context.user.id
1553
+ }
1554
+ ]
1555
+ };
1556
+ },
1557
+ aggregate: async ({ type, args, operation, context }) => {
1558
+ // Apply same scope to aggregations
1559
+ args.season = {
1560
+ terms: [
1561
+ {
1562
+ path: 'owner.id',
1563
+ operator: 'EQ',
1564
+ value: context.user.id
1565
+ }
1566
+ ]
1567
+ };
1568
+ },
1569
+ get_by_id: async ({ type, args, operation, context }) => {
1570
+ // Ensure user can only access their own episodes
1571
+ args.owner = {
1572
+ terms: [
1573
+ {
1574
+ path: 'id',
1575
+ operator: 'EQ',
1576
+ value: context.user.id
1577
+ }
1578
+ ]
1579
+ };
1580
+ }
1581
+ }
1582
+ },
1623
1583
  fields: () => ({
1624
1584
  id: { type: GraphQLID },
1625
- name: {
1626
- type: GraphQLString,
1627
- extensions: {
1628
- validations: validators.stringLength('Name', 2, 100)
1629
- }
1630
- },
1631
- email: {
1632
- type: GraphQLString,
1633
- extensions: {
1634
- validations: validators.email()
1635
- }
1636
- },
1637
- website: {
1638
- type: GraphQLString,
1639
- extensions: {
1640
- validations: validators.url()
1641
- }
1642
- },
1643
- age: {
1644
- type: GraphQLInt,
1585
+ number: { type: GraphQLInt },
1586
+ name: { type: GraphQLString },
1587
+ season: {
1588
+ type: new GraphQLNonNull(simfinity.getType('season')),
1645
1589
  extensions: {
1646
- validations: validators.numberRange('Age', 0, 120)
1590
+ relation: {
1591
+ connectionField: 'season',
1592
+ displayField: 'number'
1593
+ }
1647
1594
  }
1648
1595
  },
1649
- price: {
1650
- type: GraphQLFloat,
1596
+ owner: {
1597
+ type: new GraphQLNonNull(simfinity.getType('user')),
1651
1598
  extensions: {
1652
- validations: validators.positive('Price')
1599
+ relation: {
1600
+ connectionField: 'owner',
1601
+ displayField: 'name'
1602
+ }
1653
1603
  }
1654
1604
  }
1655
1605
  })
1656
1606
  });
1657
1607
  ```
1658
1608
 
1659
- #### Available Validators
1609
+ ### Important Notes
1660
1610
 
1661
- **String Validators:**
1662
- - `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
1663
- - `validators.maxLength(name, max)` - Validates maximum string length
1664
- - `validators.pattern(name, regex, message)` - Validates against a regex pattern
1665
- - `validators.email()` - Validates email format
1666
- - `validators.url()` - Validates URL format
1611
+ - **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
1612
+ - **Modify Args In Place**: Scope functions should modify the `args` object directly
1613
+ - **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
1614
+ - **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
1615
+ - **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
1616
+ - **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
1667
1617
 
1668
- **Number Validators:**
1669
- - `validators.numberRange(name, min, max)` - Validates number range
1670
- - `validators.positive(name)` - Ensures number is positive
1618
+ ### Use Cases
1671
1619
 
1672
- **Array Validators:**
1673
- - `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
1620
+ - **Multi-tenancy**: Filter documents by organization or tenant
1621
+ - **User-specific data**: Only show documents owned by the current user
1622
+ - **Role-based access**: Filter based on user roles or permissions
1623
+ - **Department/Team scoping**: Show only data relevant to user's department
1624
+ - **Geographic scoping**: Filter by user's location or region
1674
1625
 
1675
- **Date Validators:**
1676
- - `validators.dateFormat(name, format)` - Validates date format
1677
- - `validators.futureDate(name)` - Ensures date is in the future
1626
+ ## 🔐 Authorization
1678
1627
 
1679
- #### Validator Features
1628
+ 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.
1680
1629
 
1681
- - **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
1682
- - **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
1683
- - **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
1630
+ ### Quick Start
1684
1631
 
1685
- #### Example: Multiple Validators
1632
+ ```javascript
1633
+ const { auth } = require('@simtlix/simfinity-js');
1634
+ const { createYoga } = require('graphql-yoga');
1635
+
1636
+ const { createAuthPlugin, requireAuth, requireRole } = auth;
1637
+
1638
+ // Define your permission schema
1639
+ // Query/Mutation names match the ones generated by simfinity.connect()
1640
+ const permissions = {
1641
+ Query: {
1642
+ series: requireAuth(),
1643
+ seasons: requireAuth(),
1644
+ },
1645
+ Mutation: {
1646
+ deleteserie: requireRole('admin'),
1647
+ deletestar: requireRole('admin'),
1648
+ },
1649
+ serie: {
1650
+ '*': requireAuth(), // Wildcard: all fields require auth
1651
+ },
1652
+ };
1653
+
1654
+ // Create the Envelop auth plugin and pass it to your server
1655
+ const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
1656
+ const yoga = createYoga({ schema, plugins: [authPlugin] });
1657
+ ```
1658
+
1659
+ ### Permission Schema
1660
+
1661
+ The permission schema defines authorization rules per type and field:
1686
1662
 
1687
1663
  ```javascript
1688
- const ProductType = new GraphQLObjectType({
1689
- name: 'Product',
1690
- fields: () => ({
1691
- id: { type: GraphQLID },
1692
- name: {
1693
- type: GraphQLString,
1694
- extensions: {
1695
- validations: validators.stringLength('Product Name', 3, 200)
1696
- }
1697
- },
1698
- sku: {
1699
- type: GraphQLString,
1700
- extensions: {
1701
- validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
1702
- }
1703
- },
1704
- price: {
1705
- type: GraphQLFloat,
1706
- extensions: {
1707
- validations: validators.positive('Price')
1708
- }
1709
- },
1710
- tags: {
1711
- type: new GraphQLList(GraphQLString),
1712
- extensions: {
1713
- validations: validators.arrayLength('Tags', 10)
1714
- }
1715
- }
1716
- })
1717
- });
1664
+ const permissions = {
1665
+ // Operation types (Query, Mutation, Subscription)
1666
+ Query: {
1667
+ fieldName: ruleOrRules,
1668
+ },
1669
+
1670
+ // Object types
1671
+ TypeName: {
1672
+ '*': wildcardRule, // Applies to all fields unless overridden
1673
+ fieldName: specificRule, // Overrides wildcard for this field
1674
+ },
1675
+ };
1718
1676
  ```
1719
1677
 
1720
- ### Field-Level Validations (Manual)
1678
+ **Resolution Order:**
1679
+ 1. Check exact field rule: `permissions[TypeName][fieldName]`
1680
+ 2. Fallback to wildcard: `permissions[TypeName]['*']`
1681
+ 3. Apply default policy (ALLOW or DENY)
1721
1682
 
1722
- For custom validation logic, you can still write manual validators:
1683
+ **Rule Types:**
1684
+ - **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
1685
+ - **Array of functions**: All rules must pass (AND logic)
1686
+ - **Policy expression**: JSON AST object (see below)
1687
+
1688
+ **Rule Semantics:**
1689
+ - `return true` or `return void` → allow
1690
+ - `return false` → deny
1691
+ - `throw Error` → deny with error
1692
+
1693
+ ### Rule Helpers
1694
+
1695
+ Simfinity.js provides reusable rule builders:
1723
1696
 
1724
1697
  ```javascript
1725
- const { SimfinityError } = require('@simtlix/simfinity-js');
1698
+ const { auth } = require('@simtlix/simfinity-js');
1726
1699
 
1727
- const validateAge = {
1728
- validate: async (typeName, fieldName, value, session) => {
1729
- if (value < 0 || value > 120) {
1730
- throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
1731
- }
1732
- }
1700
+ const {
1701
+ resolvePath, // Utility to resolve dotted paths in objects
1702
+ requireAuth, // Requires ctx.user to exist
1703
+ requireRole, // Requires specific role(s)
1704
+ requirePermission, // Requires specific permission(s)
1705
+ composeRules, // Combine rules (AND logic)
1706
+ anyRule, // Combine rules (OR logic)
1707
+ isOwner, // Check resource ownership
1708
+ allow, // Always allow
1709
+ deny, // Always deny
1710
+ createRule, // Create custom rule
1711
+ } = auth;
1712
+ ```
1713
+
1714
+ #### requireAuth(userPath?)
1715
+
1716
+ Requires the user to be authenticated. Supports custom user paths in context:
1717
+
1718
+ ```javascript
1719
+ const permissions = {
1720
+ Query: {
1721
+ // Default: checks ctx.user
1722
+ me: requireAuth(),
1723
+
1724
+ // Custom path: checks ctx.auth.currentUser
1725
+ profile: requireAuth('auth.currentUser'),
1726
+
1727
+ // Deep path: checks ctx.session.data.user
1728
+ settings: requireAuth('session.data.user'),
1729
+ },
1733
1730
  };
1731
+ ```
1734
1732
 
1735
- const PersonType = new GraphQLObjectType({
1736
- name: 'Person',
1737
- fields: () => ({
1738
- id: { type: GraphQLID },
1739
- name: {
1740
- type: GraphQLString,
1741
- extensions: {
1742
- validations: {
1743
- save: [{
1744
- validate: async (typeName, fieldName, value, session) => {
1745
- if (!value || value.length < 2) {
1746
- throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
1747
- }
1748
- }
1749
- }],
1750
- update: [{
1751
- validate: async (typeName, fieldName, value, session) => {
1752
- if (value && value.length < 2) {
1753
- throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
1754
- }
1755
- }
1756
- }]
1757
- }
1758
- }
1759
- },
1760
- age: {
1761
- type: GraphQLInt,
1762
- extensions: {
1763
- validations: {
1764
- save: [validateAge],
1765
- update: [validateAge]
1766
- }
1767
- }
1768
- }
1769
- })
1770
- });
1733
+ #### requireRole(role, options?)
1734
+
1735
+ Requires the user to have a specific role. Supports custom paths:
1736
+
1737
+ ```javascript
1738
+ const permissions = {
1739
+ Query: {
1740
+ // Default: checks ctx.user.role
1741
+ adminDashboard: requireRole('ADMIN'),
1742
+ modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
1743
+
1744
+ // Custom paths: checks ctx.auth.user.profile.role
1745
+ superAdmin: requireRole('SUPER_ADMIN', {
1746
+ userPath: 'auth.user',
1747
+ rolePath: 'profile.role',
1748
+ }),
1749
+ },
1750
+ };
1771
1751
  ```
1772
1752
 
1773
- ### Type-Level Validations
1753
+ #### requirePermission(permission, options?)
1774
1754
 
1775
- Validate objects as a whole:
1755
+ Requires the user to have specific permission(s). Supports custom paths:
1776
1756
 
1777
1757
  ```javascript
1778
- const orderValidator = {
1779
- validate: async (typeName, args, modelArgs, session) => {
1780
- // Cross-field validation
1781
- if (modelArgs.deliveryDate < modelArgs.orderDate) {
1782
- throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
1783
- }
1758
+ const permissions = {
1759
+ Mutation: {
1760
+ // Default: checks ctx.user.permissions
1761
+ deletePost: requirePermission('posts:delete'),
1762
+ manageUsers: requirePermission(['users:read', 'users:write']), // All required
1784
1763
 
1785
- // Business rule validation
1786
- if (modelArgs.items.length === 0) {
1787
- throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
1788
- }
1789
- }
1764
+ // Custom paths: checks ctx.session.user.access.grants
1765
+ admin: requirePermission('admin:all', {
1766
+ userPath: 'session.user',
1767
+ permissionsPath: 'access.grants',
1768
+ }),
1769
+ },
1790
1770
  };
1771
+ ```
1791
1772
 
1792
- const OrderType = new GraphQLObjectType({
1793
- name: 'Order',
1794
- extensions: {
1795
- validations: {
1796
- save: [orderValidator],
1797
- update: [orderValidator]
1798
- }
1773
+ #### composeRules(...rules)
1774
+
1775
+ Combines multiple rules with AND logic (all must pass):
1776
+
1777
+ ```javascript
1778
+ const permissions = {
1779
+ Mutation: {
1780
+ updatePost: composeRules(
1781
+ requireAuth(),
1782
+ requireRole('EDITOR'),
1783
+ async (post, args, ctx) => post.authorId === ctx.user.id,
1784
+ ),
1799
1785
  },
1800
- fields: () => ({
1801
- // ... fields
1802
- })
1803
- });
1786
+ };
1804
1787
  ```
1805
1788
 
1806
- ### Custom Validated Scalar Types
1789
+ #### anyRule(...rules)
1807
1790
 
1808
- Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
1791
+ Combines multiple rules with OR logic (any must pass):
1809
1792
 
1810
- #### Pre-built Scalars
1793
+ ```javascript
1794
+ const permissions = {
1795
+ Post: {
1796
+ content: anyRule(
1797
+ requireRole('ADMIN'),
1798
+ async (post, args, ctx) => post.authorId === ctx.user.id,
1799
+ ),
1800
+ },
1801
+ };
1802
+ ```
1811
1803
 
1812
- Simfinity.js provides ready-to-use validated scalars for common patterns:
1804
+ #### isOwner(ownerField, userIdField)
1805
+
1806
+ Checks if the authenticated user owns the resource:
1813
1807
 
1814
1808
  ```javascript
1815
- const { scalars } = require('@simtlix/simfinity-js');
1809
+ const permissions = {
1810
+ Post: {
1811
+ '*': composeRules(
1812
+ requireAuth(),
1813
+ isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
1814
+ ),
1815
+ },
1816
+ };
1817
+ ```
1816
1818
 
1817
- const UserType = new GraphQLObjectType({
1818
- name: 'User',
1819
- fields: () => ({
1820
- id: { type: GraphQLID },
1821
- email: { type: scalars.EmailScalar }, // Type name: Email_String
1822
- website: { type: scalars.URLScalar }, // Type name: URL_String
1823
- age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
1824
- price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
1825
- }),
1826
- });
1819
+ ### Policy Expressions (JSON AST)
1820
+
1821
+ For declarative rules, use JSON AST policy expressions:
1822
+
1823
+ ```javascript
1824
+ const permissions = {
1825
+ Post: {
1826
+ content: {
1827
+ anyOf: [
1828
+ { eq: [{ ref: 'parent.published' }, true] },
1829
+ { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
1830
+ ],
1831
+ },
1832
+ },
1833
+ };
1827
1834
  ```
1828
1835
 
1829
- **Available Pre-built Scalars:**
1830
- - `scalars.EmailScalar` - Validates email format (`Email_String`)
1831
- - `scalars.URLScalar` - Validates URL format (`URL_String`)
1832
- - `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
1833
- - `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
1836
+ **Supported Operators:**
1834
1837
 
1835
- #### Factory Functions for Custom Scalars
1838
+ | Operator | Description | Example |
1839
+ |----------|-------------|---------|
1840
+ | `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
1841
+ | `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
1842
+ | `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
1843
+ | `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
1844
+ | `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
1836
1845
 
1837
- Create custom validated scalars with parameters:
1846
+ **References:**
1847
+
1848
+ Use `{ ref: 'path' }` to reference values:
1849
+ - `parent.*` - Parent resolver result (the object being resolved)
1850
+ - `args.*` - GraphQL arguments
1851
+ - `ctx.*` - GraphQL context
1852
+
1853
+ **Security:**
1854
+ - Only `parent`, `args`, and `ctx` roots are allowed
1855
+ - Unknown operators fail closed (deny)
1856
+ - No `eval()` or `Function()` - pure object traversal
1857
+
1858
+ ### Integration with GraphQL Yoga / Envelop
1859
+
1860
+ 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.
1838
1861
 
1839
1862
  ```javascript
1840
- const { scalars } = require('@simtlix/simfinity-js');
1863
+ const { createYoga } = require('graphql-yoga');
1864
+ const { createServer } = require('http');
1865
+ const simfinity = require('@simtlix/simfinity-js');
1841
1866
 
1842
- // Create a bounded string scalar (name length between 2-100 characters)
1843
- const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
1867
+ const { auth } = simfinity;
1868
+ const { createAuthPlugin, requireAuth, requireRole, composeRules, isOwner, deny } = auth;
1844
1869
 
1845
- // Create a bounded integer scalar (age between 0-120)
1846
- const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
1870
+ // Define your types and connect them
1871
+ simfinity.connect(null, SerieType, 'serie', 'series');
1872
+ simfinity.connect(null, SeasonType, 'season', 'seasons');
1873
+ simfinity.connect(null, StarType, 'star', 'stars');
1847
1874
 
1848
- // Create a bounded float scalar (rating between 0-10)
1849
- const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
1875
+ // Create base schema
1876
+ const schema = simfinity.createSchema();
1850
1877
 
1851
- // Create a pattern-based string scalar (phone number format)
1852
- const PhoneScalar = scalars.createPatternStringScalar(
1853
- 'Phone',
1854
- /^\+?[\d\s\-()]+$/,
1855
- 'Invalid phone number format'
1856
- );
1878
+ // Define permissions
1879
+ // Query/Mutation names match the ones generated by simfinity.connect()
1880
+ const permissions = {
1881
+ Query: {
1882
+ series: requireAuth(),
1883
+ seasons: requireAuth(),
1884
+ stars: requireAuth(),
1885
+ },
1886
+ Mutation: {
1887
+ addserie: requireAuth(),
1888
+ updateserie: composeRules(requireAuth(), isOwner('createdBy')),
1889
+ deleteserie: requireRole('admin'),
1890
+ deletestar: requireRole('admin'),
1891
+ },
1892
+ serie: {
1893
+ '*': requireAuth(),
1894
+ },
1895
+ season: {
1896
+ '*': requireAuth(),
1897
+ },
1898
+ };
1857
1899
 
1858
- // Use in your types
1859
- const PersonType = new GraphQLObjectType({
1860
- name: 'Person',
1861
- fields: () => ({
1862
- id: { type: GraphQLID },
1863
- name: { type: NameScalar }, // Type name: Name_String
1864
- age: { type: AgeScalar }, // Type name: Age_Int
1865
- rating: { type: RatingScalar }, // Type name: Rating_Float
1866
- phone: { type: PhoneScalar } // Type name: Phone_String
1900
+ // Create auth plugin
1901
+ const authPlugin = createAuthPlugin(permissions, {
1902
+ defaultPolicy: 'ALLOW',
1903
+ debug: false,
1904
+ });
1905
+
1906
+ // Setup Yoga with the auth plugin
1907
+ const yoga = createYoga({
1908
+ schema,
1909
+ plugins: [authPlugin],
1910
+ context: (req) => ({
1911
+ user: req.user, // Set by your authentication layer
1867
1912
  }),
1868
1913
  });
1869
- ```
1870
1914
 
1871
- **Available Factory Functions:**
1872
- - `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
1873
- - `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
1874
- - `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
1875
- - `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
1915
+ const server = createServer(yoga);
1916
+ server.listen(4000);
1917
+ ```
1876
1918
 
1877
- #### Creating Custom Scalars Manually
1919
+ ### Legacy: Integration with graphql-middleware
1878
1920
 
1879
- You can also create custom scalars using `createValidatedScalar` directly:
1921
+ > **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
1922
+ > which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
1923
+ > Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
1880
1924
 
1881
1925
  ```javascript
1882
- const { GraphQLString, GraphQLInt } = require('graphql');
1883
- const { createValidatedScalar } = require('@simtlix/simfinity-js');
1926
+ const { applyMiddleware } = require('graphql-middleware');
1927
+ const simfinity = require('@simtlix/simfinity-js');
1884
1928
 
1885
- // Email scalar with validation (generates type name: Email_String)
1886
- const EmailScalar = createValidatedScalar(
1887
- 'Email',
1888
- 'A valid email address',
1889
- GraphQLString,
1890
- (value) => {
1891
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1892
- if (!emailRegex.test(value)) {
1893
- throw new Error('Invalid email format');
1894
- }
1895
- }
1896
- );
1929
+ const { auth } = simfinity;
1930
+ const { createAuthMiddleware, requireAuth, requireRole } = auth;
1897
1931
 
1898
- // Positive integer scalar (generates type name: PositiveInt_Int)
1899
- const PositiveIntScalar = createValidatedScalar(
1900
- 'PositiveInt',
1901
- 'A positive integer',
1902
- GraphQLInt,
1903
- (value) => {
1904
- if (value <= 0) {
1905
- throw new Error('Value must be positive');
1906
- }
1907
- }
1908
- );
1932
+ const baseSchema = simfinity.createSchema();
1909
1933
 
1910
- // Use in your types
1911
- const UserType = new GraphQLObjectType({
1912
- name: 'User',
1913
- fields: () => ({
1914
- id: { type: GraphQLID },
1915
- email: { type: EmailScalar }, // Type name: Email_String
1916
- age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
1917
- }),
1934
+ const authMiddleware = createAuthMiddleware(permissions, {
1935
+ defaultPolicy: 'DENY',
1936
+ });
1937
+
1938
+ const schema = applyMiddleware(baseSchema, authMiddleware);
1939
+ ```
1940
+
1941
+ ### Plugin / Middleware Options
1942
+
1943
+ ```javascript
1944
+ const plugin = createAuthPlugin(permissions, {
1945
+ defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
1946
+ debug: false, // Enable debug logging
1918
1947
  });
1919
1948
  ```
1920
1949
 
1921
- ### Custom Error Classes
1950
+ | Option | Type | Default | Description |
1951
+ |--------|------|---------|-------------|
1952
+ | `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
1953
+ | `debug` | `boolean` | `false` | Log authorization decisions |
1922
1954
 
1923
- Create domain-specific error classes:
1955
+ ### Error Handling
1956
+
1957
+ The auth middleware uses Simfinity error classes:
1924
1958
 
1925
1959
  ```javascript
1926
- const { SimfinityError } = require('@simtlix/simfinity-js');
1960
+ const { auth } = require('@simtlix/simfinity-js');
1927
1961
 
1928
- // Business logic error
1929
- class BusinessError extends SimfinityError {
1930
- constructor(message) {
1931
- super(message, 'BUSINESS_ERROR', 400);
1932
- }
1933
- }
1962
+ const { UnauthenticatedError, ForbiddenError } = auth;
1934
1963
 
1935
- // Authorization error
1936
- class AuthorizationError extends SimfinityError {
1937
- constructor(message) {
1938
- super(message, 'UNAUTHORIZED', 401);
1939
- }
1940
- }
1964
+ // UnauthenticatedError: code 'UNAUTHENTICATED', status 401
1965
+ // ForbiddenError: code 'FORBIDDEN', status 403
1966
+ ```
1941
1967
 
1942
- // Not found error
1943
- class NotFoundError extends SimfinityError {
1944
- constructor(message) {
1945
- super(message, 'NOT_FOUND', 404);
1946
- }
1947
- }
1968
+ Custom error handling in rules:
1969
+
1970
+ ```javascript
1971
+ const permissions = {
1972
+ Mutation: {
1973
+ deleteserie: async (parent, args, ctx) => {
1974
+ if (!ctx.user) {
1975
+ throw new auth.UnauthenticatedError('Please log in');
1976
+ }
1977
+ if (ctx.user.role !== 'admin') {
1978
+ throw new auth.ForbiddenError('Only admins can delete series');
1979
+ }
1980
+ return true;
1981
+ },
1982
+ },
1983
+ };
1948
1984
  ```
1949
1985
 
1950
- ## 🔒 Query Scope
1986
+ ### Best Practices
1951
1987
 
1952
- ### Overview
1988
+ 1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
1989
+ 2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
1990
+ 3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
1991
+ 4. **Fail closed**: Custom rules should deny on unexpected conditions
1992
+ 5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
1993
+ 6. **Test thoroughly**: Auth rules are critical - test all scenarios
1953
1994
 
1954
- 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.
1995
+ ## 🔧 Middlewares
1955
1996
 
1956
- ### Defining Scope
1997
+ 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.
1957
1998
 
1958
- Define scope in the type extensions, similar to how validations are defined:
1999
+ ### Adding Middlewares
2000
+
2001
+ Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered:
1959
2002
 
1960
2003
  ```javascript
1961
- const EpisodeType = new GraphQLObjectType({
1962
- name: 'episode',
1963
- extensions: {
1964
- validations: {
1965
- create: [validateEpisodeFields],
1966
- update: [validateEpisodeBusinessRules]
1967
- },
1968
- scope: {
1969
- find: async ({ type, args, operation, context }) => {
1970
- // Modify args in place to add filter conditions
1971
- args.owner = {
1972
- terms: [
1973
- {
1974
- path: 'id',
1975
- operator: 'EQ',
1976
- value: context.user.id
1977
- }
1978
- ]
1979
- };
1980
- },
1981
- aggregate: async ({ type, args, operation, context }) => {
1982
- // Apply same scope to aggregate queries
1983
- args.owner = {
1984
- terms: [
1985
- {
1986
- path: 'id',
1987
- operator: 'EQ',
1988
- value: context.user.id
1989
- }
1990
- ]
1991
- };
1992
- },
1993
- get_by_id: async ({ type, args, operation, context }) => {
1994
- // For get_by_id, scope is automatically merged with id filter
1995
- args.owner = {
1996
- terms: [
1997
- {
1998
- path: 'id',
1999
- operator: 'EQ',
2000
- value: context.user.id
2001
- }
2002
- ]
2003
- };
2004
- }
2005
- }
2006
- },
2007
- fields: () => ({
2008
- id: { type: GraphQLID },
2009
- name: { type: GraphQLString },
2010
- owner: {
2011
- type: new GraphQLNonNull(simfinity.getType('user')),
2012
- extensions: {
2013
- relation: {
2014
- connectionField: 'owner',
2015
- displayField: 'name'
2016
- }
2017
- }
2018
- }
2019
- })
2004
+ // Basic logging middleware
2005
+ simfinity.use((params, next) => {
2006
+ console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
2007
+ next();
2020
2008
  });
2021
2009
  ```
2022
2010
 
2023
- ### Scope for Find Operations
2011
+ ### Middleware Parameters
2024
2012
 
2025
- Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results:
2013
+ Each middleware receives a `params` object containing:
2026
2014
 
2027
2015
  ```javascript
2028
- const DocumentType = new GraphQLObjectType({
2029
- name: 'Document',
2030
- extensions: {
2031
- scope: {
2032
- find: async ({ type, args, operation, context }) => {
2033
- // Only show documents owned by the current user
2034
- args.owner = {
2035
- terms: [
2036
- {
2037
- path: 'id',
2038
- operator: 'EQ',
2039
- value: context.user.id
2040
- }
2041
- ]
2042
- };
2043
- }
2044
- }
2045
- },
2046
- fields: () => ({
2047
- id: { type: GraphQLID },
2048
- title: { type: GraphQLString },
2049
- owner: {
2050
- type: new GraphQLNonNull(simfinity.getType('user')),
2051
- extensions: {
2052
- relation: {
2053
- connectionField: 'owner',
2054
- displayField: 'name'
2055
- }
2056
- }
2057
- }
2058
- })
2016
+ simfinity.use((params, next) => {
2017
+ // params object contains:
2018
+ const {
2019
+ type, // Type information (model, gqltype, controller, etc.)
2020
+ args, // GraphQL arguments passed to the operation
2021
+ operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
2022
+ context, // GraphQL context object (includes request info, user data, etc.)
2023
+ actionName, // For state machine actions (only present for state_changed operations)
2024
+ actionField, // State machine action details (only present for state_changed operations)
2025
+ entry // Custom mutation name (only present for custom_mutation operations)
2026
+ } = params;
2027
+
2028
+ // Always call next() to continue the middleware chain
2029
+ next();
2059
2030
  });
2060
2031
  ```
2061
2032
 
2062
- **Result**: All `documents` queries will automatically filter to only return documents where `owner.id` equals `context.user.id`.
2033
+ ### Common Use Cases
2063
2034
 
2064
- ### Scope for Aggregate Operations
2035
+ #### 1. Authentication & Authorization
2065
2036
 
2066
- Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope:
2037
+ ```javascript
2038
+ simfinity.use((params, next) => {
2039
+ const { context, operation, type } = params;
2040
+
2041
+ // Skip authentication for read operations
2042
+ if (operation === 'get_by_id' || operation === 'find') {
2043
+ return next();
2044
+ }
2045
+
2046
+ // Check if user is authenticated
2047
+ if (!context.user) {
2048
+ throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
2049
+ }
2050
+
2051
+ // Check permissions for specific types
2052
+ if (type?.name === 'User' && context.user.role !== 'admin') {
2053
+ throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
2054
+ }
2055
+
2056
+ next();
2057
+ });
2058
+ ```
2059
+
2060
+ #### 2. Request Logging & Monitoring
2067
2061
 
2068
2062
  ```javascript
2069
- const OrderType = new GraphQLObjectType({
2070
- name: 'Order',
2071
- extensions: {
2072
- scope: {
2073
- aggregate: async ({ type, args, operation, context }) => {
2074
- // Only aggregate orders for the current user's organization
2075
- args.organization = {
2076
- terms: [
2077
- {
2078
- path: 'id',
2079
- operator: 'EQ',
2080
- value: context.user.organizationId
2081
- }
2082
- ]
2083
- };
2063
+ simfinity.use((params, next) => {
2064
+ const { operation, type, args, context } = params;
2065
+ const startTime = Date.now();
2066
+
2067
+ console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
2068
+
2069
+ // Continue with the operation
2070
+ next();
2071
+
2072
+ const duration = Date.now() - startTime;
2073
+ console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
2074
+ });
2075
+ ```
2076
+
2077
+ #### 3. Input Validation & Sanitization
2078
+
2079
+ ```javascript
2080
+ simfinity.use((params, next) => {
2081
+ const { operation, args, type } = params;
2082
+
2083
+ // Validate input for save operations
2084
+ if (operation === 'save' && args.input) {
2085
+ // Trim string fields
2086
+ Object.keys(args.input).forEach(key => {
2087
+ if (typeof args.input[key] === 'string') {
2088
+ args.input[key] = args.input[key].trim();
2084
2089
  }
2085
- }
2086
- },
2087
- fields: () => ({
2088
- // ... fields
2089
- })
2090
+ });
2091
+
2092
+ // Validate required business rules
2093
+ if (type?.name === 'Book' && args.input.title && args.input.title.length < 3) {
2094
+ throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
2095
+ }
2096
+ }
2097
+
2098
+ next();
2090
2099
  });
2091
2100
  ```
2092
2101
 
2093
- **Result**: All `orders_aggregate` queries will automatically filter to only aggregate orders from the user's organization.
2094
-
2095
- ### Scope for Get By ID Operations
2096
-
2097
- 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:
2102
+ #### 4. Rate Limiting
2098
2103
 
2099
2104
  ```javascript
2100
- const PrivateDocumentType = new GraphQLObjectType({
2101
- name: 'PrivateDocument',
2102
- extensions: {
2103
- scope: {
2104
- get_by_id: async ({ type, args, operation, context }) => {
2105
- // Ensure user can only access their own documents
2106
- args.owner = {
2107
- terms: [
2108
- {
2109
- path: 'id',
2110
- operator: 'EQ',
2111
- value: context.user.id
2112
- }
2113
- ]
2114
- };
2115
- }
2105
+ const requestCounts = new Map();
2106
+
2107
+ simfinity.use((params, next) => {
2108
+ const { context, operation } = params;
2109
+ const userId = context.user?.id || context.ip;
2110
+ const now = Date.now();
2111
+ const windowMs = 60000; // 1 minute
2112
+ const maxRequests = 100;
2113
+
2114
+ // Only apply rate limiting to mutations
2115
+ if (operation === 'save' || operation === 'update' || operation === 'delete') {
2116
+ const userRequests = requestCounts.get(userId) || [];
2117
+ const recentRequests = userRequests.filter(time => now - time < windowMs);
2118
+
2119
+ if (recentRequests.length >= maxRequests) {
2120
+ throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
2116
2121
  }
2117
- },
2118
- fields: () => ({
2119
- // ... fields
2120
- })
2122
+
2123
+ recentRequests.push(now);
2124
+ requestCounts.set(userId, recentRequests);
2125
+ }
2126
+
2127
+ next();
2121
2128
  });
2122
2129
  ```
2123
2130
 
2124
- **Result**: When querying `privatedocument(id: "some_id")`, the system will:
2125
- 1. Create a query that includes both the id filter and the owner scope filter
2126
- 2. Only return the document if it matches both conditions
2127
- 3. Return `null` if the document exists but doesn't match the scope
2128
-
2129
- ### Scope Function Parameters
2130
-
2131
- Scope functions receive the same parameters as middleware for consistency:
2131
+ #### 5. Audit Trail
2132
2132
 
2133
2133
  ```javascript
2134
- {
2135
- type, // Type information (model, gqltype, controller, etc.)
2136
- args, // GraphQL arguments passed to the operation (modify this object)
2137
- operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
2138
- context // GraphQL context object (includes request info, user data, etc.)
2139
- }
2134
+ simfinity.use((params, next) => {
2135
+ const { operation, type, args, context } = params;
2136
+
2137
+ // Log all mutations for audit purposes
2138
+ if (operation === 'save' || operation === 'update' || operation === 'delete') {
2139
+ const auditEntry = {
2140
+ timestamp: new Date(),
2141
+ user: context.user?.id,
2142
+ operation,
2143
+ type: type?.name,
2144
+ entityId: args.id || 'new',
2145
+ data: operation === 'delete' ? null : args.input,
2146
+ ip: context.ip,
2147
+ userAgent: context.userAgent
2148
+ };
2149
+
2150
+ // Save to audit log (could be database, file, or external service)
2151
+ console.log('AUDIT:', JSON.stringify(auditEntry));
2152
+ }
2153
+
2154
+ next();
2155
+ });
2140
2156
  ```
2141
2157
 
2142
- ### Filter Structure
2158
+ ### Multiple Middlewares
2143
2159
 
2144
- When modifying `args` in scope functions, use the appropriate filter structure:
2160
+ Middlewares execute in registration order. Each middleware must call `next()` to continue the chain:
2145
2161
 
2146
- **For scalar fields:**
2147
2162
  ```javascript
2148
- args.fieldName = {
2149
- operator: 'EQ',
2150
- value: 'someValue'
2151
- };
2163
+ // Middleware 1: Authentication
2164
+ simfinity.use((params, next) => {
2165
+ console.log('1. Checking authentication...');
2166
+ // Authentication logic here
2167
+ next(); // Continue to next middleware
2168
+ });
2169
+
2170
+ // Middleware 2: Authorization
2171
+ simfinity.use((params, next) => {
2172
+ console.log('2. Checking permissions...');
2173
+ // Authorization logic here
2174
+ next(); // Continue to next middleware
2175
+ });
2176
+
2177
+ // Middleware 3: Logging
2178
+ simfinity.use((params, next) => {
2179
+ console.log('3. Logging request...');
2180
+ // Logging logic here
2181
+ next(); // Continue to GraphQL operation
2182
+ });
2152
2183
  ```
2153
2184
 
2154
- **For object/relation fields (QLTypeFilterExpression):**
2185
+ ### Error Handling in Middlewares
2186
+
2187
+ Middlewares can throw errors to stop the operation:
2188
+
2155
2189
  ```javascript
2156
- args.relationField = {
2157
- terms: [
2158
- {
2159
- path: 'fieldName',
2160
- operator: 'EQ',
2161
- value: 'someValue'
2190
+ simfinity.use((params, next) => {
2191
+ const { context, operation } = params;
2192
+
2193
+ try {
2194
+ // Validation logic
2195
+ if (!context.user && operation !== 'find') {
2196
+ throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
2162
2197
  }
2163
- ]
2164
- };
2198
+
2199
+ next(); // Continue only if validation passes
2200
+ } catch (error) {
2201
+ // Error automatically bubbles up to GraphQL error handling
2202
+ throw error;
2203
+ }
2204
+ });
2165
2205
  ```
2166
2206
 
2167
- ### Complete Example
2207
+ ### Conditional Middleware Execution
2168
2208
 
2169
- Here's a complete example showing scope for all query operations:
2209
+ Execute middleware logic conditionally based on operation type or context:
2170
2210
 
2171
2211
  ```javascript
2172
- const EpisodeType = new GraphQLObjectType({
2173
- name: 'episode',
2174
- extensions: {
2175
- validations: {
2176
- save: [validateEpisodeFields],
2177
- update: [validateEpisodeBusinessRules]
2178
- },
2179
- scope: {
2180
- find: async ({ type, args, operation, context }) => {
2181
- // Only show episodes from seasons the user has access to
2182
- args.season = {
2183
- terms: [
2184
- {
2185
- path: 'owner.id',
2186
- operator: 'EQ',
2187
- value: context.user.id
2188
- }
2189
- ]
2190
- };
2191
- },
2192
- aggregate: async ({ type, args, operation, context }) => {
2193
- // Apply same scope to aggregations
2194
- args.season = {
2195
- terms: [
2196
- {
2197
- path: 'owner.id',
2198
- operator: 'EQ',
2199
- value: context.user.id
2200
- }
2201
- ]
2202
- };
2203
- },
2204
- get_by_id: async ({ type, args, operation, context }) => {
2205
- // Ensure user can only access their own episodes
2206
- args.owner = {
2207
- terms: [
2208
- {
2209
- path: 'id',
2210
- operator: 'EQ',
2211
- value: context.user.id
2212
- }
2213
- ]
2214
- };
2215
- }
2216
- }
2217
- },
2218
- fields: () => ({
2219
- id: { type: GraphQLID },
2220
- number: { type: GraphQLInt },
2221
- name: { type: GraphQLString },
2222
- season: {
2223
- type: new GraphQLNonNull(simfinity.getType('season')),
2224
- extensions: {
2225
- relation: {
2226
- connectionField: 'season',
2227
- displayField: 'number'
2228
- }
2229
- }
2230
- },
2231
- owner: {
2232
- type: new GraphQLNonNull(simfinity.getType('user')),
2233
- extensions: {
2234
- relation: {
2235
- connectionField: 'owner',
2236
- displayField: 'name'
2237
- }
2238
- }
2212
+ simfinity.use((params, next) => {
2213
+ const { operation, type, context } = params;
2214
+
2215
+ // Only apply to specific types
2216
+ if (type?.name === 'SensitiveData') {
2217
+ // Special handling for sensitive data
2218
+ if (!context.user?.hasHighSecurity) {
2219
+ throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
2239
2220
  }
2240
- })
2221
+ }
2222
+
2223
+ // Only apply to mutation operations
2224
+ if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
2225
+ // Mutation-specific logic
2226
+ console.log(`Mutation ${operation} executing...`);
2227
+ }
2228
+
2229
+ next();
2241
2230
  });
2242
2231
  ```
2243
2232
 
2244
- ### Important Notes
2245
-
2246
- - **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
2247
- - **Modify Args In Place**: Scope functions should modify the `args` object directly
2248
- - **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
2249
- - **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
2250
- - **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
2251
- - **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
2252
-
2253
- ### Use Cases
2233
+ ### Best Practices
2254
2234
 
2255
- - **Multi-tenancy**: Filter documents by organization or tenant
2256
- - **User-specific data**: Only show documents owned by the current user
2257
- - **Role-based access**: Filter based on user roles or permissions
2258
- - **Department/Team scoping**: Show only data relevant to user's department
2259
- - **Geographic scoping**: Filter by user's location or region
2235
+ 1. **Always call `next()`**: Failing to call `next()` will hang the request
2236
+ 2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
2237
+ 3. **Keep middlewares focused**: Each middleware should handle one concern
2238
+ 4. **Order matters**: Register middlewares in logical order (auth → validation → logging)
2239
+ 5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
2240
+ 6. **Use context wisely**: Store request-specific data in the GraphQL context object
2260
2241
 
2261
2242
  ## 🔧 Advanced Features
2262
2243
 
@@ -2705,6 +2686,7 @@ app.listen(4000, () => {
2705
2686
 
2706
2687
  ## 🔗 Resources
2707
2688
 
2689
+ - **[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
2708
2690
  - **[Samples Repository](https://github.com/simtlix/simfinity.js-samples)** - Complete examples and use cases
2709
2691
  - **[MongoDB Query Language](https://docs.mongodb.com/manual/tutorial/query-documents/)** - Learn about MongoDB querying
2710
2692
  - **[GraphQL Documentation](https://graphql.org/learn/)** - Learn about GraphQL