@appweaver/core 1.4.0 → 1.4.1

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.
@@ -99,6 +99,12 @@ class ExportService {
99
99
  // Skip mapping for non-exposed fields.
100
100
  continue;
101
101
  }
102
+ // The records are read from the database rather than through a response
103
+ // schema, so the fields no response carries are dropped here instead.
104
+ if (modelSchema?.['hidden'] === true ||
105
+ resourceModel.config.virtual?.[key]?.output?.type === 'none') {
106
+ continue;
107
+ }
102
108
  let header = parentKey ? `${parentKey}.${key}` : key;
103
109
  const isArrayValue = (0, common_1.isArray)(value);
104
110
  const exportField = exportConfig?.[key];
@@ -50,6 +50,7 @@ function buildModel(config) {
50
50
  const createModel = omitOrPickScalars(resolveDefaultScalars(typebox_1.Type.Composite([scalarsSchema, virtualSchema]), config), config.create);
51
51
  const updateModel = omitOrPickScalars(resolveDefaultScalars(typebox_1.Type.Partial(typebox_1.Type.Composite([scalarsSchema, virtualSchema])), config), config.update);
52
52
  const { readOneModel, readManyModel } = buildOutputModels(baseReadModel, relationsModel, filesModel, config);
53
+ const readOneNullableModel = buildNullableOutputModel(name);
53
54
  const { createOneModel, updateOneModel, relationCreateModel, relationUpdateModel, relationInputModel } = buildInputModels(createModel, updateModel, relationsModel, idSchema, config);
54
55
  const { fileUploadModel, fileDeleteModel } = buildFileInputModels(config);
55
56
  const resourceModel = {
@@ -64,6 +65,7 @@ function buildModel(config) {
64
65
  virtualModel,
65
66
  readOneModel,
66
67
  readManyModel,
68
+ readOneNullableModel,
67
69
  createOneModel,
68
70
  updateOneModel,
69
71
  relationCreateModel,
@@ -223,6 +225,17 @@ function buildOutputModels(readModel, relationsModel, filesModel, config) {
223
225
  ], { $id: `${config.name}Multiple` });
224
226
  return { readOneModel, readManyModel };
225
227
  }
228
+ function buildNullableOutputModel(name) {
229
+ // Builds the nullable variant of the single read model, registered under its own
230
+ // name so a nullable relation can reference it instead of inlining the union.
231
+ // The response serializer cannot compile an inline `anyOf` union that cycles
232
+ // back to a model it is already writing, since it expands every union branch
233
+ // into a fresh schema. A union behind a name is expanded once and reused, which
234
+ // keeps a self-referencing relation (a category and its parent) serializable.
235
+ return typebox_1.Type.Union([typebox_1.Type.Ref(`${name}Single`), typebox_1.Type.Null()], {
236
+ $id: `${name}SingleNullable`
237
+ });
238
+ }
226
239
  function buildInputModels(createModel, updateModel, relationsModel, idSchema, config) {
227
240
  const virtualConfig = config.virtual;
228
241
  const relationsConfig = config.relations;
@@ -349,7 +362,7 @@ function relationOutputProperties(object, relationConfig, outputType) {
349
362
  return undefined;
350
363
  }
351
364
  if (config.required === false && schema.type !== 'array') {
352
- schema = (0, common_1.Nullable)(schema);
365
+ schema = nullableOutputSchema(schema);
353
366
  }
354
367
  }
355
368
  return schema;
@@ -378,6 +391,10 @@ function relationOutputProperties(object, relationConfig, outputType) {
378
391
  }, {})
379
392
  });
380
393
  }
