webspresso 0.0.106 → 0.0.107

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.
Files changed (49) hide show
  1. package/bin/webspresso.js +17 -7
  2. package/core/app.js +38 -6
  3. package/core/auth/index.js +4 -1
  4. package/core/auth/jwt.js +85 -13
  5. package/core/auth/magic-link.js +23 -5
  6. package/core/auth/manager.js +7 -1
  7. package/core/auth/redis-session.js +62 -25
  8. package/core/auth/session-manager.js +28 -2
  9. package/core/auth/sessions.js +182 -52
  10. package/core/console/eval.js +4 -1
  11. package/core/content/service.js +1 -0
  12. package/core/errors/domain.js +20 -2
  13. package/core/errors/http.js +1 -0
  14. package/core/http/hono-adapter.js +1 -1
  15. package/core/http/pipeline.js +36 -2
  16. package/core/index.js +12 -1
  17. package/core/mail/mailer.js +1 -1
  18. package/core/orm/eager-loader.js +81 -1
  19. package/core/orm/index.js +27 -13
  20. package/core/orm/json-fields.js +44 -13
  21. package/core/orm/model.js +12 -0
  22. package/core/orm/query-builder.js +33 -15
  23. package/core/orm/repository.js +59 -12
  24. package/core/orm/scopes.js +54 -4
  25. package/core/services/index.js +9 -0
  26. package/core/shutdown/shutdown-manager.js +57 -38
  27. package/core/studio/inspector.js +9 -1
  28. package/core/studio/server.js +24 -8
  29. package/core/utils/string.js +77 -16
  30. package/core/validation/index.js +18 -0
  31. package/index.d.ts +172 -8
  32. package/index.js +4 -0
  33. package/package.json +18 -8
  34. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  35. package/plugins/admin-panel/index.js +24 -11
  36. package/plugins/admin-panel/modules/user-management.js +3 -2
  37. package/plugins/csrf/index.js +2 -1
  38. package/plugins/dashboard/index.js +27 -7
  39. package/src/app-context.js +9 -1
  40. package/src/file-router.js +12 -3
  41. package/src/plugin-manager.js +273 -48
  42. package/src/server.js +26 -33
  43. package/src/services/discovery.js +9 -1
  44. package/src/services/executor.js +271 -58
  45. package/src/services/index.js +52 -3
  46. package/src/services/loader.js +120 -0
  47. package/src/services/memoize.js +223 -49
  48. package/src/services/registry.js +492 -94
  49. package/src/services/validator.js +164 -13
@@ -3,8 +3,6 @@
3
3
  * @module core/orm/json-fields
4
4
  */
5
5
 
6
- const { safeJsonParse } = require('../utils/json');
7
-
8
6
  /**
9
7
  * Get JSON column names from model
10
8
  * @param {import('./types').ModelDefinition} model - Model definition
@@ -12,6 +10,14 @@ const { safeJsonParse } = require('../utils/json');
12
10
  */
13
11
  function getJsonColumns(model) {
14
12
  if (model && model._jsonColumns) {
13
+ if (!model._jsonColumns._array) {
14
+ Object.defineProperty(model._jsonColumns, '_array', {
15
+ value: Array.from(model._jsonColumns),
16
+ writable: true,
17
+ configurable: true,
18
+ enumerable: false,
19
+ });
20
+ }
15
21
  return model._jsonColumns;
16
22
  }
17
23
  const jsonCols = new Set();
@@ -40,6 +46,12 @@ function getJsonColumns(model) {
40
46
  }
41
47
  }
42
48
  }
49
+ Object.defineProperty(jsonCols, '_array', {
50
+ value: Array.from(jsonCols),
51
+ writable: true,
52
+ configurable: true,
53
+ enumerable: false,
54
+ });
43
55
  if (model) {
44
56
  model._jsonColumns = jsonCols;
45
57
  }
