webspresso 0.0.102 → 0.0.103

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 (39) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/core/auth/jwt.js +10 -17
  5. package/core/auth/user-agent.js +61 -42
  6. package/core/compression/compression-stream.js +2 -2
  7. package/core/compression/middleware.js +1 -1
  8. package/core/compression/negotiate.js +11 -4
  9. package/core/events/dispatcher.js +56 -33
  10. package/core/openapi/orm-components.js +2 -3
  11. package/core/orm/cache/listeners.js +12 -2
  12. package/core/orm/json-fields.js +7 -1
  13. package/core/orm/model.js +99 -8
  14. package/core/orm/query-builder.js +71 -25
  15. package/core/orm/repository.js +42 -17
  16. package/core/orm/schema-helpers.js +49 -0
  17. package/core/orm/scopes.js +40 -9
  18. package/core/orm/types.js +12 -1
  19. package/core/queue/manager.js +5 -0
  20. package/core/validation/index.js +9 -3
  21. package/index.d.ts +22 -5
  22. package/package.json +2 -1
  23. package/plugins/audit-log/middleware.js +39 -4
  24. package/plugins/email/service.js +32 -1
  25. package/plugins/file-manager/api-handlers.js +10 -17
  26. package/plugins/file-manager/index.js +11 -9
  27. package/plugins/file-manager/security.js +18 -6
  28. package/plugins/site-analytics/bot-patterns.js +3 -0
  29. package/plugins/site-analytics/client-error-handler.js +3 -1
  30. package/plugins/site-analytics/index.js +10 -1
  31. package/plugins/site-analytics/tracking.js +245 -46
  32. package/src/api/api-loader.js +1 -1
  33. package/src/discovery/scan-routes.js +14 -4
  34. package/src/file-router.js +62 -6
  35. package/src/helpers.js +139 -26
  36. package/src/plugin-manager.js +34 -13
  37. package/src/routing/route-table.js +14 -4
  38. package/src/server.js +23 -7
  39. package/src/services/validator.js +57 -32
package/core/orm/types.js CHANGED
@@ -150,12 +150,23 @@
150
150
  // Model Types
151
151
  // ============================================================================
152
152
 
