webspresso 0.0.57 → 0.0.60

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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Build OpenAPI 3 document from file-router API route metadata + Zod schemas
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { zodToJsonSchema } = require('zod-to-json-schema');
8
+ const { compileSchema } = require('../compileSchema');
9
+ const { generateOrmOpenApiSchemas } = require('./orm-components');
10
+
11
+ /**
12
+ * Express route pattern → OpenAPI path (e.g. /users/:id → /users/{id})
13
+ * @param {string} expressPath
14
+ * @returns {string}
15
+ */
16
+ function expressPathToOpenApi(expressPath) {
17
+ return String(expressPath)
18
+ .replace(/:([A-Za-z0-9_]+)/g, '{$1}')
19
+ .replace(/\*/g, '{wildcard}');
20
+ }
21
+
22
+ /**
23
+ * @param {import('zod').ZodObject<any>} zodObj
24
+ * @param {'path' | 'query'} where
25
+ * @returns {object[]}
26
+ */
27
+ function expandZodObjectToParameters(zodObj, where) {
28
+ if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
29
+ const shape = zodObj.shape;
30
+ return Object.entries(shape).map(([name, zType]) => ({
31
+ name,
32
+ in: where,
33
+ required: where === 'path' ? true : !zType.isOptional(),
34
+ schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
35
+ }));
36
+ }
37
+
38
+ /**
39
+ * @param {object} route - { type, method, pattern, file }
40
+ * @param {object|null} compiled - compileSchema result
41
+ * @returns {object} OpenAPI Operation Object
42
+ */
43
+ function buildOperation(route, compiled) {
44
+ const filePath = route.file;
45
+ const method = route.method.toLowerCase();
46
+ const summary = `${method.toUpperCase()} ${filePath}`;
47
+
48
+ const op = {
49
+ tags: ['api'],
50
+ summary,
51
+ operationId: String(filePath)
52
+ .replace(/[^a-zA-Z0-9]+/g, '_')
53
+ .replace(/^_|_$/g, ''),
54
+ responses: {
55
+ 200: {
56
+ description: 'OK',
57
+ },
58
+ },
59
+ };
60
+
61
+ if (compiled?.response) {
62
+ op.responses['200'] = {
63
+ description: 'OK',
64
+ content: {
65
+ 'application/json': {
66
+ schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
67
+ },
68
+ },
69
+ };
70
+ }
71
+
72
+ const parameters = [
73
+ ...expandZodObjectToParameters(compiled?.params, 'path'),
74
+ ...expandZodObjectToParameters(compiled?.query, 'query'),
75
+ ];
76
+
77
+ if (parameters.length) {
78
+ op.parameters = parameters;
79
+ }
80
+
81
+ if (compiled?.body) {
82
+ op.requestBody = {
83
+ content: {
84
+ 'application/json': {
85
+ schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ return op;
92
+ }
93
+
94
+ /**
95
+ * @param {object} opts
96
+ * @param {object[]} opts.routes - route metadata from mountPages
97
+ * @param {string} opts.pagesDir
98
+ * @param {boolean} [opts.includeOrmSchemas]
99
+ * @param {string[]} [opts.ormExclude]
100
+ * @param {object} [opts.info]
101
+ * @param {object[]} [opts.servers]
102
+ * @returns {object} OpenAPI 3.0.x document
103
+ */
104
+ function buildOpenApiDocument(opts) {
105
+ const {
106
+ routes = [],
107
+ pagesDir,
108
+ includeOrmSchemas = false,
109
+ ormExclude = [],
110
+ info = {},
111
+ servers = [{ url: '/' }],
112
+ } = opts;
113
+
114
+ if (!pagesDir) {
115
+ throw new Error('buildOpenApiDocument: pagesDir is required');
116
+ }
117
+
118
+ const paths = {};
119
+ const apiRoutes = routes.filter((r) => r.type === 'api');
120
+ const absPages = path.resolve(pagesDir);
121
+
122
+ for (const route of apiRoutes) {
123
+ const fullPath = path.join(absPages, route.file);
124
+ if (!fs.existsSync(fullPath)) {
125
+ continue;
126
+ }
127
+
128
+ let mod;
129
+ try {
130
+ mod = require(fullPath);
131
+ } catch {
132
+ continue;
133
+ }
134
+
135
+ let compiled = null;
136
+ try {
137
+ compiled = compileSchema(fullPath, mod);
138
+ } catch {
139
+ compiled = null;
140
+ }
141
+
142
+ const openApiPath = expressPathToOpenApi(route.pattern);
143
+ const method = route.method.toLowerCase();
144
+
145
+ if (!paths[openApiPath]) {
146
+ paths[openApiPath] = {};
147
+ }
148
+
149
+ paths[openApiPath][method] = buildOperation(route, compiled);
150
+ }
151
+
152
+ const doc = {
153
+ openapi: '3.0.3',
154
+ info: {
155
+ title: info.title || 'API',
156
+ version: info.version || '1.0.0',
157
+ ...(info.description ? { description: info.description } : {}),
158
+ },
159
+ servers,
160
+ paths,
161
+ components: {
162
+ schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
163
+ },
164
+ };
165
+
166
+ return doc;
167
+ }
168
+
169
+ module.exports = {
170
+ expressPathToOpenApi,
171
+ buildOpenApiDocument,
172
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
3
+ */
4
+
5
+ const { getAllModels } = require('../orm/model');
6
+
7
+ /**
8
+ * Generate OpenAPI 3 components.schemas from registered ORM models
9
+ * @param {{ exclude?: string[] }} options
10
+ * @returns {Record<string, object>}
11
+ */
12
+ function generateOrmOpenApiSchemas(options = {}) {
13
+ const { exclude = [] } = options;
14
+ const models = getAllModels();
15
+ const schemas = {};
16
+
17
+ for (const [name, model] of models) {
18
+ if (exclude.includes(name)) continue;
19
+
20
+ const properties = {};
21
+ const required = [];
22
+
23
+ if (model.columns) {
24
+ for (const [colName, meta] of model.columns) {
25
+ properties[colName] = columnToOpenApiType(meta);
26
+
27
+ if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
28
+ required.push(colName);
29
+ }
30
+ }
31
+ }
32
+
33
+ schemas[name] = {
34
+ type: 'object',
35
+ properties,
36
+ required: required.length > 0 ? required : undefined,
37
+ };
38
+
39
+ const inputProperties = {};
40
+ const inputRequired = [];
41
+
42
+ if (model.columns) {
43
+ for (const [colName, meta] of model.columns) {
44
+ if (meta.autoIncrement || meta.auto) continue;
45
+
46
+ inputProperties[colName] = columnToOpenApiType(meta);
47
+
48
+ if (!meta.nullable && meta.default === undefined) {
49
+ inputRequired.push(colName);
50
+ }
51
+ }
52
+ }
53
+
54
+ schemas[`${name}Input`] = {
55
+ type: 'object',
56
+ properties: inputProperties,
57
+ required: inputRequired.length > 0 ? inputRequired : undefined,
58
+ };
59
+ }
60
+
61
+ return schemas;
62
+ }
63
+
64
+ /**
65
+ * @param {Object} meta - Column metadata
66
+ * @returns {Object}
67
+ */
68
+ function columnToOpenApiType(meta) {
69
+ const result = {};
70
+
71
+ switch (meta.type) {
72
+ case 'bigint':
73
+ case 'integer':
74
+ result.type = 'integer';
75
+ if (meta.type === 'bigint') result.format = 'int64';
76
+ break;
77
+
78
+ case 'float':
79
+ case 'decimal':
80
+ result.type = 'number';
81
+ if (meta.type === 'decimal') result.format = 'double';
82
+ break;
83
+
84
+ case 'boolean':
85
+ result.type = 'boolean';
86
+ break;
87
+
88
+ case 'date':
89
+ result.type = 'string';
90
+ result.format = 'date';
91
+ break;
92
+
93
+ case 'datetime':
94
+ case 'timestamp':
95
+ result.type = 'string';
96
+ result.format = 'date-time';
97
+ break;
98
+
99
+ case 'uuid':
100
+ result.type = 'string';
101
+ result.format = 'uuid';
102
+ break;
103
+
104
+ case 'json':
105
+ result.type = 'object';
106
+ break;
107
+
108
+ case 'enum':
109
+ result.type = 'string';
110
+ if (meta.enumValues) {
111
+ result.enum = meta.enumValues;
112
+ }
113
+ break;
114
+
115
+ case 'text':
116
+ case 'string':
117
+ default:
118
+ result.type = 'string';
119
+ if (meta.maxLength) {
120
+ result.maxLength = meta.maxLength;
121
+ }
122
+ break;
123
+ }
124
+
125
+ if (meta.nullable) {
126
+ result.nullable = true;
127
+ }
128
+
129
+ if (meta.default !== undefined) {
130
+ result.default = meta.default;
131
+ }
132
+
133
+ return result;
134
+ }
135
+
136
+ module.exports = {
137
+ generateOrmOpenApiSchemas,
138
+ columnToOpenApiType,
139
+ };
package/core/orm/index.js CHANGED
@@ -186,7 +186,7 @@ function createDatabase(config) {
186
186
  * Get query builder for a model
187
187
  * @param {string} modelName - Model name
188
188
  * @param {import('./types').ScopeContext} [scopeContext] - Scope context
189
- * @returns {import('knex').Knex.QueryBuilder}
189
+ * @returns {import('./query-builder').QueryBuilder}
190
190
  */
191
191
  function query(modelName, scopeContext) {
192
192
  const model = getModelInstance(modelName);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Webspresso ORM - JSON column helpers (shared by repository and query builder)
3
+ * @module core/orm/json-fields
4
+ */
5
+
6
+ /**
7
+ * Get JSON column names from model
8
+ * @param {import('./types').ModelDefinition} model - Model definition
9
+ * @returns {Set<string>} Set of JSON column names
10
+ */
11
+ function getJsonColumns(model) {
12
+ const jsonCols = new Set();
13
+ if (model.columns) {
14
+ for (const [name, meta] of model.columns) {
15
+ if (meta.type === 'json') {
16
+ jsonCols.add(name);
17
+ }
18
+ }
19
+ }
20
+ return jsonCols;
21
+ }
22
+
23
+ /**
24
+ * Serialize JSON fields for database storage
25
+ * @param {Object} data - Data to serialize
26
+ * @param {Set<string>} jsonColumns - JSON column names
27
+ * @returns {Object} Serialized data
28
+ */
29
+ function serializeJsonFields(data, jsonColumns) {
30
+ if (jsonColumns.size === 0) return data;
31
+
32
+ const serialized = { ...data };
33
+ for (const col of jsonColumns) {
34
+ if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
35
+ if (typeof serialized[col] !== 'string') {
36
+ serialized[col] = JSON.stringify(serialized[col]);
37
+ }
38
+ }
39
+ }
40
+ return serialized;
41
+ }
42
+
43
+ /**
44
+ * Deserialize JSON fields from database
45
+ * @param {Object} record - Record from database
46
+ * @param {Set<string>} jsonColumns - JSON column names
47
+ * @returns {Object} Deserialized record
48
+ */
49
+ function deserializeJsonFields(record, jsonColumns) {
50
+ if (!record || jsonColumns.size === 0) return record;
51
+
52
+ for (const col of jsonColumns) {
53
+ if (col in record && record[col] !== null && record[col] !== undefined) {
54
+ if (typeof record[col] === 'string') {
55
+ try {
56
+ record[col] = JSON.parse(record[col]);
57
+ } catch {
58
+ // If parsing fails, keep the original string value
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return record;
64
+ }
65
+
66
+ module.exports = {
67
+ getJsonColumns,
68
+ serializeJsonFields,
69
+ deserializeJsonFields,
70
+ };
@@ -5,66 +5,14 @@
5
5
  */
6
6
 
7
7
  const { applyScopes, createScopeContext } = require('./scopes');
8
-
9
- /**
10
- * Get JSON column names from model
11
- * @param {import('./types').ModelDefinition} model - Model definition
12
- * @returns {Set<string>} Set of JSON column names
13
- */
14
- function getJsonColumns(model) {
15
- const jsonCols = new Set();
16
- if (model.columns) {
17
- for (const [name, meta] of model.columns) {
18
- if (meta.type === 'json') {
19
- jsonCols.add(name);
20
- }
21
- }
22
- }
23
- return jsonCols;
24
- }
25
-
26
- /**
27
- * Serialize JSON fields for database storage
28
- * @param {Object} data - Data to serialize
29
- * @param {Set<string>} jsonColumns - JSON column names
30
- * @returns {Object} Serialized data
31
- */
32
- function serializeJsonFields(data, jsonColumns) {
33
- if (jsonColumns.size === 0) return data;
34
-
35
- const serialized = { ...data };
36
- for (const col of jsonColumns) {
37
- if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
38
- if (typeof serialized[col] !== 'string') {
39
- serialized[col] = JSON.stringify(serialized[col]);
40
- }
41
- }
42
- }
43
- return serialized;
44
- }
45
-
46
- /**
47
- * Deserialize JSON fields from database
48
- * @param {Object} record - Record from database
49
- * @param {Set<string>} jsonColumns - JSON column names
50
- * @returns {Object} Deserialized record
51
- */
52
- function deserializeJsonFields(record, jsonColumns) {
53
- if (!record || jsonColumns.size === 0) return record;
54
-
55
- for (const col of jsonColumns) {
56
- if (col in record && record[col] !== null && record[col] !== undefined) {
57
- if (typeof record[col] === 'string') {
58
- try {
59
- record[col] = JSON.parse(record[col]);
60
- } catch {
61
- // If parsing fails, keep the original string value
62
- }
63
- }
64
- }
65
- }
66
- return record;
67
- }
8
+ const { loadRelations } = require('./eager-loader');
9
+ const { ensureArray } = require('./utils');
10
+ const { ModelEvents, createEventContext, Hooks, HookCancellationError } = require('./events');
11
+ const {
12
+ getJsonColumns,
13
+ serializeJsonFields,
14
+ deserializeJsonFields,
15
+ } = require('./json-fields');
68
16
 
69
17
  /**
70
18
  * Create a new query builder
@@ -92,7 +40,7 @@ class QueryBuilder {
92
40
  this.knex = knex;
93
41
  this.scopeContext = initialContext || createScopeContext();
94
42
  this.jsonColumns = getJsonColumns(model);
95
-
43
+
96
44
  /** @type {import('./types').QueryState} */
97
45
  this.state = {
98
46
  wheres: [],
@@ -167,7 +115,7 @@ class QueryBuilder {
167
115
  orWhere(columnOrConditions, operatorOrValue, value) {
168
116
  const startIndex = this.state.wheres.length;
169
117
  this.where(columnOrConditions, operatorOrValue, value);
170
-
118
+
171
119
  // Mark the new clauses as OR
172
120
  for (let i = startIndex; i < this.state.wheres.length; i++) {
173
121
  this.state.wheres[i].boolean = 'or';
@@ -338,11 +286,22 @@ class QueryBuilder {
338
286
  return this;
339
287
  }
340
288
 
289
+ /**
290
+ * Transaction handle for lifecycle hooks (matches repository pattern)
291
+ * @returns {import('knex').Knex.Transaction|null}
292
+ */
293
+ _hookTrx() {
294
+ return this.knex.isTransaction ? this.knex : null;
295
+ }
296
+
341
297
  /**
342
298
  * Build the Knex query
299
+ * @param {Object} [options]
300
+ * @param {boolean} [options.includeLimitOffset=true] - When false, omit LIMIT/OFFSET (for aggregates / pagination)
343
301
  * @returns {import('knex').Knex.QueryBuilder}
344
302
  */
345
- toKnex() {
303
+ toKnex(options = {}) {
304
+ const { includeLimitOffset = true } = options;
346
305
  let qb = this.knex(this.model.table);
347
306
 
348
307
  // Apply global scopes
@@ -364,10 +323,10 @@ class QueryBuilder {
364
323
  }
365
324
 
366
325
  const method = where.boolean === 'or' ? 'orWhere' : 'where';
367
-
326
+
368
327
  switch (where.operator) {
369
328
  case 'in':
370
- qb = where.boolean === 'or'
329
+ qb = where.boolean === 'or'
371
330
  ? qb.orWhereIn(where.column, where.value)
372
331
  : qb.whereIn(where.column, where.value);
373
332
  break;
@@ -396,14 +355,16 @@ class QueryBuilder {
396
355
  qb = qb.orderBy(orderBy.column, orderBy.direction);
397
356
  }
398
357
 
399
- // Apply limit
400
- if (this.state.limitValue !== undefined) {
401
- qb = qb.limit(this.state.limitValue);
402
- }
358
+ if (includeLimitOffset) {
359
+ // Apply limit
360
+ if (this.state.limitValue !== undefined) {
361
+ qb = qb.limit(this.state.limitValue);
362
+ }
403
363
 
404
- // Apply offset
405
- if (this.state.offsetValue !== undefined) {
406
- qb = qb.offset(this.state.offsetValue);
364
+ // Apply offset
365
+ if (this.state.offsetValue !== undefined) {
366
+ qb = qb.offset(this.state.offsetValue);
367
+ }
407
368
  }
408
369
 
409
370
  return qb;
@@ -414,12 +375,24 @@ class QueryBuilder {
414
375
  * @returns {Promise<Object|null>}
415
376
  */
416
377
  async first() {
378
+ const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
379
+ await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
380
+ if (ctx.isCancelled) {
381
+ throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
382
+ }
383
+
417
384
  const qb = this.toKnex().first();
418
385
  const result = await qb;
419
386
  if (!result) return null;
420
-
421
- // Deserialize JSON fields
387
+
422
388
  deserializeJsonFields(result, this.jsonColumns);
389
+
390
+ const withs = this.getWiths();
391
+ if (withs.length > 0) {
392
+ await loadRelations([result], ensureArray(withs), this.model, this.knex, this.scopeContext);
393
+ }
394
+
395
+ ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, result, ctx);
423
396
  return result;
424
397
  }
425
398
 
@@ -428,12 +401,26 @@ class QueryBuilder {
428
401
  * @returns {Promise<Object[]>}
429
402
  */
430
403
  async list() {
404
+ const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
405
+ await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
406
+ if (ctx.isCancelled) {
407
+ throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
408
+ }
409
+
431
410
  const results = await this.toKnex();
432
-
433
- // Deserialize JSON fields
411
+
434
412
  for (const record of results) {
435
413
  deserializeJsonFields(record, this.jsonColumns);
436
414
  }
415
+
416
+ const withs = this.getWiths();
417
+ if (withs.length > 0 && results.length > 0) {
418
+ await loadRelations(results, ensureArray(withs), this.model, this.knex, this.scopeContext);
419
+ }
420
+
421
+ for (const record of results) {
422
+ ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
423
+ }
437
424
  return results;
438
425
  }
439
426
 
@@ -450,7 +437,7 @@ class QueryBuilder {
450
437
  * @returns {Promise<number>}
451
438
  */
452
439
  async count() {
453
- const result = await this.toKnex().count('* as count').first();
440
+ const result = await this.toKnex({ includeLimitOffset: false }).count('* as count').first();
454
441
  return parseInt(result?.count || 0, 10);
455
442
  }
456
443
 
@@ -470,22 +457,33 @@ class QueryBuilder {
470
457
  * @returns {Promise<import('./types').PaginatedResult>}
471
458
  */
472
459
  async paginate(page = 1, perPage = 15) {
473
- // Clone state for count query
474
- const countQb = this.toKnex().clone();
475
-
476
- // Get total count
477
- const countResult = await countQb.count('* as count').first();
460
+ const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
461
+ await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
462
+ if (ctx.isCancelled) {
463
+ throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
464
+ }
465
+
466
+ const base = this.toKnex({ includeLimitOffset: false });
467
+
468
+ const countResult = await base.clone().count('* as count').first();
478
469
  const total = parseInt(countResult?.count || 0, 10);
479
470
 
480
- // Get paginated data
481
471
  const offset = (page - 1) * perPage;
482
- const data = await this.toKnex().limit(perPage).offset(offset);
472
+ const data = await base.clone().limit(perPage).offset(offset);
483
473
 
484
- // Deserialize JSON fields
485
474
  for (const record of data) {
486
475
  deserializeJsonFields(record, this.jsonColumns);
487
476
  }
488
477
 
478
+ const withs = this.getWiths();
479
+ if (withs.length > 0 && data.length > 0) {
480
+ await loadRelations(data, ensureArray(withs), this.model, this.knex, this.scopeContext);
481
+ }
482
+
483
+ for (const record of data) {
484
+ ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
485
+ }
486
+
489
487
  return {
490
488
  data,
491
489
  total,
@@ -509,7 +507,6 @@ class QueryBuilder {
509
507
  * @returns {Promise<number>} Number of updated records
510
508
  */
511
509
  async update(data) {
512
- // Serialize JSON fields
513
510
  const serialized = serializeJsonFields(data, this.jsonColumns);
514
511
  return this.toKnex().update(serialized);
515
512
  }
@@ -554,4 +551,3 @@ module.exports = {
554
551
  createQueryBuilder,
555
552
  QueryBuilder,
556
553
  };
557
-