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.
Files changed (233) hide show
  1. package/.agents/AGENTS.md +21 -2
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +220 -8
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/FLAGS.md +118 -0
  7. package/.agents/HEALTH.md +118 -0
  8. package/.agents/LIMITS.md +109 -0
  9. package/.agents/LOGGING.md +93 -0
  10. package/.agents/METRICS.md +127 -0
  11. package/.agents/MIGRATION_JOBS.md +95 -0
  12. package/.agents/ORM.md +155 -1
  13. package/.agents/PLUGINS.md +38 -3
  14. package/.agents/QUEUE.md +42 -0
  15. package/.agents/RATE_LIMIT.md +147 -0
  16. package/.agents/RESILIENCE.md +235 -0
  17. package/.agents/SCALE_OUT.md +109 -0
  18. package/.agents/SECRETS.md +117 -0
  19. package/AGENTS.md +16 -0
  20. package/README.md +2 -2
  21. package/bin/commands/add-aws.js +396 -0
  22. package/bin/commands/add-ci.js +523 -0
  23. package/bin/commands/add-cloudflare.js +240 -0
  24. package/bin/commands/add-cloudrun.js +381 -0
  25. package/bin/commands/add-docker.js +6 -6
  26. package/bin/commands/add-migration-job.js +396 -0
  27. package/bin/commands/assets-sync.js +209 -0
  28. package/bin/commands/client-generate.js +52 -0
  29. package/bin/commands/console.js +53 -0
  30. package/bin/commands/db-migrate.js +47 -2
  31. package/bin/commands/db-studio.js +84 -0
  32. package/bin/commands/deploy-check.js +242 -13
  33. package/bin/commands/dev.js +1 -1
  34. package/bin/commands/export.js +87 -0
  35. package/bin/commands/new.js +5 -5
  36. package/bin/commands/openapi.js +1 -1
  37. package/bin/commands/secrets-pull.js +208 -0
  38. package/bin/commands/start.js +1 -1
  39. package/bin/utils/db.js +7 -1
  40. package/bin/utils/project.js +2 -2
  41. package/bin/webspresso.js +22 -0
  42. package/core/app.js +421 -0
  43. package/core/auth/authenticate.js +93 -0
  44. package/core/auth/hash.js +5 -4
  45. package/core/auth/index.js +34 -4
  46. package/core/auth/jwt.js +5 -54
  47. package/core/auth/magic-link.js +4 -5
  48. package/core/auth/manager.js +53 -10
  49. package/core/auth/middleware.js +16 -68
  50. package/core/auth/oauth.js +8 -17
  51. package/core/auth/redis-session.js +1008 -0
  52. package/core/auth/sessions.js +156 -0
  53. package/core/auth/totp.js +2 -1
  54. package/core/cache/byte-size.js +123 -0
  55. package/core/cache/cdn.js +296 -0
  56. package/core/cache/index.js +33 -0
  57. package/core/cache/lru.js +477 -0
  58. package/core/cache/tagged-lru.js +354 -0
  59. package/core/commands/registry.js +6 -20
  60. package/core/console/commands.js +148 -0
  61. package/core/console/context.js +260 -0
  62. package/core/console/eval.js +70 -0
  63. package/core/console/index.js +22 -0
  64. package/core/console/repl.js +102 -0
  65. package/core/early-hints/index.js +267 -0
  66. package/core/errors/domain.js +49 -0
  67. package/core/errors/http.js +31 -0
  68. package/core/errors/index.js +10 -0
  69. package/core/errors/normalize.js +88 -2
  70. package/core/events/discovery.js +6 -24
  71. package/core/events/dispatcher.js +9 -7
  72. package/core/events/signal.js +7 -0
  73. package/core/export/discovery.js +191 -0
  74. package/core/export/exporter.js +277 -0
  75. package/core/export/index.js +24 -0
  76. package/core/flags/evaluator.js +252 -0
  77. package/core/flags/hasher.js +37 -0
  78. package/core/flags/index.js +21 -0
  79. package/core/flags/manager.js +263 -0
  80. package/core/flags/store.js +126 -0
  81. package/core/health/index.js +41 -0
  82. package/core/health/middleware.js +213 -0
  83. package/core/health/normalize.js +140 -0
  84. package/core/health/probes.js +203 -0
  85. package/core/http/adapter.js +74 -0
  86. package/core/http/express-adapter.js +46 -0
  87. package/core/http/hono-adapter.js +531 -0
  88. package/core/http/index.js +41 -0
  89. package/core/http/pipeline.js +485 -0
  90. package/core/index.js +336 -0
  91. package/core/limits/index.js +34 -0
  92. package/core/limits/middleware.js +325 -0
  93. package/core/limits/normalize.js +222 -0
  94. package/core/logger/formatters.js +264 -0
  95. package/core/logger/index.js +260 -0
  96. package/core/logger/trace.js +137 -0
  97. package/core/metrics/collectors.js +125 -0
  98. package/core/metrics/index.js +45 -0
  99. package/core/metrics/middleware.js +230 -0
  100. package/core/metrics/registry.js +366 -0
  101. package/core/orm/batch.js +70 -0
  102. package/core/orm/cache/fingerprint.js +2 -2
  103. package/core/orm/cache/memory-provider.js +27 -110
  104. package/core/orm/d1.js +265 -0
  105. package/core/orm/index.js +176 -13
  106. package/core/orm/json-fields.js +22 -8
  107. package/core/orm/migrate-cloud.js +194 -0
  108. package/core/orm/model.js +10 -0
  109. package/core/orm/pool.js +97 -0
  110. package/core/orm/query-builder.js +72 -9
  111. package/core/orm/repository.js +318 -19
  112. package/core/orm/schema-helpers.js +8 -10
  113. package/core/orm/scopes.js +321 -1
  114. package/core/orm/transaction.js +10 -2
  115. package/core/orm/utils.js +4 -86
  116. package/core/queue/adapters/cloudflare.js +131 -0
  117. package/core/queue/adapters/database.js +4 -8
  118. package/core/queue/adapters/pubsub.js +400 -0
  119. package/core/queue/adapters/sqs.js +419 -0
  120. package/core/queue/adapters/wait-until.js +77 -0
  121. package/core/queue/index.js +8 -0
  122. package/core/queue/manager.js +66 -41
  123. package/core/rate-limit/index.js +22 -0
  124. package/core/rate-limit/limiter.js +219 -0
  125. package/core/rate-limit/stores/memory.js +129 -0
  126. package/core/rate-limit/stores/redis.js +151 -0
  127. package/core/rate-limit/utils.js +112 -0
  128. package/core/resilience/circuit-breaker.js +422 -0
  129. package/core/resilience/errors.js +63 -0
  130. package/core/resilience/index.js +53 -0
  131. package/core/resilience/registry.js +97 -0
  132. package/core/resilience/retry.js +203 -0
  133. package/core/schema-cache.js +9 -2
  134. package/core/secrets/index.js +113 -0
  135. package/core/secrets/manager.js +308 -0
  136. package/core/secrets/providers/aws.js +288 -0
  137. package/core/secrets/providers/env.js +44 -0
  138. package/core/secrets/providers/gcp.js +307 -0
  139. package/core/secrets/providers/memory.js +76 -0
  140. package/core/shutdown/http-adapter.js +4 -1
  141. package/core/shutdown/shutdown-manager.js +129 -19
  142. package/core/stream/index.js +19 -0
  143. package/core/stream/text-stream.js +326 -0
  144. package/core/studio/index.js +43 -0
  145. package/core/studio/inspector.js +258 -0
  146. package/core/studio/server.js +325 -0
  147. package/core/studio/ui.js +681 -0
  148. package/core/utils/array.js +12 -0
  149. package/core/utils/base64url.js +8 -0
  150. package/core/utils/crypto.js +118 -0
  151. package/core/utils/ensure-leading-slash.js +6 -0
  152. package/core/utils/escape-regexp.js +7 -0
  153. package/core/utils/fetch.js +6 -5
  154. package/core/utils/fs.js +144 -0
  155. package/core/utils/hmac.js +6 -0
  156. package/core/utils/json.js +98 -0
  157. package/core/utils/object.js +106 -0
  158. package/core/utils/random.js +9 -0
  159. package/core/utils/regex.js +77 -0
  160. package/core/utils/sha256.js +6 -0
  161. package/core/utils/string.js +87 -0
  162. package/core/utils/strip-trailing-slash.js +6 -0
  163. package/core/utils/time.js +96 -0
  164. package/core/utils/timeout.js +348 -0
  165. package/core/utils/url.js +106 -0
  166. package/core/utils/walk.js +7 -0
  167. package/index.d.ts +1549 -2
  168. package/index.js +272 -2
  169. package/package.json +3 -2
  170. package/plugins/admin-panel/core/api-extensions.js +2 -5
  171. package/plugins/admin-panel/index.js +13 -6
  172. package/plugins/admin-panel/modules/menu.js +1 -1
  173. package/plugins/api-key/crypto.js +3 -3
  174. package/plugins/api-key/index.js +3 -2
  175. package/plugins/api-key/scopes.js +5 -7
  176. package/plugins/audit-log/api-handlers.js +6 -15
  177. package/plugins/audit-log/index.js +3 -1
  178. package/plugins/audit-log/parse.js +3 -3
  179. package/plugins/basic-auth/index.js +3 -2
  180. package/plugins/cors.js +3 -2
  181. package/plugins/csrf/index.js +9 -12
  182. package/plugins/csv/index.js +8 -5
  183. package/plugins/dashboard/app.js +2 -2
  184. package/plugins/dashboard/index.js +5 -5
  185. package/plugins/data-exchange/record-selection.js +2 -5
  186. package/plugins/email/api-handlers.js +5 -18
  187. package/plugins/email/auth-bridge.js +3 -2
  188. package/plugins/email/index.js +11 -9
  189. package/plugins/health-check.js +2 -1
  190. package/plugins/index.js +5 -0
  191. package/plugins/lazy.js +5 -6
  192. package/plugins/mcp/discovery.js +18 -17
  193. package/plugins/polar/src/api.js +2 -1
  194. package/plugins/polar/src/webhooks.js +5 -6
  195. package/plugins/queue/index.js +18 -2
  196. package/plugins/rate-limit/index.js +3 -1
  197. package/plugins/redirect/index.js +16 -9
  198. package/plugins/rls/index.js +170 -0
  199. package/plugins/seo-checker/index.js +2 -2
  200. package/plugins/site-analytics/index.js +3 -1
  201. package/plugins/site-analytics/tracking.js +2 -2
  202. package/plugins/sitemap.js +3 -2
  203. package/plugins/swagger.js +12 -5
  204. package/plugins/upload/gcs-provider.js +148 -0
  205. package/plugins/upload/index.js +136 -77
  206. package/plugins/upload/orphan-pruner.js +4 -3
  207. package/plugins/upload/s3-provider.js +257 -0
  208. package/plugins/webhooks/crypto.js +6 -11
  209. package/plugins/webhooks/dispatcher.js +8 -8
  210. package/plugins/webhooks/index.js +33 -23
  211. package/plugins/webhooks/matcher.js +7 -11
  212. package/plugins/webhooks/sender.js +8 -11
  213. package/plugins/xlsx/index.js +7 -5
  214. package/src/api/api-loader.js +12 -95
  215. package/src/api/define-api.js +1 -0
  216. package/src/app-context.js +41 -5
  217. package/src/client-runtime/constants.js +10 -0
  218. package/src/client-runtime/mount.js +2 -3
  219. package/src/dev/banner.js +6 -2
  220. package/src/dev/client-gen.js +458 -0
  221. package/src/discovery/scan-routes.js +5 -2
  222. package/src/file-router.js +3 -3
  223. package/src/helpers.js +212 -24
  224. package/src/modules/module-discovery.js +1 -9
  225. package/src/pages/page-loader.js +13 -154
  226. package/src/plugin-manager.js +99 -18
  227. package/src/server.js +335 -133
  228. package/src/services/discovery.js +19 -40
  229. package/src/services/executor.js +95 -25
  230. package/src/services/memoize.js +3 -25
  231. package/src/services/registry.js +15 -1
  232. package/src/services/validator.js +38 -20
  233. package/src/template/precompile.js +8 -19
@@ -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;
@@ -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,
@@ -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
- return (active && (active.isTransaction || typeof active.commit === 'function')) ? active : null;
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: [...this.state.wheres],
844
- orderBys: [...this.state.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: { ...this.state.scopeContext },
912
+ scopeContext: cloned.scopeContext,
850
913
  };
851
914
  return cloned;
852
915
  }