webspresso 0.0.104 → 0.0.105

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 (132) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +1 -1
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/project.js +2 -2
  19. package/core/auth/hash.js +5 -4
  20. package/core/auth/jwt.js +5 -54
  21. package/core/auth/magic-link.js +4 -5
  22. package/core/auth/manager.js +3 -5
  23. package/core/auth/oauth.js +8 -17
  24. package/core/auth/totp.js +2 -1
  25. package/core/cache/byte-size.js +123 -0
  26. package/core/cache/index.js +21 -0
  27. package/core/cache/lru.js +477 -0
  28. package/core/cache/tagged-lru.js +346 -0
  29. package/core/commands/registry.js +6 -20
  30. package/core/early-hints/index.js +267 -0
  31. package/core/errors/domain.js +48 -0
  32. package/core/errors/http.js +31 -0
  33. package/core/errors/index.js +10 -0
  34. package/core/errors/normalize.js +88 -2
  35. package/core/events/discovery.js +6 -24
  36. package/core/events/dispatcher.js +2 -7
  37. package/core/health/index.js +41 -0
  38. package/core/health/middleware.js +213 -0
  39. package/core/health/normalize.js +140 -0
  40. package/core/health/probes.js +203 -0
  41. package/core/limits/index.js +34 -0
  42. package/core/limits/middleware.js +317 -0
  43. package/core/limits/normalize.js +222 -0
  44. package/core/orm/batch.js +70 -0
  45. package/core/orm/cache/fingerprint.js +2 -2
  46. package/core/orm/cache/memory-provider.js +27 -110
  47. package/core/orm/index.js +71 -8
  48. package/core/orm/json-fields.js +22 -8
  49. package/core/orm/model.js +10 -0
  50. package/core/orm/query-builder.js +63 -9
  51. package/core/orm/repository.js +318 -19
  52. package/core/orm/schema-helpers.js +8 -10
  53. package/core/orm/scopes.js +321 -1
  54. package/core/orm/transaction.js +10 -2
  55. package/core/orm/utils.js +4 -86
  56. package/core/queue/adapters/database.js +4 -8
  57. package/core/queue/manager.js +55 -41
  58. package/core/resilience/circuit-breaker.js +391 -0
  59. package/core/resilience/errors.js +63 -0
  60. package/core/resilience/index.js +53 -0
  61. package/core/resilience/registry.js +97 -0
  62. package/core/resilience/retry.js +203 -0
  63. package/core/schema-cache.js +9 -2
  64. package/core/shutdown/http-adapter.js +4 -1
  65. package/core/shutdown/shutdown-manager.js +120 -19
  66. package/core/utils/array.js +12 -0
  67. package/core/utils/base64url.js +8 -0
  68. package/core/utils/crypto.js +118 -0
  69. package/core/utils/ensure-leading-slash.js +6 -0
  70. package/core/utils/escape-regexp.js +7 -0
  71. package/core/utils/fetch.js +6 -5
  72. package/core/utils/fs.js +144 -0
  73. package/core/utils/hmac.js +6 -0
  74. package/core/utils/json.js +98 -0
  75. package/core/utils/object.js +106 -0
  76. package/core/utils/random.js +9 -0
  77. package/core/utils/regex.js +77 -0
  78. package/core/utils/sha256.js +6 -0
  79. package/core/utils/string.js +87 -0
  80. package/core/utils/strip-trailing-slash.js +6 -0
  81. package/core/utils/time.js +96 -0
  82. package/core/utils/timeout.js +348 -0
  83. package/core/utils/url.js +106 -0
  84. package/core/utils/walk.js +7 -0
  85. package/index.d.ts +616 -1
  86. package/index.js +148 -0
  87. package/package.json +1 -2
  88. package/plugins/admin-panel/core/api-extensions.js +2 -5
  89. package/plugins/admin-panel/index.js +1 -1
  90. package/plugins/admin-panel/modules/menu.js +1 -1
  91. package/plugins/api-key/crypto.js +3 -3
  92. package/plugins/api-key/scopes.js +5 -7
  93. package/plugins/audit-log/api-handlers.js +6 -15
  94. package/plugins/audit-log/parse.js +3 -3
  95. package/plugins/csrf/index.js +5 -9
  96. package/plugins/dashboard/app.js +2 -2
  97. package/plugins/dashboard/index.js +5 -5
  98. package/plugins/data-exchange/record-selection.js +2 -5
  99. package/plugins/email/api-handlers.js +5 -18
  100. package/plugins/email/auth-bridge.js +3 -2
  101. package/plugins/health-check.js +2 -1
  102. package/plugins/index.js +2 -0
  103. package/plugins/lazy.js +5 -6
  104. package/plugins/mcp/discovery.js +18 -17
  105. package/plugins/polar/src/api.js +2 -1
  106. package/plugins/polar/src/webhooks.js +5 -6
  107. package/plugins/rate-limit/index.js +3 -1
  108. package/plugins/redirect/index.js +9 -7
  109. package/plugins/rls/index.js +170 -0
  110. package/plugins/site-analytics/tracking.js +2 -2
  111. package/plugins/sitemap.js +3 -2
  112. package/plugins/swagger.js +4 -3
  113. package/plugins/upload/index.js +4 -3
  114. package/plugins/upload/orphan-pruner.js +4 -3
  115. package/plugins/webhooks/crypto.js +6 -11
  116. package/plugins/webhooks/dispatcher.js +8 -8
  117. package/plugins/webhooks/index.js +6 -0
  118. package/plugins/webhooks/matcher.js +7 -11
  119. package/plugins/webhooks/sender.js +8 -11
  120. package/src/app-context.js +41 -5
  121. package/src/dev/banner.js +6 -2
  122. package/src/file-router.js +1 -3
  123. package/src/helpers.js +159 -24
  124. package/src/modules/module-discovery.js +1 -9
  125. package/src/pages/page-loader.js +53 -1
  126. package/src/plugin-manager.js +90 -17
  127. package/src/server.js +136 -25
  128. package/src/services/discovery.js +19 -40
  129. package/src/services/executor.js +86 -25
  130. package/src/services/memoize.js +3 -25
  131. package/src/services/registry.js +6 -0
  132. package/src/template/precompile.js +8 -19
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Payload Limits Normalization & Utility
5
+ * Configures maximum body size limits and DoS protection for JSON, URL-encoded, raw, and text payloads.
6
+ *
7
+ * @module core/limits/normalize
8
+ */
9
+
10
+ const DEFAULT_LIMITS = Object.freeze({
11
+ json: '1mb',
12
+ urlencoded: '1mb',
13
+ parameterLimit: 1000,
14
+ raw: false,
15
+ text: false,
16
+ captureRawBody: false,
17
+ exclude: Object.freeze([]),
18
+ routes: Object.freeze({}),
19
+ });
20
+
21
+ /**
22
+ * Parses byte size strings (e.g. '100kb', '5mb', '1gb') into integer bytes.
23
+ * If already a number or cannot be parsed, returns the value.
24
+ *
25
+ * @param {string|number} value
26
+ * @returns {number|string}
27
+ */
28
+ function parseByteSize(value) {
29
+ if (typeof value === 'number' && Number.isFinite(value)) {
30
+ return Math.max(0, Math.floor(value));
31
+ }
32
+ if (typeof value !== 'string') {
33
+ return value;
34
+ }
35
+
36
+ const trimmed = value.trim();
37
+ const match = /^([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)?$/i.exec(trimmed);
38
+ if (!match) {
39
+ return value;
40
+ }
41
+
42
+ const num = parseFloat(match[1]);
43
+ const unit = (match[2] || 'b').toLowerCase();
44
+
45
+ const multipliers = {
46
+ b: 1,
47
+ byte: 1,
48
+ bytes: 1,
49
+ kb: 1024,
50
+ k: 1024,
51
+ kib: 1024,
52
+ mb: 1024 * 1024,
53
+ m: 1024 * 1024,
54
+ mib: 1024 * 1024,
55
+ gb: 1024 * 1024 * 1024,
56
+ g: 1024 * 1024 * 1024,
57
+ gib: 1024 * 1024 * 1024,
58
+ tb: 1024 * 1024 * 1024 * 1024,
59
+ t: 1024 * 1024 * 1024 * 1024,
60
+ tib: 1024 * 1024 * 1024 * 1024,
61
+ };
62
+
63
+ if (unit in multipliers) {
64
+ return Math.floor(num * multipliers[unit]);
65
+ }
66
+
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * Normalizes user limits options into a structured, validated configuration.
72
+ *
73
+ * @param {Object|string|number} [options]
74
+ * @returns {Object} Normalized limits configuration
75
+ */
76
+ function normalizeLimits(options = {}) {
77
+ // If options is top-level createApp options, unwrap options.limits ?? options.server?.limits
78
+ let raw = options;
79
+ if (options && typeof options === 'object') {
80
+ if ('limits' in options || (options.server && 'limits' in options.server)) {
81
+ raw = options.limits ?? options.server?.limits ?? {};
82
+ }
83
+ }
84
+
85
+ // Shorthand string or number: e.g. limits: '5mb' or limits: 5242880
86
+ if (typeof raw === 'string' || typeof raw === 'number') {
87
+ const size = parseByteSize(raw);
88
+ return {
89
+ json: { enabled: true, limit: size, strict: true, options: {} },
90
+ urlencoded: { enabled: true, limit: size, extended: true, parameterLimit: 1000, options: {} },
91
+ raw: { enabled: false, limit: size, type: 'application/octet-stream', options: {} },
92
+ text: { enabled: false, limit: size, type: 'text/plain', options: {} },
93
+ parameterLimit: 1000,
94
+ captureRawBody: false,
95
+ exclude: [],
96
+ routes: {},
97
+ };
98
+ }
99
+
100
+ const opts = (raw && typeof raw === 'object') ? raw : {};
101
+
102
+ // Global parameterLimit fallback
103
+ const globalParamLimit = typeof opts.parameterLimit === 'number'
104
+ ? Math.max(1, Math.floor(opts.parameterLimit))
105
+ : 1000;
106
+
107
+ // 1. JSON
108
+ let json = { enabled: true, limit: parseByteSize('1mb'), strict: true, options: {} };
109
+ if (opts.json === false) {
110
+ json.enabled = false;
111
+ } else if (typeof opts.json === 'string' || typeof opts.json === 'number') {
112
+ json.limit = parseByteSize(opts.json);
113
+ } else if (opts.json && typeof opts.json === 'object') {
114
+ const { limit, strict, verify, enabled, ...rest } = opts.json;
115
+ json = {
116
+ enabled: enabled !== false,
117
+ limit: limit !== undefined ? parseByteSize(limit) : parseByteSize('1mb'),
118
+ strict: strict !== false,
119
+ verify: typeof verify === 'function' ? verify : undefined,
120
+ options: rest,
121
+ };
122
+ }
123
+
124
+ // 2. URL-encoded
125
+ let urlencoded = { enabled: true, limit: parseByteSize('1mb'), extended: true, parameterLimit: globalParamLimit, options: {} };
126
+ if (opts.urlencoded === false) {
127
+ urlencoded.enabled = false;
128
+ } else if (typeof opts.urlencoded === 'string' || typeof opts.urlencoded === 'number') {
129
+ urlencoded.limit = parseByteSize(opts.urlencoded);
130
+ } else if (opts.urlencoded && typeof opts.urlencoded === 'object') {
131
+ const { limit, extended, parameterLimit, verify, enabled, ...rest } = opts.urlencoded;
132
+ urlencoded = {
133
+ enabled: enabled !== false,
134
+ limit: limit !== undefined ? parseByteSize(limit) : parseByteSize('1mb'),
135
+ extended: extended !== false,
136
+ parameterLimit: typeof parameterLimit === 'number' ? Math.max(1, Math.floor(parameterLimit)) : globalParamLimit,
137
+ verify: typeof verify === 'function' ? verify : undefined,
138
+ options: rest,
139
+ };
140
+ }
141
+
142
+ // 3. Raw Body (application/octet-stream by default)
143
+ let rawBody = { enabled: false, limit: parseByteSize('1mb'), type: 'application/octet-stream', options: {} };
144
+ if (opts.raw === true) {
145
+ rawBody.enabled = true;
146
+ rawBody.limit = parseByteSize('1mb');
147
+ } else if (typeof opts.raw === 'string' || typeof opts.raw === 'number') {
148
+ rawBody.enabled = true;
149
+ rawBody.limit = parseByteSize(opts.raw);
150
+ } else if (opts.raw && typeof opts.raw === 'object') {
151
+ const { limit, type, verify, enabled, ...rest } = opts.raw;
152
+ rawBody = {
153
+ enabled: enabled !== false,
154
+ limit: limit !== undefined ? parseByteSize(limit) : parseByteSize('1mb'),
155
+ type: type || 'application/octet-stream',
156
+ verify: typeof verify === 'function' ? verify : undefined,
157
+ options: rest,
158
+ };
159
+ }
160
+
161
+ // 4. Text Body (text/plain by default)
162
+ let textBody = { enabled: false, limit: parseByteSize('1mb'), type: 'text/plain', options: {} };
163
+ if (opts.text === true) {
164
+ textBody.enabled = true;
165
+ textBody.limit = parseByteSize('1mb');
166
+ } else if (typeof opts.text === 'string' || typeof opts.text === 'number') {
167
+ textBody.enabled = true;
168
+ textBody.limit = parseByteSize(opts.text);
169
+ } else if (opts.text && typeof opts.text === 'object') {
170
+ const { limit, type, verify, enabled, ...rest } = opts.text;
171
+ textBody = {
172
+ enabled: enabled !== false,
173
+ limit: limit !== undefined ? parseByteSize(limit) : parseByteSize('1mb'),
174
+ type: type || 'text/plain',
175
+ verify: typeof verify === 'function' ? verify : undefined,
176
+ options: rest,
177
+ };
178
+ }
179
+
180
+ // 5. Raw Body Capture setting
181
+ const captureRawBody = opts.captureRawBody !== undefined ? opts.captureRawBody : false;
182
+
183
+ // 6. Exclusions
184
+ const exclude = Array.isArray(opts.exclude) ? opts.exclude.slice() : [];
185
+
186
+ // 7. Route overrides
187
+ const routes = {};
188
+ if (opts.routes && typeof opts.routes === 'object') {
189
+ for (const [pattern, routeConfig] of Object.entries(opts.routes)) {
190
+ if (routeConfig === false) {
191
+ routes[pattern] = false;
192
+ } else if (typeof routeConfig === 'string' || typeof routeConfig === 'number') {
193
+ const size = parseByteSize(routeConfig);
194
+ routes[pattern] = {
195
+ json: { enabled: true, limit: size, strict: true, options: {} },
196
+ urlencoded: { enabled: true, limit: size, extended: true, parameterLimit: globalParamLimit, options: {} },
197
+ raw: { enabled: false, limit: size, type: 'application/octet-stream', options: {} },
198
+ text: { enabled: false, limit: size, type: 'text/plain', options: {} },
199
+ };
200
+ } else if (routeConfig && typeof routeConfig === 'object') {
201
+ routes[pattern] = normalizeLimits(routeConfig);
202
+ }
203
+ }
204
+ }
205
+
206
+ return {
207
+ json,
208
+ urlencoded,
209
+ raw: rawBody,
210
+ text: textBody,
211
+ parameterLimit: globalParamLimit,
212
+ captureRawBody,
213
+ exclude,
214
+ routes,
215
+ };
216
+ }
217
+
218
+ module.exports = {
219
+ DEFAULT_LIMITS,
220
+ parseByteSize,
221
+ normalizeLimits,
222
+ };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso ORM - Batch & Bulk Operation Utilities
5
+ * Manages SQL parameter variable limit protection, auto-chunking, and bulk operations.
6
+ *
7
+ * @module core/orm/batch
8
+ */
9
+
10
+ /**
11
+ * Calculates a safe chunk size based on dialect and column count to prevent
12
+ * SQLITE_ERROR: too many SQL variables or max query length packet issues.
13
+ *
14
+ * @param {import('knex').Knex|Object} knexInstance - Knex or transaction instance
15
+ * @param {number} columnCount - Number of columns per inserted row
16
+ * @param {number} [requestedChunkSize] - User-specified chunk size
17
+ * @returns {number} Safe chunk size
18
+ */
19
+ function calculateSafeChunkSize(knexInstance, columnCount = 10, requestedChunkSize = null) {
20
+ const cols = Math.max(1, columnCount || 10);
21
+ const clientName = (
22
+ knexInstance?.client?.dialect ||
23
+ knexInstance?.client?.config?.client ||
24
+ ''
25
+ ).toLowerCase();
26
+ const isSqlite = clientName.includes('sqlite');
27
+
28
+ // SQLite variable limits:
29
+ // Legacy SQLite: 999
30
+ // Modern SQLite (>= 3.32.0): 32,766
31
+ // We use 990 as a conservative threshold for maximum compatibility across any SQLite build.
32
+ const safeParamLimit = isSqlite ? 990 : 10000;
33
+ const maxRowsByParamLimit = Math.max(1, Math.floor(safeParamLimit / cols));
34
+
35
+ if (typeof requestedChunkSize === 'number' && requestedChunkSize > 0) {
36
+ if (isSqlite) {
37
+ // Ensure requested chunk size doesn't exceed SQLite variable limit
38
+ return Math.min(requestedChunkSize, maxRowsByParamLimit);
39
+ }
40
+ return requestedChunkSize;
41
+ }
42
+
43
+ // Default optimal chunk sizes
44
+ if (isSqlite) {
45
+ return Math.min(500, maxRowsByParamLimit);
46
+ }
47
+ return 1000;
48
+ }
49
+
50
+ /**
51
+ * Splits an array into sub-arrays of specified chunk size
52
+ * @template T
53
+ * @param {T[]} array - Source array
54
+ * @param {number} size - Chunk size
55
+ * @returns {T[][]}
56
+ */
57
+ function chunkArray(array, size) {
58
+ if (!Array.isArray(array) || array.length === 0) return [];
59
+ const chunkSize = Math.max(1, Math.floor(size || 500));
60
+ const chunks = [];
61
+ for (let i = 0; i < array.length; i += chunkSize) {
62
+ chunks.push(array.slice(i, i + chunkSize));
63
+ }
64
+ return chunks;
65
+ }
66
+
67
+ module.exports = {
68
+ calculateSafeChunkSize,
69
+ chunkArray,
70
+ };
@@ -3,7 +3,7 @@
3
3
  * @module core/orm/cache/fingerprint
4
4
  */
5
5
 
6
- const crypto = require('crypto');
6
+ const { sha256 } = require('../../utils/crypto');
7
7
 
8
8
  /**
9
9
  * @param {*} v
@@ -94,7 +94,7 @@ function hashKey(model, scopeContext, parts) {
94
94
  ...parts,
95
95
  });
96
96
  const json = JSON.stringify(payload);
97
- return crypto.createHash('sha256').update(json).digest('hex').slice(0, 40);
97
+ return sha256(json).slice(0, 40);
98
98
  }
99
99
 
100
100
  module.exports = {
@@ -1,12 +1,18 @@
1
1
  /**
2
- * In-memory CacheProvider (process-local, tag-based invalidation)
2
+ * In-memory CacheProvider (process-local, tag-based invalidation, true LRU & memory budget)
3
+ * Upgraded with zero-allocation O(1) LRU eviction, byte budget constraints, and leak-free tag sets.
3
4
  * @module core/orm/cache/memory-provider
4
5
  */
5
6
 
7
+ const { createTaggedLruCache } = require('../../cache');
8
+
6
9
  /**
7
10
  * @typedef {Object} MemoryCacheProviderOptions
8
- * @property {number} [maxEntries=10000] - Max stored keys; FIFO evict on overflow
9
- * @property {number} [defaultTtlMs=0] - Default TTL (0 = no expiry)
11
+ * @property {number} [maxEntries=10000] - Max stored keys; LRU evict on overflow
12
+ * @property {number} [maxBytes=0] - Max byte size budget (0 = disabled)
13
+ * @property {number} [maxMemoryMb=0] - Max megabytes budget (converts to bytes)
14
+ * @property {number} [defaultTtlMs=0] - Default TTL in ms (0 = no expiry)
15
+ * @property {function(*, string): number} [sizeCalculation] - Custom byte sizer
10
16
  */
11
17
 
12
18
  /**
@@ -16,116 +22,27 @@
16
22
  function createMemoryCacheProvider(options = {}) {
17
23
  const maxEntries = options.maxEntries ?? 10000;
18
24
  const defaultTtlMs = options.defaultTtlMs ?? 0;
25
+ const maxBytes = options.maxBytes ?? (options.maxMemoryMb ? options.maxMemoryMb * 1024 * 1024 : 0);
19
26
 
20
- /** @type {Map<string, { value: *, expiresAt: number, tags: Set<string> }>} */
21
- const store = new Map();
22
- /** @type {Map<string, Set<string>>} */
23
- const tagToKeys = new Map();
24
- /** @type {Map<string, Set<string>>} */
25
- const keyToTags = new Map();
26
-
27
- function removeKey(key) {
28
- const tags = keyToTags.get(key);
29
- if (tags) {
30
- for (const t of tags) {
31
- const set = tagToKeys.get(t);
32
- if (set) {
33
- set.delete(key);
34
- if (set.size === 0) tagToKeys.delete(t);
35
- }
36
- }
37
- }
38
- keyToTags.delete(key);
39
- store.delete(key);
40
- }
41
-
42
- function get(key) {
43
- const entry = store.get(key);
44
- if (!entry) return undefined;
45
- if (entry.expiresAt > 0 && Date.now() > entry.expiresAt) {
46
- removeKey(key);
47
- return undefined;
48
- }
49
- return entry.value;
50
- }
51
-
52
- function set(key, value, opts = {}) {
53
- const tags = Array.isArray(opts.tags) ? opts.tags : [];
54
- const ttlMs = opts.ttlMs ?? defaultTtlMs;
55
-
56
- if (store.has(key)) removeKey(key);
57
-
58
- if (maxEntries > 0 && store.size >= maxEntries) {
59
- // Active sweep: evict expired entries before falling back to FIFO eviction
60
- const now = Date.now();
61
- let freed = 0;
62
- for (const [k, entry] of store.entries()) {
63
- if (entry.expiresAt > 0 && now > entry.expiresAt) {
64
- removeKey(k);
65
- freed++;
66
- if (freed >= 10 || store.size < maxEntries) break;
67
- }
68
- }
69
- if (store.size >= maxEntries) {
70
- const first = store.keys().next().value;
71
- if (first !== undefined) removeKey(first);
72
- }
73
- }
74
-
75
- const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
76
- const tagSet = new Set(tags);
77
- store.set(key, { value, expiresAt, tags: tagSet });
78
- keyToTags.set(key, new Set(tags));
79
-
80
- for (const t of tags) {
81
- if (!tagToKeys.has(t)) tagToKeys.set(t, new Set());
82
- tagToKeys.get(t).add(key);
83
- }
84
- }
85
-
86
- function invalidateTags(tags) {
87
- const toDelete = new Set();
88
- for (const t of tags) {
89
- const keys = tagToKeys.get(t);
90
- if (!keys) continue;
91
- for (const k of keys) toDelete.add(k);
92
- }
93
- for (const k of toDelete) removeKey(k);
94
- for (const t of tags) tagToKeys.delete(t);
95
- }
96
-
97
- function pruneExpired() {
98
- const now = Date.now();
99
- let count = 0;
100
- for (const [k, entry] of store.entries()) {
101
- if (entry.expiresAt > 0 && now > entry.expiresAt) {
102
- removeKey(k);
103
- count++;
104
- }
105
- }
106
- return count;
107
- }
108
-
109
- function clear() {
110
- store.clear();
111
- tagToKeys.clear();
112
- keyToTags.clear();
113
- }
114
-
115
- function getSizeStats() {
116
- return {
117
- entries: store.size,
118
- tags: tagToKeys.size,
119
- };
120
- }
27
+ const cache = createTaggedLruCache({
28
+ maxEntries,
29
+ defaultTtl: defaultTtlMs,
30
+ maxBytes,
31
+ sizeCalculation: options.sizeCalculation,
32
+ });
121
33
 
122
34
  return {
123
- get,
124
- set,
125
- invalidateTags,
126
- pruneExpired,
127
- clear,
128
- getSizeStats,
35
+ get: (key) => cache.get(key),
36
+ set: (key, value, opts = {}) => cache.set(key, value, {
37
+ tags: opts.tags,
38
+ ttlMs: opts.ttlMs ?? defaultTtlMs,
39
+ size: opts.size,
40
+ }),
41
+ invalidateTags: (tags) => cache.invalidateTags(tags),
42
+ pruneExpired: () => cache.pruneExpired(),
43
+ clear: () => cache.clear(),
44
+ getSizeStats: () => cache.getSizeStats(),
45
+ getStats: () => cache.getStats(),
129
46
  };
130
47
  }
131
48
 
package/core/orm/index.js CHANGED
@@ -112,9 +112,12 @@ function createDatabase(config) {
112
112
  }
113
113
 
114
114
  let knexInstance;
115
- try {
116
- knexInstance = knex(knexConfig);
117
- } catch (e) {
115
+ if (config.knex) {
116
+ knexInstance = config.knex;
117
+ } else {
118
+ try {
119
+ knexInstance = knex(knexConfig);
120
+ } catch (e) {
118
121
  // Provide helpful error message
119
122
  if (e.message && (e.message.includes('Cannot find module') || e.message.includes('npm install'))) {
120
123
  const driverName = driverMap[config.client] || config.client;
@@ -133,6 +136,7 @@ function createDatabase(config) {
133
136
  }
134
137
  throw e;
135
138
  }
139
+ }
136
140
 
137
141
  // Create migration manager
138
142
  const migrationConfig = config.migrations || {};
@@ -284,24 +288,32 @@ function createDatabase(config) {
284
288
  const runner = (ambientTrx && typeof ambientTrx.transaction === 'function') ? ambientTrx : knexInstance;
285
289
 
286
290
  return runner.transaction(async (trx) => {
291
+ let currentScope = scopeContext ? { ...scopeContext } : createScopeContext();
287
292
  const trxContext = {
288
293
  trx,
289
294
  getRepository(modelName, sContext) {
290
295
  const model = getModelInstance(modelName);
291
- const ctx = sContext || scopeContext || createScopeContext();
296
+ const ctx = sContext || currentScope;
292
297
  return createRepository(model, trx, ctx, ormCacheLayer);
293
298
  },
294
299
  createRepository(model, sContext) {
295
- const ctx = sContext || scopeContext || createScopeContext();
300
+ const ctx = sContext || currentScope;
296
301
  return createRepository(model, trx, ctx, ormCacheLayer);
297
302
  },
298
303
  forTenant(tenantId) {
299
- if (!scopeContext) scopeContext = createScopeContext();
300
- scopeContext.tenantId = tenantId;
304
+ currentScope = { ...currentScope, tenantId };
305
+ return this;
306
+ },
307
+ forUser(user) {
308
+ currentScope = { ...currentScope, user };
309
+ return this;
310
+ },
311
+ withoutRls() {
312
+ currentScope = { ...currentScope, rlsDisabled: true };
301
313
  return this;
302
314
  },
303
315
  getScopeContext() {
304
- return { ...(scopeContext || createScopeContext()) };
316
+ return { ...currentScope };
305
317
  },
306
318
  };
307
319
  return runWithAmbientTransaction(trx, () => callback(trxContext));
@@ -318,6 +330,45 @@ function createDatabase(config) {
318
330
  getRepository,
319
331
  createRepository: createRepositoryFromModel,
320
332
  query,
333
+ forUser(user) {
334
+ return {
335
+ getRepository(modelName, customContext = {}) {
336
+ return getRepository(modelName, { ...customContext, user });
337
+ },
338
+ createRepository(model, customContext = {}) {
339
+ return createRepositoryFromModel(model, { ...customContext, user });
340
+ },
341
+ query(modelName, customContext = {}) {
342
+ return query(modelName, { ...customContext, user });
343
+ },
344
+ };
345
+ },
346
+ withoutRls() {
347
+ return {
348
+ getRepository(modelName, customContext = {}) {
349
+ return getRepository(modelName, { ...customContext, rlsDisabled: true });
350
+ },
351
+ createRepository(model, customContext = {}) {
352
+ return createRepositoryFromModel(model, { ...customContext, rlsDisabled: true });
353
+ },
354
+ query(modelName, customContext = {}) {
355
+ return query(modelName, { ...customContext, rlsDisabled: true });
356
+ },
357
+ };
358
+ },
359
+ forTenant(tenantId) {
360
+ return {
361
+ getRepository(modelName, customContext = {}) {
362
+ return getRepository(modelName, { ...customContext, tenantId });
363
+ },
364
+ createRepository(model, customContext = {}) {
365
+ return createRepositoryFromModel(model, { ...customContext, tenantId });
366
+ },
367
+ query(modelName, customContext = {}) {
368
+ return query(modelName, { ...customContext, tenantId });
369
+ },
370
+ };
371
+ },
321
372
  transaction,
322
373
  runInTransaction: transaction,
323
374
  getAmbientTransaction,
@@ -397,4 +448,16 @@ module.exports = {
397
448
  applyColumnFilters: require('./query-filters').applyColumnFilters,
398
449
  applyFilterCondition: require('./query-filters').applyFilterCondition,
399
450
  queryFilters: require('./query-filters'),
451
+ // Batch & Bulk Operation Utilities
452
+ calculateSafeChunkSize: require('./batch').calculateSafeChunkSize,
453
+ chunkArray: require('./batch').chunkArray,
454
+ // Row Level Security (RLS) & Scopes
455
+ applyRlsScope: require('./scopes').applyRlsScope,
456
+ applyScopes: require('./scopes').applyScopes,
457
+ applyInsertRls: require('./scopes').applyInsertRls,
458
+ checkUpdateRls: require('./scopes').checkUpdateRls,
459
+ isRlsBypassed: require('./scopes').isRlsBypassed,
460
+ getUserId: require('./scopes').getUserId,
461
+ getTenantId: require('./scopes').getTenantId,
462
+ createScopeContext: require('./scopes').createScopeContext,
400
463
  };
@@ -3,6 +3,8 @@
3
3
  * @module core/orm/json-fields
4
4
  */
5
5
 
6
+ const { safeJsonParse } = require('../utils/json');
7
+
6
8
  /**
7
9
  * Get JSON column names from model
8
10
  * @param {import('./types').ModelDefinition} model - Model definition
@@ -15,7 +17,25 @@ function getJsonColumns(model) {
15
17
  const jsonCols = new Set();
16
18
  if (model && model.columns) {
17
19
  for (const [name, meta] of model.columns) {
18
- if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
20
+ if (meta && (meta.type === 'json' || meta.type === 'array' || meta.type === 'files')) {
21
+ jsonCols.add(name);
22
+ }
23
+ }
24
+ }
25
+ if (model && model.schema && model.schema.shape) {
26
+ for (const [name, fieldSchema] of Object.entries(model.schema.shape)) {
27
+ let current = fieldSchema;
28
+ while (current && current._def) {
29
+ if (current._def.innerType) {
30
+ current = current._def.innerType;
31
+ } else if (current._def.schema) {
32
+ current = current._def.schema;
33
+ } else {
34
+ break;
35
+ }
36
+ }
37
+ const typeName = current?._def?.typeName;
38
+ if (typeName === 'ZodRecord' || typeName === 'ZodObject' || typeName === 'ZodArray') {
19
39
  jsonCols.add(name);
20
40
  }
21
41
  }
@@ -57,13 +77,7 @@ function deserializeJsonFields(record, jsonColumns) {
57
77
 
58
78
  for (const col of jsonColumns) {
59
79
  if (col in record && record[col] !== null && record[col] !== undefined) {
60
- if (typeof record[col] === 'string') {
61
- try {
62
- record[col] = JSON.parse(record[col]);
63
- } catch {
64
- // If parsing fails, keep the original string value
65
- }
66
- }
80
+ record[col] = safeJsonParse(record[col], record[col]);
67
81
  }
68
82
  }
69
83
  return record;
package/core/orm/model.js CHANGED
@@ -39,6 +39,7 @@ function defineModel(options) {
39
39
  timestamps: timestampsOpt,
40
40
  softDelete: softDeleteOpt,
41
41
  softDeletes: softDeletesOpt,
42
+ rls: rlsOpt,
42
43
  options: nestedOptions = {},
43
44
  } = options;
44
45
 
@@ -185,6 +186,13 @@ function defineModel(options) {
185
186
  };
186
187
  }
187
188
 
189
+ const rlsConfig = rlsOpt !== undefined
190
+ ? rlsOpt
191
+ : (nestedOptions.rls !== undefined ? nestedOptions.rls : scopes.rls);
192
+ const normalizedRls = (typeof rlsConfig === 'function' || (rlsConfig && typeof rlsConfig === 'object'))
193
+ ? rlsConfig
194
+ : null;
195
+
188
196
  // Create model definition
189
197
  const model = {
190
198
  name,
@@ -198,9 +206,11 @@ function defineModel(options) {
198
206
  timestamps: Boolean(timestampsConfig),
199
207
  tenant: scopes.tenant || null,
200
208
  },
209
+ rls: normalizedRls,
201
210
  options: {
202
211
  timestamps: timestampsConfig || false,
203
212
  softDelete: softDeleteConfig || false,
213
+ rls: normalizedRls,
204
214
  ...nestedOptions,
205
215
  },
206
216
  columns,