@martel/calyx 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.11.0](https://github.com/bmartel/calyx/compare/v1.10.1...v1.11.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **graphql,openapi:** support schema-first compiling, custom context and validation schema auto-enrichment ([e8dcce7](https://github.com/bmartel/calyx/commit/e8dcce78d75097ac88a8a3a984b5834d4d7f1818))
7
+
8
+ ## [1.10.1](https://github.com/bmartel/calyx/compare/v1.10.0...v1.10.1) (2026-07-01)
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * **graphql,openapi:** implement query AST caching and swagger response caching ([8310045](https://github.com/bmartel/calyx/commit/8310045265512553214a0a4040bc02d5085c77fa))
14
+
1
15
  # [1.10.0](https://github.com/bmartel/calyx/compare/v1.9.0...v1.10.0) (2026-07-01)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { CalyxContainer } from '../core/container.ts';
1
+ import { CalyxContainer, DynamicModule } from '../core/container.ts';
2
2
  import { Module } from '../core/decorators.ts';
3
3
  import {
4
4
  GraphQLSchema,
@@ -13,17 +13,42 @@ import {
13
13
  GraphQLBoolean,
14
14
  GraphQLList,
15
15
  GraphQLNonNull,
16
+ buildSchema as buildGqlSchema,
16
17
  } from 'graphql';
17
18
 
19
+ export interface GraphQLOptions {
20
+ typeDefs?: string;
21
+ context?: (ctx: { req: any }) => any | Promise<any>;
22
+ }
23
+
18
24
  @Module({})
19
25
  export class GraphQLModule {
26
+ static forRoot(options: GraphQLOptions): DynamicModule {
27
+ return {
28
+ module: GraphQLModule,
29
+ providers: [
30
+ {
31
+ provide: 'calyx:graphql_options',
32
+ useValue: options,
33
+ },
34
+ ],
35
+ };
36
+ }
37
+
20
38
  static buildSchema(container: CalyxContainer): GraphQLSchema | null {
21
39
  const instances = container.getProviderAndControllerInstances();
22
40
  const resolverInstances = instances.filter(
23
41
  (inst) => inst && inst.constructor && Reflect.hasMetadata('calyx:resolver', inst.constructor)
24
42
  );
25
43
 
26
- if (resolverInstances.length === 0) {
44
+ let options: GraphQLOptions | undefined;
45
+ try {
46
+ options = container.getGlobalOrAnyInstance('calyx:graphql_options');
47
+ } catch {
48
+ // Options provider not bound, fallback to default code-first
49
+ }
50
+
51
+ if (resolverInstances.length === 0 && !options?.typeDefs) {
27
52
  return null;
28
53
  }
29
54
 
@@ -98,7 +123,12 @@ export class GraphQLModule {
98
123
  // Custom Scalar
99
124
  if (typeof typeClass === 'function' && Reflect.hasMetadata('calyx:scalar', typeClass)) {
100
125
  const scalarMeta = Reflect.getMetadata('calyx:scalar', typeClass);
101
- const inst = container.get(typeClass) || new typeClass();
126
+ let inst: any;
127
+ try {
128
+ inst = container.getGlobalOrAnyInstance(typeClass);
129
+ } catch {
130
+ inst = new typeClass();
131
+ }
102
132
  const gqlScalar = new GraphQLScalarType({
103
133
  name: scalarMeta.name,
104
134
  description: Reflect.getMetadata('calyx:description', typeClass),
@@ -480,6 +510,60 @@ export class GraphQLModule {
480
510
  }
481
511
  }
482
512
 
513
+ // Support Schema-First Approach if typeDefs is specified
514
+ if (options?.typeDefs) {
515
+ const schema = buildGqlSchema(options.typeDefs);
516
+
517
+ // Attach code-first resolver actions onto schema-first AST definitions
518
+ const queryType = schema.getQueryType();
519
+ if (queryType) {
520
+ const fields = queryType.getFields();
521
+ for (const [fieldName, field] of Object.entries(fields)) {
522
+ if (queryFields[fieldName]) {
523
+ field.resolve = queryFields[fieldName].resolve;
524
+ }
525
+ }
526
+ }
527
+ const mutationType = schema.getMutationType();
528
+ if (mutationType) {
529
+ const fields = mutationType.getFields();
530
+ for (const [fieldName, field] of Object.entries(fields)) {
531
+ if (mutationFields[fieldName]) {
532
+ field.resolve = mutationFields[fieldName].resolve;
533
+ }
534
+ }
535
+ }
536
+ const subscriptionType = schema.getSubscriptionType();
537
+ if (subscriptionType) {
538
+ const fields = subscriptionType.getFields();
539
+ for (const [fieldName, field] of Object.entries(fields)) {
540
+ if (subscriptionFields[fieldName]) {
541
+ field.subscribe = subscriptionFields[fieldName].subscribe;
542
+ field.resolve = subscriptionFields[fieldName].resolve;
543
+ }
544
+ }
545
+ }
546
+
547
+ // Map Custom ObjectType ResolveFields from code-first typeMap
548
+ for (const [typeClass, codeFirstType] of typeMap.entries()) {
549
+ if (codeFirstType instanceof GraphQLObjectType) {
550
+ const typeName = codeFirstType.name;
551
+ const schemaFirstType = schema.getType(typeName);
552
+ if (schemaFirstType instanceof GraphQLObjectType) {
553
+ const codeFirstFields = codeFirstType.getFields();
554
+ const schemaFirstFields = schemaFirstType.getFields();
555
+ for (const [fieldName, cfField] of Object.entries(codeFirstFields)) {
556
+ if (cfField.resolve && schemaFirstFields[fieldName]) {
557
+ schemaFirstFields[fieldName].resolve = cfField.resolve;
558
+ }
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ return schema;
565
+ }
566
+
483
567
  if (Object.keys(queryFields).length === 0) {
484
568
  return null;
485
569
  }
@@ -119,6 +119,7 @@ export class CalyxApplication {
119
119
  private hasWebSockets = false;
120
120
  private serverPort = 3000;
121
121
  private graphqlSchema: any = null;
122
+ private graphqlQueryCache = new Map<string, any>();
122
123
  private isInitialized = false;
123
124
  private versioningOptions?: VersioningOptions;
124
125
 
@@ -1215,11 +1216,40 @@ export class CalyxApplication {
1215
1216
  const body = await req.json() as any;
1216
1217
  const { query, variables } = body;
1217
1218
 
1218
- const { graphql } = await import('graphql');
1219
- const result = await graphql({
1219
+ const { parse, validate, execute } = await import('graphql');
1220
+
1221
+ let document = this.graphqlQueryCache.get(query);
1222
+ if (!document) {
1223
+ document = parse(query);
1224
+ const errors = validate(this.graphqlSchema, document);
1225
+ if (errors.length > 0) {
1226
+ return new Response(JSON.stringify({ errors }), {
1227
+ status: 200,
1228
+ headers: { 'content-type': 'application/json' },
1229
+ });
1230
+ }
1231
+ this.graphqlQueryCache.set(query, document);
1232
+ }
1233
+
1234
+ let options: any;
1235
+ try {
1236
+ options = this.container.get('calyx:graphql_options');
1237
+ } catch {}
1238
+
1239
+ let contextValue: any = { req };
1240
+ if (options?.context) {
1241
+ contextValue = await options.context({ req });
1242
+ if (contextValue && typeof contextValue === 'object') {
1243
+ contextValue.req = req;
1244
+ } else {
1245
+ contextValue = { req, ...contextValue };
1246
+ }
1247
+ }
1248
+
1249
+ const result = await execute({
1220
1250
  schema: this.graphqlSchema,
1221
- source: query,
1222
- contextValue: { req },
1251
+ document,
1252
+ contextValue,
1223
1253
  variableValues: variables,
1224
1254
  });
1225
1255
 
@@ -1321,13 +1351,39 @@ export class CalyxApplication {
1321
1351
  const { id, payload } = data;
1322
1352
  const { query, variables } = payload;
1323
1353
 
1324
- const { subscribe, parse } = await import('graphql');
1354
+ const { subscribe, parse, validate } = await import('graphql');
1325
1355
 
1356
+ let document = this.graphqlQueryCache.get(query);
1357
+ if (!document) {
1358
+ document = parse(query);
1359
+ const errors = validate(this.graphqlSchema, document);
1360
+ if (errors.length > 0) {
1361
+ ws.send(JSON.stringify({ type: 'error', id, payload: errors }));
1362
+ break;
1363
+ }
1364
+ this.graphqlQueryCache.set(query, document);
1365
+ }
1366
+
1367
+ let options: any;
1368
+ try {
1369
+ options = this.container.get('calyx:graphql_options');
1370
+ } catch {}
1371
+
1372
+ let contextValue: any = { req: ws.data?.req };
1373
+ if (options?.context) {
1374
+ contextValue = await options.context({ req: ws.data?.req });
1375
+ if (contextValue && typeof contextValue === 'object') {
1376
+ contextValue.req = ws.data?.req;
1377
+ } else {
1378
+ contextValue = { req: ws.data?.req, ...contextValue };
1379
+ }
1380
+ }
1381
+
1326
1382
  const subResult = await subscribe({
1327
1383
  schema: this.graphqlSchema,
1328
- document: parse(query),
1384
+ document,
1329
1385
  variableValues: variables,
1330
- contextValue: { req: ws.data?.req },
1386
+ contextValue,
1331
1387
  });
1332
1388
 
1333
1389
  if (subResult && Symbol.asyncIterator in subResult) {
@@ -73,20 +73,41 @@ export class SwaggerModule {
73
73
  if (document.components.schemas[schemaName]) return;
74
74
 
75
75
  const props = Reflect.getMetadata('calyx:api_properties', typeClass) || [];
76
+ const rules = Reflect.getMetadata('calyx:validation_rules', typeClass) || [];
76
77
  const schemaProps: Record<string, any> = {};
77
78
  const requiredProps: string[] = [];
78
79
 
79
- for (const p of props) {
80
- let pType = 'string';
81
- if (p.type) {
82
- pType = p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase();
80
+ const allKeys = new Set<string>();
81
+ for (const p of props) allKeys.add(p.propertyKey);
82
+ for (const r of rules) allKeys.add(r.propertyKey);
83
+
84
+ for (const key of allKeys) {
85
+ const p = props.find((x: any) => x.propertyKey === key) || {};
86
+ const propertyRules = rules.filter((r: any) => r.propertyKey === key);
87
+
88
+ let pType = p.type ? (p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase()) : 'string';
89
+ let format: string | undefined = undefined;
90
+
91
+ if (propertyRules.some((r: any) => r.type === 'number')) {
92
+ pType = 'number';
93
+ } else if (propertyRules.some((r: any) => r.type === 'string')) {
94
+ pType = 'string';
95
+ } else if (propertyRules.some((r: any) => r.type === 'email')) {
96
+ pType = 'string';
97
+ format = 'email';
83
98
  }
84
- schemaProps[p.propertyKey] = {
99
+
100
+ schemaProps[key] = {
85
101
  type: pType === 'number' || pType === 'boolean' || pType === 'object' || pType === 'array' ? pType : 'string',
86
- description: p.description,
102
+ description: p.description || '',
103
+ ...(format ? { format } : {}),
87
104
  };
88
- if (p.required) {
89
- requiredProps.push(p.propertyKey);
105
+
106
+ const isOptional = propertyRules.some((r: any) => r.type === 'optional');
107
+ const isRequired = p.required ?? !isOptional;
108
+
109
+ if (isRequired) {
110
+ requiredProps.push(key);
90
111
  }
91
112
  }
92
113
 
@@ -271,12 +292,14 @@ export class SwaggerModule {
271
292
  const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
272
293
  const uiPath = `/${path}`.replace(/\/\/+/g, '/');
273
294
 
295
+ const jsonString = JSON.stringify(document);
296
+
274
297
  app.use((req: any, res: any, next: any) => {
275
298
  const url = new URL(req.url);
276
299
  if (url.pathname === jsonPath && req.method === 'GET') {
277
300
  res.status(200);
278
301
  res.set('content-type', 'application/json');
279
- res.send(JSON.stringify(document));
302
+ res.send(jsonString);
280
303
  return;
281
304
  }
282
305
  next();
@@ -349,3 +349,104 @@ describe('Native Code-First GraphQL Module', () => {
349
349
  ws.close();
350
350
  });
351
351
  });
352
+
353
+ describe('Schema-First GraphQL Module', () => {
354
+ let app: any;
355
+ let baseUrl: string;
356
+ const PORT = 3929;
357
+
358
+ const typeDefs = `
359
+ type Author {
360
+ id: Int!
361
+ name: String!
362
+ }
363
+
364
+ type PostGql {
365
+ id: Int!
366
+ title: String!
367
+ author: Author!
368
+ }
369
+
370
+ type Query {
371
+ getPost(id: Int!): PostGql
372
+ }
373
+ `;
374
+
375
+ @Resolver(PostGql)
376
+ class SchemaFirstResolver {
377
+ @Query()
378
+ getPost(@Args('id') id: number) {
379
+ return {
380
+ id,
381
+ title: 'Schema-First Works!',
382
+ authorId: 888,
383
+ };
384
+ }
385
+
386
+ @ResolveField()
387
+ author(@Parent() post: any) {
388
+ return {
389
+ id: post.authorId,
390
+ name: 'Bob',
391
+ };
392
+ }
393
+ }
394
+
395
+ @Module({
396
+ imports: [
397
+ GraphQLModule.forRoot({
398
+ typeDefs,
399
+ context: async ({ req }) => {
400
+ return { customVal: 'injected' };
401
+ },
402
+ }),
403
+ ],
404
+ providers: [SchemaFirstResolver],
405
+ })
406
+ class SchemaFirstApp {}
407
+
408
+ beforeAll(async () => {
409
+ app = await CalyxFactory.create(SchemaFirstApp);
410
+ await app.listen(PORT);
411
+ baseUrl = `http://baseUrl:${PORT}`;
412
+ });
413
+
414
+ afterAll(async () => {
415
+ await app.close();
416
+ });
417
+
418
+ test('should parse typeDefs and bind query/field resolver methods', async () => {
419
+ const res = await fetch(`http://localhost:${PORT}/graphql`, {
420
+ method: 'POST',
421
+ headers: { 'content-type': 'application/json' },
422
+ body: JSON.stringify({
423
+ query: `
424
+ query {
425
+ getPost(id: 42) {
426
+ id
427
+ title
428
+ author {
429
+ id
430
+ name
431
+ }
432
+ }
433
+ }
434
+ `,
435
+ }),
436
+ });
437
+
438
+ expect(res.status).toBe(200);
439
+ const body = await res.json();
440
+ expect(body.errors).toBeUndefined();
441
+ expect(body.data).toEqual({
442
+ getPost: {
443
+ id: 42,
444
+ title: 'Schema-First Works!',
445
+ author: {
446
+ id: 888,
447
+ name: 'Bob',
448
+ },
449
+ },
450
+ });
451
+ });
452
+ });
@@ -19,6 +19,10 @@ import {
19
19
  DocumentBuilder,
20
20
  SwaggerModule,
21
21
  PartialType,
22
+ IsString,
23
+ IsNumber,
24
+ IsOptional,
25
+ IsEmail,
22
26
  } from '../src/index.ts';
23
27
 
24
28
  // 1. DTO Model
@@ -42,6 +46,22 @@ class CreateItemDto {
42
46
  // 3. Partial DTO using mapped type
43
47
  class UpdateItemDto extends PartialType(CreateItemDto) {}
44
48
 
49
+ // 4. Validation-rich DTO (without explicit @ApiProperty)
50
+ class ValidationRichDto {
51
+ @IsString()
52
+ title!: string;
53
+
54
+ @IsNumber()
55
+ amount!: number;
56
+
57
+ @IsOptional()
58
+ @IsString()
59
+ note?: string;
60
+
61
+ @IsEmail()
62
+ contactEmail!: string;
63
+ }
64
+
45
65
  @ApiTags('Items')
46
66
  @ApiBearerAuth('jwt')
47
67
  @Controller('items')
@@ -66,6 +86,12 @@ class ItemsController {
66
86
  updateItem(@Body() body: UpdateItemDto) {
67
87
  return { id: 3, ...body };
68
88
  }
89
+
90
+ @Post('validate')
91
+ @ApiOperation({ summary: 'Validation endpoint' })
92
+ validateDto(@Body() body: ValidationRichDto) {
93
+ return body;
94
+ }
69
95
  }
70
96
 
71
97
  @Module({
@@ -150,6 +176,21 @@ describe('OpenAPI (Swagger) Generation', () => {
150
176
  expect(spec.components.schemas.UpdateItemDto).toBeDefined();
151
177
  expect(spec.components.schemas.UpdateItemDto.required).toBeUndefined(); // all optional
152
178
  expect(spec.components.schemas.UpdateItemDto.properties.name.type).toBe('string');
179
+
180
+ // 6. Validation Decorator Schema Auto-enrichment
181
+ expect(spec.components.schemas.ValidationRichDto).toBeDefined();
182
+ const richSchema = spec.components.schemas.ValidationRichDto;
183
+ expect(richSchema.properties.title.type).toBe('string');
184
+ expect(richSchema.properties.amount.type).toBe('number');
185
+ expect(richSchema.properties.note.type).toBe('string');
186
+ expect(richSchema.properties.contactEmail.type).toBe('string');
187
+ expect(richSchema.properties.contactEmail.format).toBe('email');
188
+
189
+ // Ensure note is optional, but others are required
190
+ expect(richSchema.required).toContain('title');
191
+ expect(richSchema.required).toContain('amount');
192
+ expect(richSchema.required).toContain('contactEmail');
193
+ expect(richSchema.required).not.toContain('note');
153
194
  });
154
195
 
155
196
  test('should serve Swagger UI html wrapper', async () => {