webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -3,6 +3,8 @@
3
3
  * @module plugins/audit-log/api-handlers
4
4
  */
5
5
 
6
+ const { purgeAuditLogs } = require('./purge');
7
+
6
8
  /**
7
9
  * @param {Object} options
8
10
  * @param {import('knex').Knex} options.knex
@@ -19,12 +21,34 @@ function createAuditLogHandlers(options) {
19
21
  if (query.action) {
20
22
  qb.where('action', String(query.action));
21
23
  }
24
+ const recordId = query.recordId || query.resource_id || query.resourceId;
25
+ if (recordId !== undefined && recordId !== null && String(recordId).trim() !== '') {
26
+ qb.where('resource_id', String(recordId));
27
+ }
28
+ const actorId = query.actorId || query.actor_id;
29
+ if (actorId !== undefined && actorId !== null && String(actorId).trim() !== '') {
30
+ qb.where('actor_id', Number(actorId));
31
+ }
32
+ const actorEmail = query.actorEmail || query.actor_email;
33
+ if (actorEmail) {
34
+ qb.where('actor_email', 'like', `%${String(actorEmail).trim()}%`);
35
+ }
22
36
  if (query.from) {
23
37
  qb.where('created_at', '>=', String(query.from));
24
38
  }
25
39
  if (query.to) {
26
40
  qb.where('created_at', '<=', String(query.to));
27
41
  }
42
+ const searchTerm = query.search || query.q;
43
+ if (searchTerm && String(searchTerm).trim() !== '') {
44
+ const term = `%${String(searchTerm).trim()}%`;
45
+ qb.where((sub) => {
46
+ sub.where('actor_email', 'like', term)
47
+ .orWhere('path', 'like', term)
48
+ .orWhere('resource_id', 'like', term)
49
+ .orWhere('resource_model', 'like', term);
50
+ });
51
+ }
28
52
  }
29
53
 
30
54
  async function listHandler(req, res) {
@@ -51,13 +75,119 @@ function createAuditLogHandlers(options) {
51
75
  }
52
76
  }
53
77
 
78
+ /**
79
+ * Dedicated Record Timeline Handler
80
+ * GET /audit-logs/timeline/:model/:id or GET /audit-logs/timeline?model=Post&id=1
81
+ */
82
+ async function timelineHandler(req, res) {
83
+ try {
84
+ const model = req.params?.model || req.query?.model;
85
+ const recordId = req.params?.id || req.query?.id || req.query?.recordId || req.query?.resource_id;
86
+
87
+ if (!model || recordId === undefined) {
88
+ return res.status(400).json({ error: 'model and id are required to fetch timeline' });
89
+ }
90
+
91
+ const rows = await knex(tableName)
92
+ .where({
93
+ resource_model: String(model),
94
+ resource_id: String(recordId),
95
+ })
96
+ .orderBy('created_at', 'asc');
97
+
98
+ const totalChanges = rows.length;
99
+ const createdEvent = rows.find((r) => r.action === 'create') || null;
100
+ const lastModifiedEvent = rows.length > 0 ? rows[rows.length - 1] : null;
101
+
102
+ const events = rows.map((r, index) => {
103
+ let parsedMetadata = r.metadata;
104
+ if (typeof parsedMetadata === 'string') {
105
+ try {
106
+ parsedMetadata = JSON.parse(parsedMetadata);
107
+ } catch {}
108
+ }
109
+
110
+ const changedFields = Array.isArray(parsedMetadata?.changedFields)
111
+ ? parsedMetadata.changedFields
112
+ : [];
113
+
114
+ return {
115
+ id: r.id,
116
+ action: r.action,
117
+ createdAt: r.created_at,
118
+ actor: {
119
+ id: r.actor_id,
120
+ email: r.actor_email,
121
+ },
122
+ resourceModel: r.resource_model,
123
+ resourceId: r.resource_id,
124
+ httpMethod: r.http_method,
125
+ path: r.path,
126
+ ip: r.ip,
127
+ userAgent: r.user_agent,
128
+ changedFields,
129
+ metadata: parsedMetadata,
130
+ index: index + 1,
131
+ };
132
+ });
133
+
134
+ res.json({
135
+ model: String(model),
136
+ recordId: String(recordId),
137
+ summary: {
138
+ totalChanges,
139
+ createdAt: createdEvent?.created_at || (events[0] ? events[0].createdAt : null),
140
+ createdBy: createdEvent?.actor_email || (events[0] ? events[0].actor?.email : null),
141
+ lastModifiedAt: lastModifiedEvent?.created_at || null,
142
+ lastModifiedBy: lastModifiedEvent?.actor_email || null,
143
+ },
144
+ timeline: events,
145
+ });
146
+ } catch (err) {
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ }
150
+
151
+ async function purgeHandler(req, res) {
152
+ try {
153
+ const body = req.body || {};
154
+ const query = req.query || {};
155
+ const rawDays = body.days !== undefined
156
+ ? body.days
157
+ : (body.retentionDays !== undefined ? body.retentionDays : (query.days !== undefined ? query.days : 90));
158
+ const days = parseInt(rawDays, 10);
159
+ if (Number.isNaN(days) || days < 1) {
160
+ return res.status(400).json({ error: 'days must be a positive integer' });
161
+ }
162
+
163
+ const olderThan = body.olderThan
164
+ ? new Date(body.olderThan)
165
+ : new Date(Date.now() - days * 24 * 60 * 60 * 1000);
166
+
167
+ if (Number.isNaN(olderThan.getTime())) {
168
+ return res.status(400).json({ error: 'invalid olderThan date' });
169
+ }
170
+
171
+ const deleted = await purgeAuditLogs(knex, {
172
+ tableName,
173
+ olderThan,
174
+ });
175
+
176
+ res.json({ ok: true, success: true, deleted, olderThan: olderThan.toISOString() });
177
+ } catch (err) {
178
+ res.status(500).json({ error: err.message });
179
+ }
180
+ }
181
+
54
182
  /**
55
183
  * Programmatic list (for other plugins)
56
184
  * @param {Object} [filters]
57
185
  * @param {string} [filters.model]
58
186
  * @param {string} [filters.action]
187
+ * @param {string} [filters.recordId]
59
188
  * @param {string} [filters.from]
60
189
  * @param {string} [filters.to]
190
+ * @param {string} [filters.search]
61
191
  * @param {number} [filters.limit=100]
62
192
  * @param {number} [filters.offset=0]
63
193
  */
@@ -72,9 +202,27 @@ function createAuditLogHandlers(options) {
72
202
  .limit(limit);
73
203
  }
74
204
 
205
+ /**
206
+ * Programmatic query for single record timeline
207
+ * @param {string} model
208
+ * @param {string|number} recordId
209
+ */
210
+ async function queryTimeline(model, recordId) {
211
+ if (!model || recordId === undefined) return [];
212
+ return knex(tableName)
213
+ .where({
214
+ resource_model: String(model),
215
+ resource_id: String(recordId),
216
+ })
217
+ .orderBy('created_at', 'asc');
218
+ }
219
+
75
220
  return {
76
221
  listHandler,
222
+ timelineHandler,
223
+ purgeHandler,
77
224
  queryLogs,
225
+ queryTimeline,
78
226
  };
79
227
  }
