@volcanicminds/backend 3.3.0 → 3.5.0

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/README.md +15 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config/general.d.ts +3 -0
  7. package/dist/lib/config/general.d.ts.map +1 -1
  8. package/dist/lib/config/general.js +3 -0
  9. package/dist/lib/config/general.js.map +1 -1
  10. package/dist/lib/config/plugins.d.ts.map +1 -1
  11. package/dist/lib/config/plugins.js +5 -0
  12. package/dist/lib/config/plugins.js.map +1 -1
  13. package/dist/lib/hooks/onError.d.ts.map +1 -1
  14. package/dist/lib/hooks/onError.js +8 -7
  15. package/dist/lib/hooks/onError.js.map +1 -1
  16. package/dist/lib/loader/general.d.ts.map +1 -1
  17. package/dist/lib/loader/general.js +3 -0
  18. package/dist/lib/loader/general.js.map +1 -1
  19. package/dist/lib/loader/router.d.ts.map +1 -1
  20. package/dist/lib/loader/router.js +29 -5
  21. package/dist/lib/loader/router.js.map +1 -1
  22. package/dist/lib/util/cache.d.ts +37 -0
  23. package/dist/lib/util/cache.d.ts.map +1 -0
  24. package/dist/lib/util/cache.js +189 -0
  25. package/dist/lib/util/cache.js.map +1 -0
  26. package/dist/lib/util/tracker.d.ts +1 -0
  27. package/dist/lib/util/tracker.d.ts.map +1 -1
  28. package/dist/lib/util/tracker.js +3 -3
  29. package/dist/lib/util/tracker.js.map +1 -1
  30. package/dist/types/global.d.ts +28 -0
  31. package/lib/config/general.ts +7 -1
  32. package/lib/config/plugins.ts +8 -0
  33. package/lib/hooks/onError.ts +12 -7
  34. package/lib/loader/general.ts +3 -0
  35. package/lib/loader/router.ts +40 -5
  36. package/lib/manifest/generator.ts +1 -1
  37. package/lib/util/cache.ts +258 -0
  38. package/lib/util/tracker.ts +5 -3
  39. package/package.json +4 -2
