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.
- package/bin/webspresso.js +17 -7
- package/core/app.js +38 -6
- package/core/auth/index.js +4 -1
- package/core/auth/jwt.js +85 -13
- package/core/auth/magic-link.js +23 -5
- package/core/auth/manager.js +7 -1
- package/core/auth/redis-session.js +62 -25
- package/core/auth/session-manager.js +28 -2
- package/core/auth/sessions.js +182 -52
- package/core/console/eval.js +4 -1
- package/core/content/service.js +1 -0
- package/core/errors/domain.js +20 -2
- package/core/errors/http.js +1 -0
- package/core/http/hono-adapter.js +1 -1
- package/core/http/pipeline.js +36 -2
- package/core/index.js +12 -1
- package/core/mail/mailer.js +1 -1
- package/core/orm/eager-loader.js +81 -1
- package/core/orm/index.js +27 -13
- package/core/orm/json-fields.js +44 -13
- package/core/orm/model.js +12 -0
- package/core/orm/query-builder.js +33 -15
- package/core/orm/repository.js +59 -12
- package/core/orm/scopes.js +54 -4
- package/core/services/index.js +9 -0
- package/core/shutdown/shutdown-manager.js +57 -38
- package/core/studio/inspector.js +9 -1
- package/core/studio/server.js +24 -8
- package/core/utils/string.js +77 -16
- package/core/validation/index.js +18 -0
- package/index.d.ts +172 -8
- package/index.js +4 -0
- package/package.json +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
- package/plugins/admin-panel/index.js +24 -11
- package/plugins/admin-panel/modules/user-management.js +3 -2
- package/plugins/csrf/index.js +2 -1
- package/plugins/dashboard/index.js +27 -7
- package/src/app-context.js +9 -1
- package/src/file-router.js +12 -3
- package/src/plugin-manager.js +273 -48
- package/src/server.js +26 -33
- package/src/services/discovery.js +9 -1
- package/src/services/executor.js +271 -58
- package/src/services/index.js +52 -3
- package/src/services/loader.js +120 -0
- package/src/services/memoize.js +223 -49
- package/src/services/registry.js +492 -94
- package/src/services/validator.js +164 -13
package/core/orm/json-fields.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 {
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
798
|
+
const offset = (page - 1) * perPage;
|
|
791
799
|
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
796
|
-
|
|
807
|
+
const dataPromise = self
|
|
808
|
+
.toKnex({ includeLimitOffset: false })
|
|
809
|
+
.limit(perPage)
|
|
810
|
+
.offset(offset);
|
|
797
811
|
|
|
798
|
-
|
|
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
|
}
|
package/core/orm/repository.js
CHANGED
|
@@ -366,13 +366,33 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
// Insert and return the record
|
|
369
|
-
|
|
369
|
+
let record = null;
|
|
370
|
+
let insertedResult = null;
|
|
370
371
|
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
const
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
.update(updateData);
|
|
742
|
+
let record = null;
|
|
743
|
+
let updateResult = null;
|
|
725
744
|
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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);
|
package/core/orm/scopes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
288
|
-
|
|
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
|
-
}
|
|
310
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
351
|
-
closePromise.catch(() => {});
|
|
369
|
+
} else {
|
|
370
|
+
await allClosePromise;
|
|
352
371
|
}
|
|
353
372
|
}
|
|
354
373
|
}
|
package/core/studio/inspector.js
CHANGED
|
@@ -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,
|
|
236
|
+
const rawRes = await knex.raw(sql, cleanBindings);
|
|
229
237
|
const durationMs = Date.now() - start;
|
|
230
238
|
|
|
231
239
|
let rows = [];
|
package/core/studio/server.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const http = require('http');
|
|
10
10
|
const express = require('express');
|
|
11
|
-
const {
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|