@@ -49,17 +61,22 @@ function getJsonColumns(model) {
49
61
  /**
50
62
  * Serialize JSON fields for database storage
51
63
  * @param {Object} data - Data to serialize
52
- * @param {Set<string>} jsonColumns - JSON column names
64
+ * @param {Set<string>|Array<string>} jsonColumns - JSON column names
53
65
  * @returns {Object} Serialized data
54
66
  */
55
67
  function serializeJsonFields(data, jsonColumns) {
56
- if (jsonColumns.size === 0) return data;
68
+ if (!data || !jsonColumns) return data;
69
+ const size = jsonColumns.size !== undefined ? jsonColumns.size : jsonColumns.length;
70
+ if (size === 0) return data;
57
71
 
72
+ const cols = jsonColumns._array || (Array.isArray(jsonColumns) ? jsonColumns : Array.from(jsonColumns));
58
73
  const serialized = { ...data };
59
- for (const col of jsonColumns) {
60
- if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
61
- if (typeof serialized[col] !== 'string') {
62
- serialized[col] = JSON.stringify(serialized[col]);
74
+ for (let i = 0; i < cols.length; i++) {
75
+ const col = cols[i];
76
+ if (col in serialized) {
77
+ const val = serialized[col];
78
+ if (val !== null && val !== undefined && typeof val !== 'string') {
79
+ serialized[col] = JSON.stringify(val);
63
80
  }
64
81
  }
65
82
  }
@@ -69,15 +86,28 @@ function serializeJsonFields(data, jsonColumns) {
69
86
  /**
70
87
  * Deserialize JSON fields from database
71
88
  * @param {Object} record - Record from database
72
- * @param {Set<string>} jsonColumns - JSON column names
89
+ * @param {Set<string>|Array<string>} jsonColumns - JSON column names
73
90
  * @returns {Object} Deserialized record
74
91
  */
75
92
  function deserializeJsonFields(record, jsonColumns) {
76
- if (!record || jsonColumns.size === 0) return record;
93
+ if (!record || !jsonColumns) return record;
94
+ const size = jsonColumns.size !== undefined ? jsonColumns.size : jsonColumns.length;
95
+ if (size === 0) return record;
77
96
 
78
- for (const col of jsonColumns) {
79
- if (col in record && record[col] !== null && record[col] !== undefined) {
80
- record[col] = safeJsonParse(record[col], record[col]);
97
+ const cols = jsonColumns._array || (Array.isArray(jsonColumns) ? jsonColumns : Array.from(jsonColumns));
98
+ for (let i = 0; i < cols.length; i++) {
99
+ const col = cols[i];
100
+ if (col in record) {
101
+ const val = record[col];
102
+ // If already parsed (object, array, number, boolean) or null/undefined, skip parsing
103
+ if (typeof val === 'string') {
104
+ if (val.length === 0) continue;
105
+ try {
106
+ record[col] = JSON.parse(val);
107
+ } catch (_) {
108
+ // Keep raw string if parsing fails
109
+ }
110
+ }
81
111
  }
82
112
  }
83
113
  return record;
@@ -88,3 +118,4 @@ module.exports = {
88
118
  serializeJsonFields,
89
119
  deserializeJsonFields,
90
120
  };
121
+
package/core/orm/model.js CHANGED
@@ -8,6 +8,7 @@ const z = require('zod');
8
8
  const { trimUrlPathSlashes } = require('../url-path-normalize');
9
9
  const { createSchemaHelpers, extractColumnsFromSchema } = require('./schema-helpers');
10
10
  const { ModelEvents, Hooks } = require('./events');
11
+ const { getJsonColumns } = require('./json-fields');
11
12
  const zdb = z ? createSchemaHelpers(z) : null;
12
13
 
13
14
  /**
@@ -193,6 +194,11 @@ function defineModel(options) {
193
194
  ? rlsConfig
194
195
  : null;
195
196
 
197
+ const hasShape = normalizedSchema && normalizedSchema.shape;
198
+ const resolvedDeletedCol = ((columns && columns.has('deletedAt')) || (hasShape && normalizedSchema.shape.deletedAt)) ? 'deletedAt' : 'deleted_at';
199
+ const resolvedCreatedCol = ((columns && columns.has('createdAt')) || (hasShape && normalizedSchema.shape.createdAt)) ? 'createdAt' : 'created_at';
200
+ const resolvedUpdatedCol = ((columns && columns.has('updatedAt')) || (hasShape && normalizedSchema.shape.updatedAt)) ? 'updatedAt' : 'updated_at';
201
+
196
202
  // Create model definition
197
203
  const model = {
198
204
  name,
@@ -200,6 +206,9 @@ function defineModel(options) {
200
206
  schema: normalizedSchema,
201
207
  _partialSchema: typeof normalizedSchema?.partial === 'function' ? normalizedSchema.partial() : normalizedSchema,
202
208
  primaryKey,
209
+ _deletedCol: resolvedDeletedCol,
210
+ _createdCol: resolvedCreatedCol,
211
+ _updatedCol: resolvedUpdatedCol,
203
212
  relations: normalizedRelations,
204
213
  scopes: {
205
214
  softDelete: Boolean(softDeleteConfig),
@@ -259,6 +268,9 @@ function defineModel(options) {
259
268
  cache: cacheOption,
260
269
  };
261
270
 
271
+ // Precompute JSON column metadata
272
+ getJsonColumns(model);
273
+
262
274
  // Register model
263
275
  modelRegistry.set(name, model);
264
276
 
@@ -465,7 +465,13 @@ class QueryBuilder {
465
465
  * @returns {import('knex').Knex.QueryBuilder}
466
466
  */
467
467
  toKnex(options = {}) {
468
- const { includeLimitOffset = true, action = this.action || 'read', mutationData = null } = options;
468
+ const {
469
+ includeLimitOffset = true,
470
+ includeOrderBy = true,
471
+ includeSelect = true,
472
+ action = this.action || 'read',
473
+ mutationData = null,
474
+ } = options;
469
475
  const activeKnex = this.getActiveKnex();
470
476
  let qb = activeKnex(this.model.table);
471
477
 
@@ -473,7 +479,7 @@ class QueryBuilder {
473
479
  qb = applyScopes(qb, this.scopeContext, this.model, action, mutationData);
474
480
 
475
481
  // Apply selects
476
- if (this.state.selects.length > 0) {
482
+ if (includeSelect && this.state.selects.length > 0) {
477
483
  qb = qb.select(this.state.selects);
478
484
  }
479
485
 
@@ -516,11 +522,13 @@ class QueryBuilder {
516
522
  }
517
523
 
518
524
  // Apply order by
519
- for (const orderBy of this.state.orderBys) {
520
- if (orderBy.raw) {
521
- qb = qb.orderByRaw(orderBy.sql, orderBy.bindings);
522
- } else {
523
- qb = qb.orderBy(orderBy.column, orderBy.direction);
525
+ if (includeOrderBy) {
526
+ for (const orderBy of this.state.orderBys) {
527
+ if (orderBy.raw) {
528
+ qb = qb.orderByRaw(orderBy.sql, orderBy.bindings);
529
+ } else {
530
+ qb = qb.orderBy(orderBy.column, orderBy.direction);
531
+ }
524
532
  }
525
533
  }
526
534
 
@@ -718,7 +726,7 @@ class QueryBuilder {
718
726
 
719
727
  async function loadFromDb() {
720
728
  const result = await self
721
- .toKnex({ includeLimitOffset: false })
729
+ .toKnex({ includeLimitOffset: false, includeOrderBy: false, includeSelect: false })
722
730
  .count('* as count')
723
731
  .first();
724
732
  return parseInt(result?.count || 0, 10);
@@ -787,15 +795,25 @@ class QueryBuilder {
787
795
  }
788
796
  }
789
797
 
790
- const base = self.toKnex({ includeLimitOffset: false });
798
+ const offset = (page - 1) * perPage;
791
799
 
792
- const countResult = await base.clone().count('* as count').first();
793
- const total = parseInt(countResult?.count || 0, 10);
800
+ // Execute count query (without ORDER BY, LIMIT, OFFSET, or SELECT projections)
801
+ // and data query (with pagination) in parallel to halve database latency
802
+ const countPromise = self
803
+ .toKnex({ includeLimitOffset: false, includeOrderBy: false, includeSelect: false })
804
+ .count('* as count')
805
+ .first();
794
806
 
795
- const offset = (page - 1) * perPage;
796
- const data = await base.clone().limit(perPage).offset(offset);
807
+ const dataPromise = self
808
+ .toKnex({ includeLimitOffset: false })
809
+ .limit(perPage)
810
+ .offset(offset);
797
811
 
798
- if (self.jsonColumns.size > 0) {
812
+ const [countResult, rawData] = await Promise.all([countPromise, dataPromise]);
813
+ const total = parseInt(countResult?.count || 0, 10);
814
+ const data = total === 0 ? [] : rawData;
815
+
816
+ if (data.length > 0 && self.jsonColumns.size > 0) {
799
817
  for (const record of data) {
800
818
  deserializeJsonFields(record, self.jsonColumns);
801
819
  }
@@ -806,7 +824,7 @@ class QueryBuilder {
806
824
  await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
807
825
  }
808
826
 
809
- if (shouldEmit) {
827
+ if (shouldEmit && data.length > 0) {
810
828
  for (const record of data) {
811
829
  ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
812
830
  }
@@ -366,13 +366,33 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
366
366
  }
367
367
 
368
368
  // Insert and return the record
369
- const [id] = await getActiveKnex()(model.table).insert(insertData).returning(model.primaryKey);
369
+ let record = null;
370
+ let insertedResult = null;
370
371
 
371
- // For databases that don't support returning (SQLite), id might be the row itself
372
- const insertedId = typeof id === 'object' ? id[model.primaryKey] : id;
372
+ try {
373
+ insertedResult = await getActiveKnex()(model.table).insert(insertData).returning('*');
374
+ } catch (_) {
375
+ try {
376
+ insertedResult = await getActiveKnex()(model.table).insert(insertData).returning(model.primaryKey);
377
+ } catch (_2) {
378
+ insertedResult = await getActiveKnex()(model.table).insert(insertData);
379
+ }
380
+ }
373
381
 
374
- // Fetch the created record (without emitting find events)
375
- const record = await findById(insertedId, {}, false);
382
+ const firstRow = Array.isArray(insertedResult) ? insertedResult[0] : insertedResult;
383
+ const hasFullRow = firstRow &&
384
+ typeof firstRow === 'object' &&
385
+ (model.columns ? Object.keys(firstRow).length >= Math.min(model.columns.size, 2) : Object.keys(firstRow).length > 1);
386
+
387
+ if (hasFullRow) {
388
+ record = { ...firstRow };
389
+ if (hasJson) {
390
+ deserializeJsonFields(record, jsonColumns);
391
+ }
392
+ } else {
393
+ const insertedId = (firstRow && typeof firstRow === 'object') ? firstRow[model.primaryKey] : firstRow;
394
+ record = await findById(insertedId, {}, false);
395
+ }
376
396
 
377
397
  // Emit afterCreate
378
398
  ModelEvents.emit(model.name, Hooks.AFTER_CREATE, record, ctx);
@@ -719,16 +739,43 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
719
739
  }
720
740
 
721
741
  // Update the record
722
- const updated = await baseQuery('update', updateData)
723
- .where(model.primaryKey, id)
724
- .update(updateData);
742
+ let record = null;
743
+ let updateResult = null;
725
744
 
726
- if (updated === 0) {
727
- return null;
745
+ try {
746
+ updateResult = await baseQuery('update', updateData)
747
+ .where(model.primaryKey, id)
748
+ .update(updateData)
749
+ .returning('*');
750
+ } catch (_) {
751
+ updateResult = await baseQuery('update', updateData)
752
+ .where(model.primaryKey, id)
753
+ .update(updateData);
728
754
  }
729
755
 
730
- // Fetch and return the updated record (without emitting find events)
731
- const record = await findById(id, {}, false);
756
+ if (Array.isArray(updateResult)) {
757
+ if (updateResult.length === 0) {
758
+ return null;
759
+ }
760
+ const firstRow = updateResult[0];
761
+ const hasFullRow = firstRow &&
762
+ typeof firstRow === 'object' &&
763
+ (model.columns ? Object.keys(firstRow).length >= Math.min(model.columns.size, 2) : Object.keys(firstRow).length > 1);
764
+
765
+ if (hasFullRow) {
766
+ record = { ...firstRow };
767
+ if (hasJson) {
768
+ deserializeJsonFields(record, jsonColumns);
769
+ }
770
+ } else {
771
+ record = await findById(id, {}, false);
772
+ }
773
+ } else {
774
+ if (updateResult === 0) {
775
+ return null;
776
+ }
777
+ record = await findById(id, {}, false);
778
+ }
732
779
 
733
780
  // Emit afterUpdate
734
781
  ModelEvents.emit(model.name, Hooks.AFTER_UPDATE, record, ctx);
@@ -12,7 +12,55 @@ const { generateNanoid } = require('./utils/nanoid');
12
12
  * @returns {string} Column name ('deletedAt' or 'deleted_at')
13
13
  */
14
14
  function getSoftDeleteColumn(model) {
15
- return (model && model.columns && model.columns.has('deletedAt')) ? 'deletedAt' : 'deleted_at';
15
+ if (model && model._deletedCol) {
16
+ return model._deletedCol;
17
+ }
18
+ const col = (model && (
19
+ (model.columns && model.columns.has('deletedAt')) ||
20
+ (model.schema && model.schema.shape && model.schema.shape.deletedAt)
21
+ )) ? 'deletedAt' : 'deleted_at';
22
+ if (model) {
23
+ model._deletedCol = col;
24
+ }
25
+ return col;
26
+ }
27
+
28
+ /**
29
+ * Determine the created at timestamp column name for a model
30
+ * @param {import('./types').ModelDefinition} model - Model definition
31
+ * @returns {string} Column name ('createdAt' or 'created_at')
32
+ */
33
+ function getCreatedAtColumn(model) {
34
+ if (model && model._createdCol) {
35
+ return model._createdCol;
36
+ }
37
+ const col = (model && (
38
+ (model.columns && model.columns.has('createdAt')) ||
39
+ (model.schema && model.schema.shape && model.schema.shape.createdAt)
40
+ )) ? 'createdAt' : 'created_at';
41
+ if (model) {
42
+ model._createdCol = col;
43
+ }
44
+ return col;
45
+ }
46
+
47
+ /**
48
+ * Determine the updated at timestamp column name for a model
49
+ * @param {import('./types').ModelDefinition} model - Model definition
50
+ * @returns {string} Column name ('updatedAt' or 'updated_at')
51
+ */
52
+ function getUpdatedAtColumn(model) {
53
+ if (model && model._updatedCol) {
54
+ return model._updatedCol;
55
+ }
56
+ const col = (model && (
57
+ (model.columns && model.columns.has('updatedAt')) ||
58
+ (model.schema && model.schema.shape && model.schema.shape.updatedAt)
59
+ )) ? 'updatedAt' : 'updated_at';
60
+ if (model) {
61
+ model._updatedCol = col;
62
+ }
63
+ return col;
16
64
  }
17
65
 
18
66
  /**
@@ -397,8 +445,8 @@ function applyInsertTimestamps(data, model) {
397
445
  }
398
446
 
399
447
  const now = new Date();
400
- const createdCol = (model.columns && model.columns.has('createdAt')) ? 'createdAt' : 'created_at';
401
- const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
448
+ const createdCol = model._createdCol || ((model.columns && model.columns.has('createdAt')) ? 'createdAt' : 'created_at');
449
+ const updatedCol = model._updatedCol || ((model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at');
402
450
 
403
451
  return {
404
452
  ...data,
@@ -418,7 +466,7 @@ function applyUpdateTimestamps(data, model) {
418
466
  return data;
419
467
  }
420
468
 
421
- const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
469
+ const updatedCol = model._updatedCol || ((model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at');
422
470
 
423
471
  return {
424
472
  ...data,
@@ -558,6 +606,8 @@ module.exports = {
558
606
  getSoftDeleteData,
559
607
  getRestoreData,
560
608
  getSoftDeleteColumn,
609
+ getCreatedAtColumn,
610
+ getUpdatedAtColumn,
561
611
  createScopeContext,
562
612
  };
563
613
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Webspresso Core Services Layer
3
+ * Lightweight, framework-agnostic business logic services with zero dependency sprawl
4
+ * @module core/services
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ module.exports = require('../../src/services');
@@ -283,46 +283,68 @@ class ShutdownManager {
283
283
  // 2. Close HTTP server adapters (gracefully drain in-flight HTTP requests)
284
284
  if (this._adapters.length > 0) {
285
285
  this._log('info', 'Closing HTTP server');
286
+ const remainingMs = getRemainingTime();
286
287
 
287
- for (const adapter of this._adapters) {
288
- const remainingMs = getRemainingTime();
289
-
290
- if (isForce || (deadline > 0 && remainingMs <= 0)) {
291
- // Force mode or deadline expired: immediately forceClose
288
+ if (isForce || (deadline > 0 && remainingMs <= 0)) {
289
+ // Force mode or deadline expired: immediately forceClose all adapters
290
+ for (const adapter of this._adapters) {
292
291
  try {
293
292
  if (typeof adapter.forceClose === 'function') {
294
293
  adapter.forceClose();
295
294
  }
296
- const closeRes = adapter.close();
297
- if (closeRes && typeof closeRes.then === 'function') {
298
- await Promise.race([
299
- Promise.resolve(closeRes),
300
- new Promise(r => {
301
- const t = setTimeout(r, 100);
302
- if (typeof t.unref === 'function') t.unref();
303
- })
304
- ]);
305
- }
306
295
  } catch (err) {
307
296
  this._log('error', 'Error force closing adapter:', err);
308
297
  }
309
- } else {
310
- // Graceful mode: close idle connections, close server, and force close on timeout
298
+ }
299
+ await Promise.all(
300
+ this._adapters.map(async (adapter) => {
301
+ try {
302
+ if (typeof adapter.close === 'function') {
303
+ const closeRes = adapter.close();
304
+ if (closeRes && typeof closeRes.then === 'function') {
305
+ await Promise.race([
306
+ Promise.resolve(closeRes),
307
+ new Promise((r) => {
308
+ const t = setTimeout(r, 25);
309
+ if (typeof t.unref === 'function') t.unref();
310
+ }),
311
+ ]);
312
+ }
313
+ }
314
+ } catch (err) {
315
+ this._log('error', 'Error closing adapter:', err);
316
+ }
317
+ })
318
+ );
319
+ } else {
320
+ // Graceful mode: close idle connections, close servers, and force close on timeout
321
+ for (const adapter of this._adapters) {
311
322
  try {
312
323
  if (typeof adapter.closeIdleConnections === 'function') {
313
324
  adapter.closeIdleConnections();
314
325
  }
315
326
  } catch (e) {}
327
+ }
328
+
329
+ const closePromises = this._adapters.map(async (adapter) => {
330
+ try {
331
+ if (typeof adapter.close === 'function') {
332
+ await adapter.close();
333
+ }
334
+ } catch (err) {
335
+ this._log('error', 'Error closing server adapter:', err);
336
+ }
337
+ });
338
+ const allClosePromise = Promise.all(closePromises);
316
339
 
340
+ if (deadline > 0) {
341
+ const adapterTimeout = getRemainingTime();
317
342
  let timer = null;
318
- const closePromise = adapter.close();
319
-
320
- if (deadline > 0) {
321
- const adapterTimeout = getRemainingTime();
322
- const timeoutPromise = new Promise((resolve) => {
323
- timer = setTimeout(() => {
324
- this._log('warn', `Graceful shutdown timed out with ${adapterTimeout}ms remaining budget`);
325
- this._log('info', 'Force closing active connections');
343
+ const timeoutPromise = new Promise((resolve) => {
344
+ timer = setTimeout(() => {
345
+ this._log('warn', `Graceful shutdown timed out with ${adapterTimeout}ms remaining budget`);
346
+ this._log('info', 'Force closing active connections');
347
+ for (const adapter of this._adapters) {
326
348
  try {
327
349
  if (typeof adapter.forceClose === 'function') {
328
350
  adapter.forceClose();
@@ -330,25 +352,22 @@ class ShutdownManager {
330
352
  } catch (err) {
331
353
  this._log('error', 'Error force closing connections after timeout:', err);
332
354
  }
333
- resolve();
334
- }, adapterTimeout);
335
-
336
- if (typeof timer.unref === 'function') {
337
- timer.unref();
338
355
  }
339
- });
356
+ resolve();
357
+ }, adapterTimeout);
340
358
 
341
- await Promise.race([closePromise, timeoutPromise]);
342
- } else {
343
- await closePromise;
344
- }
359
+ if (typeof timer.unref === 'function') {
360
+ timer.unref();
361
+ }
362
+ });
363
+
364
+ await Promise.race([allClosePromise, timeoutPromise]);
345
365
 
346
366
  if (timer) {
347
367
  clearTimeout(timer);
348
368
  }
349
-
350
- // Prevent unhandled rejection without blocking shutdown if timeout won
351
- closePromise.catch(() => {});
369
+ } else {
370
+ await allClosePromise;
352
371
  }
353
372
  }
354
373
  }
@@ -70,6 +70,10 @@ async function listTables(knex) {
70
70
  * @returns {Promise<Array<{ name: string, type: string, nullable: boolean, defaultValue: any, isPrimaryKey: boolean }>>}
71
71
  */
72
72
  async function getTableSchema(knex, tableName) {
73
+ if (typeof tableName !== 'string' || !/^[a-zA-Z0-9_]+$/.test(tableName)) {
74
+ throw new Error('Invalid table name');
75
+ }
76
+
73
77
  const dialect = getDialect(knex);
74
78
 
75
79
  if (dialect === 'sqlite') {
@@ -224,8 +228,12 @@ async function deleteRow(knex, tableName, id, pkField = 'id') {
224
228
  * @returns {Promise<{ rows: Array<any>, durationMs: number, changes?: number }>}
225
229
  */
226
230
  async function executeRawQuery(knex, sql, bindings = []) {
231
+ if (typeof sql !== 'string') {
232
+ throw new TypeError('SQL query must be a string');
233
+ }
234
+ const cleanBindings = Array.isArray(bindings) ? bindings : [];
227
235
  const start = Date.now();
228
- const rawRes = await knex.raw(sql, bindings);
236
+ const rawRes = await knex.raw(sql, cleanBindings);
229
237
  const durationMs = Date.now() - start;
230
238
 
231
239
  let rows = [];
@@ -8,7 +8,7 @@
8
8
 
9
9
  const http = require('http');
10
10
  const express = require('express');
11
- const { exec } = require('child_process');
11
+ const { spawn } = require('child_process');
12
12
  const {
13
13
  listTables,
14
14
  getTableSchema,
@@ -104,9 +104,12 @@ function createStudioApp(options = {}) {
104
104
  app.post('/_studio/api/query', async (req, res, next) => {
105
105
  try {
106
106
  const { sql, bindings = [] } = req.body || {};
107
- if (!sql) {
107
+ if (!sql || typeof sql !== 'string') {
108
108
  return res.status(400).json({ error: 'SQL query string is required.' });
109
109
  }
110
+ if (!Array.isArray(bindings)) {
111
+ return res.status(400).json({ error: 'Bindings must be an array.' });
112
+ }
110
113
  const result = await executeRawQuery(knex, sql, bindings);
111
114
  res.json(result);
112
115
  } catch (err) {
@@ -261,14 +264,27 @@ async function getTelemetrySnapshot(knex, options = {}) {
261
264
  * @param {string} url
262
265
  */
263
266
  function openBrowser(url) {
264
- const platform = process.platform;
265
- let cmd = '';
266
- if (platform === 'darwin') cmd = `open "${url}"`;
267
- else if (platform === 'win32') cmd = `start "" "${url}"`;
268
- else cmd = `xdg-open "${url}"`;
267
+ try {
268
+ const parsed = new URL(url);
269
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
270
+ return;
271
+ }
272
+ if (!/^[a-zA-Z0-9.:/?#\[\]@!$&'()*+,;=-]+$/.test(url)) {
273
+ return;
274
+ }
275
+ } catch (_) {
276
+ return;
277
+ }
269
278
 
279
+ const platform = process.platform;
270
280
  try {
271
- exec(cmd);
281
+ if (platform === 'darwin') {
282
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
283
+ } else if (platform === 'win32') {
284
+ spawn('explorer.exe', [url], { detached: true, stdio: 'ignore' }).unref();
285
+ } else {
286
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
287
+ }
272
288
  } catch (_) {}
273
289
  }
274
290