@@ -0,0 +1,258 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /**
3
+ * In-memory LRU + TTL cache backing the per-route `cache` capability.
4
+ *
5
+ * No external dependency: a JS `Map` preserves insertion order, so LRU is
6
+ * "delete + reinsert on read" (marks most-recently-used) and "evict the first key
7
+ * on overflow" (the least-recently-used). TTL is enforced lazily on read plus a
8
+ * periodic background sweep. Entries are keyed as `keyGroup :: scope :: method url`
9
+ * where `scope` isolates by tenant / authenticated subject / role set so a cached
10
+ * response is never served across tenants, users or privilege levels.
11
+ *
12
+ * The store is configured once at boot from `global.config.options.cache`
13
+ * (see index.ts) and exposed both as `global.cache` and via the package root
14
+ * exports `invalidateCache` / `cache`.
15
+ */
16
+ import type { FastifyReply, FastifyRequest } from 'fastify'
17
+ import type { NormalizedRouteCache, RouteCache } from '../../types/global.js'
18
+
19
+ interface Entry {
20
+ value: any
21
+ expires: number // epoch ms; Infinity when no TTL
22
+ }
23
+
24
+ interface CacheSettings {
25
+ enabled: boolean
26
+ ttl: number // default TTL (seconds) for routes without an explicit ttl
27
+ maxEntries: number // LRU cap
28
+ }
29
+
30
+ // Single source of truth for the cache defaults. `enabled` is opt-in (off by
31
+ // default, consistent with the other optional features like `manifest` /
32
+ // `multi_tenant`); `ttl`/`maxEntries` are the numeric fallbacks applied per-field
33
+ // by configureCache, so no other module needs to restate them.
34
+ const DEFAULTS: CacheSettings = { enabled: false, ttl: 3600, maxEntries: 1000 }
35
+
36
+ let settings: CacheSettings = { ...DEFAULTS }
37
+ const store = new Map<string, Entry>()
38
+ let hits = 0
39
+ let misses = 0
40
+ let sweepTimer: ReturnType<typeof setInterval> | null = null
41
+
42
+ const nowMs = () => Date.now()
43
+
44
+ function startSweep(intervalMs = 60_000) {
45
+ if (sweepTimer) clearInterval(sweepTimer)
46
+ sweepTimer = setInterval(() => {
47
+ const t = nowMs()
48
+ for (const [k, e] of store) if (e.expires <= t) store.delete(k)
49
+ }, intervalMs)
50
+ // Never keep the event loop alive just for the sweep.
51
+ sweepTimer.unref?.()
52
+ }
53
+
54
+ /** (Re)configure the cache from global options and log the effective config. */
55
+ export function configureCache(opts?: CacheSettings | Partial<CacheSettings>): CacheSettings {
56
+ settings = {
57
+ // Opt-in master switch: enabled only when explicitly set to true.
58
+ enabled: opts?.enabled === true,
59
+ ttl: Number(opts?.ttl) > 0 ? Number(opts?.ttl) : DEFAULTS.ttl,
60
+ maxEntries: Number(opts?.maxEntries) > 0 ? Number(opts?.maxEntries) : DEFAULTS.maxEntries
61
+ }
62
+ store.clear()
63
+ hits = 0
64
+ misses = 0
65
+ if (settings.enabled) {
66
+ startSweep()
67
+ if (log?.i)
68
+ log.info(
69
+ `Cache 🧊 enabled — ttl ${settings.ttl}s, maxEntries ${settings.maxEntries}, strategy LRU+TTL`
70
+ )
71
+ } else {
72
+ if (sweepTimer) {
73
+ clearInterval(sweepTimer)
74
+ sweepTimer = null
75
+ }
76
+ if (log?.i) log.info('Cache 🧊 disabled')
77
+ }
78
+ return settings
79
+ }
80
+
81
+ export const cacheEnabled = () => settings.enabled
82
+ export const defaultTtl = () => settings.ttl
83
+
84
+ export function cacheGet(key: string): any {
85
+ const e = store.get(key)
86
+ if (!e) {
87
+ misses++
88
+ return undefined
89
+ }
90
+ if (e.expires <= nowMs()) {
91
+ store.delete(key)
92
+ misses++
93
+ return undefined
94
+ }
95
+ // LRU touch: re-insert to mark as most-recently used.
96
+ store.delete(key)
97
+ store.set(key, e)
98
+ hits++
99
+ return e.value
100
+ }
101
+
102
+ export function cacheSet(key: string, value: any, ttlSec?: number): void {
103
+ const ttl = Number(ttlSec) > 0 ? Number(ttlSec) : settings.ttl
104
+ if (store.has(key)) store.delete(key)
105
+ store.set(key, { value, expires: ttl > 0 ? nowMs() + ttl * 1000 : Infinity })
106
+ // Evict least-recently-used entries beyond the cap.
107
+ while (store.size > settings.maxEntries) {
108
+ const oldest = store.keys().next().value
109
+ if (oldest === undefined) break
110
+ store.delete(oldest)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Invalidate cached entries. With no argument clears everything; otherwise removes
116
+ * every entry belonging to the given key-group(s). Returns the number removed.
117
+ */
118
+ export function invalidateCache(keyGroups?: string | string[]): number {
119
+ if (keyGroups == null) {
120
+ const n = store.size
121
+ store.clear()
122
+ if (log?.d) log.debug(`Cache 🧊 flushAll — ${n} entries removed`)
123
+ return n
124
+ }
125
+ const list = Array.isArray(keyGroups) ? keyGroups : [keyGroups]
126
+ const prefixes = list.map((g) => `${g}::`)
127
+ let n = 0
128
+ for (const k of [...store.keys()]) {
129
+ if (prefixes.some((p) => k.startsWith(p))) {
130
+ store.delete(k)
131
+ n++
132
+ }
133
+ }
134
+ if (log?.d) log.debug(`Cache 🧊 invalidate [${list.join(', ')}] — ${n} entries removed`)
135
+ return n
136
+ }
137
+
138
+ export function cacheStats() {
139
+ return { size: store.size, hits, misses, ...settings }
140
+ }
141
+
142
+ /** Facade exposed as `global.cache` and re-exported from the package root. */
143
+ export const cache = {
144
+ get: cacheGet,
145
+ set: cacheSet,
146
+ del: (key: string) => store.delete(key),
147
+ invalidate: invalidateCache,
148
+ flushAll: () => invalidateCache(),
149
+ stats: cacheStats,
150
+ enabled: cacheEnabled
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Route-config normalization + request scoping + Fastify hook factory
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Normalize the authored `cache` prop (`true` | number | object) into the stored
158
+ * form, resolving the key-group to the api folder (area) when omitted. Returns
159
+ * undefined when there is nothing to do (absent/false and no invalidation). */
160
+ export function normalizeRouteCache(
161
+ input: boolean | number | RouteCache | undefined,
162
+ defaultKeyGroup: string
163
+ ): NormalizedRouteCache | undefined {
164
+ if (input == null || input === false) return undefined
165
+ const obj: RouteCache = input === true ? {} : typeof input === 'number' ? { ttl: input } : input
166
+ const enabled = obj.enabled !== false
167
+ const invalidates =
168
+ obj.invalidates == null ? undefined : Array.isArray(obj.invalidates) ? obj.invalidates : [obj.invalidates]
169
+ if (!enabled && !invalidates?.length) return undefined
170
+ return {
171
+ enabled,
172
+ ttl: typeof obj.ttl === 'number' && obj.ttl > 0 ? obj.ttl : undefined,
173
+ keyGroup: obj.keyGroup || defaultKeyGroup,
174
+ invalidates
175
+ }
176
+ }
177
+
178
+ const HIT = Symbol('volcanic.cacheHit')
179
+ const HEADER_ALLOW = /^(content-type|v-)/i
180
+
181
+ /** Scope segment: isolates cached data by tenant, authenticated subject and roles. */
182
+ function scope(req: any): string {
183
+ const tenant = req.tenant?.id ?? ''
184
+ const subject = req.user?.externalId ?? req.token?.getId?.() ?? 'anon'
185
+ const rolesList = typeof req.roles === 'function' ? req.roles() : []
186
+ const rolesKey = [...(rolesList || [])].sort().join(',')
187
+ return `${tenant}|${subject}|${rolesKey}`
188
+ }
189
+
190
+ function keyFor(req: any, keyGroup: string): string {
191
+ return `${keyGroup}::${scope(req)}::${req.method} ${req.url}`
192
+ }
193
+
194
+ /** Skip caching when a tenant is expected (multi-tenant + tenantContext) but missing,
195
+ * to avoid ever leaking data across tenants. */
196
+ function tenantMissing(req: any): boolean {
197
+ const mt = global.config?.options?.multi_tenant?.enabled
198
+ const tenantCtx = req.routeOptions?.config?.tenantContext
199
+ return Boolean(mt && tenantCtx !== false && !req.tenant)
200
+ }
201
+
202
+ /** Build the per-route read (preHandler) and write (onSend) hooks for a cached route. */
203
+ export function buildCacheHooks(routeCache: NormalizedRouteCache) {
204
+ const cacheable = routeCache.enabled === true
205
+ const keyGroup = routeCache.keyGroup
206
+ const ttl = routeCache.ttl
207
+ const invalidates = routeCache.invalidates
208
+
209
+ const preHandler = cacheable
210
+ ? async (req: FastifyRequest, reply: FastifyReply) => {
211
+ if (!cacheEnabled() || (req as any).method !== 'GET' || tenantMissing(req)) return
212
+ const hit = cacheGet(keyFor(req, keyGroup))
213
+ if (hit) {
214
+ for (const [h, v] of Object.entries(hit.headers as Record<string, any>)) reply.header(h, v as any)
215
+ ;(req as any)[HIT] = true
216
+ reply.code(hit.statusCode)
217
+ if (log?.d) log.debug(`Cache 🧊 hit ${keyGroup} ${(req as any).method} ${(req as any).url}`)
218
+ return reply.send(hit.payload)
219
+ }
220
+ if (log?.d) log.debug(`Cache 🧊 miss ${keyGroup} ${(req as any).method} ${(req as any).url}`)
221
+ }
222
+ : undefined
223
+
224
+ const onSend = async (req: FastifyRequest, reply: FastifyReply, payload: any) => {
225
+ // Master switch off: nothing to store and — on a volatile in-memory cache — nothing
226
+ // to invalidate (the store is already empty), so skip the whole hook. Also avoids
227
+ // scanning the store on every mutation when caching is globally disabled.
228
+ if (!cacheEnabled()) return payload
229
+ // Store fresh GET 2xx responses (skip replays and anything with Set-Cookie).
230
+ if (
231
+ cacheable &&
232
+ !(req as any)[HIT] &&
233
+ (req as any).method === 'GET' &&
234
+ reply.statusCode >= 200 &&
235
+ reply.statusCode < 300 &&
236
+ typeof payload === 'string' &&
237
+ !tenantMissing(req) &&
238
+ !reply.getHeader('set-cookie')
239
+ ) {
240
+ const headers: Record<string, any> = {}
241
+ const all = reply.getHeaders()
242
+ for (const h of Object.keys(all)) if (HEADER_ALLOW.test(h)) headers[h] = all[h]
243
+ cacheSet(keyFor(req, keyGroup), { payload, headers, statusCode: reply.statusCode }, ttl)
244
+ }
245
+ // Invalidate declared key-groups after a successful mutation.
246
+ if (
247
+ invalidates?.length &&
248
+ (req as any).method !== 'GET' &&
249
+ reply.statusCode >= 200 &&
250
+ reply.statusCode < 300
251
+ ) {
252
+ invalidateCache(invalidates)
253
+ }
254
+ return payload
255
+ }
256
+
257
+ return { preHandler, onSend }
258
+ }
@@ -88,21 +88,23 @@ function getTrackingConfigIfEnabled(req) {
88
88
  }
89
89
  }
90
90
 
91
- function isFieldChanged(oldValue, newValue) {
91
+ export function isFieldChanged(oldValue, newValue) {
92
92
  if ((oldValue instanceof Date || newValue instanceof Date) && oldValue != null && newValue != undefined) {
93
93
  return !dayjs(oldValue).isSame(dayjs(newValue))
94
94
  }
95
95
 
96
96
  if ((oldValue instanceof Object || newValue instanceof Object) && oldValue != null && newValue != undefined) {
97
97
  const primaryKey = global.trackingConfig?.primaryKey
98
+ // Guard the `in` operator with an object check: `'id' in 'someString'` throws
99
+ // on a string primitive, so reach the string fallback instead of crashing.
98
100
  const oldId =
99
- oldValue != null && primaryKey in oldValue
101
+ oldValue != null && typeof oldValue === 'object' && primaryKey in oldValue
100
102
  ? oldValue[primaryKey]
101
103
  : typeof oldValue === 'string'
102
104
  ? oldValue
103
105
  : undefined
104
106
  const newId =
105
- newValue != null && primaryKey in newValue
107
+ newValue != null && typeof newValue === 'object' && primaryKey in newValue
106
108
  ? newValue[primaryKey]
107
109
  : typeof newValue === 'string'
108
110
  ? newValue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volcanicminds/backend",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "codename": "rome",
6
6
  "license": "MIT",
@@ -58,7 +58,8 @@
58
58
  "prod": "cd dist && node server.js",
59
59
  "start": "tsx --env-file .env server.ts",
60
60
  "dev": "tsx watch --env-file .env server.ts",
61
- "test": "npm run test:core && npm run test:typeorm",
61
+ "pretest": "npm run build",
62
+ "test": "node scripts/run-tests.mjs",
62
63
  "test:core": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2231 NODE_ENV=memory BROWSER=false JWT_SECRET=tEst0nly_k7Qx2mNp9vLrT4wYbE6sH1jD8fG3aZ5c JWT_REFRESH_SECRET=tEst0nly_R3frsh9vLrT4wYbE6sH1jD8fG3aZ5cK7 mocha --import=tsx ./test/index.spec.ts -t 100000 --exit",
63
64
  "test:typeorm": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/typeorm/**/*.spec.ts -t 20000 --exit",
64
65
  "test:pglite": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/pglite/**/*.spec.ts -t 60000 --exit",
@@ -90,6 +91,7 @@
90
91
  "@fastify/multipart": "^9.3.0",
91
92
  "@fastify/rate-limit": "^10.3.0",
92
93
  "@fastify/schedule": "^6.0.0",
94
+ "@fastify/static": "^9.1.3",
93
95
  "@fastify/swagger": "^9.6.1",
94
96
  "@fastify/swagger-ui": "^5.2.3",
95
97
  "dayjs": "^1.11.19",