webspresso 0.0.97 → 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 (100) 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/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
@@ -9,11 +9,14 @@ const { createAnalyticsApiHandlers } = require('./api-handlers');
9
9
  const { generateAnalyticsComponent } = require('./admin-component');
10
10
  const { generateErrorTrackerScript } = require('./client-error-tracker');
11
11
  const { createErrorReportHandler } = require('./client-error-handler');
12
+ const { purgeAnalyticsData } = require('./purge');
12
13
 
13
14
  /**
14
15
  * Site Analytics Plugin Factory
15
16
  * @param {Object} options
16
17
  * @param {Object} options.db - Database instance (must expose .knex)
18
+ * @param {string} [options.title] - Admin page title
19
+ * @param {string} [options.adminTitle] - Admin page title alias
17
20
  * @param {string[]} [options.excludePaths=[]] - Extra paths to exclude from tracking
18
21
  * @param {boolean} [options.trackBots=true] - Record bot visits (still counted separately)
19
22
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
@@ -21,11 +24,22 @@ const { createErrorReportHandler } = require('./client-error-handler');
21
24
  * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
22
25
  * @param {boolean} [options.trackClientErrors=true] - Capture and report client-side JS errors
23
26
  * @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table
27
+ * @param {Object|boolean} [options.retention] - Retention policy configuration (set false to disable)
28
+ * @param {number} [options.retention.days=90] - Days to retain page views
29
+ * @param {number} [options.retention.errorDays=30] - Days to retain client errors
30
+ * @param {number} [options.retention.intervalMs=86400000] - Auto-cleanup timer frequency (24h)
31
+ * @param {boolean} [options.retention.runOnStart=true] - Run initial purge shortly after startup
32
+ * @param {number} [options.retentionDays=90] - Shorthand for retention.days
33
+ * @param {number} [options.errorRetentionDays=30] - Shorthand for retention.errorDays
34
+ * @param {number} [options.cleanupIntervalMs=86400000] - Shorthand for retention.intervalMs
35
+ * @param {boolean} [options.autoCleanup=true] - Enable/disable background periodic cleanup
24
36
  * @returns {Object} Plugin definition
25
37
  */