394
+ function nullableOutputSchema(schema) {
395
+ const modelRef = schema.$ref;
396
+ return modelRef ? typebox_1.Type.Ref(`${modelRef}Nullable`) : (0, common_1.Nullable)(schema);
397
+ }
381
398
  function removeHiddenFields(schema) {
382
399
  const properties = {};
383
400
  for (const [name, field] of Object.entries(schema.properties)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appweaver/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Appweaver - the backend framework for AI-first development (@core)",
5
5
  "author": "Luka Matosevic",
6
6
  "license": "MIT",
@@ -9,6 +9,9 @@ const common_1 = require("@appweaver/common");
9
9
  const context_1 = require("../../context");
10
10
  const security_1 = require("../../security");
11
11
  const errors_1 = require("../../errors");
12
+ /** Levels of a self referencing relation read when the relation configures no
13
+ * `maxDepth`, i.e. the relation itself and nothing below it. */
14
+ const DEFAULT_RELATION_MAX_DEPTH = 1;
12
15
  /**
13
16
  * Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
14
17
  * its configured output type allows it for the given action, or always when no action is specified, and the relations
@@ -41,7 +44,7 @@ function mapRelationInclusions(resourceName, action) {
41
44
  }
42
45
  // Check if the relation should be included based on the output type
43
46
  if (shouldIncludeRelation(relationField?.output?.type, action)) {
44
- inclusion[key] = buildNestedInclusion(relationField, action);
47
+ inclusion[key] = buildNestedInclusion(relationField, key, action);
45
48
  }
46
49
  }
47
50
  return inclusion;
@@ -294,34 +297,67 @@ function createdByConnect(resourceName) {
294
297
  : undefined;
295
298
  }
296
299
  /**
297
- * Resolves the inclusion value of a single relation field by walking its configured nested output includes
298
- * recursively. Returns `true` when the relation has no nested includes to apply for the given action, or a nested
299
- * `include` clause otherwise.
300
+ * Resolves the inclusion value of a single relation field, walking both the levels a self referencing relation
301
+ * repeats itself for and the nested includes it configures. Returns `true` when the relation reads no further than
302
+ * itself for the given action, or a nested `include` clause otherwise.
303
+ *
304
+ * A relation whose related model holds the same relation again points back at its own model, so it repeats itself
305
+ * down the tree up to its configured `output.maxDepth`, letting a category carry its ancestors without every level
306
+ * being spelled out. An `output.include` entry naming that same relation configures the level itself and replaces
307
+ * the repetition.
300
308
  *
301
309
  * @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
302
- * from its `output.include` property.
310
+ * from its `output.include` and `output.maxDepth` properties.
311
+ * @param {string} key - The field name the relation is declared under, matched against the related model to detect a
312
+ * relation pointing back at its own model.
303
313
  * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
304
314
  * of every nested relation.
305
- * @return {boolean|Object} True if the relation has no nested relations to include for the given action, or the
306
- * nested `include` clause otherwise.
315
+ * @param {number} [depth=1] - The level of the relation being resolved, counting the relation itself as the first.
316
+ * @return {boolean|Object} True if the relation reads no further than itself, or the nested `include` clause
317
+ * otherwise.
307
318
  */
308
- function buildNestedInclusion(relationField, action) {
319
+ function buildNestedInclusion(relationField, key, action, depth = 1) {
309
320
  const nestedIncludeConfig = relationField?.output?.include;
310
- if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
311
- return true;
312
- }
313
321
  const nestedInclusion = {};
314
- for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig)) {
322
+ // A file field carries no related model, so it reads no further than itself
323
+ const relatedModel = relationField?.model
324
+ ? (0, context_1.injectModel)((0, common_1.capitalize)(relationField.model), false)
325
+ : undefined;
326
+ const selfRelation = selfReferencingRelation(relatedModel, key);
327
+ const maxDepth = relationField?.output?.maxDepth ?? DEFAULT_RELATION_MAX_DEPTH;
328
+ if (selfRelation && depth < maxDepth && !nestedIncludeConfig?.[key]) {
329
+ nestedInclusion[key] = buildNestedInclusion({ ...selfRelation, output: relationField.output }, key, action, depth + 1);
330
+ }
331
+ for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig ?? {})) {
315
332
  // Check if the nested relation should be included
316
- if (shouldIncludeRelation(nestedOutput?.type, action)) {
317
- // Recursively build nested inclusions
318
- nestedInclusion[nestedKey] = buildNestedInclusion({ output: nestedOutput }, action);
333
+ if (!shouldIncludeRelation(nestedOutput?.type, action)) {
334
+ continue;
319
335
  }
336
+ // The related model carries the field the nested include names, so each
337
+ // level is resolved against the model it actually belongs to
338
+ const nestedRelation = relatedModel?.config?.relations?.[nestedKey] ??
339
+ relatedModel?.config?.files?.[nestedKey];
340
+ nestedInclusion[nestedKey] = buildNestedInclusion({ ...nestedRelation, output: nestedOutput }, nestedKey, action);
320
341
  }
321
342
  return Object.keys(nestedInclusion).length > 0
322
343
  ? { include: nestedInclusion }
323
344
  : true;
324
345
  }
