@rvoh/psychic 3.2.0 → 3.3.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.
@@ -74,3 +74,40 @@ export function OpenAPI(modelOrSerializer, _opts) {
74
74
  });
75
75
  };
76
76
  }
77
+ // eslint-disable-next-line @typescript-eslint/no-namespace
78
+ (function (OpenAPI) {
79
+ /**
80
+ * Type-narrowed helper for nesting a Dream-model-driven shape inside a
81
+ * request body. Returns the same `{ for, only, including, required,
82
+ * combining }` shape the renderer recognizes natively, but typed via a
83
+ * function-level generic so `only` / `including` / `required` are
84
+ * constrained to columns of the model passed as the first argument.
85
+ *
86
+ * ```ts
87
+ * @OpenAPI(ActionPlan, {
88
+ * requestBody: {
89
+ * including: ['type', 'clientId'],
90
+ * combining: {
91
+ * planItems: {
92
+ * type: 'array',
93
+ * items: OpenAPI.forDream(ActionItem, {
94
+ * required: ['title', 'type'],
95
+ * }),
96
+ * },
97
+ * },
98
+ * },
99
+ * })
100
+ * ```
101
+ *
102
+ * Wrong column names raise a compile-time error:
103
+ *
104
+ * ```ts
105
+ * OpenAPI.forDream(Pet, { including: ['notARealColumn'] })
106
+ * // ^^^^^^^^^^^^^^^^^ type error
107
+ * ```
108
+ */
109
+ function forDream(model, opts = {}) {
110
+ return { for: model, ...opts };
111
+ }
112
+ OpenAPI.forDream = forDream;
113
+ })(OpenAPI || (OpenAPI = {}));
@@ -1,4 +1,4 @@
1
- import { Ajv } from 'ajv';
1
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
3
  /**
4
4
  * @internal
@@ -99,7 +99,7 @@ export function createValidator(schema, options = {}) {
99
99
  ...options,
100
100
  init: undefined,
101
101
  };
102
- const ajv = new Ajv({
102
+ const ajv = new Ajv2020({
103
103
  removeAdditional: false,
104
104
  useDefaults: true,
105
105
  strict: false,
@@ -51,11 +51,15 @@ export default class SerializerOpenapiRenderer {
51
51
  alreadyExtractedDescendantSerializers[this.serializer.globalName] = true;
52
52
  const referencedSerializersAndOpenapiSchemaBodyShorthand = this._renderedOpenapi(alreadyExtractedDescendantSerializers);
53
53
  if (this.allOfSiblings.length) {
54
- const openapi = referencedSerializersAndOpenapiSchemaBodyShorthand.openapi;
54
+ // Property-level locks live only at the `allOf` wrapper (never on the
55
+ // inline branch or the `$ref`'d siblings) so that all branches'
56
+ // properties are visible to `unevaluatedProperties` for the union check.
55
57
  return {
56
58
  ...referencedSerializersAndOpenapiSchemaBodyShorthand,
57
59
  openapi: {
58
- allOf: [openapi, ...this.allOfSiblings],
60
+ type: 'object',
61
+ allOf: [referencedSerializersAndOpenapiSchemaBodyShorthand.openapi, ...this.allOfSiblings],
62
+ unevaluatedProperties: false,
59
63
  },
60
64
  };
61
65
  }
@@ -98,7 +102,13 @@ export default class SerializerOpenapiRenderer {
98
102
  type: 'object',
99
103
  required: sort(uniq(requiredProperties.map(property => this.setCase(property)))),
100
104
  properties: sortObjectByKey(referencedSerializersAndAttributes.attributes),
101
- additionalProperties: false,
105
+ // Property-level locks (`additionalProperties` / `unevaluatedProperties`)
106
+ // are not emitted on leaf schemas: when a leaf is composed via `$ref`
107
+ // inside an `allOf`, neither keyword sees properties contributed by
108
+ // sibling branches, so a per-leaf lock incorrectly rejects flattened
109
+ // properties. Strictness is enforced at the `allOf`-wrapper level
110
+ // (`unevaluatedProperties: false`) when flattening occurs, and at the
111
+ // validation-pipeline level for top-level schemas.
102
112
  },
103
113
  };
104
114
  }
@@ -3,6 +3,7 @@ import { DreamApp } from '@rvoh/dream';
3
3
  import { openapiShorthandPrimitiveTypes, } from '@rvoh/dream/openapi';
4
4
  import NonSerializerSuppliedToSerializerBodySegment from '../error/openapi/NonSerializerSuppliedToSerializerBodySegment.js';
5
5
  import isArrayParamName from '../helpers/isArrayParamName.js';
6
+ import buildDreamRequestBodyShape from './helpers/buildDreamRequestBodyShape.js';
6
7
  import isBlankDescription from './helpers/isBlankDescription.js';
7
8
  import maybeNullOpenapiShorthandToOpenapiShorthand from './helpers/maybeNullOpenapiShorthandToOpenapiShorthand.js';
8
9
  import primitiveOpenapiStatementToOpenapi from './helpers/primitiveOpenapiStatementToOpenapi.js';
@@ -20,6 +21,7 @@ import SerializerOpenapiRenderer from './SerializerOpenapiRenderer.js';
20
21
  * - Nullable array shorthands like `['string[]', 'null']` → `{ type: ['array', 'null'], items: { type: 'string' } }`
21
22
  * - Serializer references like `{ $serializer: SomeSerializer }` → `{ $ref: '#/components/schemas/SerializerOpenapiName' }`
22
23
  * - Serializable references like `{ $serializable: SomeModel, key: 'summary' }` → resolved serializer reference
24
+ * - Nested model-derived request-body references like `{ for: SomeModel, including, only, required, combining }` → an inline object schema derived from the model's param-safe columns (request-only)
23
25
  *
24
26
  * The class recursively processes nested structures (objects, arrays, unions) and maintains
25
27
  * a collection of referenced serializers that need to be included in the final OpenAPI document.
@@ -52,17 +54,19 @@ export default class OpenapiSegmentExpander {
52
54
  casing;
53
55
  suppressResponseEnums;
54
56
  target;
57
+ source;
55
58
  /**
56
59
  * @internal
57
60
  *
58
61
  * Used to recursively expand nested object structures
59
62
  * within nested openapi objects
60
63
  */
