webspresso 0.0.104 → 0.0.106
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 +21 -2
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +220 -8
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/FLAGS.md +118 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/LOGGING.md +93 -0
- package/.agents/METRICS.md +127 -0
- package/.agents/MIGRATION_JOBS.md +95 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +38 -3
- package/.agents/QUEUE.md +42 -0
- package/.agents/RATE_LIMIT.md +147 -0
- package/.agents/RESILIENCE.md +235 -0
- package/.agents/SCALE_OUT.md +109 -0
- package/.agents/SECRETS.md +117 -0
- package/AGENTS.md +16 -0
- package/README.md +2 -2
- package/bin/commands/add-aws.js +396 -0
- package/bin/commands/add-ci.js +523 -0
- package/bin/commands/add-cloudflare.js +240 -0
- package/bin/commands/add-cloudrun.js +381 -0
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/add-migration-job.js +396 -0
- package/bin/commands/assets-sync.js +209 -0
- package/bin/commands/client-generate.js +52 -0
- package/bin/commands/console.js +53 -0
- package/bin/commands/db-migrate.js +47 -2
- package/bin/commands/db-studio.js +84 -0
- package/bin/commands/deploy-check.js +242 -13
- package/bin/commands/dev.js +1 -1
- package/bin/commands/export.js +87 -0
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/secrets-pull.js +208 -0
- package/bin/commands/start.js +1 -1
- package/bin/utils/db.js +7 -1
- package/bin/utils/project.js +2 -2
- package/bin/webspresso.js +22 -0
- package/core/app.js +421 -0
- package/core/auth/authenticate.js +93 -0
- package/core/auth/hash.js +5 -4
- package/core/auth/index.js +34 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +53 -10
- package/core/auth/middleware.js +16 -68
- package/core/auth/oauth.js +8 -17
- package/core/auth/redis-session.js +1008 -0
- package/core/auth/sessions.js +156 -0
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/cdn.js +296 -0
- package/core/cache/index.js +33 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +354 -0
- package/core/commands/registry.js +6 -20
- package/core/console/commands.js +148 -0
- package/core/console/context.js +260 -0
- package/core/console/eval.js +70 -0
- package/core/console/index.js +22 -0
- package/core/console/repl.js +102 -0
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +49 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +9 -7
- package/core/events/signal.js +7 -0
- package/core/export/discovery.js +191 -0
- package/core/export/exporter.js +277 -0
- package/core/export/index.js +24 -0
- package/core/flags/evaluator.js +252 -0
- package/core/flags/hasher.js +37 -0
- package/core/flags/index.js +21 -0
- package/core/flags/manager.js +263 -0
- package/core/flags/store.js +126 -0
- 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/http/adapter.js +74 -0
- package/core/http/express-adapter.js +46 -0
- package/core/http/hono-adapter.js +531 -0
- package/core/http/index.js +41 -0
- package/core/http/pipeline.js +485 -0
- package/core/index.js +336 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +325 -0
- package/core/limits/normalize.js +222 -0
- package/core/logger/formatters.js +264 -0
- package/core/logger/index.js +260 -0
- package/core/logger/trace.js +137 -0
- package/core/metrics/collectors.js +125 -0
- package/core/metrics/index.js +45 -0
- package/core/metrics/middleware.js +230 -0
- package/core/metrics/registry.js +366 -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/d1.js +265 -0
- package/core/orm/index.js +176 -13
- package/core/orm/json-fields.js +22 -8
- package/core/orm/migrate-cloud.js +194 -0
- package/core/orm/model.js +10 -0
- package/core/orm/pool.js +97 -0
- package/core/orm/query-builder.js +72 -9
- 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/cloudflare.js +131 -0
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/adapters/pubsub.js +400 -0
- package/core/queue/adapters/sqs.js +419 -0
- package/core/queue/adapters/wait-until.js +77 -0
- package/core/queue/index.js +8 -0
- package/core/queue/manager.js +66 -41
- package/core/rate-limit/index.js +22 -0
- package/core/rate-limit/limiter.js +219 -0
- package/core/rate-limit/stores/memory.js +129 -0
- package/core/rate-limit/stores/redis.js +151 -0
- package/core/rate-limit/utils.js +112 -0
- package/core/resilience/circuit-breaker.js +422 -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/secrets/index.js +113 -0
- package/core/secrets/manager.js +308 -0
- package/core/secrets/providers/aws.js +288 -0
- package/core/secrets/providers/env.js +44 -0
- package/core/secrets/providers/gcp.js +307 -0
- package/core/secrets/providers/memory.js +76 -0
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +129 -19
- package/core/stream/index.js +19 -0
- package/core/stream/text-stream.js +326 -0
- package/core/studio/index.js +43 -0
- package/core/studio/inspector.js +258 -0
- package/core/studio/server.js +325 -0
- package/core/studio/ui.js +681 -0
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -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/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 +1549 -2
- package/index.js +272 -2
- package/package.json +3 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +13 -6
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/index.js +3 -2
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/index.js +3 -1
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +3 -2
- package/plugins/cors.js +3 -2
- package/plugins/csrf/index.js +9 -12
- package/plugins/csv/index.js +8 -5
- 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/email/index.js +11 -9
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +5 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/queue/index.js +18 -2
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +16 -9
- package/plugins/rls/index.js +170 -0
- package/plugins/seo-checker/index.js +2 -2
- package/plugins/site-analytics/index.js +3 -1
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +12 -5
- package/plugins/upload/gcs-provider.js +148 -0
- package/plugins/upload/index.js +136 -77
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/upload/s3-provider.js +257 -0
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +33 -23
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/plugins/xlsx/index.js +7 -5
- package/src/api/api-loader.js +12 -95
- package/src/api/define-api.js +1 -0
- package/src/app-context.js +41 -5
- package/src/client-runtime/constants.js +10 -0
- package/src/client-runtime/mount.js +2 -3
- package/src/dev/banner.js +6 -2
- package/src/dev/client-gen.js +458 -0
- package/src/discovery/scan-routes.js +5 -2
- package/src/file-router.js +3 -3
- package/src/helpers.js +212 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +13 -154
- package/src/plugin-manager.js +99 -18
- package/src/server.js +335 -133
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +95 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +15 -1
- package/src/services/validator.js +38 -20
- package/src/template/precompile.js +8 -19
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;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Cloud-Ready Database Migration Runner
|
|
3
|
+
* Zero-dependency: Safe pre-flight connectivity retries, stale lock auto-healing,
|
|
4
|
+
* dry-run evaluation, and zero-downtime deployment compatibility for Google Cloud Run & AWS.
|
|
5
|
+
*
|
|
6
|
+
* @module core/orm/migrate-cloud
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { sleep } = require('../utils/time');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wait for database connectivity with exponential backoff
|
|
15
|
+
* Handles Cloud SQL Auth Proxy / AWS RDS Aurora cold-starts
|
|
16
|
+
* @param {import('knex').Knex} knex
|
|
17
|
+
* @param {Object} [options]
|
|
18
|
+
* @param {number} [options.retries=5]
|
|
19
|
+
* @param {number} [options.initialDelayMs=1000]
|
|
20
|
+
* @param {number} [options.maxDelayMs=10000]
|
|
21
|
+
* @param {Function} [options.onRetry]
|
|
22
|
+
* @returns {Promise<boolean>}
|
|
23
|
+
*/
|
|
24
|
+
async function waitForDatabaseConnection(knex, options = {}) {
|
|
25
|
+
const retries = options.retries ?? 5;
|
|
26
|
+
const initialDelay = options.initialDelayMs ?? 1000;
|
|
27
|
+
const maxDelay = options.maxDelayMs ?? 10000;
|
|
28
|
+
const onRetry = options.onRetry || (() => {});
|
|
29
|
+
|
|
30
|
+
let attempt = 0;
|
|
31
|
+
let delay = initialDelay;
|
|
32
|
+
|
|
33
|
+
while (attempt <= retries) {
|
|
34
|
+
try {
|
|
35
|
+
await knex.raw('SELECT 1');
|
|
36
|
+
return true;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
attempt++;
|
|
39
|
+
if (attempt > retries) {
|
|
40
|
+
throw new Error(`Database connection failed after ${retries} attempts: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
onRetry({ attempt, retries, delay, error: err });
|
|
43
|
+
await sleep(delay);
|
|
44
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Safely inspect and release stale migration lock
|
|
53
|
+
* @param {import('knex').Knex} knex
|
|
54
|
+
* @param {Object} [migrationConfig]
|
|
55
|
+
* @returns {Promise<boolean>} True if lock was released
|
|
56
|
+
*/
|
|
57
|
+
async function freeStaleMigrationLock(knex, migrationConfig = {}) {
|
|
58
|
+
try {
|
|
59
|
+
if (knex.migrate && typeof knex.migrate.forceFreeMigrationsLock === 'function') {
|
|
60
|
+
await knex.migrate.forceFreeMigrationsLock(migrationConfig);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get pending and completed migrations without running them
|
|
69
|
+
* @param {import('knex').Knex} knex
|
|
70
|
+
* @param {Object} [migrationConfig]
|
|
71
|
+
* @returns {Promise<{ completed: string[], pending: string[] }>}
|
|
72
|
+
*/
|
|
73
|
+
async function getMigrationStatus(knex, migrationConfig = {}) {
|
|
74
|
+
if (!knex.migrate || typeof knex.migrate.list !== 'function') {
|
|
75
|
+
return { completed: [], pending: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [completedList, pendingList] = await knex.migrate.list(migrationConfig);
|
|
79
|
+
const completed = (completedList || []).map((m) => (typeof m === 'string' ? m : m.name || String(m)));
|
|
80
|
+
const pending = (pendingList || []).map((m) => (typeof m === 'string' ? m : m.name || String(m)));
|
|
81
|
+
|
|
82
|
+
return { completed, pending };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Execute Cloud Migration with safety guarantees
|
|
87
|
+
* @param {import('knex').Knex} knex
|
|
88
|
+
* @param {Object} [options]
|
|
89
|
+
* @param {Object} [options.migrationConfig] - Custom knex migration options
|
|
90
|
+
* @param {boolean} [options.dryRun=false] - Only report pending migrations without executing
|
|
91
|
+
* @param {boolean} [options.unlockIfStale=true] - Force unlock if migrations lock was left hanging
|
|
92
|
+
* @param {boolean} [options.waitForDb=true] - Retry connecting to database before failing
|
|
93
|
+
* @param {number} [options.retries=5] - Connection retries
|
|
94
|
+
* @param {Function} [options.logger] - Custom logger callback
|
|
95
|
+
* @returns {Promise<{
|
|
96
|
+
* success: boolean;
|
|
97
|
+
* batch: number;
|
|
98
|
+
* applied: string[];
|
|
99
|
+
* pendingBefore: string[];
|
|
100
|
+
* durationMs: number;
|
|
101
|
+
* dryRun: boolean;
|
|
102
|
+
* }>}
|
|
103
|
+
*/
|
|
104
|
+
async function runCloudMigration(knex, options = {}) {
|
|
105
|
+
const {
|
|
106
|
+
migrationConfig = {},
|
|
107
|
+
dryRun = false,
|
|
108
|
+
unlockIfStale = true,
|
|
109
|
+
waitForDb = true,
|
|
110
|
+
retries = 5,
|
|
111
|
+
logger = null,
|
|
112
|
+
} = options;
|
|
113
|
+
|
|
114
|
+
const log = (msg) => {
|
|
115
|
+
if (typeof logger === 'function') logger(msg);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const startTime = Date.now();
|
|
119
|
+
|
|
120
|
+
// 1. Connectivity Check & Wait
|
|
121
|
+
if (waitForDb) {
|
|
122
|
+
log('Checking database connectivity...');
|
|
123
|
+
await waitForDatabaseConnection(knex, {
|
|
124
|
+
retries,
|
|
125
|
+
onRetry: ({ attempt, retries: max, delay, error }) => {
|
|
126
|
+
log(`Database not ready (attempt ${attempt}/${max}: ${error.message}). Retrying in ${delay}ms...`);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
log('Database connection established.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Inspect current migration status
|
|
133
|
+
const { completed, pending } = await getMigrationStatus(knex, migrationConfig);
|
|
134
|
+
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
return {
|
|
137
|
+
success: true,
|
|
138
|
+
batch: 0,
|
|
139
|
+
applied: [],
|
|
140
|
+
pendingBefore: pending,
|
|
141
|
+
durationMs: Date.now() - startTime,
|
|
142
|
+
dryRun: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 3. Stale Lock Handling
|
|
147
|
+
if (unlockIfStale) {
|
|
148
|
+
try {
|
|
149
|
+
// Check if locked
|
|
150
|
+
const lockTable = migrationConfig.tableName ? `${migrationConfig.tableName}_lock` : 'knex_migrations_lock';
|
|
151
|
+
const hasLockTable = await knex.schema.hasTable(lockTable);
|
|
152
|
+
if (hasLockTable) {
|
|
153
|
+
const lockRow = await knex(lockTable).first();
|
|
154
|
+
if (lockRow && (lockRow.is_locked === 1 || lockRow.is_locked === true)) {
|
|
155
|
+
log('Warning: Found active migration lock from previous aborted job. Unlocking...');
|
|
156
|
+
await freeStaleMigrationLock(knex, migrationConfig);
|
|
157
|
+
log('Migration lock released successfully.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (lockErr) {
|
|
161
|
+
log(`Note: Lock check skipped (${lockErr.message})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 4. Run Migration
|
|
166
|
+
let batch = 0;
|
|
167
|
+
let applied = [];
|
|
168
|
+
|
|
169
|
+
if (pending.length > 0) {
|
|
170
|
+
log(`Applying ${pending.length} pending migration(s)...`);
|
|
171
|
+
const result = await knex.migrate.latest(migrationConfig);
|
|
172
|
+
batch = result[0];
|
|
173
|
+
applied = result[1] || [];
|
|
174
|
+
log(`Migration batch ${batch} applied successfully (${applied.length} files).`);
|
|
175
|
+
} else {
|
|
176
|
+
log('Database schema is already up to date.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
batch,
|
|
182
|
+
applied,
|
|
183
|
+
pendingBefore: pending,
|
|
184
|
+
durationMs: Date.now() - startTime,
|
|
185
|
+
dryRun: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
waitForDatabaseConnection,
|
|
191
|
+
freeStaleMigrationLock,
|
|
192
|
+
getMigrationStatus,
|
|
193
|
+
runCloudMigration,
|
|
194
|
+
};
|
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,
|
package/core/orm/pool.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serverless & Container Database Connection Pool Management
|
|
3
|
+
* Optimizes Knex pool configurations for Google Cloud Run, AWS Lambda/Fargate,
|
|
4
|
+
* and autoscaling container workloads to prevent database socket exhaustion.
|
|
5
|
+
* @module core/orm/pool
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect if running in a serverless or auto-scaling container environment
|
|
12
|
+
* @param {Record<string, string>} [env=process.env]
|
|
13
|
+
* @returns {{ isServerless: boolean, platform: 'cloudrun'|'aws-lambda'|'ecs'|'serverless'|'standard' }}
|
|
14
|
+
*/
|
|
15
|
+
function isServerlessEnvironment(env = process.env) {
|
|
16
|
+
if (env.K_SERVICE || env.K_REVISION || env.CLOUD_RUN_JOB) {
|
|
17
|
+
return { isServerless: true, platform: 'cloudrun' };
|
|
18
|
+
}
|
|
19
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
20
|
+
return { isServerless: true, platform: 'aws-lambda' };
|
|
21
|
+
}
|
|
22
|
+
if (env.AWS_EXECUTION_ENV && !env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
23
|
+
return { isServerless: true, platform: 'ecs' };
|
|
24
|
+
}
|
|
25
|
+
if (env.SERVERLESS === 'true' || env.WEBSPRESSO_SERVERLESS === 'true' || env.CONTAINER_SCALE === 'true') {
|
|
26
|
+
return { isServerless: true, platform: 'serverless' };
|
|
27
|
+
}
|
|
28
|
+
return { isServerless: false, platform: 'standard' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalize and apply serverless pool protections to Knex configuration
|
|
33
|
+
* @param {Object} knexConfig
|
|
34
|
+
* @param {Object|Record<string, string>} [optionsOrEnv]
|
|
35
|
+
* @returns {Object} Normalized Knex configuration
|
|
36
|
+
*/
|
|
37
|
+
function normalizeServerlessPoolConfig(knexConfig = {}, optionsOrEnv = {}) {
|
|
38
|
+
// Determine if optionsOrEnv is an environment map or options object
|
|
39
|
+
const env = optionsOrEnv && optionsOrEnv.env ? optionsOrEnv.env : (
|
|
40
|
+
(optionsOrEnv.K_SERVICE || optionsOrEnv.K_REVISION || optionsOrEnv.CLOUD_RUN_JOB || optionsOrEnv.AWS_LAMBDA_FUNCTION_NAME || optionsOrEnv.AWS_EXECUTION_ENV || optionsOrEnv.SERVERLESS || optionsOrEnv.WEBSPRESSO_SERVERLESS || optionsOrEnv.CONTAINER_SCALE || optionsOrEnv.NODE_ENV)
|
|
41
|
+
? optionsOrEnv
|
|
42
|
+
: process.env
|
|
43
|
+
);
|
|
44
|
+
const explicitOptions = typeof optionsOrEnv === 'object' && !optionsOrEnv.NODE_ENV ? optionsOrEnv : {};
|
|
45
|
+
|
|
46
|
+
const client = knexConfig.client || 'sqlite3';
|
|
47
|
+
// SQLite and Cloudflare D1 don't use external TCP connection pools
|
|
48
|
+
if (client === 'better-sqlite3' || client === 'sqlite3' || knexConfig.d1) {
|
|
49
|
+
return knexConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { isServerless, platform } = isServerlessEnvironment(env);
|
|
53
|
+
const userPool = knexConfig.pool || {};
|
|
54
|
+
|
|
55
|
+
// Check if serverless pool optimization should be applied
|
|
56
|
+
const isExplicitlyRequested = Boolean(
|
|
57
|
+
knexConfig.serverlessPool === true ||
|
|
58
|
+
userPool.serverless === true ||
|
|
59
|
+
env.WEBSPRESSO_SERVERLESS_POOL === 'true'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const isAutoEnabled = isServerless && knexConfig.serverlessPool !== false && userPool.serverless !== false;
|
|
63
|
+
|
|
64
|
+
if (!isExplicitlyRequested && !isAutoEnabled) {
|
|
65
|
+
return knexConfig;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Serverless pool defaults:
|
|
69
|
+
// In serverless, idle containers shouldn't hold open DB connections: min: 0
|
|
70
|
+
const serverlessMin = explicitOptions.min ?? (userPool.min !== undefined ? userPool.min : 0);
|
|
71
|
+
|
|
72
|
+
// Max connections per container instance should be small (default: 2 for Lambda, 5 for Cloud Run/containers)
|
|
73
|
+
const defaultMax = isServerless && platform === 'aws-lambda' ? 2 : 5;
|
|
74
|
+
const serverlessMax = explicitOptions.max ?? (userPool.max !== undefined ? userPool.max : defaultMax);
|
|
75
|
+
|
|
76
|
+
const normalizedPool = {
|
|
77
|
+
...userPool,
|
|
78
|
+
min: serverlessMin,
|
|
79
|
+
max: serverlessMax,
|
|
80
|
+
idleTimeoutMillis: explicitOptions.idleTimeoutMillis ?? userPool.idleTimeoutMillis ?? 30000,
|
|
81
|
+
acquireTimeoutMillis: explicitOptions.acquireTimeoutMillis ?? userPool.acquireTimeoutMillis ?? 10000,
|
|
82
|
+
createTimeoutMillis: explicitOptions.createTimeoutMillis ?? userPool.createTimeoutMillis ?? 5000,
|
|
83
|
+
destroyTimeoutMillis: explicitOptions.destroyTimeoutMillis ?? userPool.destroyTimeoutMillis ?? 5000,
|
|
84
|
+
propagateCreateError: userPool.propagateCreateError ?? false,
|
|
85
|
+
serverless: true,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...knexConfig,
|
|
90
|
+
pool: normalizedPool,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
isServerlessEnvironment,
|
|
96
|
+
normalizeServerlessPoolConfig,
|
|
97
|
+
};
|
|
@@ -383,12 +383,56 @@ class QueryBuilder {
|
|
|
383
383
|
return this;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Set authenticated user context for RLS
|
|
388
|
+
* @param {Object|null} user - User object
|
|
389
|
+
* @returns {this}
|
|
390
|
+
*/
|
|
391
|
+
forUser(user) {
|
|
392
|
+
this.scopeContext.user = user;
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Bypass Row Level Security on this query
|
|
398
|
+
* @returns {this}
|
|
399
|
+
*/
|
|
400
|
+
withoutRls() {
|
|
401
|
+
this.scopeContext.rlsDisabled = true;
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Apply custom scope context
|
|
407
|
+
* @param {Object} customContext
|
|
408
|
+
* @returns {this}
|
|
409
|
+
*/
|
|
410
|
+
withContext(customContext) {
|
|
411
|
+
Object.assign(this.scopeContext, customContext);
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Explicitly set action mode for RLS
|
|
417
|
+
* @param {'read'|'update'|'delete'|'create'} action
|
|
418
|
+
* @returns {this}
|
|
419
|
+
*/
|
|
420
|
+
asAction(action) {
|
|
421
|
+
this.action = action;
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
|
|
386
425
|
/**
|
|
387
426
|
* Resolve active Knex instance (explicit or ambient transaction)
|
|
388
427
|
* @returns {import('knex').Knex|import('knex').Knex.Transaction}
|
|
389
428
|
*/
|
|
390
429
|
getActiveKnex() {
|
|
391
430
|
if (this.knex && (this.knex.isTransaction || typeof this.knex.commit === 'function')) {
|
|
431
|
+
if (typeof this.knex.isCompleted === 'function' && this.knex.isCompleted()) {
|
|
432
|
+
const ambientTrx = getAmbientTransaction();
|
|
433
|
+
if (ambientTrx) return ambientTrx;
|
|
434
|
+
return this.knex;
|
|
435
|
+
}
|
|
392
436
|
return this.knex;
|
|
393
437
|
}
|
|
394
438
|
const ambientTrx = getAmbientTransaction();
|
|
@@ -404,7 +448,14 @@ class QueryBuilder {
|
|
|
404
448
|
*/
|
|
405
449
|
_hookTrx() {
|
|
406
450
|
const active = this.getActiveKnex();
|
|
407
|
-
|
|
451
|
+
if (!active) return null;
|
|
452
|
+
if (active.isTransaction || typeof active.commit === 'function') {
|
|
453
|
+
if (typeof active.isCompleted === 'function' && active.isCompleted()) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return active;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
408
459
|
}
|
|
409
460
|
|
|
410
461
|
/**
|
|
@@ -414,12 +465,12 @@ class QueryBuilder {
|
|
|
414
465
|
* @returns {import('knex').Knex.QueryBuilder}
|
|
415
466
|
*/
|
|
416
467
|
toKnex(options = {}) {
|
|
417
|
-
const { includeLimitOffset = true } = options;
|
|
468
|
+
const { includeLimitOffset = true, action = this.action || 'read', mutationData = null } = options;
|
|
418
469
|
const activeKnex = this.getActiveKnex();
|
|
419
470
|
let qb = activeKnex(this.model.table);
|
|
420
471
|
|
|
421
|
-
// Apply global scopes
|
|
422
|
-
qb = applyScopes(qb, this.scopeContext, this.model);
|
|
472
|
+
// Apply global scopes (soft delete, multi-tenant, RLS)
|
|
473
|
+
qb = applyScopes(qb, this.scopeContext, this.model, action, mutationData);
|
|
423
474
|
|
|
424
475
|
// Apply selects
|
|
425
476
|
if (this.state.selects.length > 0) {
|
|
@@ -488,6 +539,15 @@ class QueryBuilder {
|
|
|
488
539
|
return qb;
|
|
489
540
|
}
|
|
490
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Compile the query to a SQL string and parameter bindings
|
|
544
|
+
* @param {Object} [options]
|
|
545
|
+
* @returns {{ sql: string, bindings: Array<any>, method: string }}
|
|
546
|
+
*/
|
|
547
|
+
toSQL(options = {}) {
|
|
548
|
+
return this.toKnex(options).toSQL();
|
|
549
|
+
}
|
|
550
|
+
|
|
491
551
|
/**
|
|
492
552
|
* Execute query and return first result
|
|
493
553
|
* @returns {Promise<Object|null>}
|
|
@@ -795,7 +855,7 @@ class QueryBuilder {
|
|
|
795
855
|
* @returns {Promise<number>} Number of deleted records
|
|
796
856
|
*/
|
|
797
857
|
async delete() {
|
|
798
|
-
const n = await this.toKnex().delete();
|
|
858
|
+
const n = await this.toKnex({ action: 'delete' }).delete();
|
|
799
859
|
if (this.cacheLayer && n > 0) {
|
|
800
860
|
this.cacheLayer.invalidateModelAll(this.model);
|
|
801
861
|
}
|
|
@@ -808,8 +868,10 @@ class QueryBuilder {
|
|
|
808
868
|
* @returns {Promise<number>} Number of updated records
|
|
809
869
|
*/
|
|
810
870
|
async update(data) {
|
|
871
|
+
const { checkUpdateRls } = require('./scopes');
|
|
872
|
+
checkUpdateRls(data, this.scopeContext, this.model);
|
|
811
873
|
const serialized = serializeJsonFields(data, this.jsonColumns);
|
|
812
|
-
const n = await this.toKnex().update(serialized);
|
|
874
|
+
const n = await this.toKnex({ action: 'update', mutationData: data }).update(serialized);
|
|
813
875
|
if (this.cacheLayer && n > 0) {
|
|
814
876
|
this.cacheLayer.invalidateModelAll(this.model);
|
|
815
877
|
}
|
|
@@ -839,14 +901,15 @@ class QueryBuilder {
|
|
|
839
901
|
clone() {
|
|
840
902
|
const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext }, this.cacheLayer);
|
|
841
903
|
cloned.jsonColumns = this.jsonColumns;
|
|
904
|
+
cloned.action = this.action;
|
|
842
905
|
cloned.state = {
|
|
843
|
-
wheres:
|
|
844
|
-
orderBys:
|
|
906
|
+
wheres: this.state.wheres.map(w => ({ ...w })),
|
|
907
|
+
orderBys: this.state.orderBys.map(o => ({ ...o })),
|
|
845
908
|
selects: [...this.state.selects],
|
|
846
909
|
withs: [...this.state.withs],
|
|
847
910
|
limitValue: this.state.limitValue,
|
|
848
911
|
offsetValue: this.state.offsetValue,
|
|
849
|
-
scopeContext:
|
|
912
|
+
scopeContext: cloned.scopeContext,
|
|
850
913
|
};
|
|
851
914
|
return cloned;
|
|
852
915
|
}
|