153
+ /**
154
+ * @typedef {Object} ModelConfigOptions
155
+ * @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps=false] - Auto timestamps
156
+ * @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete=false] - Soft delete support
157
+ * @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes=false] - Soft delete support alias
158
+ */
159
+
153
160
  /**
154
161
  * @typedef {Object} ModelOptions
155
162
  * @property {string} name - Model name (e.g., 'User')
156
163
  * @property {string} table - Database table name (e.g., 'users')
157
- * @property {import('zod').ZodObject} schema - Zod schema for validation
164
+ * @property {import('zod').ZodObject|Record<string, any>} schema - Zod schema or plain object schema shape
158
165
  * @property {string} [primaryKey='id'] - Primary key column name
166
+ * @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps] - Auto timestamps (injects createdAt/updatedAt)
167
+ * @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete] - Soft delete support (injects deletedAt)
168
+ * @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes] - Soft delete support alias
169
+ * @property {ModelConfigOptions} [options] - Additional model options (e.g. timestamps, softDelete)
159
170
  * @property {RelationsMap} [relations={}] - Relation definitions
160
171
  * @property {ScopeOptions} [scopes={}] - Scope options
161
172
  * @property {AdminMetadata} [admin] - Admin panel metadata
@@ -527,6 +527,11 @@ class QueueManager extends EventEmitter {
527
527
  }
528
528
  } catch (err) {
529
529
  this.emit('error', err);
530
+ // Back off polling interval on database or adapter error to avoid tight spin-loop
531
+ this._currentPollInterval = Math.min(
532
+ this.maxPollInterval,
533
+ Math.round(this._currentPollInterval * 2)
534
+ );
530
535
  this._scheduleNextTick(this._currentPollInterval);
531
536
  return;
532
537
  }
@@ -22,13 +22,19 @@ function hasDangerousKeys(obj) {
22
22
  return false;
23
23
  }
24
24
  if (Array.isArray(obj)) {
25
- return obj.some(item => hasDangerousKeys(item));
25
+ const len = obj.length;
26
+ for (let i = 0; i < len; i++) {
27
+ if (hasDangerousKeys(obj[i])) return true;
28
+ }
29
+ return false;
26
30
  }
27
- for (const key of Object.keys(obj)) {
31
+ for (const key in obj) {
32
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
28
33
  if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
29
34
  return true;
30
35
  }
31
- if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
36
+ const val = obj[key];
37
+ if (val !== null && typeof val === 'object' && hasDangerousKeys(val)) {
32
38
  return true;
33
39
  }
34
40
  }
package/index.d.ts CHANGED
@@ -1072,11 +1072,20 @@ export type InferModel<T> = T extends ModelDefinition<infer TModel>
1072
1072
  export type InferModelCreate<T> = Partial<InferModel<T>> & Record<string, any>;
1073
1073
  export type InferModelUpdate<T> = Partial<InferModel<T>>;
1074
1074
 
1075
- export interface ModelOptions<TSchema extends ZodObject<any> = ZodObject<Record<string, ZodTypeAny>>> {
1075
+ export interface ModelOptions<TSchema = ZodObject<Record<string, ZodTypeAny>>> {
1076
1076
  name: string;
1077
1077
  table: string;
1078
- schema: TSchema;
1078
+ schema: TSchema | Record<string, any>;
1079
1079
  primaryKey?: string;
1080
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1081
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1082
+ softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
1083
+ options?: {
1084
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1085
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1086
+ softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
1087
+ [key: string]: unknown;
1088
+ };
1080
1089
  relations?: Record<string, RelationDefinition>;
1081
1090
  scopes?: ScopeOptions;
1082
1091
  admin?: AdminMetadata;
@@ -1104,6 +1113,11 @@ export interface ModelDefinition<TModel = Record<string, any>> {
1104
1113
  timestamps: boolean;
1105
1114
  tenant: string | null;
1106
1115
  };
1116
+ options?: {
1117
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1118
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1119
+ [key: string]: unknown;
1120
+ };
1107
1121
  columns: Map<string, unknown>;
1108
1122
  admin: {
1109
1123
  enabled: boolean;
@@ -1129,9 +1143,9 @@ export interface ModelDefinition<TModel = Record<string, any>> {
1129
1143
  cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
1130
1144
  }
1131
1145
 
1132
- export function defineModel<TSchema extends ZodObject<any>>(
1146
+ export function defineModel<TSchema = ZodObject<any>>(
1133
1147
  options: ModelOptions<TSchema>
1134
- ): ModelDefinition<import('zod').z.infer<TSchema>>;
1148
+ ): ModelDefinition<any>;
1135
1149
 
1136
1150
  export function getModel<T = Record<string, any>>(name: string): ModelDefinition<T> | undefined;
1137
1151
 
@@ -1147,7 +1161,10 @@ export function isCascadeEnabled(relation: RelationDefinition, operation: 'delet
1147
1161
 
1148
1162
  /** zdb column helpers (id, string, timestamp, …) + `schema()` */
1149
1163
  export type Zdb = Record<string, any> & {
1150
- schema(shape: Record<string, ZodTypeAny>): ZodObject<Record<string, ZodTypeAny>>;
1164
+ schema(shape: Record<string, any>): ZodObject<Record<string, ZodTypeAny>>;
1165
+ timestamps(options?: { snakeCase?: boolean; createdAt?: string; updatedAt?: string }): Record<string, any>;
1166
+ softDelete(options?: Record<string, any>): any;
1167
+ softDeletes(options?: { snakeCase?: boolean; deletedAt?: string }): Record<string, any>;
1151
1168
  };
1152
1169
 
1153
1170
  export const zdb: Zdb;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.102",
