@intranefr/superbackend 1.4.4 → 1.5.1

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 (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,696 @@
1
+ const CacheEntry = require('../models/CacheEntry');
2
+ const { getSettingValue } = require('./globalSettings.service');
3
+
4
+ function now() {
5
+ return Date.now();
6
+ }
7
+
8
+ function safeJsonParse(str) {
9
+ try {
10
+ return JSON.parse(String(str));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ function toInt(val, fallback) {
17
+ const n = parseInt(String(val), 10);
18
+ return Number.isFinite(n) ? n : fallback;
19
+ }
20
+
21
+ function toBool(val, fallback) {
22
+ if (val === undefined || val === null) return fallback;
23
+ if (typeof val === 'boolean') return val;
24
+ const s = String(val).toLowerCase().trim();
25
+ if (s === 'true') return true;
26
+ if (s === 'false') return false;
27
+ return fallback;
28
+ }
29
+
30
+ function estimateBytes(str) {
31
+ return Buffer.byteLength(String(str || ''), 'utf8');
32
+ }
33
+
34
+ function normalizeNamespace(ns) {
35
+ const v = String(ns || '').trim();
36
+ return v || 'default';
37
+ }
38
+
39
+ function normalizeKey(key) {
40
+ const v = String(key || '').trim();
41
+ if (!v) throw Object.assign(new Error('key is required'), { code: 'VALIDATION' });
42
+ return v;
43
+ }
44
+
45
+ function isExpired(expiresAt) {
46
+ if (!expiresAt) return false;
47
+ const t = new Date(expiresAt).getTime();
48
+ if (!Number.isFinite(t)) return false;
49
+ return t <= Date.now();
50
+ }
51
+
52
+ function computeExpiresAt(ttlSeconds) {
53
+ if (ttlSeconds === null) return null;
54
+ if (ttlSeconds === undefined) return null;
55
+ const n = Number(ttlSeconds);
56
+ if (!Number.isFinite(n)) return null;
57
+ if (n <= 0) return null;
58
+ return new Date(Date.now() + n * 1000);
59
+ }
60
+
61
+ function encodeValue(value, atRestFormat) {
62
+ if (atRestFormat === 'base64') {
63
+ if (Buffer.isBuffer(value)) {
64
+ return value.toString('base64');
65
+ }
66
+ return Buffer.from(String(value ?? ''), 'utf8').toString('base64');
67
+ }
68
+
69
+ if (typeof value === 'string') return value;
70
+ if (typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
71
+ if (value === null) return 'null';
72
+ if (value === undefined) return 'undefined';
73
+ return JSON.stringify(value);
74
+ }
75
+
76
+ function decodeValue(stored, atRestFormat) {
77
+ const s = String(stored ?? '');
78
+
79
+ if (atRestFormat === 'base64') {
80
+ return Buffer.from(s, 'base64');
81
+ }
82
+
83
+ const parsed = safeJsonParse(s);
84
+ return parsed === null ? s : parsed;
85
+ }
86
+
87
+ class MemoryStore {
88
+ constructor() {
89
+ this.map = new Map(); // key -> entry
90
+ this.bytes = 0;
91
+ this.hits = 0;
92
+ this.misses = 0;
93
+ this.offloads = 0;
94
+ }
95
+
96
+ _touch(key, entry, evictionPolicy) {
97
+ entry.lastAccessAt = new Date();
98
+ entry.hits = (entry.hits || 0) + 1;
99
+
100
+ if (evictionPolicy === 'lru') {
101
+ // Move to end (most recently used)
102
+ this.map.delete(key);
103
+ this.map.set(key, entry);
104
+ }
105
+
106
+ if (evictionPolicy === 'lfu') {
107
+ entry.freq = (entry.freq || 0) + 1;
108
+ }
109
+ }
110
+
111
+ _candidateKey(evictionPolicy) {
112
+ if (this.map.size === 0) return null;
113
+
114
+ if (evictionPolicy === 'fifo') {
115
+ return this.map.keys().next().value;
116
+ }
117
+
118
+ if (evictionPolicy === 'lru') {
119
+ return this.map.keys().next().value;
120
+ }
121
+
122
+ if (evictionPolicy === 'lfu') {
123
+ let selected = null;
124
+ let selectedFreq = Infinity;
125
+ let selectedTime = Infinity;
126
+
127
+ for (const [k, e] of this.map.entries()) {
128
+ const f = Number(e.freq || 0);
129
+ const t = new Date(e.updatedAt || e.createdAt || 0).getTime();
130
+ if (f < selectedFreq || (f === selectedFreq && t < selectedTime)) {
131
+ selected = k;
132
+ selectedFreq = f;
133
+ selectedTime = t;
134
+ }
135
+ }
136
+
137
+ return selected;
138
+ }
139
+
140
+ return this.map.keys().next().value;
141
+ }
142
+
143
+ set(compoundKey, entry, evictionPolicy) {
144
+ const existing = this.map.get(compoundKey);
145
+ if (existing) {
146
+ this.bytes -= Number(existing.sizeBytes || 0);
147
+ }
148
+
149
+ this.map.set(compoundKey, entry);
150
+ this.bytes += Number(entry.sizeBytes || 0);
151
+
152
+ if (evictionPolicy === 'lru') {
153
+ // ensure insertion order means most recently used at end
154
+ this.map.delete(compoundKey);
155
+ this.map.set(compoundKey, entry);
156
+ }
157
+ }
158
+
159
+ get(compoundKey, evictionPolicy) {
160
+ const entry = this.map.get(compoundKey);
161
+ if (!entry) {
162
+ this.misses += 1;
163
+ return null;
164
+ }
165
+
166
+ if (isExpired(entry.expiresAt)) {
167
+ this.delete(compoundKey);
168
+ this.misses += 1;
169
+ return null;
170
+ }
171
+
172
+ this.hits += 1;
173
+ this._touch(compoundKey, entry, evictionPolicy);
174
+ return entry;
175
+ }
176
+
177
+ delete(compoundKey) {
178
+ const entry = this.map.get(compoundKey);
179
+ if (!entry) return false;
180
+ this.map.delete(compoundKey);
181
+ this.bytes -= Number(entry.sizeBytes || 0);
182
+ return true;
183
+ }
184
+
185
+ clear() {
186
+ this.map.clear();
187
+ this.bytes = 0;
188
+ }
189
+
190
+ listKeys({ prefix }) {
191
+ const out = [];
192
+ for (const k of this.map.keys()) {
193
+ if (prefix && !String(k).startsWith(prefix)) continue;
194
+ out.push(k);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ stats() {
200
+ return {
201
+ entries: this.map.size,
202
+ estimatedBytes: this.bytes,
203
+ hits: this.hits,
204
+ misses: this.misses,
205
+ offloads: this.offloads,
206
+ };
207
+ }
208
+ }
209
+
210
+ class CacheLayerService {
211
+ constructor() {
212
+ this.memory = new MemoryStore();
213
+ this._configCache = { value: null, ts: 0 };
214
+ }
215
+
216
+ async getConfig() {
217
+ const cached = this._configCache;
218
+ if (cached.value && Date.now() - cached.ts < 2000) {
219
+ return cached.value;
220
+ }
221
+
222
+ const envBackend = process.env.CACHE_LAYER_BACKEND;
223
+ const envRedisUrl = process.env.CACHE_LAYER_REDIS_URL;
224
+ const envRedisPrefix = process.env.CACHE_LAYER_REDIS_PREFIX;
225
+ const envThreshold = process.env.CACHE_LAYER_OFFLOAD_THRESHOLD_BYTES;
226
+ const envMaxEntry = process.env.CACHE_LAYER_MAX_ENTRY_BYTES;
227
+ const envDefaultTtl = process.env.CACHE_LAYER_DEFAULT_TTL_SECONDS;
228
+ const envEviction = process.env.CACHE_LAYER_EVICTION_POLICY;
229
+ const envAtRest = process.env.CACHE_LAYER_AT_REST_FORMAT;
230
+
231
+ const backend = (envBackend || (await getSettingValue('CACHE_LAYER_BACKEND', 'memory')) || 'memory')
232
+ .toString()
233
+ .toLowerCase();
234
+
235
+ const evictionPolicy = (envEviction || (await getSettingValue('CACHE_LAYER_EVICTION_POLICY', 'lru')) || 'lru')
236
+ .toString()
237
+ .toLowerCase();
238
+
239
+ const redisUrl = envRedisUrl || (await getSettingValue('CACHE_LAYER_REDIS_URL', null));
240
+ const redisPrefix = envRedisPrefix || (await getSettingValue('CACHE_LAYER_REDIS_PREFIX', 'superbackend:'));
241
+
242
+ const offloadThresholdBytes = toInt(
243
+ envThreshold || (await getSettingValue('CACHE_LAYER_OFFLOAD_THRESHOLD_BYTES', String(5 * 1024 * 1024))),
244
+ 5 * 1024 * 1024,
245
+ );
246
+
247
+ const maxEntryBytes = toInt(
248
+ envMaxEntry || (await getSettingValue('CACHE_LAYER_MAX_ENTRY_BYTES', String(256 * 1024))),
249
+ 256 * 1024,
250
+ );
251
+
252
+ const defaultTtlSeconds = toInt(
253
+ envDefaultTtl || (await getSettingValue('CACHE_LAYER_DEFAULT_TTL_SECONDS', String(10 * 60))),
254
+ 10 * 60,
255
+ );
256
+
257
+ const atRestFormat = (envAtRest || (await getSettingValue('CACHE_LAYER_AT_REST_FORMAT', 'string')) || 'string')
258
+ .toString()
259
+ .toLowerCase();
260
+
261
+ const resolved = {
262
+ backend: backend === 'redis' ? 'redis' : 'memory',
263
+ evictionPolicy: ['fifo', 'lru', 'lfu'].includes(evictionPolicy) ? evictionPolicy : 'lru',
264
+ redisUrl: redisUrl ? String(redisUrl) : null,
265
+ redisPrefix: String(redisPrefix || 'superbackend:'),
266
+ offloadThresholdBytes,
267
+ maxEntryBytes,
268
+ defaultTtlSeconds,
269
+ atRestFormat: atRestFormat === 'base64' ? 'base64' : 'string',
270
+ };
271
+
272
+ this._configCache = { value: resolved, ts: Date.now() };
273
+ return resolved;
274
+ }
275
+
276
+ _compoundKey(namespace, key) {
277
+ return `${normalizeNamespace(namespace)}:${normalizeKey(key)}`;
278
+ }
279
+
280
+ async _ensureRedisClient(config) {
281
+ if (!config.redisUrl) {
282
+ throw Object.assign(new Error('Redis is enabled but CACHE_LAYER_REDIS_URL is not configured'), { code: 'VALIDATION' });
283
+ }
284
+
285
+ let redis;
286
+ try {
287
+ redis = require('redis');
288
+ } catch {
289
+ throw Object.assign(
290
+ new Error('Redis backend requires the "redis" package. Please add it to dependencies.'),
291
+ { code: 'VALIDATION' },
292
+ );
293
+ }
294
+
295
+ if (this._redisClient && this._redisUrl === config.redisUrl) {
296
+ return this._redisClient;
297
+ }
298
+
299
+ if (this._redisClient) {
300
+ try {
301
+ await this._redisClient.quit();
302
+ } catch {
303
+ // ignore
304
+ }
305
+ }
306
+
307
+ const client = redis.createClient({ url: config.redisUrl });
308
+ client.on('error', (err) => {
309
+ try {
310
+ console.log('[CacheLayer] Redis error:', err?.message || err);
311
+ } catch {
312
+ // ignore
313
+ }
314
+ });
315
+
316
+ await client.connect();
317
+ this._redisClient = client;
318
+ this._redisUrl = config.redisUrl;
319
+ return client;
320
+ }
321
+
322
+ async set(key, value, opts = {}) {
323
+ const config = await this.getConfig();
324
+ const namespace = normalizeNamespace(opts.namespace);
325
+ const k = normalizeKey(key);
326
+
327
+ const ttlSeconds =
328
+ opts.ttlSeconds === undefined
329
+ ? config.defaultTtlSeconds
330
+ : opts.ttlSeconds;
331
+
332
+ const allowNoExpiry = toBool(opts.allowNoExpiry, true);
333
+ const expiresAt = ttlSeconds === null && allowNoExpiry ? null : computeExpiresAt(ttlSeconds);
334
+
335
+ const atRestFormat = (opts.atRestFormat || config.atRestFormat) === 'base64' ? 'base64' : 'string';
336
+
337
+ const encoded = encodeValue(value, atRestFormat);
338
+ const sizeBytes = estimateBytes(encoded);
339
+ if (sizeBytes > config.maxEntryBytes) {
340
+ throw Object.assign(new Error('Value exceeds max entry size'), { code: 'VALIDATION' });
341
+ }
342
+
343
+ if (config.backend === 'redis') {
344
+ const client = await this._ensureRedisClient(config);
345
+ const redisKey = `${config.redisPrefix}${namespace}:${k}`;
346
+ if (expiresAt) {
347
+ const ttl = Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000));
348
+ await client.set(redisKey, encoded, { EX: ttl });
349
+ } else {
350
+ await client.set(redisKey, encoded);
351
+ }
352
+ return { ok: true };
353
+ }
354
+
355
+ const compound = this._compoundKey(namespace, k);
356
+ const entry = {
357
+ namespace,
358
+ key: k,
359
+ value: encoded,
360
+ atRestFormat,
361
+ sizeBytes,
362
+ expiresAt,
363
+ createdAt: new Date(),
364
+ updatedAt: new Date(),
365
+ hits: 0,
366
+ freq: 0,
367
+ lastAccessAt: null,
368
+ source: 'manual',
369
+ };
370
+
371
+ this.memory.set(compound, entry, config.evictionPolicy);
372
+ await this._maybeOffload(config);
373
+ return { ok: true };
374
+ }
375
+
376
+ async get(key, opts = {}) {
377
+ const config = await this.getConfig();
378
+ const namespace = normalizeNamespace(opts.namespace);
379
+ const k = normalizeKey(key);
380
+
381
+ if (config.backend === 'redis') {
382
+ const client = await this._ensureRedisClient(config);
383
+ const redisKey = `${config.redisPrefix}${namespace}:${k}`;
384
+ const raw = await client.get(redisKey);
385
+ if (raw === null || raw === undefined) return null;
386
+ const atRestFormat = (opts.atRestFormat || config.atRestFormat) === 'base64' ? 'base64' : 'string';
387
+ return decodeValue(raw, atRestFormat);
388
+ }
389
+
390
+ const compound = this._compoundKey(namespace, k);
391
+ const entry = this.memory.get(compound, config.evictionPolicy);
392
+ if (entry) {
393
+ return decodeValue(entry.value, entry.atRestFormat || config.atRestFormat);
394
+ }
395
+
396
+ const mongo = await CacheEntry.findOne({ namespace, key: k }).lean();
397
+ if (!mongo) {
398
+ return null;
399
+ }
400
+
401
+ if (isExpired(mongo.expiresAt)) {
402
+ await CacheEntry.deleteOne({ _id: mongo._id });
403
+ return null;
404
+ }
405
+
406
+ await CacheEntry.updateOne(
407
+ { _id: mongo._id },
408
+ { $inc: { hits: 1 }, $set: { lastAccessAt: new Date() } },
409
+ );
410
+
411
+ if (toBool(opts.rehydrate, true)) {
412
+ const compound2 = this._compoundKey(namespace, k);
413
+ this.memory.set(
414
+ compound2,
415
+ {
416
+ namespace,
417
+ key: k,
418
+ value: mongo.value,
419
+ atRestFormat: mongo.atRestFormat || config.atRestFormat,
420
+ sizeBytes: mongo.sizeBytes || estimateBytes(mongo.value),
421
+ expiresAt: mongo.expiresAt || null,
422
+ createdAt: mongo.createdAt || new Date(),
423
+ updatedAt: new Date(),
424
+ hits: mongo.hits || 0,
425
+ freq: mongo.hits || 0,
426
+ lastAccessAt: mongo.lastAccessAt || null,
427
+ source: 'offloaded',
428
+ },
429
+ config.evictionPolicy,
430
+ );
431
+ await this._maybeOffload(config);
432
+ }
433
+
434
+ return decodeValue(mongo.value, mongo.atRestFormat || config.atRestFormat);
435
+ }
436
+
437
+ async delete(key, opts = {}) {
438
+ const config = await this.getConfig();
439
+ const namespace = normalizeNamespace(opts.namespace);
440
+ const k = normalizeKey(key);
441
+
442
+ if (config.backend === 'redis') {
443
+ const client = await this._ensureRedisClient(config);
444
+ const redisKey = `${config.redisPrefix}${namespace}:${k}`;
445
+ const n = await client.del(redisKey);
446
+ return { ok: n > 0 };
447
+ }
448
+
449
+ const compound = this._compoundKey(namespace, k);
450
+ const mem = this.memory.delete(compound);
451
+ const mongo = await CacheEntry.deleteOne({ namespace, key: k });
452
+ return { ok: Boolean(mem || mongo?.deletedCount) };
453
+ }
454
+
455
+ async clear(opts = {}) {
456
+ const config = await this.getConfig();
457
+ const backend = String(opts.backend || 'all');
458
+ const namespace = opts.namespace ? normalizeNamespace(opts.namespace) : null;
459
+ const prefix = opts.prefix ? String(opts.prefix) : null;
460
+
461
+ const cleared = { memory: 0, mongo: 0, redis: 0 };
462
+
463
+ if (backend === 'memory' || backend === 'all') {
464
+ if (!namespace && !prefix) {
465
+ cleared.memory = this.memory.map.size;
466
+ this.memory.clear();
467
+ } else {
468
+ const pfx = `${namespace || ''}${namespace ? ':' : ''}${prefix || ''}`;
469
+ const keys = this.memory.listKeys({ prefix: pfx || null });
470
+ for (const k of keys) {
471
+ if (this.memory.delete(k)) cleared.memory += 1;
472
+ }
473
+ }
474
+ }
475
+
476
+ if (backend === 'mongo' || backend === 'all') {
477
+ const filter = {};
478
+ if (namespace) filter.namespace = namespace;
479
+ if (prefix) filter.key = { $regex: `^${prefix}` };
480
+ const res = await CacheEntry.deleteMany(filter);
481
+ cleared.mongo = res.deletedCount || 0;
482
+ }
483
+
484
+ if (backend === 'redis' || backend === 'all') {
485
+ const effectiveBackend = config.backend === 'redis' ? 'redis' : 'memory';
486
+ if (effectiveBackend !== 'redis') {
487
+ // redis not enabled but allow explicit clear attempt
488
+ return { ok: true, cleared };
489
+ }
490
+
491
+ const client = await this._ensureRedisClient(config);
492
+ const scanPrefix = `${config.redisPrefix}${namespace || ''}${namespace ? ':' : ''}${prefix || ''}`;
493
+
494
+ let cursor = 0;
495
+ do {
496
+ // eslint-disable-next-line no-await-in-loop
497
+ const result = await client.scan(cursor, { MATCH: `${scanPrefix}*`, COUNT: 200 });
498
+ cursor = Number(result.cursor);
499
+ const keys = result.keys || [];
500
+ if (keys.length > 0) {
501
+ // eslint-disable-next-line no-await-in-loop
502
+ const n = await client.del(keys);
503
+ cleared.redis += Number(n || 0);
504
+ }
505
+ } while (cursor !== 0);
506
+ }
507
+
508
+ return { ok: true, cleared };
509
+ }
510
+
511
+ async listKeys(opts = {}) {
512
+ const config = await this.getConfig();
513
+ const namespace = opts.namespace ? normalizeNamespace(opts.namespace) : null;
514
+ const prefix = opts.prefix ? String(opts.prefix) : null;
515
+
516
+ if (config.backend === 'redis') {
517
+ const client = await this._ensureRedisClient(config);
518
+ const scanPrefix = `${config.redisPrefix}${namespace || ''}${namespace ? ':' : ''}${prefix || ''}`;
519
+
520
+ const out = [];
521
+ let cursor = 0;
522
+ do {
523
+ const result = await client.scan(cursor, { MATCH: `${scanPrefix}*`, COUNT: 200 });
524
+ cursor = Number(result.cursor);
525
+ const keys = result.keys || [];
526
+ for (const fullKey of keys) {
527
+ const full = String(fullKey);
528
+ if (!full.startsWith(config.redisPrefix)) continue;
529
+ const withoutPrefix = full.slice(config.redisPrefix.length);
530
+ const idx = withoutPrefix.indexOf(':');
531
+ const ns = idx >= 0 ? withoutPrefix.slice(0, idx) : 'default';
532
+ const k = idx >= 0 ? withoutPrefix.slice(idx + 1) : withoutPrefix;
533
+ out.push({
534
+ namespace: ns,
535
+ key: k,
536
+ backend: 'redis',
537
+ });
538
+ }
539
+ } while (cursor !== 0);
540
+
541
+ return out;
542
+ }
543
+
544
+ const filter = {};
545
+ if (namespace) filter.namespace = namespace;
546
+ if (prefix) filter.key = { $regex: `^${prefix}` };
547
+
548
+ const mongoKeys = await CacheEntry.find(filter).select('namespace key updatedAt expiresAt sizeBytes hits lastAccessAt atRestFormat source').sort({ updatedAt: -1 }).limit(500).lean();
549
+ const memKeys = [];
550
+ for (const compound of this.memory.map.keys()) {
551
+ if (namespace && !String(compound).startsWith(`${namespace}:`)) continue;
552
+ if (prefix && !String(compound).startsWith(`${namespace || 'default'}:${prefix}`)) continue;
553
+ const entry = this.memory.map.get(compound);
554
+ if (!entry) continue;
555
+ memKeys.push({
556
+ namespace: entry.namespace,
557
+ key: entry.key,
558
+ updatedAt: entry.updatedAt,
559
+ expiresAt: entry.expiresAt,
560
+ sizeBytes: entry.sizeBytes,
561
+ hits: entry.hits,
562
+ lastAccessAt: entry.lastAccessAt,
563
+ atRestFormat: entry.atRestFormat,
564
+ source: entry.source || 'manual',
565
+ backend: 'memory',
566
+ });
567
+ }
568
+
569
+ return {
570
+ memory: memKeys,
571
+ mongo: mongoKeys.map((e) => ({ ...e, backend: 'mongo' })),
572
+ };
573
+ }
574
+
575
+ async getEntry(key, opts = {}) {
576
+ const config = await this.getConfig();
577
+ const namespace = normalizeNamespace(opts.namespace);
578
+ const k = normalizeKey(key);
579
+
580
+ if (config.backend === 'redis') {
581
+ const client = await this._ensureRedisClient(config);
582
+ const redisKey = `${config.redisPrefix}${namespace}:${k}`;
583
+ const raw = await client.get(redisKey);
584
+ if (raw === null || raw === undefined) return null;
585
+ const atRestFormat = (opts.atRestFormat || config.atRestFormat) === 'base64' ? 'base64' : 'string';
586
+ const decoded = decodeValue(raw, atRestFormat);
587
+ return {
588
+ namespace,
589
+ key: k,
590
+ backend: 'redis',
591
+ atRestFormat,
592
+ value: atRestFormat === 'base64' ? decoded.toString('base64') : raw,
593
+ decoded: atRestFormat === 'base64' ? '[base64]' : decoded,
594
+ };
595
+ }
596
+
597
+ const compound = this._compoundKey(namespace, k);
598
+ const mem = this.memory.map.get(compound);
599
+ if (mem && !isExpired(mem.expiresAt)) {
600
+ return {
601
+ ...mem,
602
+ backend: 'memory',
603
+ decoded: mem.atRestFormat === 'base64' ? '[base64]' : decodeValue(mem.value, mem.atRestFormat),
604
+ };
605
+ }
606
+
607
+ const mongo = await CacheEntry.findOne({ namespace, key: k }).lean();
608
+ if (!mongo) return null;
609
+ if (isExpired(mongo.expiresAt)) {
610
+ await CacheEntry.deleteOne({ _id: mongo._id });
611
+ return null;
612
+ }
613
+
614
+ return {
615
+ ...mongo,
616
+ backend: 'mongo',
617
+ decoded: mongo.atRestFormat === 'base64' ? '[base64]' : decodeValue(mongo.value, mongo.atRestFormat),
618
+ };
619
+ }
620
+
621
+ async metrics() {
622
+ const config = await this.getConfig();
623
+
624
+ const memory = this.memory.stats();
625
+ const mongoCount = await CacheEntry.countDocuments({});
626
+ const mongoAgg = await CacheEntry.aggregate([
627
+ { $group: { _id: null, bytes: { $sum: '$sizeBytes' } } },
628
+ ]);
629
+
630
+ const mongoBytes = Number(mongoAgg?.[0]?.bytes || 0);
631
+
632
+ let redis = null;
633
+ if (config.backend === 'redis') {
634
+ try {
635
+ const client = await this._ensureRedisClient(config);
636
+ const info = await client.info('memory');
637
+ const usedLine = String(info || '')
638
+ .split('\n')
639
+ .find((l) => l.startsWith('used_memory:'));
640
+ const used = usedLine ? toInt(usedLine.split(':')[1], 0) : 0;
641
+ redis = { usedMemoryBytes: used };
642
+ } catch (err) {
643
+ redis = { error: err?.message || 'Redis error' };
644
+ }
645
+ }
646
+
647
+ return {
648
+ backend: config.backend,
649
+ evictionPolicy: config.evictionPolicy,
650
+ defaultTtlSeconds: config.defaultTtlSeconds,
651
+ offloadThresholdBytes: config.offloadThresholdBytes,
652
+ maxEntryBytes: config.maxEntryBytes,
653
+ atRestFormat: config.atRestFormat,
654
+ memory,
655
+ mongo: { entries: mongoCount, estimatedBytes: mongoBytes },
656
+ redis,
657
+ };
658
+ }
659
+
660
+ async _maybeOffload(config) {
661
+ if (config.backend !== 'memory') return;
662
+ if (this.memory.bytes <= config.offloadThresholdBytes) return;
663
+
664
+ while (this.memory.bytes > config.offloadThresholdBytes && this.memory.map.size > 0) {
665
+ const candidate = this.memory._candidateKey(config.evictionPolicy);
666
+ if (!candidate) break;
667
+ const entry = this.memory.map.get(candidate);
668
+ if (!entry) {
669
+ this.memory.map.delete(candidate);
670
+ continue;
671
+ }
672
+
673
+ this.memory.offloads += 1;
674
+
675
+ await CacheEntry.updateOne(
676
+ { namespace: entry.namespace, key: entry.key },
677
+ {
678
+ $set: {
679
+ value: entry.value,
680
+ atRestFormat: entry.atRestFormat,
681
+ sizeBytes: entry.sizeBytes,
682
+ expiresAt: entry.expiresAt,
683
+ lastAccessAt: entry.lastAccessAt,
684
+ source: 'offloaded',
685
+ },
686
+ $setOnInsert: { hits: 0 },
687
+ },
688
+ { upsert: true },
689
+ );
690
+
691
+ this.memory.delete(candidate);
692
+ }
693
+ }
694
+ }
695
+
696
+ module.exports = new CacheLayerService();