346
+ /**
347
+ * Reads the relation a related model holds under the given field name, when it points back at that same model. It is
348
+ * the relation a self referencing field repeats itself through, i.e. the `parent` of the parent of a category.
349
+ *
350
+ * @param {ResourceModel} [model] - The related model the field is looked up on.
351
+ * @param {string} key - The field name of the relation.
352
+ * @return {RelationField | undefined} The relation of the model under that name when it references the model itself,
353
+ * undefined otherwise.
354
+ */
355
+ function selfReferencingRelation(model, key) {
356
+ const relation = key ? model?.config?.relations?.[key] : undefined;
357
+ return relation && (0, common_1.capitalize)(relation.model) === model?.name
358
+ ? relation
359
+ : undefined;
360
+ }
325
361
  /**
326
362
  * Decides whether a relation with the given configured output type is included for the given action. The `none` type
327
363
  * is never included, the `single` type is excluded from the query action, and the `multiple` type is only included on
package/server/swagger.js CHANGED
@@ -11,7 +11,7 @@ const context_1 = require("../context");
11
11
  exports.default = (0, fastify_plugin_1.default)((server) => {
12
12
  server.register(swagger_1.default, {
13
13
  hideUntagged: common_1.config.SWAGGER_HIDE_UNTAGGED,
14
- transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(document))),
14
+ transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(inlineNullableRefs(document)))),
15
15
  openapi: {
16
16
  info: {
17
17
  title: common_1.config.APP_NAME,
@@ -64,6 +64,54 @@ exports.default = (0, fastify_plugin_1.default)((server) => {
64
64
  });
65
65
  }
66
66
  });
67
+ /**
68
+ * Replaces every reference to a nullable model variant with the union it stands
69
+ * for. Each model registers a `<Model>SingleNullable` schema the response
70
+ * serializer needs as a name of its own, since it cannot compile an inline
71
+ * union that cycles back to a model it is already writing. The document says
72
+ * the same thing without that indirection, leaving the variants unreferenced
73
+ * for {@link pruneUnusedSchemas} to drop.
74
+ *
75
+ * @param {Object} document The transform argument of the Swagger plugin,
76
+ * wrapping the OpenAPI document in its `openapiObject` property.
77
+ * @returns {Object} The same argument, with every reference to a nullable model
78
+ * variant replaced in place.
79
+ */
80
+ function inlineNullableRefs(document) {
81
+ const schemas = document.openapiObject?.components?.schemas ?? {};
82
+ // The schemas are registered under generated names, so the variants are
83
+ // recognized by the title carrying the name they were declared with
84
+ const variants = new Map();
85
+ for (const [name, schema] of Object.entries(schemas)) {
86
+ if (/SingleNullable$/.test(schema?.title ?? '') && (0, common_1.isArray)(schema?.anyOf)) {
87
+ variants.set(`#/components/schemas/${name}`, schema.anyOf);
88
+ }
89
+ }
90
+ if (variants.size === 0) {
91
+ return document;
92
+ }
93
+ const visit = (node) => {
94
+ if (!node || typeof node !== 'object') {
95
+ return;
96
+ }
97
+ if ((0, common_1.isArray)(node)) {
98
+ for (const item of node)
99
+ visit(item);
100
+ return;
101
+ }
102
+ for (const value of Object.values(node)) {
103
+ visit(value);
104
+ }
105
+ const union = variants.get(node.$ref);
106
+ if (union) {
107
+ delete node.$ref;
108
+ node.anyOf = structuredClone(union);
109
+ }
110
+ };
111
+ visit(document.openapiObject?.paths);
112
+ visit(schemas);
113
+ return document;
114
+ }
67
115
  /**
68
116
  * Rewrites the JSON Schema type lists of the document into the equivalent
69
117
  * `anyOf` unions. The query filter schemas declare their plain values as a