26
38
  function siteAnalyticsPlugin(options = {}) {
27
39
  const {
28
40
  db,
41
+ title,
42
+ adminTitle,
29
43
  excludePaths = [],
30
44
  trackBots = true,
31
45
  tableName = 'analytics_page_views',
@@ -33,14 +47,103 @@ function siteAnalyticsPlugin(options = {}) {
33
47
  errorsTableName = 'analytics_client_errors',
34
48
  } = options;
35
49
 
50
+ const pageTitle = title || adminTitle || 'Analytics';
51
+
36
52
  if (!db) {
37
53
  throw new Error('site-analytics plugin requires a database instance. Pass `db` in options.');
38
54
  }
39
55
 
56
+ // Retention configuration
57
+ const retentionOption = options.retention;
58
+ const isRetentionDisabled =
59
+ retentionOption === false ||
60
+ (typeof retentionOption === 'object' && retentionOption.enabled === false) ||
61
+ options.retentionDays === null ||
62
+ options.retentionDays === 0;
63
+
64
+ const autoCleanupEnabled =
65
+ !isRetentionDisabled &&
66
+ options.autoCleanup !== false &&
67
+ (typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false);
68
+
69
+ const retentionConfig = {
70
+ enabled: !isRetentionDisabled,
71
+ autoCleanup: autoCleanupEnabled,
72
+ days: (typeof options.retentionDays === 'number' && options.retentionDays > 0)
73
+ ? options.retentionDays
74
+ : (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 90),
75
+ errorDays: (typeof options.errorRetentionDays === 'number' && options.errorRetentionDays > 0)
76
+ ? options.errorRetentionDays
77
+ : (typeof retentionOption === 'object' && retentionOption?.errorDays ? retentionOption.errorDays : 30),
78
+ intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
79
+ ? options.cleanupIntervalMs
80
+ : (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
81
+ runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
82
+ ? !!retentionOption.runOnStart
83
+ : (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : true),
84
+ };
85
+
86
+ let cleanupInterval = null;
87
+ let cleanupStartTimeout = null;
88
+
89
+ async function runCleanup() {
90
+ try {
91
+ const knex = db.knex || db;
92
+ return await purgeAnalyticsData(knex, {
93
+ tableName,
94
+ errorsTableName,
95
+ days: retentionConfig.days,
96
+ errorDays: retentionConfig.errorDays,
97
+ });
98
+ } catch (err) {
99
+ if (process.env.NODE_ENV !== 'test') {
100
+ console.warn('[site-analytics] Failed to run retention cleanup:', err.message);
101
+ }
102
+ }
103
+ }
104
+
105
+ function startCleanup(customIntervalMs) {
106
+ stopCleanup();
107
+ if (!retentionConfig.enabled) return;
108
+ if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
109
+ retentionConfig.intervalMs = customIntervalMs;
110
+ }
111
+
112
+ if (retentionConfig.runOnStart) {
113
+ const delayMs = typeof options.cleanupStartDelayMs === 'number'
114
+ ? options.cleanupStartDelayMs
115
+ : (process.env.NODE_ENV === 'test' ? 50 : 5000);
116
+ cleanupStartTimeout = setTimeout(() => {
117
+ runCleanup();
118
+ }, delayMs);
119
+ if (cleanupStartTimeout && cleanupStartTimeout.unref) {
120
+ cleanupStartTimeout.unref();
121
+ }
122
+ }
123
+
124
+ cleanupInterval = setInterval(() => {
125
+ runCleanup();
126
+ }, retentionConfig.intervalMs);
127
+ if (cleanupInterval && cleanupInterval.unref) {
128
+ cleanupInterval.unref();
129
+ }
130
+ }
131
+
132
+ function stopCleanup() {
133
+ if (cleanupStartTimeout) {
134
+ clearTimeout(cleanupStartTimeout);
135
+ cleanupStartTimeout = null;
136
+ }
137
+ if (cleanupInterval) {
138
+ clearInterval(cleanupInterval);
139
+ cleanupInterval = null;
140
+ }
141
+ }
142
+
40
143
  return {
41
144
  name: 'site-analytics',
42
145
  version: '1.0.0',
43
- description: 'Self-hosted page view analytics with admin dashboard',
146
+ description: 'Self-hosted page view analytics with admin dashboard and auto-retention cleanup',
44
147
  dependencies: { 'admin-panel': '*' },
45
148
 
46
149
  csp: {
@@ -48,6 +151,29 @@ function siteAnalyticsPlugin(options = {}) {
48
151
  connectSrc: ['https://cdn.jsdelivr.net'],
49
152
  },
50
153
 
154
+ api: {
155
+ purge(opts = {}) {
156
+ const knex = db.knex || db;
157
+ return purgeAnalyticsData(knex, {
158
+ tableName,
159
+ errorsTableName,
160
+ days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
161
+ errorDays: opts.errorDays !== undefined ? opts.errorDays : (opts.errorRetentionDays !== undefined ? opts.errorRetentionDays : retentionConfig.errorDays),
162
+ ...opts,
163
+ });
164
+ },
165
+ purgeAnalyticsData: (kx, opts) => purgeAnalyticsData(kx || db.knex || db, {
166
+ tableName,
167
+ errorsTableName,
168
+ ...opts,
169
+ }),
170
+ startCleanup,
171
+ stopCleanup,
172
+ getRetentionConfig() {
173
+ return { ...retentionConfig };
174
+ },
175
+ },
176
+
51
177
  register(ctx) {
52
178
  const knex = db.knex || db;
53
179
 
@@ -66,6 +192,22 @@ function siteAnalyticsPlugin(options = {}) {
66
192
  const script = generateErrorTrackerScript({ endpoint: '/_analytics/report-error' });
67
193
  ctx.injectBody(`<script>${script}</script>`, { id: 'site-analytics-error-tracker', priority: 5 });
68
194
  }
195
+
196
+ if (retentionConfig.enabled && autoCleanupEnabled) {
197
+ startCleanup();
198
+ }
199
+
200
+ return () => {
201
+ stopCleanup();
202
+ };
203
+ },
204
+
205
+ dispose() {
206
+ stopCleanup();
207
+ },
208
+
209
+ shutdown() {
210
+ stopCleanup();
69
211
  },
70
212
 
71
213
  onRoutesReady(ctx) {
@@ -91,10 +233,11 @@ function siteAnalyticsPlugin(options = {}) {
91
233
 
92
234
  adminApi.registerModule({
93
235
  id: 'analytics',
236
+ title: pageTitle,
94
237
 
95
238
  pages: [{
96
239
  id: 'analytics',
97
- title: 'Analytics',
240
+ title: pageTitle,
98
241
  path: '/analytics',
99
242
  icon: 'chart',
100
243
  component: generateAnalyticsComponent(),
@@ -119,6 +262,7 @@ function siteAnalyticsPlugin(options = {}) {
119
262
  { method: 'get', path: '/countries', handler: handlers.getCountries },
120
263
  { method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
121
264
  { method: 'get', path: '/recent', handler: handlers.getRecent },
265
+ { method: 'post', path: '/purge', handler: handlers.purge },
122
266
  ],
123
267
  },
124
268
  });
@@ -127,3 +271,4 @@ function siteAnalyticsPlugin(options = {}) {
127
271
  }
128
272
 
129
273
  module.exports = siteAnalyticsPlugin;
274
+ module.exports.purgeAnalyticsData = purgeAnalyticsData;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Analytics Data Purge & Retention Service
3
+ * Deletes page views and client errors older than a retention cutoff to prevent database bloat.
4
+ * @module plugins/site-analytics/purge
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * Purge old analytics data from database
11
+ * @param {import('knex').Knex} knex - Knex query builder instance
12
+ * @param {Object} [options={}] - Purge options
13
+ * @param {string} [options.tableName='analytics_page_views'] - Page views table name
14
+ * @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table name
15
+ * @param {Date|string|number} [options.olderThan] - Cutoff date for page views
16
+ * @param {Date|string|number} [options.errorsOlderThan] - Cutoff date for client errors
17
+ * @param {Date|string|number} [options.errorOlderThan] - Alias for errorsOlderThan
18
+ * @param {number} [options.days] - Days of page views to keep (if olderThan not specified)
19
+ * @param {number} [options.retentionDays=90] - Days of page views to keep (alias for days)
20
+ * @param {number} [options.errorDays] - Days of client errors to keep (if errorsOlderThan not specified)
21
+ * @param {number} [options.errorRetentionDays=30] - Days of client errors to keep (alias for errorDays)
22
+ * @param {boolean} [options.purgePageViews=true] - Whether to purge page views
23
+ * @param {boolean} [options.purgeErrors=true] - Whether to purge client errors
24
+ * @returns {Promise<{ ok: boolean, success: boolean, viewsDeleted: number, pageViewsDeleted: number, errorsDeleted: number, totalDeleted: number, olderThan: Date, errorOlderThan: Date, viewsCutoff: string, errorsCutoff: string }>}
25
+ */
26
+ async function purgeAnalyticsData(knex, options = {}) {
27
+ if (!knex) {
28
+ throw new Error('purgeAnalyticsData requires a database (knex) instance');
29
+ }
30
+
31
+ const tableName = options.tableName || 'analytics_page_views';
32
+ const errorsTableName = options.errorsTableName || 'analytics_client_errors';
33
+ const purgePageViews = options.purgePageViews !== false;
34
+ const purgeErrors = options.purgeErrors !== false;
35
+
36
+ const now = Date.now();
37
+
38
+ // Validate days / retentionDays
39
+ if (options.days !== undefined) {
40
+ const d = typeof options.days === 'number' ? options.days : Number(options.days);
41
+ if (Number.isNaN(d) || d <= 0) {
42
+ throw new Error('purgeAnalyticsData: days must be a positive number');
43
+ }
44
+ }
45
+ if (options.retentionDays !== undefined) {
46
+ const d = typeof options.retentionDays === 'number' ? options.retentionDays : Number(options.retentionDays);
47
+ if (Number.isNaN(d) || d <= 0) {
48
+ throw new Error('purgeAnalyticsData: retentionDays must be a positive number');
49
+ }
50
+ }
51
+
52
+ // Validate errorDays / errorRetentionDays
53
+ if (options.errorDays !== undefined) {
54
+ const ed = typeof options.errorDays === 'number' ? options.errorDays : Number(options.errorDays);
55
+ if (Number.isNaN(ed) || ed <= 0) {
56
+ throw new Error('purgeAnalyticsData: errorDays must be a positive number');
57
+ }
58
+ }
59
+ if (options.errorRetentionDays !== undefined) {
60
+ const ed = typeof options.errorRetentionDays === 'number' ? options.errorRetentionDays : Number(options.errorRetentionDays);
61
+ if (Number.isNaN(ed) || ed <= 0) {
62
+ throw new Error('purgeAnalyticsData: errorRetentionDays must be a positive number');
63
+ }
64
+ }
65
+
66
+ // Calculate or validate page views cutoff
67
+ let olderThan;
68
+ if (options.olderThan !== undefined) {
69
+ olderThan = options.olderThan instanceof Date ? options.olderThan : new Date(options.olderThan);
70
+ if (Number.isNaN(olderThan.getTime())) {
71
+ throw new Error('purgeAnalyticsData: invalid olderThan date');
72
+ }
73
+ } else {
74
+ const daysRaw = options.days !== undefined ? options.days : (options.retentionDays !== undefined ? options.retentionDays : 90);
75
+ const days = Number(daysRaw);
76
+ olderThan = new Date(now - days * 24 * 60 * 60 * 1000);
77
+ }
78
+
79
+ // Calculate or validate client errors cutoff
80
+ let errorOlderThan;
81
+ const rawErrorCutoff = options.errorsOlderThan !== undefined ? options.errorsOlderThan : options.errorOlderThan;
82
+ if (rawErrorCutoff !== undefined) {
83
+ errorOlderThan = rawErrorCutoff instanceof Date ? rawErrorCutoff : new Date(rawErrorCutoff);
84
+ if (Number.isNaN(errorOlderThan.getTime())) {
85
+ throw new Error('purgeAnalyticsData: invalid errorsOlderThan date');
86
+ }
87
+ } else {
88
+ const errDaysRaw = options.errorDays !== undefined
89
+ ? options.errorDays
90
+ : (options.errorRetentionDays !== undefined ? options.errorRetentionDays : 30);
91
+ const errDays = Number(errDaysRaw);
92
+ errorOlderThan = new Date(now - errDays * 24 * 60 * 60 * 1000);
93
+ }
94
+
95
+ function applyCutoffFilter(query, columnName, cutoffDate) {
96
+ const client = knex?.client?.config?.client;
97
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
98
+ if (isSqlite) {
99
+ const iso = cutoffDate.toISOString();
100
+ const formatted = iso.replace('T', ' ').replace(/\..+$/, '');
101
+ return query.where((builder) => {
102
+ builder
103
+ .where(columnName, '<', iso)
104
+ .orWhere(columnName, '<', formatted)
105
+ .orWhere(columnName, '<', cutoffDate.getTime());
106
+ });
107
+ }
108
+ return query.where(columnName, '<', cutoffDate);
109
+ }
110
+
111
+ let pageViewsDeleted = 0;
112
+ let errorsDeleted = 0;
113
+
114
+ if (purgePageViews) {
115
+ let hasTable = true;
116
+ if (knex.schema && typeof knex.schema.hasTable === 'function') {
117
+ try {
118
+ hasTable = await knex.schema.hasTable(tableName);
119
+ } catch (e) {
120
+ hasTable = false;
121
+ }
122
+ }
123
+ if (hasTable) {
124
+ try {
125
+ const deleted = await applyCutoffFilter(knex(tableName), 'created_at', olderThan).delete();
126
+ pageViewsDeleted = Number(deleted) || 0;
127
+ } catch (e) {
128
+ // Table or deletion error handled safely
129
+ }
130
+ }
131
+ }
132
+
133
+ if (purgeErrors) {
134
+ let hasTable = true;
135
+ if (knex.schema && typeof knex.schema.hasTable === 'function') {
136
+ try {
137
+ hasTable = await knex.schema.hasTable(errorsTableName);
138
+ } catch (e) {
139
+ hasTable = false;
140
+ }
141
+ }
142
+ if (hasTable) {
143
+ try {
144
+ const deleted = await applyCutoffFilter(knex(errorsTableName), 'created_at', errorOlderThan).delete();
145
+ errorsDeleted = Number(deleted) || 0;
146
+ } catch (e) {
147
+ // Table or deletion error handled safely
148
+ }
149
+ }
150
+ }
151
+
152
+ return {
153
+ ok: true,
154
+ success: true,
155
+ viewsDeleted: pageViewsDeleted,
156
+ pageViewsDeleted,
157
+ errorsDeleted,
158
+ totalDeleted: pageViewsDeleted + errorsDeleted,
159
+ olderThan,
160
+ errorOlderThan,
161
+ viewsCutoff: olderThan.toISOString(),
162
+ errorsCutoff: errorOlderThan.toISOString(),
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ purgeAnalyticsData,
168
+ };