3
+ "version": "0.0.103",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -176,6 +176,7 @@
176
176
  "dotenv": "^16.3.1",
177
177
  "express-rate-limit": "^8.4.1",
178
178
  "release-it": "^20.2.1",
179
+ "softscope": "^0.3.2",
179
180
  "supertest": "^6.3.4",
180
181
  "typescript": "~5.6.3",
181
182
  "vitest": "^4.1.11",
@@ -95,11 +95,9 @@ function createAuditMiddleware(options) {
95
95
  metadata: metadata || null,
96
96
  };
97
97
 
98
- knex(tableName).insert(row).catch((err) => {
99
- console.warn('[audit-log] Failed to insert audit row:', err.message);
100
- });
98
+ knex(tableName).insert(row).catch(logAuditError);
101
99
  } catch (e) {
102
- console.warn('[audit-log] finish handler error:', e.message);
100
+ logAuditError(e, 'finish handler error');
103
101
  }
104
102
  });
105
103
 
@@ -107,9 +105,46 @@ function createAuditMiddleware(options) {
107
105
  };
108
106
  }
109
107
 
108
+ let lastAuditWarnTime = 0;
109
+ let suppressedAuditErrors = 0;
110
+
111
+ /**
112
+ * Format and sanitize DB errors to prevent leaking raw SQL queries
113
+ * @param {Error|Object|string} e
114
+ * @returns {string}
115
+ */
116
+ function formatDbError(e) {
117
+ if (!e) return 'Unknown error';
118
+ let msg;
119
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
120
+ msg = e.message.split(' - ').pop().trim();
121
+ } else {
122
+ msg = String(e.code || e.message || 'Database error');
123
+ }
124
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
125
+ }
126
+
127
+ function logAuditError(err, prefix = 'Failed to insert audit row') {
128
+ const now = Date.now();
129
+ const formatted = formatDbError(err);
130
+ if (lastAuditWarnTime === 0 || now - lastAuditWarnTime >= 60000) {
131
+ const elapsedSec = lastAuditWarnTime > 0 ? Math.round((now - lastAuditWarnTime) / 1000) : 0;
132
+ if (suppressedAuditErrors > 0) {
133
+ console.warn(`[audit-log] ${prefix} (suppressed ${suppressedAuditErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
134
+ } else {
135
+ console.warn(`[audit-log] ${prefix}: ${formatted}`);
136
+ }
137
+ lastAuditWarnTime = now;
138
+ suppressedAuditErrors = 0;
139
+ } else {
140
+ suppressedAuditErrors++;
141
+ }
142
+ }
143
+
110
144
  module.exports = {
111
145
  createAuditMiddleware,
112
146
  stringifyId,
113
147
  extractResourceIdFromJsonBody,
114
148
  buildMetadata,
149
+ formatDbError,
115
150
  };
@@ -30,6 +30,37 @@ function createEmailService(options) {
30
30
  console.warn('[email]', err.message);
31
31
  }
32
32
 
33
+ let lastEmailLogWarnTime = 0;
34
+ let suppressedEmailLogErrors = 0;
35
+
36
+ function formatDbError(e) {
37
+ if (!e) return 'Unknown error';
38
+ let msg;
39
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
40
+ msg = e.message.split(' - ').pop().trim();
41
+ } else {
42
+ msg = String(e.code || e.message || 'Database error');
43
+ }
44
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
45
+ }
46
+
47
+ function logEmailError(err) {
48
+ const now = Date.now();
49
+ const formatted = formatDbError(err);
50
+ if (lastEmailLogWarnTime === 0 || now - lastEmailLogWarnTime >= 60000) {
51
+ const elapsedSec = lastEmailLogWarnTime > 0 ? Math.round((now - lastEmailLogWarnTime) / 1000) : 0;
52
+ if (suppressedEmailLogErrors > 0) {
53
+ console.warn(`[email] Failed to write email log (suppressed ${suppressedEmailLogErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
54
+ } else {
55
+ console.warn('[email] Failed to write email log:', formatted);
56
+ }
57
+ lastEmailLogWarnTime = now;
58
+ suppressedEmailLogErrors = 0;
59
+ } else {
60
+ suppressedEmailLogErrors++;
61
+ }
62
+ }
63
+
33
64
  /**
34
65
  * @param {Object} row
35
66
  */
@@ -49,7 +80,7 @@ function createEmailService(options) {
49
80
  created_at: new Date(),
50
81
  });
51
82
  } catch (err) {
52
- console.warn('[email] Failed to write email log:', err.message);
83
+ logEmailError(err);
53
84
  }
54
85
  }