61
- constructor(bodySegment, { renderOpts, target }) {
64
+ constructor(bodySegment, { renderOpts, target, source }) {
62
65
  this.bodySegment = bodySegment;
63
66
  this.casing = renderOpts.casing;
64
67
  this.suppressResponseEnums = renderOpts.suppressResponseEnums;
65
68
  this.target = target;
69
+ this.source = source ?? 'unknown';
66
70
  }
67
71
  /**
68
72
  * returns the shorthanded body segment, rendered
@@ -118,6 +122,8 @@ export default class OpenapiSegmentExpander {
118
122
  return this.serializerStatement(bodySegment);
119
123
  case '$serializable':
120
124
  return this.serializableStatement(bodySegment);
125
+ case '$for':
126
+ return this.forStatement(bodySegment);
121
127
  case 'unknown_object':
122
128
  return {
123
129
  referencedSerializers: [],
@@ -153,6 +159,8 @@ export default class OpenapiSegmentExpander {
153
159
  return '$serializer';
154
160
  if (serializableRefBodySegment.$serializable)
155
161
  return '$serializable';
162
+ if (typeof bodySegment?.for === 'function')
163
+ return '$for';
156
164
  if (refBodySegment.$ref)
157
165
  return '$ref';
158
166
  if (schemaRefBodySegment.$schema)
@@ -448,6 +456,40 @@ The following values will be allowed:
448
456
  }
449
457
  return { referencedSerializers: [serializer], openapi: serializerRef };
450
458
  }
459
+ /**
460
+ * @internal
461
+ *
462
+ * Expand a nested `for:` request-body sentinel into a fully-resolved
463
+ * `OpenapiSchemaObject`. The sentinel takes the same shape as the
464
+ * top-level model-driven `requestBody`:
465
+ *
466
+ * ```ts
467
+ * { for: SomeModel, including?, only?, required?, combining? }
468
+ * ```
469
+ *
470
+ * and produces the same shape that the top-level model-driven
471
+ * `requestBody` produces — param-safe columns inferred from the model,
472
+ * with `combining` entries (which themselves may contain further `for:`
473
+ * sentinels) merged into the resulting properties.
474
+ *
475
+ * The `for:` sentinel is request-only at the type level. If it is
476
+ * encountered while expanding a response shape, this method throws —
477
+ * the type system should have prevented this, so reaching here indicates
478
+ * a misuse that bypassed type checking (e.g., via `as any`).
479
+ */
480
+ forStatement(bodySegment) {
481
+ if (this.target !== 'request') {
482
+ throw new Error(`'for:' sentinel is only valid inside request body shorthand; encountered while expanding a response.`);
483
+ }
484
+ const ref = bodySegment;
485
+ const paramsShape = buildDreamRequestBodyShape(ref.for, {
486
+ only: ref.only,
487
+ including: ref.including,
488
+ required: ref.required,
489
+ combining: ref.combining,
490
+ }, this.source);
491
+ return this.recursivelyParseBody(paramsShape);
492
+ }
451
493
  serializableStatement(bodySegment) {
452
494
  const serializableRef = bodySegment;
453
495
  const key = serializableRef.$serializableSerializerKey || serializableRef.key || 'default';
@@ -6,10 +6,9 @@ import NonSerializerDerivedInToSchemaObjects from '../error/openapi/NonSerialize
6
6
  import OpenApiSerializerForEndpointNotAFunction from '../error/openapi/SerializerForEndpointNotAFunction.js';
7
7
  import isObject from '../helpers/isObject.js';
8
8
  import PsychicApp from '../psychic-app/index.js';
9
- import openapiParamNamesForDreamClass from '../server/helpers/openapiParamNamesForDreamClass.js';
10
9
  import OpenapiSegmentExpander from './body-segment.js';
11
10
  import { DEFAULT_OPENAPI_RESPONSES } from './defaults.js';
12
- import { dreamColumnOpenapiShape } from './helpers/dreamColumnOpenapiShape.js';
11
+ import buildDreamRequestBodyShape from './helpers/buildDreamRequestBodyShape.js';
13
12
  import openapiOpts from './helpers/openapiOpts.js';
14
13
  import openapiRoute from './helpers/openapiRoute.js';
15
14
  import safelyAttachCursorPaginationParamToRequestBodySegment from './helpers/safelyAttachCursorPaginationParamToRequestBodySegment.js';
@@ -519,30 +518,19 @@ export default class OpenapiEndpointRenderer {
519
518
  const dreamClass = forDreamClass || this.getSingleDreamModelClass();
520
519
  if (!dreamClass)
521
520
  return this.defaultRequestBody();
522
- const { only, including, combining } = (this.requestBody || {});
523
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
- const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, { only, including });
525
- const paramsShape = {
526
- type: 'object',
527
- properties: {},
528
- };
529
- const required = this.requestBody?.required;
530
- if (required) {
531
- paramsShape.required = required;
532
- }
533
- paramsShape.properties = paramSafeColumns.reduce((acc, columnName) => {
534
- acc[columnName] = dreamColumnOpenapiShape(this.controllerClass.controllerActionPath(this.action), dreamClass, columnName, undefined, {
535
- allowGenericJson: true,
536
- });
537
- return acc;
538
- }, paramsShape.properties);
539
- paramsShape.properties = {
540
- ...paramsShape.properties,
541
- ...(combining || {}),
542
- };
521
+ const { only, including, required, combining } = (this.requestBody ||
522
+ {});
523
+ const source = this.controllerClass.controllerActionPath(this.action);
524
+ const paramsShape = buildDreamRequestBodyShape(dreamClass, {
525
+ only: only,
526
+ including: including,
527
+ required: required,
528
+ combining: combining,
529
+ }, source);
543
530
  let processedSchema = new OpenapiSegmentExpander(paramsShape, {
544
531
  renderOpts,
545
532
  target: 'request',
533
+ source,
546
534
  }).render().openapi;
547
535
  const bodyPaginationPageParam = this.paginate?.body;
548
536
  if (bodyPaginationPageParam) {
@@ -0,0 +1,34 @@
1
+ import openapiParamNamesForDreamClass from '../../server/helpers/openapiParamNamesForDreamClass.js';
2
+ import { dreamColumnOpenapiShape } from './dreamColumnOpenapiShape.js';
3
+ /**
4
+ * @internal
5
+ *
6
+ * Builds an unexpanded `OpenapiSchemaObject` for a Dream model's request-body shape,
7
+ * resolving param-safe columns, attaching `required`, and merging `combining` entries.
8
+ *
9
+ * Used by both the top-level model-driven `requestBody` path in `OpenapiEndpointRenderer`
10
+ * and the `$dream` sentinel expansion inside `OpenapiSegmentExpander`. Single source of
11
+ * truth keeps top-level and nested semantics identical.
12
+ */
13
+ export default function buildDreamRequestBodyShape(dreamClass, opts, source) {
14
+ const { only, including, required, combining } = opts;
15
+ const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, { only, including });
16
+ const paramsShape = {
17
+ type: 'object',
18
+ properties: {},
19
+ };
20
+ if (required) {
21
+ paramsShape.required = required;
22
+ }
23
+ paramsShape.properties = paramSafeColumns.reduce((acc, columnName) => {
24
+ acc[columnName] = dreamColumnOpenapiShape(source, dreamClass, columnName, undefined, {
25
+ allowGenericJson: true,
26
+ });
27
+ return acc;
28
+ }, paramsShape.properties);
29
+ paramsShape.properties = {
30
+ ...paramsShape.properties,
31
+ ...(combining || {}),
32
+ };
33
+ return paramsShape;
34
+ }
@@ -4,7 +4,10 @@ import CannotInferControllerFromTopLevelRouteError from '../error/router/cannot-
4
4
  import pascalizeFileName from '../helpers/pascalizeFileName.js';
5
5
  import PsychicApp from '../psychic-app/index.js';
6
6
  export function routePath(routePath) {
7
- return `/${routePath.replace(/^\//, '')}`;
7
+ const normalized = `/${routePath.replace(/^\//, '')}`;
8
+ if (normalized === '/')
9
+ return normalized;
10
+ return normalized.replace(/\/+$/, '');
8
11
  }
9
12
  export function resourcePath(routePath) {
10
13
  return `/${routePath}/:id`;
@@ -72,7 +72,8 @@ export default class PsychicRouter {
72
72
  prefixPathWithNamespaces(str) {
73
73
  if (!this.currentNamespaces.length)
74
74
  return str;
75
- return '/' + this.currentNamespacePaths.join('/') + '/' + str;
75
+ const prefix = '/' + this.currentNamespacePaths.join('/');
76
+ return str ? `${prefix}/${str}` : prefix;
76
77
  }
77
78
  crud(httpMethod, path, controllerOrMiddleware, action) {
78
79
  this.checkPathForInvalidChars(path);
@@ -74,3 +74,40 @@ export function OpenAPI(modelOrSerializer, _opts) {
74
74
  });
75
75
  };
76
76
  }
77
+ // eslint-disable-next-line @typescript-eslint/no-namespace
78
+ (function (OpenAPI) {
79
+ /**
80
+ * Type-narrowed helper for nesting a Dream-model-driven shape inside a
81
+ * request body. Returns the same `{ for, only, including, required,
82
+ * combining }` shape the renderer recognizes natively, but typed via a
83
+ * function-level generic so `only` / `including` / `required` are
84
+ * constrained to columns of the model passed as the first argument.
85
+ *
86
+ * ```ts
87
+ * @OpenAPI(ActionPlan, {
88
+ * requestBody: {
89
+ * including: ['type', 'clientId'],
90
+ * combining: {
91
+ * planItems: {
92
+ * type: 'array',
93
+ * items: OpenAPI.forDream(ActionItem, {
94
+ * required: ['title', 'type'],
95
+ * }),
96
+ * },
97
+ * },
98
+ * },
99
+ * })
100
+ * ```
101
+ *
102
+ * Wrong column names raise a compile-time error:
103
+ *
104
+ * ```ts
105
+ * OpenAPI.forDream(Pet, { including: ['notARealColumn'] })
106
+ * // ^^^^^^^^^^^^^^^^^ type error
107
+ * ```
108
+ */
109
+ function forDream(model, opts = {}) {
110
+ return { for: model, ...opts };
111
+ }
112
+ OpenAPI.forDream = forDream;
113
+ })(OpenAPI || (OpenAPI = {}));
@@ -1,4 +1,4 @@
1
- import { Ajv } from 'ajv';
1
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
3
  /**
4
4
  * @internal
@@ -99,7 +99,7 @@ export function createValidator(schema, options = {}) {
99
99
  ...options,
100
100
  init: undefined,
101
101
  };
102
- const ajv = new Ajv({
102
+ const ajv = new Ajv2020({
103
103
  removeAdditional: false,
104
104
  useDefaults: true,
105
105
  strict: false,
@@ -51,11 +51,15 @@ export default class SerializerOpenapiRenderer {
51
51
  alreadyExtractedDescendantSerializers[this.serializer.globalName] = true;
52
52
  const referencedSerializersAndOpenapiSchemaBodyShorthand = this._renderedOpenapi(alreadyExtractedDescendantSerializers);
53
53
  if (this.allOfSiblings.length) {
54
- const openapi = referencedSerializersAndOpenapiSchemaBodyShorthand.openapi;
54
+ // Property-level locks live only at the `allOf` wrapper (never on the
55
+ // inline branch or the `$ref`'d siblings) so that all branches'
56
+ // properties are visible to `unevaluatedProperties` for the union check.
55
57
  return {
56
58
  ...referencedSerializersAndOpenapiSchemaBodyShorthand,
57
59
  openapi: {
58
- allOf: [openapi, ...this.allOfSiblings],
60
+ type: 'object',
61
+ allOf: [referencedSerializersAndOpenapiSchemaBodyShorthand.openapi, ...this.allOfSiblings],
62
+ unevaluatedProperties: false,
59
63
  },
60
64
  };
61
65
  }
@@ -98,7 +102,13 @@ export default class SerializerOpenapiRenderer {
98
102
  type: 'object',
99
103
  required: sort(uniq(requiredProperties.map(property => this.setCase(property)))),
100
104
  properties: sortObjectByKey(referencedSerializersAndAttributes.attributes),
101
- additionalProperties: false,
105
+ // Property-level locks (`additionalProperties` / `unevaluatedProperties`)
106
+ // are not emitted on leaf schemas: when a leaf is composed via `$ref`
107
+ // inside an `allOf`, neither keyword sees properties contributed by
108
+ // sibling branches, so a per-leaf lock incorrectly rejects flattened
109
+ // properties. Strictness is enforced at the `allOf`-wrapper level
110
+ // (`unevaluatedProperties: false`) when flattening occurs, and at the
111
+ // validation-pipeline level for top-level schemas.
102
112
  },
103
113
  };
104
114
  }
@@ -3,6 +3,7 @@ import { DreamApp } from '@rvoh/dream';
3
3
  import { openapiShorthandPrimitiveTypes, } from '@rvoh/dream/openapi';
4
4
  import NonSerializerSuppliedToSerializerBodySegment from '../error/openapi/NonSerializerSuppliedToSerializerBodySegment.js';
5
5
  import isArrayParamName from '../helpers/isArrayParamName.js';
6
+ import buildDreamRequestBodyShape from './helpers/buildDreamRequestBodyShape.js';
6
7
  import isBlankDescription from './helpers/isBlankDescription.js';
7
8
  import maybeNullOpenapiShorthandToOpenapiShorthand from './helpers/maybeNullOpenapiShorthandToOpenapiShorthand.js';
8
9
  import primitiveOpenapiStatementToOpenapi from './helpers/primitiveOpenapiStatementToOpenapi.js';
@@ -20,6 +21,7 @@ import SerializerOpenapiRenderer from './SerializerOpenapiRenderer.js';
20
21
  * - Nullable array shorthands like `['string[]', 'null']` → `{ type: ['array', 'null'], items: { type: 'string' } }`
21
22
  * - Serializer references like `{ $serializer: SomeSerializer }` → `{ $ref: '#/components/schemas/SerializerOpenapiName' }`
22
23
  * - Serializable references like `{ $serializable: SomeModel, key: 'summary' }` → resolved serializer reference
24
+ * - Nested model-derived request-body references like `{ for: SomeModel, including, only, required, combining }` → an inline object schema derived from the model's param-safe columns (request-only)
23
25
  *
24
26
  * The class recursively processes nested structures (objects, arrays, unions) and maintains
25
27
  * a collection of referenced serializers that need to be included in the final OpenAPI document.
@@ -52,17 +54,19 @@ export default class OpenapiSegmentExpander {
52
54
  casing;
53
55
  suppressResponseEnums;
54
56
  target;
57
+ source;
55
58
  /**
56
59
  * @internal
57
60
  *
58
61
  * Used to recursively expand nested object structures
59
62
  * within nested openapi objects
60
63
  */
61
- constructor(bodySegment, { renderOpts, target }) {
64
+ constructor(bodySegment, { renderOpts, target, source }) {
62
65
  this.bodySegment = bodySegment;
63
66
  this.casing = renderOpts.casing;
64
67
  this.suppressResponseEnums = renderOpts.suppressResponseEnums;
65
68
  this.target = target;
69
+ this.source = source ?? 'unknown';
66
70
  }
67
71
  /**
68
72
  * returns the shorthanded body segment, rendered
@@ -118,6 +122,8 @@ export default class OpenapiSegmentExpander {
118
122
  return this.serializerStatement(bodySegment);
119
123
  case '$serializable':
120
124
  return this.serializableStatement(bodySegment);
125
+ case '$for':
126
+ return this.forStatement(bodySegment);
121
127
  case 'unknown_object':
122
128
  return {
123
129
  referencedSerializers: [],
@@ -153,6 +159,8 @@ export default class OpenapiSegmentExpander {
153
159
  return '$serializer';
154
160
  if (serializableRefBodySegment.$serializable)
155
161
  return '$serializable';
162
+ if (typeof bodySegment?.for === 'function')
163
+ return '$for';
156
164
  if (refBodySegment.$ref)
157
165
  return '$ref';
158
166
  if (schemaRefBodySegment.$schema)
@@ -448,6 +456,40 @@ The following values will be allowed:
448
456
  }
449
457
  return { referencedSerializers: [serializer], openapi: serializerRef };
450
458
  }
459
+ /**
460
+ * @internal
461
+ *
462
+ * Expand a nested `for:` request-body sentinel into a fully-resolved
463
+ * `OpenapiSchemaObject`. The sentinel takes the same shape as the
464
+ * top-level model-driven `requestBody`:
465
+ *
466
+ * ```ts
467
+ * { for: SomeModel, including?, only?, required?, combining? }
468
+ * ```
469
+ *
470
+ * and produces the same shape that the top-level model-driven
471
+ * `requestBody` produces — param-safe columns inferred from the model,
472
+ * with `combining` entries (which themselves may contain further `for:`
473
+ * sentinels) merged into the resulting properties.
474
+ *
475
+ * The `for:` sentinel is request-only at the type level. If it is
476
+ * encountered while expanding a response shape, this method throws —
477
+ * the type system should have prevented this, so reaching here indicates
478
+ * a misuse that bypassed type checking (e.g., via `as any`).
479
+ */
480
+ forStatement(bodySegment) {
481
+ if (this.target !== 'request') {
482
+ throw new Error(`'for:' sentinel is only valid inside request body shorthand; encountered while expanding a response.`);
483
+ }
484
+ const ref = bodySegment;
485
+ const paramsShape = buildDreamRequestBodyShape(ref.for, {
486
+ only: ref.only,
487
+ including: ref.including,
488
+ required: ref.required,
489
+ combining: ref.combining,
490
+ }, this.source);
491
+ return this.recursivelyParseBody(paramsShape);
492
+ }
451
493
  serializableStatement(bodySegment) {
452
494
  const serializableRef = bodySegment;
453
495
  const key = serializableRef.$serializableSerializerKey || serializableRef.key || 'default';
@@ -6,10 +6,9 @@ import NonSerializerDerivedInToSchemaObjects from '../error/openapi/NonSerialize
6
6
  import OpenApiSerializerForEndpointNotAFunction from '../error/openapi/SerializerForEndpointNotAFunction.js';
7
7
  import isObject from '../helpers/isObject.js';
8
8
  import PsychicApp from '../psychic-app/index.js';
9
- import openapiParamNamesForDreamClass from '../server/helpers/openapiParamNamesForDreamClass.js';
10
9
  import OpenapiSegmentExpander from './body-segment.js';
11
10
  import { DEFAULT_OPENAPI_RESPONSES } from './defaults.js';
12
- import { dreamColumnOpenapiShape } from './helpers/dreamColumnOpenapiShape.js';
11
+ import buildDreamRequestBodyShape from './helpers/buildDreamRequestBodyShape.js';
13
12
  import openapiOpts from './helpers/openapiOpts.js';
14
13
  import openapiRoute from './helpers/openapiRoute.js';
15
14
  import safelyAttachCursorPaginationParamToRequestBodySegment from './helpers/safelyAttachCursorPaginationParamToRequestBodySegment.js';
@@ -519,30 +518,19 @@ export default class OpenapiEndpointRenderer {
519
518
  const dreamClass = forDreamClass || this.getSingleDreamModelClass();
520
519
  if (!dreamClass)
521
520
  return this.defaultRequestBody();
522
- const { only, including, combining } = (this.requestBody || {});
523
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
- const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, { only, including });
525
- const paramsShape = {
526
- type: 'object',
527
- properties: {},
528
- };
529
- const required = this.requestBody?.required;
530
- if (required) {
531
- paramsShape.required = required;
532
- }
533
- paramsShape.properties = paramSafeColumns.reduce((acc, columnName) => {
534
- acc[columnName] = dreamColumnOpenapiShape(this.controllerClass.controllerActionPath(this.action), dreamClass, columnName, undefined, {
535
- allowGenericJson: true,
536
- });
537
- return acc;
538
- }, paramsShape.properties);
539
- paramsShape.properties = {
540
- ...paramsShape.properties,
541
- ...(combining || {}),
542
- };
521
+ const { only, including, required, combining } = (this.requestBody ||
522
+ {});
523
+ const source = this.controllerClass.controllerActionPath(this.action);
524
+ const paramsShape = buildDreamRequestBodyShape(dreamClass, {
525
+ only: only,
526
+ including: including,
527
+ required: required,
528
+ combining: combining,
529
+ }, source);
543
530
  let processedSchema = new OpenapiSegmentExpander(paramsShape, {
544
531
  renderOpts,
545
532
  target: 'request',
533
+ source,
546
534
  }).render().openapi;
547
535
  const bodyPaginationPageParam = this.paginate?.body;
548
536
  if (bodyPaginationPageParam) {
@@ -0,0 +1,34 @@
1
+ import openapiParamNamesForDreamClass from '../../server/helpers/openapiParamNamesForDreamClass.js';
2
+ import { dreamColumnOpenapiShape } from './dreamColumnOpenapiShape.js';
3
+ /**
4
+ * @internal
5
+ *
6
+ * Builds an unexpanded `OpenapiSchemaObject` for a Dream model's request-body shape,
7
+ * resolving param-safe columns, attaching `required`, and merging `combining` entries.
8
+ *
9
+ * Used by both the top-level model-driven `requestBody` path in `OpenapiEndpointRenderer`
10
+ * and the `$dream` sentinel expansion inside `OpenapiSegmentExpander`. Single source of
11
+ * truth keeps top-level and nested semantics identical.
12
+ */
13
+ export default function buildDreamRequestBodyShape(dreamClass, opts, source) {
14
+ const { only, including, required, combining } = opts;
15
+ const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, { only, including });
16
+ const paramsShape = {
17
+ type: 'object',
18
+ properties: {},
19
+ };
20
+ if (required) {
21
+ paramsShape.required = required;
22
+ }
23
+ paramsShape.properties = paramSafeColumns.reduce((acc, columnName) => {
24
+ acc[columnName] = dreamColumnOpenapiShape(source, dreamClass, columnName, undefined, {
25
+ allowGenericJson: true,
26
+ });
27
+ return acc;
28
+ }, paramsShape.properties);
29
+ paramsShape.properties = {
30
+ ...paramsShape.properties,
31
+ ...(combining || {}),
32
+ };
33
+ return paramsShape;
34
+ }
@@ -4,7 +4,10 @@ import CannotInferControllerFromTopLevelRouteError from '../error/router/cannot-
4
4
  import pascalizeFileName from '../helpers/pascalizeFileName.js';
5
5
  import PsychicApp from '../psychic-app/index.js';
6
6
  export function routePath(routePath) {
7
- return `/${routePath.replace(/^\//, '')}`;
7
+ const normalized = `/${routePath.replace(/^\//, '')}`;
8
+ if (normalized === '/')
9
+ return normalized;
10
+ return normalized.replace(/\/+$/, '');
8
11
  }
9
12
  export function resourcePath(routePath) {
10
13
  return `/${routePath}/:id`;
@@ -72,7 +72,8 @@ export default class PsychicRouter {
72
72
  prefixPathWithNamespaces(str) {
73
73
  if (!this.currentNamespaces.length)
74
74
  return str;
75
- return '/' + this.currentNamespacePaths.join('/') + '/' + str;
75
+ const prefix = '/' + this.currentNamespacePaths.join('/');
76
+ return str ? `${prefix}/${str}` : prefix;
76
77
  }
77
78
  crud(httpMethod, path, controllerOrMiddleware, action) {
78
79
  this.checkPathForInvalidChars(path);
@@ -1,6 +1,6 @@
1
1
  import { Dream } from '@rvoh/dream';
2
- import { DreamSerializable, DreamSerializableArray } from '@rvoh/dream/types';
3
- import { OpenapiEndpointRendererOpts } from '../openapi-renderer/endpoint.js';
2
+ import { DreamParamSafeAttributes, DreamSerializable, DreamSerializableArray, UpdateableProperties } from '@rvoh/dream/types';
3
+ import { OpenapiEndpointRendererOpts, OpenapiSchemaNestedFor, OpenapiSchemaRequestPropertiesShorthandWithFor } from '../openapi-renderer/endpoint.js';
4
4
  export declare function BeforeAction(opts?: {
5
5
  isStatic?: boolean;
6
6
  only?: string[];
@@ -9,3 +9,41 @@ export declare function BeforeAction(opts?: {
9
9
  export declare function OpenAPI(): any;
10
10
  export declare function OpenAPI<const ForOption extends typeof Dream>(opts: OpenapiEndpointRendererOpts<undefined, ForOption>): any;
11
11
  export declare function OpenAPI<const I extends DreamSerializable | DreamSerializableArray, const ForOption extends typeof Dream>(modelOrSerializer: I, opts?: OpenapiEndpointRendererOpts<I, ForOption>): any;
12
+ export declare namespace OpenAPI {
13
+ /**
14
+ * Type-narrowed helper for nesting a Dream-model-driven shape inside a
15
+ * request body. Returns the same `{ for, only, including, required,
16
+ * combining }` shape the renderer recognizes natively, but typed via a
17
+ * function-level generic so `only` / `including` / `required` are
18
+ * constrained to columns of the model passed as the first argument.
19
+ *
20
+ * ```ts
21
+ * @OpenAPI(ActionPlan, {
22
+ * requestBody: {
23
+ * including: ['type', 'clientId'],
24
+ * combining: {
25
+ * planItems: {
26
+ * type: 'array',
27
+ * items: OpenAPI.forDream(ActionItem, {
28
+ * required: ['title', 'type'],
29
+ * }),
30
+ * },
31
+ * },
32
+ * },
33
+ * })
34
+ * ```
35
+ *
36
+ * Wrong column names raise a compile-time error:
37
+ *
38
+ * ```ts
39
+ * OpenAPI.forDream(Pet, { including: ['notARealColumn'] })
40
+ * // ^^^^^^^^^^^^^^^^^ type error
41
+ * ```
42
+ */
43
+ function forDream<const M extends typeof Dream>(model: M, opts?: {
44
+ only?: readonly (keyof DreamParamSafeAttributes<InstanceType<M>>)[];
45
+ including?: readonly Exclude<keyof UpdateableProperties<InstanceType<M>>, keyof DreamParamSafeAttributes<InstanceType<M>>>[];
46
+ required?: readonly (keyof UpdateableProperties<InstanceType<M>>)[];
47
+ combining?: OpenapiSchemaRequestPropertiesShorthandWithFor;
48
+ }): OpenapiSchemaNestedFor;
49
+ }
@@ -1,4 +1,5 @@
1
- import { Ajv, type ErrorObject, type JSONSchemaType, type ValidateFunction } from 'ajv';
1
+ import { type ErrorObject, type JSONSchemaType, type ValidateFunction } from 'ajv';
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
3
  /**
3
4
  * @internal
4
5
  *
@@ -72,8 +73,8 @@ export interface ValidationError {
72
73
  message: string;
73
74
  params?: Record<string, unknown>;
74
75
  }
75
- export type AjvValidationOpts = ConstructorParameters<typeof Ajv>[0];
76
+ export type AjvValidationOpts = ConstructorParameters<typeof Ajv2020>[0];
76
77
  export type ValidateOpenapiSchemaOptions = AjvValidationOpts & CustomOpenapiValidationOptions;
77
78
  export interface CustomOpenapiValidationOptions {
78
- init?: (ajv: Ajv) => void;
79
+ init?: (ajv: Ajv2020) => void;
79
80
  }
@@ -4,6 +4,13 @@ import { OpenapiEndpointResponse, OpenapiRenderOpts, OpenapiResponses } from './
4
4
  export interface OpenapiBodySegmentRendererOpts {
5
5
  renderOpts: OpenapiRenderOpts;
6
6
  target: OpenapiBodyTarget;
7
+ /**
8
+ * Identifier (typically a controller-action path) used to enrich error
9
+ * messages emitted by `dreamColumnOpenapiShape` when an unrecognized DB
10
+ * type is encountered while expanding a `$dream` sentinel. Optional —
11
+ * defaults to `'unknown'` when not supplied.
12
+ */
13
+ source?: string;
7
14
  }
8
15
  /**
9
16
  * @internal
@@ -17,6 +24,7 @@ export interface OpenapiBodySegmentRendererOpts {
17
24
  * - Nullable array shorthands like `['string[]', 'null']` → `{ type: ['array', 'null'], items: { type: 'string' } }`
18
25
  * - Serializer references like `{ $serializer: SomeSerializer }` → `{ $ref: '#/components/schemas/SerializerOpenapiName' }`
19
26
  * - Serializable references like `{ $serializable: SomeModel, key: 'summary' }` → resolved serializer reference
27
+ * - Nested model-derived request-body references like `{ for: SomeModel, including, only, required, combining }` → an inline object schema derived from the model's param-safe columns (request-only)
20
28
  *
21
29
  * The class recursively processes nested structures (objects, arrays, unions) and maintains
22
30
  * a collection of referenced serializers that need to be included in the final OpenAPI document.
@@ -49,13 +57,14 @@ export default class OpenapiSegmentExpander {
49
57
  private casing;
50
58
  private suppressResponseEnums;
51
59
  private target;
60
+ private source;
52
61
  /**
53
62
  * @internal
54
63
  *
55
64
  * Used to recursively expand nested object structures
56
65
  * within nested openapi objects
57
66
  */
58
- constructor(bodySegment: OpenapiBodySegment, { renderOpts, target }: OpenapiBodySegmentRendererOpts);
67
+ constructor(bodySegment: OpenapiBodySegment, { renderOpts, target, source }: OpenapiBodySegmentRendererOpts);
59
68
  /**
60
69
  * returns the shorthanded body segment, rendered
61
70
  * to the appropriate openapi shape
@@ -138,6 +147,28 @@ export default class OpenapiSegmentExpander {
138
147
  * recursively a $ref statement
139
148
  */
140
149
  private serializerStatement;
150
+ /**
151
+ * @internal
152
+ *
153
+ * Expand a nested `for:` request-body sentinel into a fully-resolved
154
+ * `OpenapiSchemaObject`. The sentinel takes the same shape as the
155
+ * top-level model-driven `requestBody`:
156
+ *
157
+ * ```ts
158
+ * { for: SomeModel, including?, only?, required?, combining? }
159
+ * ```
160
+ *
161
+ * and produces the same shape that the top-level model-driven
162
+ * `requestBody` produces — param-safe columns inferred from the model,
163
+ * with `combining` entries (which themselves may contain further `for:`
164
+ * sentinels) merged into the resulting properties.
165
+ *
166
+ * The `for:` sentinel is request-only at the type level. If it is
167
+ * encountered while expanding a response shape, this method throws —
168
+ * the type system should have prevented this, so reaching here indicates
169
+ * a misuse that bypassed type checking (e.g., via `as any`).
170
+ */
171
+ private forStatement;
141
172
  private serializableStatement;
142
173
  /**
143
174
  * @internal
@@ -1,5 +1,5 @@
1
1
  import { Dream } from '@rvoh/dream';
2
- import { OpenapiAllTypes, OpenapiFormats, OpenapiSchemaArray, OpenapiSchemaBody, OpenapiSchemaBodyShorthand, OpenapiSchemaExpressionAllOf, OpenapiSchemaExpressionAnyOf, OpenapiSchemaExpressionOneOf, OpenapiSchemaExpressionRef, OpenapiSchemaObject, OpenapiSchemaProperties, OpenapiSchemaPropertiesShorthand } from '@rvoh/dream/openapi';
2
+ import { OpenapiAllTypes, OpenapiFormats, OpenapiSchemaArray, OpenapiSchemaBody, OpenapiSchemaBodyShorthand, OpenapiSchemaExpressionAllOf, OpenapiSchemaExpressionAnyOf, OpenapiSchemaExpressionOneOf, OpenapiSchemaExpressionRef, OpenapiSchemaObject, OpenapiSchemaProperties, OpenapiSchemaPropertiesShorthand, OpenapiShorthandPrimitiveTypes } from '@rvoh/dream/openapi';
3
3
  import { DreamAttributes, DreamOrViewModelClassSerializerKey, DreamParamSafeAttributes, DreamSerializable, DreamSerializableArray, SerializerCasing, UpdateableProperties, ViewModelClass } from '@rvoh/dream/types';
4
4
  import PsychicController from '../controller/index.js';
5
5
  import { HttpStatusCode, HttpStatusCodeNumber } from '../error/http/status-codes.js';
@@ -453,7 +453,7 @@ export interface OpenapiEndpointRendererOpts<I extends DreamSerializable | Dream
453
453
  * })
454
454
  * ```
455
455
  */
456
- requestBody?: OpenapiSchemaBodyShorthand | OpenapiSchemaRequestBodyForOption<I, ForOption> | null;
456
+ requestBody?: OpenapiSchemaRequestBodyShorthandWithFor | OpenapiSchemaRequestBodyForOption<I, ForOption> | null;
457
457
  /**
458
458
  * an array of tag names you wish to apply to this endpoint.
459
459
  * tag names will determine placement of this request within
@@ -745,6 +745,80 @@ export interface OpenapiEndpointRendererDefaultResponseOption {
745
745
  description?: string;
746
746
  maybeNull?: boolean;
747
747
  }
748
+ /**
749
+ * @internal
750
+ *
751
+ * Predecessor map used to decrement a depth counter inside recursive
752
+ * `$dream` shorthand types. Capped at 3 levels of `$dream`-aware
753
+ * recursion; beyond that the shape falls back to the unconstrained
754
+ * Dream-side `OpenapiSchemaPropertiesShorthand` and accepts but does
755
+ * not narrow further nesting.
756
+ */
757
+ type DreamShorthandPrev = [never, 0, 1, 2, 3];
758
+ /**
759
+ * Nested model-derived request-body shorthand. Same shape as the
760
+ * top-level model-driven `requestBody` (with `for:` naming the model),
761
+ * but usable at any nesting site — inside `combining`, inside raw
762
+ * `properties` of a non-model body, and inside `items` of an array.
763
+ *
764
+ * Mirrors the response-side `$serializable` / `$serializer` keys
765
+ * recognized by `OpenapiSegmentExpander`, but expands via param-safe
766
+ * column derivation instead of serializer resolution.
767
+ *
768
+ * `for:` at this nesting position is request-only at the type level.
769
+ * Response shorthand types do not include it.
770
+ *
771
+ * ```ts
772
+ * @OpenAPI(ActionPlan, {
773
+ * requestBody: {
774
+ * including: ['type', 'clientId'],
775
+ * combining: {
776
+ * planItems: {
777
+ * type: 'array',
778
+ * items: { for: ActionItem, required: ['title', 'type'] },
779
+ * },
780
+ * },
781
+ * },
782
+ * })
783
+ * ```
784
+ */
785
+ export type OpenapiSchemaNestedFor<Depth extends 0 | 1 | 2 | 3 = 3> = {
786
+ for: typeof Dream;
787
+ including?: readonly string[];
788
+ only?: readonly string[];
789
+ required?: readonly string[];
790
+ combining?: Depth extends 0 ? OpenapiSchemaPropertiesShorthand : OpenapiSchemaRequestPropertiesShorthandWithFor<DreamShorthandPrev[Depth] & (0 | 1 | 2 | 3)>;
791
+ };
792
+ /**
793
+ * Property-shorthand record (request-only) whose values may be any of
794
+ * the existing Dream-side body shorthands OR a nested `for:` sentinel.
795
+ * Used as the type of `combining` and as the value type of nested
796
+ * `properties` / `items` in request bodies.
797
+ *
798
+ * `for:`-aware recursion is depth-bounded (cap = 3); past the cap the
799
+ * value type falls back to the unconstrained Dream-side shorthand to
800
+ * avoid TypeScript "type instantiation is excessively deep" errors.
801
+ */
802
+ export interface OpenapiSchemaRequestPropertiesShorthandWithFor<Depth extends 0 | 1 | 2 | 3 = 3> {
803
+ [key: string]: OpenapiSchemaRequestBodyShorthandWithFor<Depth> | OpenapiShorthandPrimitiveTypes;
804
+ }
805
+ /**
806
+ * Body-shorthand value (request-only) — a Dream-side shorthand OR a
807
+ * nested `for:` sentinel OR an object/array shape whose nested values
808
+ * may themselves contain `for:`. Recurses through `items` and
809
+ * `properties` with a bounded depth.
810
+ */
811
+ export type OpenapiSchemaRequestBodyShorthandWithFor<Depth extends 0 | 1 | 2 | 3 = 3> = OpenapiSchemaBodyShorthand | OpenapiSchemaNestedFor<Depth> | {
812
+ type: 'array' | readonly ['array', 'null'] | readonly ['null', 'array'];
813
+ items: OpenapiSchemaRequestBodyShorthandWithFor<Depth>;
814
+ description?: string;
815
+ } | {
816
+ type: 'object' | readonly ['object', 'null'] | readonly ['null', 'object'];
817
+ properties?: OpenapiSchemaRequestPropertiesShorthandWithFor<Depth>;
818
+ required?: readonly string[];
819
+ description?: string;
820
+ additionalProperties?: boolean | OpenapiSchemaRequestBodyShorthandWithFor<Depth>;
821
+ };
748
822
  export type OpenapiSchemaRequestBodyForOption<Serializable extends DreamSerializable | DreamSerializableArray | undefined, ForOption extends typeof Dream | undefined = undefined> = OpenapiSchemaRequestBodyForDreamClass<ForOption extends typeof Dream ? ForOption : typeof Dream> | OpenapiSchemaRequestBodyForBaseDreamClass<Serializable>;
749
823
  export interface OpenapiSchemaRequestBodyForDreamClass<ForOption extends typeof Dream> {
750
824
  /**
@@ -818,7 +892,7 @@ export interface OpenapiSchemaRequestBodyForDreamClass<ForOption extends typeof
818
892
  including?: Exclude<keyof UpdateableProperties<InstanceType<ForOption>>, keyof DreamParamSafeAttributes<InstanceType<ForOption>>>[];
819
893
  /**
820
894
  * expand the included fields to allow additional fields
821
- * that are unrelated to the model's params
895
+ * that are unrelated to the model's params.
822
896
  *
823
897
  * ```ts
824
898
  * @OpenAPI({
@@ -833,8 +907,35 @@ export interface OpenapiSchemaRequestBodyForDreamClass<ForOption extends typeof
833
907
  * ...
834
908
  * }
835
909
  * ```
910
+ *
911
+ * `combining` values may also be a nested `for:` sentinel — the same
912
+ * shape as the top-level model-driven `requestBody`, just nested. It
913
+ * derives an object schema from another Dream model's param-safe
914
+ * columns and accepts the same `including` / `only` / `required` /
915
+ * `combining` siblings.
916
+ *
917
+ * ```ts
918
+ * @OpenAPI(ActionPlan, {
919
+ * requestBody: {
920
+ * including: ['type', 'clientId'],
921
+ * combining: {
922
+ * planItems: {
923
+ * type: 'array',
924
+ * items: {
925
+ * for: ActionItem,
926
+ * required: ['title', 'type'],
927
+ * },
928
+ * },
929
+ * },
930
+ * },
931
+ * })
932
+ * ```
933
+ *
934
+ * The nested `for:` sentinel is request-only; it is not accepted in
935
+ * `responses` shorthand (use the existing `$serializable` / `$serializer`
936
+ * keys there).
836
937
  */
837
- combining?: OpenapiSchemaPropertiesShorthand;
938
+ combining?: OpenapiSchemaRequestPropertiesShorthandWithFor;
838
939
  /**
839
940
  * Specify which fields are required for your openapi
840
941
  * request body.
@@ -897,7 +998,7 @@ export interface OpenapiSchemaRequestBodyForBaseDreamClass<Serializable extends
897
998
  including?: Including;
898
999
  /**
899
1000
  * expand the included fields to allow additional fields
900
- * that are unrelated to the model's params
1001
+ * that are unrelated to the model's params.
901
1002
  *
902
1003
  * ```ts
903
1004
  * @OpenAPI({
@@ -912,8 +1013,35 @@ export interface OpenapiSchemaRequestBodyForBaseDreamClass<Serializable extends
912
1013
  * ...
913
1014
  * }
914
1015
  * ```
1016
+ *
1017
+ * `combining` values may also be a nested `for:` sentinel — the same
1018
+ * shape as the top-level model-driven `requestBody`, just nested. It
1019
+ * derives an object schema from another Dream model's param-safe
1020
+ * columns and accepts the same `including` / `only` / `required` /
1021
+ * `combining` siblings.
1022
+ *
1023
+ * ```ts
1024
+ * @OpenAPI(ActionPlan, {
1025
+ * requestBody: {
1026
+ * including: ['type', 'clientId'],
1027
+ * combining: {
1028
+ * planItems: {
1029
+ * type: 'array',
1030
+ * items: {
1031
+ * for: ActionItem,
1032
+ * required: ['title', 'type'],
1033
+ * },
1034
+ * },
1035
+ * },
1036
+ * },
1037
+ * })
1038
+ * ```
1039
+ *
1040
+ * The nested `for:` sentinel is request-only; it is not accepted in
1041
+ * `responses` shorthand (use the existing `$serializable` / `$serializer`
1042
+ * keys there).
915
1043
  */
916
- combining?: OpenapiSchemaPropertiesShorthand;
1044
+ combining?: OpenapiSchemaRequestPropertiesShorthandWithFor;
917
1045
  /**
918
1046
  * Specify which fields are required for your openapi
919
1047
  * request body.
@@ -1051,3 +1179,4 @@ export interface ReferencedSerializersAndOpenapiContent {
1051
1179
  referencedSerializers: SerializerArray;
1052
1180
  openapi: OpenapiContent;
1053
1181
  }
1182
+ export {};
@@ -0,0 +1,19 @@
1
+ import { Dream } from '@rvoh/dream';
2
+ import { OpenapiSchemaObject } from '@rvoh/dream/openapi';
3
+ export interface BuildDreamRequestBodyShapeOpts {
4
+ only?: readonly string[] | undefined;
5
+ including?: readonly string[] | undefined;
6
+ required?: readonly string[] | undefined;
7
+ combining?: Record<string, unknown> | undefined;
8
+ }
9
+ /**
10
+ * @internal
11
+ *
12
+ * Builds an unexpanded `OpenapiSchemaObject` for a Dream model's request-body shape,
13
+ * resolving param-safe columns, attaching `required`, and merging `combining` entries.
14
+ *
15
+ * Used by both the top-level model-driven `requestBody` path in `OpenapiEndpointRenderer`
16
+ * and the `$dream` sentinel expansion inside `OpenapiSegmentExpander`. Single source of
17
+ * truth keeps top-level and nested semantics identical.
18
+ */
19
+ export default function buildDreamRequestBodyShape(dreamClass: typeof Dream, opts: BuildDreamRequestBodyShapeOpts, source: string): OpenapiSchemaObject;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.2.0",
5
+ "version": "3.3.0",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",