80
228
 
@@ -16,40 +16,167 @@ const { purgeAuditLogs } = require('./purge');
16
16
  * @param {string} [options.tableName='audit_logs']
17
17
  * @param {boolean} [options.includeDefaultPage=true] - Register Mithril list page + menu
18
18
  * @param {string} [options.apiPrefix='/audit-logs'] - GET list under admin API
19
+ * @param {Object|boolean} [options.retention] - Retention policy configuration (set false to disable)
20
+ * @param {number} [options.retention.days=90] - Days to retain audit logs
21
+ * @param {number} [options.retention.intervalMs=86400000] - Auto-cleanup timer frequency (24h)
22
+ * @param {boolean} [options.retention.runOnStart=true] - Run initial purge shortly after startup
23
+ * @param {number} [options.retentionDays=90] - Shorthand for retention.days
24
+ * @param {number} [options.cleanupIntervalMs=86400000] - Shorthand for retention.intervalMs
25
+ * @param {boolean} [options.autoCleanup=true] - Enable automatic background cleanup
26
+ * @param {boolean} [options.cleanupOnStart=true] - Run initial purge on start
27
+ * @param {number} [options.cleanupStartDelayMs] - Delay in ms before start purge
19
28
  */
20
29
  function auditLogPlugin(options = {}) {
21
30
  const {
22
31
  db,
32
+ title,
33
+ adminTitle,
34
+ pageTitle: customPageTitle,
23
35
  adminPath: adminPathOpt,
24
36
  tableName = 'audit_logs',
25
37
  includeDefaultPage = true,
26
38
  apiPrefix = '/audit-logs',
39
+ registerRecordAction = true,
27
40
  } = options;
28
41
 
42
+ const pageTitle = title || adminTitle || customPageTitle || 'Audit log';
43
+
29
44
  if (!db) {
30
45
  throw new Error('audit-log plugin requires a database instance. Pass `db` in options.');
31
46
  }
32
47
 
33
48
  const knex = db.knex || db;
34
49
 
50
+ // Retention configuration
51
+ const retentionOption = options.retention;
52
+ const isRetentionDisabled =
53
+ retentionOption === false ||
54
+ (typeof retentionOption === 'object' && retentionOption.enabled === false) ||
55
+ options.retentionDays === null ||
56
+ options.retentionDays === 0;
57
+
58
+ const autoCleanupEnabled =
59
+ !isRetentionDisabled &&
60
+ options.autoCleanup !== false &&
61
+ (typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false);
62
+
63
+ const retentionConfig = {
64
+ enabled: !isRetentionDisabled,
65
+ autoCleanup: autoCleanupEnabled,
66
+ days: (typeof options.retentionDays === 'number' && options.retentionDays > 0)
67
+ ? options.retentionDays
68
+ : (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 90),
69
+ intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
70
+ ? options.cleanupIntervalMs
71
+ : (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
72
+ runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
73
+ ? !!retentionOption.runOnStart
74
+ : (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : true),
75
+ };
76
+
77
+ let cleanupInterval = null;
78
+ let cleanupStartTimeout = null;
79
+
80
+ async function runCleanup() {
81
+ try {
82
+ return await purgeAuditLogs(knex, {
83
+ tableName,
84
+ days: retentionConfig.days,
85
+ });
86
+ } catch (err) {
87
+ if (process.env.NODE_ENV !== 'test') {
88
+ console.warn('[audit-log] Failed to run retention cleanup:', err.message);
89
+ }
90
+ }
91
+ }
92
+
93
+ function startCleanup(customIntervalMs) {
94
+ stopCleanup();
95
+ if (!retentionConfig.enabled) return;
96
+ if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
97
+ retentionConfig.intervalMs = customIntervalMs;
98
+ }
99
+
100
+ if (retentionConfig.runOnStart) {
101
+ const delayMs = typeof options.cleanupStartDelayMs === 'number'
102
+ ? options.cleanupStartDelayMs
103
+ : (process.env.NODE_ENV === 'test' ? 50 : 5000);
104
+ cleanupStartTimeout = setTimeout(() => {
105
+ runCleanup();
106
+ }, delayMs);
107
+ if (cleanupStartTimeout && cleanupStartTimeout.unref) {
108
+ cleanupStartTimeout.unref();
109
+ }
110
+ }
111
+
112
+ cleanupInterval = setInterval(() => {
113
+ runCleanup();
114
+ }, retentionConfig.intervalMs);
115
+ if (cleanupInterval && cleanupInterval.unref) {
116
+ cleanupInterval.unref();
117
+ }
118
+ }
119
+
120
+ function stopCleanup() {
121
+ if (cleanupStartTimeout) {
122
+ clearTimeout(cleanupStartTimeout);
123
+ cleanupStartTimeout = null;
124
+ }
125
+ if (cleanupInterval) {
126
+ clearInterval(cleanupInterval);
127
+ cleanupInterval = null;
128
+ }
129
+ }
130
+
35
131
  const handlers = createAuditLogHandlers({ knex, tableName });
36
132
 
37
133
  return {
38
134
  name: 'audit-log',
39
135
  version: '1.0.0',
40
- description: 'Audit trail for admin panel model CRUD API',
136
+ description: 'Audit trail for admin panel model CRUD API with retention cleanup',
41
137
  dependencies: { 'admin-panel': '*' },
42
138
 
43
139
  api: {
140
+ purge(opts = {}) {
141
+ return purgeAuditLogs(knex, {
142
+ tableName,
143
+ days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
144
+ olderThan: opts.olderThan,
145
+ ...opts,
146
+ });
147
+ },
44
148
  purgeAuditLogs: (kx, opts = {}) =>
45
- purgeAuditLogs(kx || knex, { tableName: opts.tableName || tableName, olderThan: opts.olderThan }),
149
+ purgeAuditLogs(kx || knex, { tableName: opts.tableName || tableName, olderThan: opts.olderThan, days: opts.days, retentionDays: opts.retentionDays }),
150
+ startCleanup,
151
+ stopCleanup,
152
+ getRetentionConfig() {
153
+ return { ...retentionConfig };
154
+ },
46
155
  queryLogs: (filters) => handlers.queryLogs(filters),
156
+ queryTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
157
+ getRecordTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
47
158
  getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
48
159
  },
49
160
 
50
161
  register(ctx) {
51
162
  const adminPath = adminPathOpt || '/_admin';
52
163
  ctx.app.use(createAuditMiddleware({ knex, adminPath, tableName }));
164
+
165
+ if (retentionConfig.enabled && autoCleanupEnabled) {
166
+ startCleanup();
167
+ }
168
+
169
+ return () => {
170
+ stopCleanup();
171
+ };
172
+ },
173
+
174
+ dispose() {
175
+ stopCleanup();
176
+ },
177
+
178
+ shutdown() {
179
+ stopCleanup();
53
180
  },
54
181
 
55
182
  onRoutesReady(ctx) {
@@ -61,11 +188,12 @@ function auditLogPlugin(options = {}) {
61
188
 
62
189
  adminApi.registerModule({
63
190
  id: 'audit-log',
191
+ title: pageTitle,
64
192
 
65
193
  pages: includeDefaultPage
66
194
  ? [{
67
195
  id: 'audit-log',
68
- title: 'Audit log',
196
+ title: pageTitle,
69
197
  path: '/audit-log',
70
198
  icon: 'database',
71
199
  component: generateAuditLogComponent({ apiPrefix }),
@@ -86,9 +214,31 @@ function auditLogPlugin(options = {}) {
86
214
  prefix: apiPrefix,
87
215
  routes: [
88
216
  { method: 'get', path: '', handler: handlers.listHandler },
217
+ { method: 'get', path: '/timeline', handler: handlers.timelineHandler },
218
+ { method: 'get', path: '/timeline/:model/:id', handler: handlers.timelineHandler },
219
+ { method: 'post', path: '/purge', handler: handlers.purgeHandler },
89
220
  ],
90
221
  },
91
222
  });
223
+
224
+ if (registerRecordAction && typeof adminApi.registerAction === 'function') {
225
+ adminApi.registerAction('audit-timeline', {
226
+ id: 'audit-timeline',
227
+ label: 'Zaman Tüneli',
228
+ icon: 'clock',
229
+ color: 'blue',
230
+ models: '*',
231
+ handler: async (record, model) => {
232
+ const primaryKey = model?.primaryKey || 'id';
233
+ const id = record ? record[primaryKey] : null;
234
+ const modelName = model?.name || (typeof model === 'string' ? model : '');
235
+ return {
236
+ redirect: `/audit-log?model=${encodeURIComponent(modelName)}&recordId=${encodeURIComponent(id)}`,
237
+ message: `Zaman tüneli: ${modelName} #${id}`,
238
+ };
239
+ },
240
+ });
241
+ }
92
242
  },
93
243
  };
94
244
  }
@@ -3,24 +3,74 @@
3
3
  * @module plugins/audit-log/purge
4
4
  */
5
5
 
6
+ 'use strict';
7
+
6
8
  /**
7
9
  * @param {import('knex').Knex} knex
8
- * @param {Object} options
10
+ * @param {Object} [options={}]
9
11
  * @param {string} [options.tableName='audit_logs']
10
- * @param {Date|string} options.olderThan - Rows with created_at strictly before this are removed
11
- * @returns {Promise<number>} Deleted row count (driver-dependent; 0 if unknown)
12
+ * @param {Date|string|number} [options.olderThan] - Rows with created_at strictly before this are removed
13
+ * @param {number} [options.days] - Number of days of audit logs to keep
14
+ * @param {number} [options.retentionDays=90] - Alias for days
15
+ * @returns {Promise<number>} Deleted row count
12
16
  */
13
- async function purgeAuditLogs(knex, options) {
17
+ async function purgeAuditLogs(knex, options = {}) {
18
+ if (!knex) {
19
+ throw new Error('purgeAuditLogs requires a database (knex) instance');
20
+ }
21
+
14
22
  const tableName = options.tableName || 'audit_logs';
15
- const olderThan = options.olderThan instanceof Date
16
- ? options.olderThan
17
- : new Date(options.olderThan);
18
23
 
19
- if (Number.isNaN(olderThan.getTime())) {
20
- throw new Error('purgeAuditLogs: invalid olderThan date');
24
+ let olderThan;
25
+ if (options.olderThan !== undefined) {
26
+ olderThan = options.olderThan instanceof Date
27
+ ? options.olderThan
28
+ : new Date(options.olderThan);
29
+
30
+ if (Number.isNaN(olderThan.getTime())) {
31
+ throw new Error('purgeAuditLogs: invalid olderThan date');
32
+ }
33
+ } else {
34
+ const rawDays = options.days !== undefined ? options.days : (options.retentionDays !== undefined ? options.retentionDays : 90);
35
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
36
+ if (Number.isNaN(days) || days <= 0) {
37
+ throw new Error('purgeAuditLogs: days must be a positive number');
38
+ }
39
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
40
+ }
41
+
42
+ let hasTable = true;
43
+ if (knex.schema && typeof knex.schema.hasTable === 'function') {
44
+ try {
45
+ hasTable = await knex.schema.hasTable(tableName);
46
+ } catch {
47
+ hasTable = false;
48
+ }
49
+ }
50
+
51
+ if (!hasTable) {
52
+ return 0;
53
+ }
54
+
55
+ const client = knex?.client?.config?.client;
56
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
57
+
58
+ let query = knex(tableName);
59
+ if (isSqlite) {
60
+ const iso = olderThan.toISOString();
61
+ const cutoffMs = olderThan.getTime();
62
+ query = query.where((builder) => {
63
+ builder.whereRaw(
64
+ "((typeof(created_at) = 'integer' and created_at < ?) or (typeof(created_at) = 'text' and created_at < ?))",
65
+ [cutoffMs, iso]
66
+ );
67
+ });
68
+ } else {
69
+ query = query.where('created_at', '<', olderThan);
21
70
  }
22
71
 
23
- return knex(tableName).where('created_at', '<', olderThan).delete();
72
+ const deleted = await query.delete();
73
+ return Number(deleted) || 0;
24
74
  }
25
75
 
26
76
  module.exports = {