55
86
 
@@ -4,15 +4,7 @@
4
4
  */
5
5
 
6
6
  const multer = require('multer');
7
- const {
8
- listFiles,
9
- createDirectory,
10
- renameItem,
11
- deleteItem,
12
- moveItem,
13
- saveUploadedFile,
14
- calculateStorageUsage,
15
- } = require('./core');
7
+ const core = require('./core');
16
8
 
17
9
  /**
18
10
  * Create Multer middleware for in-memory upload parsing
@@ -71,7 +63,7 @@ function createFileManagerApiHandlers(options = {}) {
71
63
  const permissions = options.permissions || null;
72
64
  const securityOpts = options.security || {};
73
65
 
74
- const parseUpload = createUploadMiddleware(maxUploadBytes);
66
+ const parseUpload = options.uploadMiddleware || createUploadMiddleware(maxUploadBytes);
75
67
 
76
68
  return {
77
69
  async list(req, res) {
@@ -88,7 +80,7 @@ function createFileManagerApiHandlers(options = {}) {
88
80
  const page = req.query.page ? parseInt(req.query.page, 10) : undefined;
89
81
  const perPage = req.query.perPage ? parseInt(req.query.perPage, 10) : undefined;
90
82
 
91
- const result = await listFiles(baseDir, relPath, {
83
+ const result = await core.listFiles(baseDir, relPath, {
92
84
  search,
93
85
  type,
94
86
  sort,
@@ -115,7 +107,7 @@ function createFileManagerApiHandlers(options = {}) {
115
107
  if (!name) {
116
108
  return res.status(400).json({ success: false, error: 'Directory name is required' });
117
109
  }
118
- const result = await createDirectory(baseDir, relPath, name);
110
+ const result = await core.createDirectory(baseDir, relPath, name);
119
111
  return res.status(201).json({ success: true, data: result });
120
112
  } catch (err) {
121
113
  const status = err.statusCode || err.status || 400;
@@ -133,7 +125,7 @@ function createFileManagerApiHandlers(options = {}) {
133
125
  if (!itemPath || !newName) {
134
126
  return res.status(400).json({ success: false, error: 'Path and newName are required' });
135
127
  }
136
- const result = await renameItem(baseDir, itemPath, newName);
128
+ const result = await core.renameItem(baseDir, itemPath, newName);
137
129
  return res.json({ success: true, data: result });
138
130
  } catch (err) {
139
131
  const status = err.statusCode || err.status || 400;
@@ -151,7 +143,7 @@ function createFileManagerApiHandlers(options = {}) {
151
143
  if (!source) {
152
144
  return res.status(400).json({ success: false, error: 'Source path is required' });
153
145
  }
154
- const result = await moveItem(baseDir, source, destination);
146
+ const result = await core.moveItem(baseDir, source, destination);
155
147
  return res.json({ success: true, data: result });
156
148
  } catch (err) {
157
149
  const status = err.statusCode || err.status || 400;
@@ -169,7 +161,7 @@ function createFileManagerApiHandlers(options = {}) {
169
161
  if (!itemPath) {
170
162
  return res.status(400).json({ success: false, error: 'Path is required' });
171
163
  }
172
- const result = await deleteItem(baseDir, itemPath);
164
+ const result = await core.deleteItem(baseDir, itemPath);
173
165
  return res.json({ success: true, data: result });
174
166
  } catch (err) {
175
167
  const status = err.statusCode || err.status || 400;
@@ -199,7 +191,7 @@ function createFileManagerApiHandlers(options = {}) {
199
191
 
200
192
  // Storage Quota Check
201
193
  if (maxTotalStorageBytes && typeof maxTotalStorageBytes === 'number') {
202
- const currentUsage = await calculateStorageUsage(baseDir);
194
+ const currentUsage = await core.calculateStorageUsage(baseDir);
203
195
  const incomingBytes = files.reduce((acc, f) => acc + (f.size || f.buffer?.length || 0), 0);
204
196
  if (currentUsage + incomingBytes > maxTotalStorageBytes) {
205
197
  return res.status(507).json({
@@ -229,7 +221,7 @@ function createFileManagerApiHandlers(options = {}) {
229
221
  try {
230
222
  const uploaded = [];
231
223
  for (const file of files) {
232
- const saved = await saveUploadedFile(
224
+ const saved = await core.saveUploadedFile(
233
225
  baseDir,
234
226
  targetRelPath,
235
227
  file.buffer,
@@ -257,5 +249,6 @@ function createFileManagerApiHandlers(options = {}) {
257
249
 
258
250
  module.exports = {
259
251
  createFileManagerApiHandlers,
252
+ createUploadMiddleware,
260
253
  isActionAllowed,
261
254
  };
@@ -114,15 +114,17 @@ function fileManagerPlugin(options = {}) {
114
114
  // so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
115
115
  if (typeof adminApi.getRegistry === 'function') {
116
116
  const registry = adminApi.getRegistry();
117
- registry.configure({
118
- fileManager: {
119
- enabled: true,
120
- apiPrefix,
121
- publicBasePath,
122
- pageTitle,
123
- },
124
- });
125
- registry.registerClientComponent('files', componentCode);
117
+ if (registry) {
118
+ registry.configure({
119
+ fileManager: {
120
+ enabled: true,
121
+ apiPrefix,
122
+ publicBasePath,
123
+ pageTitle,
124
+ },
125
+ });
126
+ registry.registerClientComponent('files', componentCode);
127
+ }
126
128
  }
127
129
 
128
130
  adminApi.registerModule({
@@ -24,6 +24,16 @@ const DANGEROUS_EXTENSIONS = new Set([
24
24
  'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
25
25
  ]);
26
26
 
27
+ /**
28
+ * Common safe extensions allowed when passed as raw extensions (without filename dot)
29
+ */
30
+ const COMMON_SAFE_EXTENSIONS = new Set([
31
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff',
32
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
33
+ 'txt', 'csv', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'jsx', 'tsx',
34
+ 'sql', 'yaml', 'yml', 'zip', 'tar', 'gz', 'mp3', 'wav', 'mp4', 'mov', 'webm',
35
+ ]);
36
+
27
37
  /**
28
38
  * Known magic numbers table for high-speed signature verification.
29
39
  * Only validates when extension matches known binary format.
@@ -118,9 +128,8 @@ function validateSafeExtension(filename) {
118
128
  throw new ValidationError('Filename must have a valid extension');
119
129
  }
120
130
 
121
- // Check all dotted segments against dangerous extensions to block double extensions (e.g. payload.php.jpg)
122
- for (let i = 1; i < parts.length; i++) {
123
- const segment = parts[i];
131
+ // Check all segments against dangerous extensions to block double extensions and dangerous dotfiles (e.g. .env, payload.php.jpg)
132
+ for (const segment of parts) {
124
133
  if (DANGEROUS_EXTENSIONS.has(segment)) {
125
134
  throw new SecurityError(
126
135
  `Dangerous extension or double-extension detected: .${segment}`,
@@ -129,11 +138,14 @@ function validateSafeExtension(filename) {
129
138
  }
130
139
  }
131
140
 
132
- const finalExt = parts[parts.length - 1];
133
- if (DANGEROUS_EXTENSIONS.has(finalExt)) {
134
- throw new SecurityError(`File type .${finalExt} is forbidden for upload`, 403);
141
+ if (parts.length < 2 && !cleanName.startsWith('.')) {
142
+ if (COMMON_SAFE_EXTENSIONS.has(parts[0])) {
143
+ return parts[0];
144
+ }
145
+ throw new ValidationError('Filename must have a valid extension');
135
146
  }
136
147
 
148
+ const finalExt = parts[parts.length - 1];
137
149
  return finalExt;
138
150
  }
139
151
 
@@ -27,9 +27,12 @@ const BOT_PATTERNS = [
27
27
  { pattern: /petalbot/i, name: 'PetalBot' },
28
28
  { pattern: /gptbot/i, name: 'GPTBot' },
29
29
  { pattern: /claudebot/i, name: 'ClaudeBot' },
30
+ { pattern: /amazonbot/i, name: 'Amazon' },
30
31
  { pattern: /anthropic/i, name: 'Anthropic' },
31
32
  { pattern: /ccbot/i, name: 'CCBot' },
32
33
  { pattern: /bytespider/i, name: 'ByteSpider' },
34
+ { pattern: /dataforseo/i, name: 'DataForSEO' },
35
+ { pattern: /yisouspider/i, name: 'Yisou' },
33
36
  { pattern: /open-research-bot/i, name: 'open-research-bot' },
34
37
  { pattern: /ptb12/i, name: 'PTB12' },
35
38
  { pattern: /curl\//i, name: 'curl' },
@@ -4,6 +4,8 @@
4
4
  * @module plugins/site-analytics/client-error-handler
5
5
  */
