webspresso 0.0.103 → 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.
- package/.agents/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +4 -3
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/orm-map-html.js +2 -8
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +6 -57
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +45 -30
- package/core/auth/totp.js +5 -16
- package/core/auth/user-agent.js +1 -21
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/content/renderer.js +1 -13
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/middleware.js +2 -8
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/forms/form-builder.js +1 -9
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +76 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/query-filters.js +393 -0
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +132 -31
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +191 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +121 -0
- package/core/utils/fs.js +144 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/safe-compare.js +3 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +627 -2
- package/index.js +158 -0
- package/package.json +1 -2
- package/plugins/admin-panel/auth.js +15 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
- package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
- package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
- package/plugins/admin-panel/core/api-extensions.js +3 -80
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -26
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +8 -7
- package/plugins/audit-log/middleware.js +11 -2
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +7 -23
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/core.js +34 -4
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/mcp/index.js +23 -1
- package/plugins/mcp/transports/sse.js +10 -1
- package/plugins/orm-cache-admin/admin-component.js +3 -2
- package/plugins/polar/src/api.js +11 -3
- package/plugins/polar/src/webhooks.js +7 -13
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +57 -7
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/rls/index.js +170 -0
- package/plugins/schema-explorer/admin-component.js +4 -2
- package/plugins/schema-explorer.js +40 -29
- package/plugins/seo-checker/index.js +5 -2
- package/plugins/site-analytics/tracking.js +21 -18
- package/plugins/sitemap.js +21 -17
- package/plugins/swagger.js +13 -17
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -28
- package/plugins/webhooks/dispatcher.js +14 -9
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +28 -17
- package/plugins/webhooks/sender.js +40 -13
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/dev-reload/index.js +47 -6
- package/src/file-router.js +1 -3
- package/src/helpers.js +180 -33
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +137 -26
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- 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
|
|
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
|
|
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;
|
|
9
|
-
* @property {number} [
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
116
|
-
knexInstance = knex
|
|
117
|
-
}
|
|
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 ||
|
|
296
|
+
const ctx = sContext || currentScope;
|
|
292
297
|
return createRepository(model, trx, ctx, ormCacheLayer);
|
|
293
298
|
},
|
|
294
299
|
createRepository(model, sContext) {
|
|
295
|
-
const ctx = sContext ||
|
|
300
|
+
const ctx = sContext || currentScope;
|
|
296
301
|
return createRepository(model, trx, ctx, ormCacheLayer);
|
|
297
302
|
},
|
|
298
303
|
forTenant(tenantId) {
|
|
299
|
-
|
|
300
|
-
|
|
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 { ...
|
|
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,
|
|
@@ -392,4 +443,21 @@ module.exports = {
|
|
|
392
443
|
// Schema Diff & Makemigrations (Django-inspired)
|
|
393
444
|
diffSchema: require('./migrations/diff').diffSchema,
|
|
394
445
|
makeMigrations: require('./migrations/diff').makeMigrations,
|
|
446
|
+
// Universal Query Filters (REST Resources & Admin Panel)
|
|
447
|
+
buildFilteredQuery: require('./query-filters').buildFilteredQuery,
|
|
448
|
+
applyColumnFilters: require('./query-filters').applyColumnFilters,
|
|
449
|
+
applyFilterCondition: require('./query-filters').applyFilterCondition,
|
|
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,
|
|
395
463
|
};
|
package/core/orm/json-fields.js
CHANGED
|
@@ -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
|
-
|
|
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,
|