webspresso 0.0.96 → 0.0.98

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 (121) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -0,0 +1,354 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Development HTTP & Database Query Profiler
5
+ * AsyncLocalStorage-powered per-request database query tracking and N+1 query detection
6
+ * @module src/dev/profiler
7
+ */
8
+
9
+ const { AsyncLocalStorage } = require('async_hooks');
10
+ const { colors: defaultColors, stripAnsi } = require('./colors');
11
+
12
+ // Ambient request storage for profiling queries executed during an HTTP request
13
+ const profilerStorage = new AsyncLocalStorage();
14
+
15
+ // Internal tables to ignore when calculating N+1 warnings
16
+ const IGNORED_SYSTEM_TABLES = new Set([
17
+ 'sqlite_master',
18
+ 'sqlite_sequence',
19
+ 'sqlite_temp_master',
20
+ 'knex_migrations',
21
+ 'knex_migrations_lock',
22
+ 'information_schema',
23
+ 'pg_catalog',
24
+ 'spatial_ref_sys',
25
+ ]);
26
+
27
+ /**
28
+ * Robustly extracts the primary target table name from an SQL query string
29
+ * @param {string} sql
30
+ * @returns {string|null} Table name, or null if unparseable
31
+ */
32
+ function extractTableFromSql(sql) {
33
+ if (!sql || typeof sql !== 'string') return null;
34
+ const cleaned = sql.replace(/\s+/g, ' ').trim();
35
+
36
+ // 1. SELECT ... FROM [schema.]table
37
+ const fromMatch = cleaned.match(/\bfrom\s+(?:(?:[`"']?([a-zA-Z0-9_]+)[`"']?\.)?[`"']?([a-zA-Z0-9_]+)[`"']?)/i);
38
+ if (fromMatch) {
39
+ return fromMatch[2] || fromMatch[1];
40
+ }
41
+
42
+ // 2. INSERT INTO [schema.]table
43
+ const intoMatch = cleaned.match(/\binto\s+(?:(?:[`"']?([a-zA-Z0-9_]+)[`"']?\.)?[`"']?([a-zA-Z0-9_]+)[`"']?)/i);
44
+ if (intoMatch) {
45
+ return intoMatch[2] || intoMatch[1];
46
+ }
47
+
48
+ // 3. UPDATE [schema.]table
49
+ const updateMatch = cleaned.match(/\bupdate\s+(?:(?:[`"']?([a-zA-Z0-9_]+)[`"']?\.)?[`"']?([a-zA-Z0-9_]+)[`"']?)/i);
50
+ if (updateMatch) {
51
+ return updateMatch[2] || updateMatch[1];
52
+ }
53
+
54
+ // 4. DELETE FROM [schema.]table
55
+ const deleteMatch = cleaned.match(/\bdelete\s+(?:from\s+)?(?:(?:[`"']?([a-zA-Z0-9_]+)[`"']?\.)?[`"']?([a-zA-Z0-9_]+)[`"']?)/i);
56
+ if (deleteMatch) {
57
+ return deleteMatch[2] || deleteMatch[1];
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Analyzes collected queries for potential N+1 anti-patterns
65
+ * @param {Array<Object>} queries - List of executed query records
66
+ * @param {Object} [options]
67
+ * @param {number} [options.threshold=4] - Minimum repeated queries to trigger warning
68
+ * @returns {Array<{ table: string, count: number, totalMs: number, recommendation: string, sampleSql: string }>}
69
+ */
70
+ function detectNPlusOne(queries = [], options = {}) {
71
+ const threshold = options.threshold ?? 4;
72
+ if (!Array.isArray(queries) || queries.length < threshold) {
73
+ return [];
74
+ }
75
+
76
+ const tableStats = new Map();
77
+
78
+ for (const q of queries) {
79
+ if (!q || !q.table) continue;
80
+ const lower = q.table.toLowerCase();
81
+ if (IGNORED_SYSTEM_TABLES.has(lower) || lower.startsWith('knex_')) {
82
+ continue;
83
+ }
84
+
85
+ let stats = tableStats.get(lower);
86
+ if (!stats) {
87
+ stats = { table: q.table, count: 0, totalMs: 0, queries: [] };
88
+ tableStats.set(lower, stats);
89
+ }
90
+
91
+ stats.count++;
92
+ stats.totalMs += q.durationMs || 0;
93
+ stats.queries.push(q);
94
+ }
95
+
96
+ const warnings = [];
97
+
98
+ for (const [, stats] of tableStats) {
99
+ if (stats.count >= threshold) {
100
+ const roundedMs = Math.round(stats.totalMs * 100) / 100;
101
+ warnings.push({
102
+ table: stats.table,
103
+ count: stats.count,
104
+ totalMs: roundedMs,
105
+ sampleSql: stats.queries[0]?.sql || '',
106
+ recommendation: `Potential N+1 query pattern detected on table '${stats.table}' (${stats.count} queries in single request, ${roundedMs}ms). Consider eager loading with repo.query().include('${stats.table}') or batching.`,
107
+ });
108
+ }
109
+ }
110
+
111
+ return warnings;
112
+ }
113
+
114
+ /**
115
+ * Formats a dev request log line with execution time, DB metrics, and optional query detail
116
+ * @param {Object} params
117
+ * @param {string} params.method
118
+ * @param {string} params.url
119
+ * @param {number} params.status
120
+ * @param {number} params.durationMs
121
+ * @param {Array<Object>} [params.queries]
122
+ * @param {number} [params.slowQueryMs=50]
123
+ * @param {boolean} [params.verbose=false]
124
+ * @param {Object} [params.colors]
125
+ * @returns {{ line: string, queryLines: string[], plainLine: string }}
126
+ */
127
+ function formatRequestLog(params) {
128
+ const {
129
+ method = 'GET',
130
+ url = '/',
131
+ status = 200,
132
+ durationMs = 0,
133
+ queries = [],
134
+ slowQueryMs = 50,
135
+ verbose = false,
136
+ colors = defaultColors,
137
+ } = params;
138
+
139
+ // 1. Method color
140
+ let methodColored = method;
141
+ const m = method.toUpperCase();
142
+ if (m === 'GET') methodColored = colors.cyan(m);
143
+ else if (m === 'POST') methodColored = colors.green(m);
144
+ else if (m === 'PUT' || m === 'PATCH') methodColored = colors.yellow(m);
145
+ else if (m === 'DELETE') methodColored = colors.red(m);
146
+ else methodColored = colors.white(m);
147
+
148
+ // 2. Status color
149
+ let statusColored = String(status);
150
+ if (status >= 500) statusColored = colors.red(String(status));
151
+ else if (status >= 400) statusColored = colors.yellow(String(status));
152
+ else if (status >= 300) statusColored = colors.cyan(String(status));
153
+ else if (status >= 200) statusColored = colors.green(String(status));
154
+
155
+ // 3. Duration color
156
+ let durationColored = `${durationMs}ms`;
157
+ if (durationMs > 500) durationColored = colors.red(durationColored);
158
+ else if (durationMs > 150) durationColored = colors.yellow(durationColored);
159
+ else durationColored = colors.gray(durationColored);
160
+
161
+ // 4. DB Queries summary
162
+ let dbSummary = '';
163
+ const queryCount = queries.length;
164
+ if (queryCount > 0) {
165
+ const totalDbMs = Math.round(queries.reduce((acc, q) => acc + (q.durationMs || 0), 0) * 10) / 10;
166
+ const slowQueries = queries.filter((q) => (q.durationMs || 0) >= slowQueryMs);
167
+ const slowNote = slowQueries.length > 0 ? ` ${colors.yellow(`⚠️ ${slowQueries.length} slow >${slowQueryMs}ms`)}` : '';
168
+ dbSummary = ` ${colors.gray('|')} ${colors.magenta(`${queryCount} DB ${queryCount === 1 ? 'query' : 'queries'}`)} ${colors.gray(`(${totalDbMs}ms${slowNote})`)}`;
169
+ }
170
+
171
+ const line = `[${methodColored} ${statusColored}] ${colors.bold(url)} ${colors.gray('—')} ${durationColored}${dbSummary}`;
172
+ const plainLine = stripAnsi(line);
173
+
174
+ // 5. Verbose query breakdown lines
175
+ const queryLines = [];
176
+ if (verbose && queryCount > 0) {
177
+ for (let i = 0; i < queries.length; i++) {
178
+ const q = queries[i];
179
+ const isSlow = (q.durationMs || 0) >= slowQueryMs;
180
+ const msLabel = `${q.durationMs}ms`;
181
+ const timeTag = isSlow ? colors.yellow(msLabel) : colors.gray(msLabel);
182
+ const isLast = i === queries.length - 1;
183
+ const branch = isLast ? '└─' : '├─';
184
+ const cleanSql = (q.sql || '').replace(/\s+/g, ' ').slice(0, 120);
185
+ queryLines.push(` ${colors.gray(branch)} [DB ${timeTag}] ${colors.dim(cleanSql)}`);
186
+ }
187
+ }
188
+
189
+ return { line, queryLines, plainLine };
190
+ }
191
+
192
+ /**
193
+ * Attaches query instrumentation listeners to a Knex instance
194
+ * @param {import('knex').Knex} knexInstance
195
+ * @returns {void}
196
+ */
197
+ function attachProfilerToKnex(knexInstance) {
198
+ if (!knexInstance || typeof knexInstance.on !== 'function') return;
199
+ if (knexInstance.__wsDevProfilerAttached) return;
200
+ knexInstance.__wsDevProfilerAttached = true;
201
+
202
+ const queryStarts = new Map();
203
+
204
+ knexInstance.on('query', (queryData) => {
205
+ if (queryData && queryData.__knexQueryUid) {
206
+ queryStarts.set(queryData.__knexQueryUid, process.hrtime.bigint());
207
+ // Self-cleaning bound to avoid memory leak if responses fail to fire
208
+ if (queryStarts.size > 1000) {
209
+ const oldestKey = queryStarts.keys().next().value;
210
+ queryStarts.delete(oldestKey);
211
+ }
212
+ }
213
+ });
214
+
215
+ function recordQuery(queryData, isError, error) {
216
+ const uid = queryData?.__knexQueryUid;
217
+ const start = uid ? queryStarts.get(uid) : null;
218
+ if (uid) queryStarts.delete(uid);
219
+
220
+ let durationMs = 0;
221
+ if (start) {
222
+ const diff = process.hrtime.bigint() - start;
223
+ durationMs = Number(diff) / 1e6;
224
+ }
225
+
226
+ const store = profilerStorage.getStore();
227
+ if (store && Array.isArray(store.queries)) {
228
+ const sql = queryData?.sql || '';
229
+ store.queries.push({
230
+ uid,
231
+ sql,
232
+ bindings: queryData?.bindings || [],
233
+ durationMs: Math.round(durationMs * 100) / 100,
234
+ table: extractTableFromSql(sql),
235
+ isError: Boolean(isError),
236
+ error: error ? error.message : null,
237
+ });
238
+ }
239
+ }
240
+
241
+ knexInstance.on('query-response', (response, queryData) => {
242
+ recordQuery(queryData, false);
243
+ });
244
+
245
+ knexInstance.on('query-error', (error, queryData) => {
246
+ recordQuery(queryData, true, error);
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Creates Express middleware for dev request logging, DB query profiling, and N+1 detection
252
+ * @param {Object} [options]
253
+ * @param {Object} [options.db] - Knex/ORM database instance
254
+ * @param {Object} [options.logger=console] - Output logger
255
+ * @param {boolean} [options.detectNPlusOne=true] - Whether to detect and warn on N+1 queries
256
+ * @param {number} [options.thresholdNPlusOne=4] - Threshold repeated queries
257
+ * @param {number} [options.slowQueryMs=50] - Threshold for slow query warning
258
+ * @param {boolean} [options.verbose=false] - Whether to print indented SQL queries
259
+ * @param {boolean} [options.colors=true] - Whether to use ANSI colors
260
+ * @returns {import('express').RequestHandler}
261
+ */
262
+ function createDevProfilerMiddleware(options = {}) {
263
+ const {
264
+ db = null,
265
+ logger = console,
266
+ detectNPlusOne: shouldDetectNPlusOne = true,
267
+ thresholdNPlusOne = 4,
268
+ slowQueryMs = 50,
269
+ verbose = false,
270
+ colors: useColors = true,
271
+ } = options;
272
+
273
+ // Auto-instrument Knex instance if available
274
+ if (db && db.knex) {
275
+ attachProfilerToKnex(db.knex);
276
+ }
277
+
278
+ return function devProfilerMiddleware(req, res, next) {
279
+ const startHr = process.hrtime.bigint();
280
+ const requestId = (req.headers && req.headers['x-request-id'])
281
+ ? String(req.headers['x-request-id'])
282
+ : Math.random().toString(36).slice(2, 8);
283
+
284
+ const store = {
285
+ requestId,
286
+ method: (req.method || 'GET').toUpperCase(),
287
+ path: req.originalUrl || req.url || '/',
288
+ queries: [],
289
+ };
290
+
291
+ // Expose profiler details on req object for inspection
292
+ req.profiler = store;
293
+
294
+ profilerStorage.run(store, () => {
295
+ res.on('finish', () => {
296
+ const durationMs = Number(process.hrtime.bigint() - startHr) / 1e6;
297
+ const roundedDuration = Math.round(durationMs * 10) / 10;
298
+ const statusCode = res.statusCode || 200;
299
+
300
+ const formatted = formatRequestLog({
301
+ method: store.method,
302
+ url: store.path,
303
+ status: statusCode,
304
+ durationMs: roundedDuration,
305
+ queries: store.queries,
306
+ slowQueryMs,
307
+ verbose,
308
+ colors: useColors ? defaultColors : {
309
+ bold: (s) => s,
310
+ dim: (s) => s,
311
+ cyan: (s) => s,
312
+ green: (s) => s,
313
+ yellow: (s) => s,
314
+ red: (s) => s,
315
+ magenta: (s) => s,
316
+ gray: (s) => s,
317
+ white: (s) => s,
318
+ },
319
+ });
320
+
321
+ if (logger && typeof logger.log === 'function') {
322
+ logger.log(formatted.line);
323
+ if (Array.isArray(formatted.queryLines) && formatted.queryLines.length > 0) {
324
+ for (const ql of formatted.queryLines) {
325
+ logger.log(ql);
326
+ }
327
+ }
328
+ }
329
+
330
+ // N+1 Query Warning Check
331
+ if (shouldDetectNPlusOne && store.queries.length >= thresholdNPlusOne) {
332
+ const warnings = detectNPlusOne(store.queries, { threshold: thresholdNPlusOne });
333
+ for (const warn of warnings) {
334
+ if (logger && typeof logger.warn === 'function') {
335
+ const prefix = defaultColors.yellow('⚠️ [webspresso:n+1]');
336
+ logger.warn(` ${prefix} ${warn.recommendation}`);
337
+ }
338
+ }
339
+ }
340
+ });
341
+
342
+ next();
343
+ });
344
+ };
345
+ }
346
+
347
+ module.exports = {
348
+ profilerStorage,
349
+ attachProfilerToKnex,
350
+ extractTableFromSql,
351
+ detectNPlusOne,
352
+ formatRequestLog,
353
+ createDevProfilerMiddleware,
354
+ };