6
6
 
7
+ const { formatDbError } = require('./tracking');
8
+
7
9
  const DEFAULT_TABLE = 'analytics_client_errors';
8
10
 
9
11
  /**
@@ -71,7 +73,7 @@ function createErrorReportHandler(options) {
71
73
 
72
74
  res.status(202).json({ ok: true });
73
75
  } catch (e) {
74
- console.error('[site-analytics] Client error report failed:', e.message);
76
+ console.error('[site-analytics] Client error report failed:', formatDbError(e));
75
77
  res.status(500).json({ error: 'Report failed' });
76
78
  }
77
79
  };
@@ -140,6 +140,8 @@ function siteAnalyticsPlugin(options = {}) {
140
140
  }
141
141
  }
142
142
 
143
+ let trackingMiddleware = null;
144
+
143
145
  return {
144
146
  name: 'site-analytics',
145
147
  version: '1.0.0',
@@ -177,13 +179,17 @@ function siteAnalyticsPlugin(options = {}) {
177
179
  register(ctx) {
178
180
  const knex = db.knex || db;
179
181
 
180
- const trackingMiddleware = createTrackingMiddleware({
182
+ trackingMiddleware = createTrackingMiddleware({
181
183
  knex,
182
184
  excludePaths,
183
185
  trackBots,
184
186
  tableName,
185
187
  batchSize: options.batchSize ?? 20,
186
188
  flushIntervalMs: options.flushIntervalMs ?? 3000,
189
+ maxRetries: options.maxRetries ?? 3,
190
+ maxBackoffMs: options.maxBackoffMs ?? 60000,
191
+ maxQueueSize: options.maxQueueSize ?? 5000,
192
+ errorLogIntervalMs: options.errorLogIntervalMs ?? 60000,
187
193
  });
188
194
 
189
195
  ctx.app.use(trackingMiddleware);
@@ -199,15 +205,18 @@ function siteAnalyticsPlugin(options = {}) {
199
205
 
200
206
  return () => {
201
207
  stopCleanup();
208
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
202
209
  };
203
210
  },
204
211
 
205
212
  dispose() {
206
213
  stopCleanup();
214
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
207
215
  },
208
216
 
209
217
  shutdown() {
210
218
  stopCleanup();
219
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
211
220
  },
212
221
 
213
222
  onRoutesReady(ctx) {