@getstrata/core 1.1.9 → 2.0.1

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 (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/core/database/transaction.d.ts +2 -1
  3. package/dist/core/database/transactionControl.d.ts +5 -0
  4. package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
  5. package/dist/core/http/metricsMiddleware.d.ts +1 -2
  6. package/dist/core/http/requestMetaContext.d.ts +2 -0
  7. package/dist/core/http/throttleMiddleware.d.ts +11 -2
  8. package/dist/core/metrics/prometheus.d.ts +11 -1
  9. package/dist/core/queue/jobRunner.d.ts +3 -1
  10. package/dist/core/queue/redisQueue.d.ts +20 -3
  11. package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
  12. package/dist/entries/audit/exportAuditLogs.js +522 -62
  13. package/dist/entries/auth/basicAuthGuard.js +245 -219
  14. package/dist/entries/auth/oauth/samlProvider.js +185 -161
  15. package/dist/entries/auth/oneTimeToken.js +130 -127
  16. package/dist/entries/auth/passwordLogin.js +185 -180
  17. package/dist/entries/auth/saml/samlServiceProvider.js +185 -161
  18. package/dist/entries/auth/scimAuthMiddleware.js +525 -11
  19. package/dist/entries/database/migrations.js +132 -129
  20. package/dist/entries/database/schema.js +58 -39
  21. package/dist/entries/http/metricsMiddleware.js +124 -27
  22. package/dist/entries/http/response.js +107 -0
  23. package/dist/entries/http/routeMiddleware.js +40 -0
  24. package/dist/entries/http/throttleMiddleware.js +124 -11
  25. package/dist/entries/jobs/exportAuditLogsJob.js +522 -62
  26. package/dist/entries/metrics/prometheus.js +71 -11
  27. package/dist/entries/queue/createAppQueue.js +215 -21
  28. package/dist/entries/queue/jobRunner.js +3 -2
  29. package/dist/entries/queue/publicQueue.js +215 -21
  30. package/dist/entries/queue/queueMetrics.js +215 -21
  31. package/dist/entries/queue/redisQueue.js +223 -22
  32. package/dist/entries/tenant/databaseTenantContext.js +477 -16
  33. package/dist/entries/tenant/tenantDatabaseScope.js +523 -9
  34. package/dist/framework/public-api.d.ts +2 -2
  35. package/dist/index.js +954 -457
  36. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -26,6 +26,31 @@ function createAsyncContextStore(key) {
26
26
  }
27
27
  var init_asyncContextStore = () => {};
28
28
 
29
+ // ../../src/core/database/boundConnection.ts
30
+ function boundConnectionState() {
31
+ const globalRecord = globalThis;
32
+ const existing = globalRecord[BOUND_CONNECTION_KEY];
33
+ if (existing) {
34
+ return existing;
35
+ }
36
+ const created = { connection: null };
37
+ globalRecord[BOUND_CONNECTION_KEY] = created;
38
+ return created;
39
+ }
40
+ function bindDatabaseConnection(connection) {
41
+ boundConnectionState().connection = connection;
42
+ }
43
+ function getBoundDatabaseConnection() {
44
+ return boundConnectionState().connection;
45
+ }
46
+ function resetBoundDatabaseConnection() {
47
+ boundConnectionState().connection = null;
48
+ }
49
+ var BOUND_CONNECTION_KEY;
50
+ var init_boundConnection = __esm(() => {
51
+ BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
52
+ });
53
+
29
54
  // ../../src/core/database/connectionContext.ts
30
55
  function runWithDatabaseConnection(connection, callback) {
31
56
  return activeConnection.run(connection, callback);
@@ -111,6 +136,202 @@ var init_defaultConnection = __esm(() => {
111
136
  DEFAULT_CONNECTION_KEY = Symbol.for("@getstrata/defaultDatabaseConnection");
112
137
  });
113
138
 
139
+ // ../../src/core/database/schema/driver.ts
140
+ function normalizeConnectionName(connection) {
141
+ const normalized = connection.trim().toLowerCase();
142
+ if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
143
+ return "pgsql";
144
+ }
145
+ if (normalized === "mysql" || normalized === "mariadb") {
146
+ return "mysql";
147
+ }
148
+ if (normalized === "sqlite") {
149
+ return "sqlite";
150
+ }
151
+ throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
152
+ }
153
+ function resolveDriverFromUrl(url) {
154
+ const normalized = url.trim().toLowerCase();
155
+ if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
156
+ return "pgsql";
157
+ }
158
+ if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
159
+ return "mysql";
160
+ }
161
+ if (normalized.startsWith("sqlite:")) {
162
+ return "sqlite";
163
+ }
164
+ return null;
165
+ }
166
+ function resolveDatabaseDriver(options = {}) {
167
+ const connection = options.connection ?? process.env.DB_CONNECTION;
168
+ if (connection) {
169
+ return normalizeConnectionName(connection);
170
+ }
171
+ const url = options.url ?? process.env.DATABASE_URL ?? "";
172
+ const fromUrl = resolveDriverFromUrl(url);
173
+ if (fromUrl) {
174
+ return fromUrl;
175
+ }
176
+ return "pgsql";
177
+ }
178
+ var init_driver = () => {};
179
+
180
+ // ../../src/core/database/dialect.ts
181
+ function assertSafeIdentifier(identifier) {
182
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
183
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
184
+ }
185
+ return identifier;
186
+ }
187
+ function quoteIdentifierFor(dialect, column) {
188
+ return dialect.quoteIdentifier(column);
189
+ }
190
+ function dialectFor(driver) {
191
+ return dialects[driver];
192
+ }
193
+ function currentSqlDialect() {
194
+ return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
195
+ }
196
+ function useSqlDialect(driver) {
197
+ dialectOverride = dialectFor(driver);
198
+ return dialectOverride;
199
+ }
200
+ function sqlTimestamp(value = new Date) {
201
+ return currentSqlDialect().timestampValue(value);
202
+ }
203
+ function resetSqlDialect() {
204
+ dialectOverride = null;
205
+ }
206
+ function runWithSqlDialect(driver, callback) {
207
+ return dialectContext.run(dialectFor(driver), callback);
208
+ }
209
+ var postgresDialect, mysqlDialect, sqliteDialect, dialects, dialectContext, dialectOverride = null;
210
+ var init_dialect = __esm(() => {
211
+ init_asyncContextStore();
212
+ init_driver();
213
+ postgresDialect = {
214
+ driver: "pgsql",
215
+ placeholder(index) {
216
+ return `$${index}`;
217
+ },
218
+ quoteIdentifier(identifier) {
219
+ return `"${assertSafeIdentifier(identifier)}"`;
220
+ },
221
+ nowExpression() {
222
+ return "NOW()";
223
+ },
224
+ timestampValue(value) {
225
+ return value.toISOString();
226
+ },
227
+ returningClause(columns) {
228
+ return ` RETURNING ${columns}`;
229
+ },
230
+ ilikeOperator() {
231
+ return "ILIKE";
232
+ },
233
+ nullsLastSuffix() {
234
+ return " NULLS LAST";
235
+ },
236
+ castToText(expression) {
237
+ return `${expression}::text`;
238
+ },
239
+ upsertSuffix(conflictColumns, updateColumns) {
240
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
241
+ if (updateColumns.length === 0) {
242
+ return ` ON CONFLICT (${target}) DO NOTHING`;
243
+ }
244
+ const assignments = updateColumns.map((column) => {
245
+ const quoted = quoteIdentifierFor(this, column);
246
+ return `${quoted} = excluded.${quoted}`;
247
+ }).join(", ");
248
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
249
+ }
250
+ };
251
+ mysqlDialect = {
252
+ driver: "mysql",
253
+ placeholder() {
254
+ return "?";
255
+ },
256
+ quoteIdentifier(identifier) {
257
+ return `\`${assertSafeIdentifier(identifier)}\``;
258
+ },
259
+ nowExpression() {
260
+ return "CURRENT_TIMESTAMP";
261
+ },
262
+ timestampValue(value) {
263
+ return value.toISOString().slice(0, 19).replace("T", " ");
264
+ },
265
+ returningClause() {
266
+ return "";
267
+ },
268
+ ilikeOperator() {
269
+ return "LIKE";
270
+ },
271
+ nullsLastSuffix() {
272
+ return "";
273
+ },
274
+ castToText(expression) {
275
+ return `CAST(${expression} AS CHAR)`;
276
+ },
277
+ upsertSuffix(conflictColumns, updateColumns) {
278
+ if (updateColumns.length === 0) {
279
+ const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
280
+ return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
281
+ }
282
+ const assignments = updateColumns.map((column) => {
283
+ const quoted = quoteIdentifierFor(this, column);
284
+ return `${quoted} = VALUES(${quoted})`;
285
+ }).join(", ");
286
+ return ` ON DUPLICATE KEY UPDATE ${assignments}`;
287
+ }
288
+ };
289
+ sqliteDialect = {
290
+ driver: "sqlite",
291
+ placeholder() {
292
+ return "?";
293
+ },
294
+ quoteIdentifier(identifier) {
295
+ return `"${assertSafeIdentifier(identifier)}"`;
296
+ },
297
+ nowExpression() {
298
+ return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
299
+ },
300
+ timestampValue(value) {
301
+ return value.toISOString();
302
+ },
303
+ returningClause(columns) {
304
+ return ` RETURNING ${columns}`;
305
+ },
306
+ ilikeOperator() {
307
+ return "LIKE";
308
+ },
309
+ nullsLastSuffix() {
310
+ return "";
311
+ },
312
+ castToText(expression) {
313
+ return `CAST(${expression} AS TEXT)`;
314
+ },
315
+ upsertSuffix(conflictColumns, updateColumns) {
316
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
317
+ if (updateColumns.length === 0) {
318
+ return ` ON CONFLICT (${target}) DO NOTHING`;
319
+ }
320
+ const assignments = updateColumns.map((column) => {
321
+ const quoted = quoteIdentifierFor(this, column);
322
+ return `${quoted} = excluded.${quoted}`;
323
+ }).join(", ");
324
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
325
+ }
326
+ };
327
+ dialects = {
328
+ pgsql: postgresDialect,
329
+ mysql: mysqlDialect,
330
+ sqlite: sqliteDialect
331
+ };
332
+ dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
333
+ });
334
+
114
335
  // ../../src/core/tenant/tenantContext.ts
115
336
  function runWithTenant(tenant, callback) {
116
337
  return tenantContext.run(tenant, callback);
@@ -141,6 +362,58 @@ var init_tenantContext = __esm(() => {
141
362
  tenantContext = createAsyncContextStore("@getstrata/tenantContext");
142
363
  });
143
364
 
365
+ // ../../src/core/database/repositoryConnection.ts
366
+ function resolveRepositoryConnection() {
367
+ return getActiveDatabaseConnection(getBoundDatabaseConnection() ?? getDefaultDatabaseQuery());
368
+ }
369
+ function compileTaggedSql(strings, values) {
370
+ const dialect = currentSqlDialect();
371
+ let query = strings[0] ?? "";
372
+ const params = [];
373
+ for (let index = 0;index < values.length; index += 1) {
374
+ params.push(values[index]);
375
+ query += `${dialect.placeholder(params.length)}${strings[index + 1] ?? ""}`;
376
+ }
377
+ return { query, params };
378
+ }
379
+ function isTaggedStrings(value) {
380
+ if (!Array.isArray(value) || typeof value[0] !== "string") {
381
+ return false;
382
+ }
383
+ return typeof value.raw !== "undefined";
384
+ }
385
+ function runOnConnection(connection, args) {
386
+ if (typeof connection === "function") {
387
+ return connection(...args);
388
+ }
389
+ if (typeof connection.unsafe !== "function") {
390
+ throw new Error("Database connection cannot run SQL.");
391
+ }
392
+ const strings = args[0];
393
+ if (!isTaggedStrings(strings)) {
394
+ throw new Error("Database connection cannot run tagged SQL.");
395
+ }
396
+ const compiled = compileTaggedSql(strings, args.slice(1));
397
+ return connection.unsafe(compiled.query, compiled.params);
398
+ }
399
+ var repositoryConnection;
400
+ var init_repositoryConnection = __esm(() => {
401
+ init_boundConnection();
402
+ init_connectionContext();
403
+ init_defaultConnection();
404
+ init_dialect();
405
+ repositoryConnection = new Proxy(function repositoryConnection() {}, {
406
+ apply(_target, _thisArg, args) {
407
+ return runOnConnection(resolveRepositoryConnection(), args);
408
+ },
409
+ get(_target, property) {
410
+ const connection = resolveRepositoryConnection();
411
+ const value = connection[property];
412
+ return typeof value === "function" ? value.bind(connection) : value;
413
+ }
414
+ });
415
+ });
416
+
144
417
  // ../../src/core/tenant/tenancyConfig.ts
145
418
  function readTenancyDriver(env = process.env) {
146
419
  const raw = env.TENANCY_DRIVER?.trim();
@@ -163,6 +436,80 @@ var init_tenancyConfig = __esm(() => {
163
436
  TENANCY_DRIVERS = ["rls", "column", "none"];
164
437
  });
165
438
 
439
+ // ../../src/core/database/transactionControl.ts
440
+ function unwrapTransactionRollback(error, depth = 0) {
441
+ if (error instanceof TransactionRollback) {
442
+ return error;
443
+ }
444
+ if (depth >= 5 || !(error instanceof Error) || error.cause === undefined) {
445
+ return null;
446
+ }
447
+ return unwrapTransactionRollback(error.cause, depth + 1);
448
+ }
449
+ function requestTransactionRollback() {
450
+ const state = transactionScopes.getStore();
451
+ const index = state ? state.rollback.length - 1 : -1;
452
+ if (!state || index < 0) {
453
+ return;
454
+ }
455
+ const flags = state.rollback;
456
+ const current = flags[index];
457
+ if (current === undefined) {
458
+ return;
459
+ }
460
+ flags[index] = true;
461
+ }
462
+ function scopeRequestsRollback() {
463
+ const state = transactionScopes.getStore();
464
+ if (!state || state.rollback.length === 0) {
465
+ return false;
466
+ }
467
+ return state.rollback[state.rollback.length - 1] === true;
468
+ }
469
+ async function commitOrRollbackScope(result) {
470
+ if (scopeRequestsRollback()) {
471
+ throw new TransactionRollback(result);
472
+ }
473
+ return result;
474
+ }
475
+ async function settleTransaction(work) {
476
+ try {
477
+ return await work();
478
+ } catch (error) {
479
+ const rollback = unwrapTransactionRollback(error);
480
+ if (rollback) {
481
+ return rollback.result;
482
+ }
483
+ throw error;
484
+ }
485
+ }
486
+ async function runWithTransactionScope(callback) {
487
+ const existing = transactionScopes.getStore();
488
+ if (existing) {
489
+ existing.rollback.push(false);
490
+ try {
491
+ return await callback();
492
+ } finally {
493
+ existing.rollback.pop();
494
+ }
495
+ }
496
+ const state = { rollback: [false] };
497
+ return await transactionScopes.run(state, callback);
498
+ }
499
+ var TransactionRollback, transactionScopes;
500
+ var init_transactionControl = __esm(() => {
501
+ init_asyncContextStore();
502
+ TransactionRollback = class TransactionRollback extends Error {
503
+ result;
504
+ constructor(result) {
505
+ super("Transaction rolled back");
506
+ this.name = "TransactionRollback";
507
+ this.result = result;
508
+ }
509
+ };
510
+ transactionScopes = createAsyncContextStore("@getstrata/transactionScopes");
511
+ });
512
+
166
513
  // ../../src/core/events/eventBus.ts
167
514
  class EventBus {
168
515
  constructor() {}
@@ -294,33 +641,171 @@ var init_deferredModelEvents = __esm(() => {
294
641
  deferredModelEvents = createAsyncContextStore("@getstrata/deferredModelEvents");
295
642
  });
296
643
 
644
+ // ../../src/core/database/connection.ts
645
+ function createDatabaseConnection(source) {
646
+ return {
647
+ async unsafe(query, params = []) {
648
+ return await source.unsafe(query, params);
649
+ }
650
+ };
651
+ }
652
+ var init_connection = () => {};
653
+
654
+ // ../../src/core/database/transaction.ts
655
+ async function runInSavepoint(connection, operation) {
656
+ const inherited = transactionFrame.getStore();
657
+ let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
658
+ if (!parent) {
659
+ parent = { connection, childActive: false };
660
+ outerFrames.set(connection, parent);
661
+ }
662
+ if (parent.childActive) {
663
+ throw new Error("Concurrent nested transactions on one connection are not supported. Await each transaction before starting the next.");
664
+ }
665
+ parent.childActive = true;
666
+ const name = `strata_${crypto.randomUUID().replaceAll("-", "")}`;
667
+ try {
668
+ return await settleTransaction(() => runWithDeferredModelEvents(async () => {
669
+ await connection.unsafe(`SAVEPOINT ${name}`);
670
+ try {
671
+ const result = await transactionFrame.run({ connection, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await operation(createDatabaseConnection(connection)))));
672
+ await connection.unsafe(`RELEASE SAVEPOINT ${name}`);
673
+ return result;
674
+ } catch (error) {
675
+ await connection.unsafe(`ROLLBACK TO SAVEPOINT ${name}`);
676
+ await connection.unsafe(`RELEASE SAVEPOINT ${name}`);
677
+ throw error;
678
+ }
679
+ }));
680
+ } finally {
681
+ parent.childActive = false;
682
+ }
683
+ }
684
+ function supportsTransactions(connection) {
685
+ return typeof connection.begin === "function";
686
+ }
687
+ async function runInTransaction(operation) {
688
+ const pool = resolveRepositoryConnection();
689
+ if (hasActiveDatabaseConnection()) {
690
+ return await runInSavepoint(getActiveDatabaseConnection(pool), operation);
691
+ }
692
+ if (!supportsTransactions(pool)) {
693
+ throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
694
+ }
695
+ return await settleTransaction(() => runWithDeferredModelEvents(async () => {
696
+ return await pool.begin(async (transaction) => {
697
+ return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await operation(createDatabaseConnection(transaction))))));
698
+ });
699
+ }));
700
+ }
701
+ var transactionFrame, outerFrames;
702
+ var init_transaction = __esm(() => {
703
+ init_deferredModelEvents();
704
+ init_asyncContextStore();
705
+ init_connection();
706
+ init_connectionContext();
707
+ init_repositoryConnection();
708
+ init_transactionControl();
709
+ transactionFrame = createAsyncContextStore("@getstrata/transactionFrame");
710
+ outerFrames = new WeakMap;
711
+ });
712
+
713
+ // ../../src/core/tenant/databaseTenantContext.ts
714
+ function stringifyBypassIdentifier(identifier) {
715
+ if (typeof identifier === "number") {
716
+ return String(identifier);
717
+ }
718
+ return identifier.trim();
719
+ }
720
+ async function applyIdentifierToTransaction(transaction, identifier) {
721
+ await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
722
+ stringifyBypassIdentifier(identifier)
723
+ ]);
724
+ }
725
+ function assertBypassIdentifier(identifier) {
726
+ if (typeof identifier === "number") {
727
+ if (!Number.isInteger(identifier) || identifier <= 0) {
728
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
729
+ }
730
+ return;
731
+ }
732
+ if (typeof identifier === "string" && identifier.trim() !== "") {
733
+ return;
734
+ }
735
+ throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
736
+ }
737
+ async function runWithScopedTenantTransaction(apply, callback) {
738
+ if (!isRlsTenancy()) {
739
+ return await callback();
740
+ }
741
+ if (hasActiveDatabaseConnection()) {
742
+ const connection = getActiveDatabaseConnection(getDefaultDatabasePool());
743
+ return await runInTransaction(async () => {
744
+ const [previous] = await connection.unsafe(`SELECT current_setting('app.tenant_id', true) AS tenant_id,
745
+ current_setting('app.bypass_rls', true) AS bypass_rls,
746
+ current_setting('app.bypass_identifier', true) AS bypass_identifier`);
747
+ await apply(connection);
748
+ const result = await callback();
749
+ await connection.unsafe(`SELECT set_config('app.tenant_id', $1, true),
750
+ set_config('app.bypass_rls', $2, true), set_config('app.bypass_identifier', $3, true)`, [previous?.tenant_id ?? "", previous?.bypass_rls ?? "", previous?.bypass_identifier ?? ""]);
751
+ return result;
752
+ });
753
+ }
754
+ const pool = getDefaultDatabasePool();
755
+ const begin = pool.begin;
756
+ if (typeof begin !== "function") {
757
+ throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
758
+ }
759
+ return await settleTransaction(() => runWithDeferredModelEvents(async () => {
760
+ return await begin.bind(pool)(async (transaction) => {
761
+ await apply(transaction);
762
+ return await runWithDatabaseConnection(transaction, () => runWithTransactionScope(async () => commitOrRollbackScope(await callback())));
763
+ });
764
+ }));
765
+ }
766
+ async function runWithMigrationBypassForIdentifier(identifier, callback) {
767
+ assertBypassIdentifier(identifier);
768
+ return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
769
+ }
770
+ var init_databaseTenantContext = __esm(() => {
771
+ init_defaultConnection();
772
+ init_connectionContext();
773
+ init_transaction();
774
+ init_transactionControl();
775
+ init_deferredModelEvents();
776
+ init_tenancyConfig();
777
+ });
778
+
297
779
  // ../../src/core/tenant/tenantDatabaseScope.ts
298
780
  async function applyTenantContextToTransaction(transaction, tenantId) {
299
781
  await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
300
782
  await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
301
783
  }
302
784
  async function runWithTenantDatabase(tenant, callback) {
785
+ const outerTenant = currentTenant();
786
+ if (hasActiveDatabaseConnection() && outerTenant && outerTenant.id !== tenant.id) {
787
+ throw new Error("Cannot switch tenants inside an active database transaction.");
788
+ }
303
789
  if (!isTenancyEnabled() || !isRlsTenancy()) {
304
790
  return await runWithTenant(tenant, callback);
305
791
  }
306
792
  if (hasActiveDatabaseConnection()) {
307
793
  const activeConnection = getActiveDatabaseConnection(getDefaultDatabasePool());
308
- await applyTenantContextToTransaction(activeConnection, tenant.id);
309
- return await runWithTenant(tenant, callback);
794
+ return await runWithScopedTenantTransaction(() => applyTenantContextToTransaction(activeConnection, tenant.id), () => runWithTenant(tenant, callback));
310
795
  }
311
796
  const pool = getDefaultDatabasePool();
312
797
  const begin = pool.begin;
313
798
  if (typeof begin !== "function") {
314
799
  throw new Error("RLS tenant scope requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
315
800
  }
316
- return await runWithDeferredModelEvents(async () => {
317
- return await begin(async (transaction) => {
801
+ return await settleTransaction(() => runWithDeferredModelEvents(async () => {
802
+ return await begin.bind(pool)(async (transaction) => {
318
803
  await applyTenantContextToTransaction(transaction, tenant.id);
319
804
  return await runWithDatabaseConnection(transaction, async () => {
320
- return await runWithTenant(tenant, callback);
805
+ return await runWithTransactionScope(async () => commitOrRollbackScope(await runWithTenant(tenant, callback)));
321
806
  });
322
807
  });
323
- });
808
+ }));
324
809
  }
325
810
  function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
326
811
  return hasActiveDatabaseConnection() && currentTenant()?.id === tenantId;
@@ -328,7 +813,9 @@ function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
328
813
  var init_tenantDatabaseScope = __esm(() => {
329
814
  init_defaultConnection();
330
815
  init_connectionContext();
816
+ init_transactionControl();
331
817
  init_deferredModelEvents();
818
+ init_databaseTenantContext();
332
819
  init_tenancyConfig();
333
820
  init_tenantContext();
334
821
  });
@@ -545,29 +1032,8 @@ function resolveAuthUserDirectory(container) {
545
1032
  return null;
546
1033
  }
547
1034
 
548
- // ../../src/core/database/boundConnection.ts
549
- var BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
550
- function boundConnectionState() {
551
- const globalRecord = globalThis;
552
- const existing = globalRecord[BOUND_CONNECTION_KEY];
553
- if (existing) {
554
- return existing;
555
- }
556
- const created = { connection: null };
557
- globalRecord[BOUND_CONNECTION_KEY] = created;
558
- return created;
559
- }
560
- function bindDatabaseConnection(connection) {
561
- boundConnectionState().connection = connection;
562
- }
563
- function getBoundDatabaseConnection() {
564
- return boundConnectionState().connection;
565
- }
566
- function resetBoundDatabaseConnection() {
567
- boundConnectionState().connection = null;
568
- }
569
-
570
1035
  // ../../src/core/auth/basicAuthGuard.ts
1036
+ init_boundConnection();
571
1037
  init_defaultConnection();
572
1038
 
573
1039
  // ../../src/core/http/statelessAuth.ts
@@ -683,199 +1149,8 @@ function revealMfaSecret(stored) {
683
1149
  return decryptField(stored, key);
684
1150
  }
685
1151
 
686
- // ../../src/core/database/dialect.ts
687
- init_asyncContextStore();
688
-
689
- // ../../src/core/database/schema/driver.ts
690
- function normalizeConnectionName(connection) {
691
- const normalized = connection.trim().toLowerCase();
692
- if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
693
- return "pgsql";
694
- }
695
- if (normalized === "mysql" || normalized === "mariadb") {
696
- return "mysql";
697
- }
698
- if (normalized === "sqlite") {
699
- return "sqlite";
700
- }
701
- throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
702
- }
703
- function resolveDriverFromUrl(url) {
704
- const normalized = url.trim().toLowerCase();
705
- if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
706
- return "pgsql";
707
- }
708
- if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
709
- return "mysql";
710
- }
711
- if (normalized.startsWith("sqlite:")) {
712
- return "sqlite";
713
- }
714
- return null;
715
- }
716
- function resolveDatabaseDriver(options = {}) {
717
- const connection = options.connection ?? process.env.DB_CONNECTION;
718
- if (connection) {
719
- return normalizeConnectionName(connection);
720
- }
721
- const url = options.url ?? process.env.DATABASE_URL ?? "";
722
- const fromUrl = resolveDriverFromUrl(url);
723
- if (fromUrl) {
724
- return fromUrl;
725
- }
726
- return "pgsql";
727
- }
728
-
729
- // ../../src/core/database/dialect.ts
730
- function assertSafeIdentifier(identifier) {
731
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
732
- throw new Error(`Invalid SQL identifier: ${identifier}`);
733
- }
734
- return identifier;
735
- }
736
- function quoteIdentifierFor(dialect, column) {
737
- return dialect.quoteIdentifier(column);
738
- }
739
- var postgresDialect = {
740
- driver: "pgsql",
741
- placeholder(index) {
742
- return `$${index}`;
743
- },
744
- quoteIdentifier(identifier) {
745
- return `"${assertSafeIdentifier(identifier)}"`;
746
- },
747
- nowExpression() {
748
- return "NOW()";
749
- },
750
- timestampValue(value) {
751
- return value.toISOString();
752
- },
753
- returningClause(columns) {
754
- return ` RETURNING ${columns}`;
755
- },
756
- ilikeOperator() {
757
- return "ILIKE";
758
- },
759
- nullsLastSuffix() {
760
- return " NULLS LAST";
761
- },
762
- castToText(expression) {
763
- return `${expression}::text`;
764
- },
765
- upsertSuffix(conflictColumns, updateColumns) {
766
- const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
767
- if (updateColumns.length === 0) {
768
- return ` ON CONFLICT (${target}) DO NOTHING`;
769
- }
770
- const assignments = updateColumns.map((column) => {
771
- const quoted = quoteIdentifierFor(this, column);
772
- return `${quoted} = excluded.${quoted}`;
773
- }).join(", ");
774
- return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
775
- }
776
- };
777
- var mysqlDialect = {
778
- driver: "mysql",
779
- placeholder() {
780
- return "?";
781
- },
782
- quoteIdentifier(identifier) {
783
- return `\`${assertSafeIdentifier(identifier)}\``;
784
- },
785
- nowExpression() {
786
- return "CURRENT_TIMESTAMP";
787
- },
788
- timestampValue(value) {
789
- return value.toISOString().slice(0, 19).replace("T", " ");
790
- },
791
- returningClause() {
792
- return "";
793
- },
794
- ilikeOperator() {
795
- return "LIKE";
796
- },
797
- nullsLastSuffix() {
798
- return "";
799
- },
800
- castToText(expression) {
801
- return `CAST(${expression} AS CHAR)`;
802
- },
803
- upsertSuffix(conflictColumns, updateColumns) {
804
- if (updateColumns.length === 0) {
805
- const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
806
- return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
807
- }
808
- const assignments = updateColumns.map((column) => {
809
- const quoted = quoteIdentifierFor(this, column);
810
- return `${quoted} = VALUES(${quoted})`;
811
- }).join(", ");
812
- return ` ON DUPLICATE KEY UPDATE ${assignments}`;
813
- }
814
- };
815
- var sqliteDialect = {
816
- driver: "sqlite",
817
- placeholder() {
818
- return "?";
819
- },
820
- quoteIdentifier(identifier) {
821
- return `"${assertSafeIdentifier(identifier)}"`;
822
- },
823
- nowExpression() {
824
- return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
825
- },
826
- timestampValue(value) {
827
- return value.toISOString();
828
- },
829
- returningClause(columns) {
830
- return ` RETURNING ${columns}`;
831
- },
832
- ilikeOperator() {
833
- return "LIKE";
834
- },
835
- nullsLastSuffix() {
836
- return "";
837
- },
838
- castToText(expression) {
839
- return `CAST(${expression} AS TEXT)`;
840
- },
841
- upsertSuffix(conflictColumns, updateColumns) {
842
- const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
843
- if (updateColumns.length === 0) {
844
- return ` ON CONFLICT (${target}) DO NOTHING`;
845
- }
846
- const assignments = updateColumns.map((column) => {
847
- const quoted = quoteIdentifierFor(this, column);
848
- return `${quoted} = excluded.${quoted}`;
849
- }).join(", ");
850
- return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
851
- }
852
- };
853
- var dialects = {
854
- pgsql: postgresDialect,
855
- mysql: mysqlDialect,
856
- sqlite: sqliteDialect
857
- };
858
- var dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
859
- var dialectOverride = null;
860
- function dialectFor(driver) {
861
- return dialects[driver];
862
- }
863
- function currentSqlDialect() {
864
- return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
865
- }
866
- function useSqlDialect(driver) {
867
- dialectOverride = dialectFor(driver);
868
- return dialectOverride;
869
- }
870
- function sqlTimestamp(value = new Date) {
871
- return currentSqlDialect().timestampValue(value);
872
- }
873
- function resetSqlDialect() {
874
- dialectOverride = null;
875
- }
876
- function runWithSqlDialect(driver, callback) {
877
- return dialectContext.run(dialectFor(driver), callback);
878
- }
1152
+ // ../../src/core/auth/passwordLogin.ts
1153
+ init_dialect();
879
1154
 
880
1155
  // ../../src/core/auth/tokenHash.ts
881
1156
  import { createHmac as createHmac2 } from "crypto";
@@ -2393,7 +2668,9 @@ function missingOptionalPeer(packageName, reason, error) {
2393
2668
  }
2394
2669
 
2395
2670
  // ../../src/core/auth/saml/samlAssertionReplay.ts
2671
+ init_boundConnection();
2396
2672
  init_defaultConnection();
2673
+ init_dialect();
2397
2674
 
2398
2675
  // ../../src/core/database/errors.ts
2399
2676
  function isPostgresError(error) {
@@ -2711,6 +2988,7 @@ class SamlProvider {
2711
2988
  }
2712
2989
  }
2713
2990
  // ../../src/core/auth/oneTimeToken.ts
2991
+ init_dialect();
2714
2992
  import { randomBytes as randomBytes4 } from "crypto";
2715
2993
  var AUTH_ONE_TIME_PURPOSES = {
2716
2994
  passwordReset: "password_reset",
@@ -2776,7 +3054,11 @@ class PolicyGate {
2776
3054
  }
2777
3055
  }
2778
3056
  }
3057
+ // ../../src/core/database/bindConnection.ts
3058
+ init_boundConnection();
3059
+
2779
3060
  // ../../src/core/auth/scimAuthMiddleware.ts
3061
+ init_boundConnection();
2780
3062
  init_defaultConnection();
2781
3063
  init_connectionContext();
2782
3064
 
@@ -2795,78 +3077,33 @@ function parseScimTenantTokens(raw) {
2795
3077
  const token = tokenPart.trim();
2796
3078
  if (Number.isInteger(tenantId) && tenantId > 0 && token.length > 0) {
2797
3079
  tokens.set(tenantId, token);
2798
- }
2799
- }
2800
- return tokens;
2801
- }
2802
- function resolveScimTenantFromToken(token) {
2803
- const presented = token.trim();
2804
- if (presented.length === 0) {
2805
- return null;
2806
- }
2807
- const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
2808
- for (const [tenantId, expectedToken] of tenantTokens) {
2809
- if (timingSafeCompareString(presented, expectedToken)) {
2810
- return tenantId;
2811
- }
2812
- }
2813
- const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
2814
- if (fallbackToken.length === 0) {
2815
- return null;
2816
- }
2817
- if (timingSafeCompareString(presented, fallbackToken)) {
2818
- return 1;
2819
- }
2820
- return null;
2821
- }
2822
-
2823
- // ../../src/core/database/repositoryConnection.ts
2824
- init_defaultConnection();
2825
- function resolveRepositoryConnection() {
2826
- return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
2827
- }
2828
- function compileTaggedSql(strings, values) {
2829
- const dialect = currentSqlDialect();
2830
- let query = strings[0] ?? "";
2831
- const params = [];
2832
- for (let index = 0;index < values.length; index += 1) {
2833
- params.push(values[index]);
2834
- query += `${dialect.placeholder(params.length)}${strings[index + 1] ?? ""}`;
2835
- }
2836
- return { query, params };
2837
- }
2838
- function isTaggedStrings(value) {
2839
- if (!Array.isArray(value) || typeof value[0] !== "string") {
2840
- return false;
3080
+ }
2841
3081
  }
2842
- return typeof value.raw !== "undefined";
3082
+ return tokens;
2843
3083
  }
2844
- function runOnConnection(connection, args) {
2845
- if (typeof connection === "function") {
2846
- return connection(...args);
3084
+ function resolveScimTenantFromToken(token) {
3085
+ const presented = token.trim();
3086
+ if (presented.length === 0) {
3087
+ return null;
2847
3088
  }
2848
- if (typeof connection.unsafe !== "function") {
2849
- throw new Error("Database connection cannot run SQL.");
3089
+ const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
3090
+ for (const [tenantId, expectedToken] of tenantTokens) {
3091
+ if (timingSafeCompareString(presented, expectedToken)) {
3092
+ return tenantId;
3093
+ }
2850
3094
  }
2851
- const strings = args[0];
2852
- if (!isTaggedStrings(strings)) {
2853
- throw new Error("Database connection cannot run tagged SQL.");
3095
+ const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
3096
+ if (fallbackToken.length === 0) {
3097
+ return null;
2854
3098
  }
2855
- const compiled = compileTaggedSql(strings, args.slice(1));
2856
- return connection.unsafe(compiled.query, compiled.params);
2857
- }
2858
- var repositoryConnection = new Proxy(function repositoryConnection() {}, {
2859
- apply(_target, _thisArg, args) {
2860
- return runOnConnection(resolveRepositoryConnection(), args);
2861
- },
2862
- get(_target, property) {
2863
- const connection = resolveRepositoryConnection();
2864
- const value = connection[property];
2865
- return typeof value === "function" ? value.bind(connection) : value;
3099
+ if (timingSafeCompareString(presented, fallbackToken)) {
3100
+ return 1;
2866
3101
  }
2867
- });
3102
+ return null;
3103
+ }
2868
3104
 
2869
3105
  // ../../src/core/tenant/resolveTenant.ts
3106
+ init_repositoryConnection();
2870
3107
  init_tenancyConfig();
2871
3108
  async function resolveTenant(tenantId) {
2872
3109
  if (!isTenancyEnabled()) {
@@ -3479,6 +3716,7 @@ function buildPaginationMeta(input) {
3479
3716
  }
3480
3717
 
3481
3718
  // ../../src/core/database/query.ts
3719
+ init_dialect();
3482
3720
  function quoteIdentifier(identifier) {
3483
3721
  return currentSqlDialect().quoteIdentifier(identifier);
3484
3722
  }
@@ -4233,6 +4471,9 @@ function indexMorphToRelation(children, parentsByType, relation) {
4233
4471
  return result;
4234
4472
  }
4235
4473
 
4474
+ // ../../src/core/database/baseRepository.ts
4475
+ init_repositoryConnection();
4476
+
4236
4477
  // ../../src/core/database/pluck.ts
4237
4478
  function uniqueColumnSelect(table, columns) {
4238
4479
  const seen = new Set;
@@ -5042,8 +5283,9 @@ class BaseRepository {
5042
5283
  }
5043
5284
  var baseRepository_default = BaseRepository;
5044
5285
  // ../../src/core/database/bunSql.ts
5045
- var {SQL } = globalThis.Bun;
5286
+ init_boundConnection();
5046
5287
  init_defaultConnection();
5288
+ var {SQL } = globalThis.Bun;
5047
5289
  function createBunSqlPool(options) {
5048
5290
  if (!options.url) {
5049
5291
  throw new Error("DATABASE_URL is not configured. Set url before creating a Bun SQL pool.");
@@ -5061,18 +5303,12 @@ function bindBunSql(sql) {
5061
5303
  registerDefaultDatabasePool(sql);
5062
5304
  return sql;
5063
5305
  }
5064
- // ../../src/core/database/connection.ts
5065
- function createDatabaseConnection(source) {
5066
- return {
5067
- async unsafe(query, params = []) {
5068
- return await source.unsafe(query, params);
5069
- }
5070
- };
5071
- }
5072
5306
 
5073
5307
  // ../../src/framework/public-api.ts
5308
+ init_connection();
5074
5309
  init_connectionContext();
5075
5310
  init_defaultConnection();
5311
+ init_dialect();
5076
5312
 
5077
5313
  // ../../src/core/database/inflection.ts
5078
5314
  function singularize(word) {
@@ -5265,6 +5501,7 @@ async function withMigrationLock(db, callback, lockKey = MIGRATION_LOCK_KEY) {
5265
5501
  }
5266
5502
  }
5267
5503
  // ../../src/core/database/migrations/runner.ts
5504
+ init_dialect();
5268
5505
  import { readdir } from "fs/promises";
5269
5506
  import { join } from "path";
5270
5507
  import { pathToFileURL } from "url";
@@ -7151,6 +7388,7 @@ function createMysqlConnection(url) {
7151
7388
  }
7152
7389
  // ../../src/core/database/namedConnections.ts
7153
7390
  init_connectionContext();
7391
+ init_dialect();
7154
7392
  var REGISTRY_KEY = Symbol.for("@getstrata/namedConnections");
7155
7393
  function registry() {
7156
7394
  const globalRecord = globalThis;
@@ -7191,6 +7429,7 @@ function runOnNamedConnection(name, callback) {
7191
7429
 
7192
7430
  // ../../src/framework/public-api.ts
7193
7431
  init_queryProxy();
7432
+ init_repositoryConnection();
7194
7433
 
7195
7434
  // ../../src/core/database/schema/columnDefinition.ts
7196
7435
  class ColumnDefinition {
@@ -7413,6 +7652,10 @@ class Blueprint {
7413
7652
  });
7414
7653
  }
7415
7654
  }
7655
+
7656
+ // ../../src/core/database/schema/index.ts
7657
+ init_driver();
7658
+
7416
7659
  // ../../src/core/database/schema/errors.ts
7417
7660
  class UnsupportedSchemaFeatureError extends Error {
7418
7661
  constructor(feature, driver) {
@@ -7725,6 +7968,7 @@ function grammarForDriver(driver) {
7725
7968
  }
7726
7969
  }
7727
7970
  // ../../src/core/database/schema/schema.ts
7971
+ init_driver();
7728
7972
  class SchemaBuilder {
7729
7973
  #driver;
7730
7974
  #statements = [];
@@ -7840,25 +8084,9 @@ function createSqliteConnection(filename) {
7840
8084
  function defineTable(definition) {
7841
8085
  return definition;
7842
8086
  }
7843
- // ../../src/core/database/transaction.ts
7844
- init_deferredModelEvents();
7845
- function supportsTransactions(connection) {
7846
- return typeof connection.begin === "function";
7847
- }
7848
- async function runInTransaction(operation) {
7849
- const pool = resolveRepositoryConnection();
7850
- if (!supportsTransactions(pool)) {
7851
- throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
7852
- }
7853
- const begin = pool.begin;
7854
- return await runWithDeferredModelEvents(async () => {
7855
- return await begin(async (transaction) => {
7856
- return await operation(createDatabaseConnection(transaction));
7857
- });
7858
- });
7859
- }
7860
8087
 
7861
8088
  // ../../src/framework/public-api.ts
8089
+ init_transaction();
7862
8090
  init_eventBus();
7863
8091
 
7864
8092
  // ../../src/core/mail/mailer.ts
@@ -9202,7 +9430,8 @@ function wrapRouteHandler(handler, middleware) {
9202
9430
  function applyMiddlewareToRoutes(routes, middleware) {
9203
9431
  const wrapped = {};
9204
9432
  for (const [path, routeHandler] of Object.entries(routes)) {
9205
- wrapped[path] = wrapRouteHandler(routeHandler, middleware);
9433
+ const identity = async (_request, next) => await runWithRequestMeta({ ...currentRequestMeta(), routeTemplate: path }, next);
9434
+ wrapped[path] = wrapRouteHandler(routeHandler, [identity, ...middleware]);
9206
9435
  }
9207
9436
  return wrapped;
9208
9437
  }
@@ -9406,6 +9635,9 @@ function toPaginatedResourceCollection(items, meta, transformer) {
9406
9635
  meta
9407
9636
  };
9408
9637
  }
9638
+ // ../../src/core/http/response.ts
9639
+ init_transactionControl();
9640
+
9409
9641
  // ../../src/core/http/safeInternalPath.ts
9410
9642
  var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
9411
9643
  function looksLikeExternalTarget(value) {
@@ -9707,6 +9939,7 @@ function withErrorHandling(handler) {
9707
9939
  try {
9708
9940
  return await handler(...args);
9709
9941
  } catch (error) {
9942
+ requestTransactionRollback();
9710
9943
  const request = args.find((arg) => arg instanceof Request);
9711
9944
  const webResponse = await webErrorResponse(error, request);
9712
9945
  if (webResponse) {
@@ -9804,7 +10037,9 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
9804
10037
  return response;
9805
10038
  };
9806
10039
  }
9807
- // ../../src/core/http/loginThrottleMiddleware.ts
10040
+ // ../../src/core/http/throttleMiddleware.ts
10041
+ import { createHash as createHash3 } from "crypto";
10042
+ init_tenantContext();
9808
10043
  var {RedisClient: RedisClient2 } = globalThis.Bun;
9809
10044
 
9810
10045
  // ../../src/core/http/throttleResponse.ts
@@ -9827,6 +10062,104 @@ async function tooManyRequestsResponse(request, message, decaySeconds) {
9827
10062
  return new Response(html.body, { status: 429, headers });
9828
10063
  }
9829
10064
 
10065
+ // ../../src/core/http/throttleMiddleware.ts
10066
+ function resolveThrottleIdentity(request) {
10067
+ const user = currentAuthUser();
10068
+ if (user?.tokenId !== undefined) {
10069
+ return `token:${user.tokenId}`;
10070
+ }
10071
+ if (user) {
10072
+ return `user:${user.id}`;
10073
+ }
10074
+ return readClientIp(request) ?? "unknown";
10075
+ }
10076
+ var CONSUME_THROTTLE = `
10077
+ local count = redis.call('INCR', KEYS[1])
10078
+ local ttl = redis.call('PTTL', KEYS[1])
10079
+ if ttl < 0 then
10080
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
10081
+ end
10082
+ return count
10083
+ `;
10084
+ function redisThrottleKey(request, prefix, identity) {
10085
+ const bucket = JSON.stringify([
10086
+ currentTenant()?.id ?? null,
10087
+ request.method,
10088
+ currentRequestMeta().routeTemplate ?? "__unmatched__",
10089
+ identity
10090
+ ]);
10091
+ return namespacedRedisKey(`${prefix}:${createHash3("sha256").update(bucket).digest("hex")}`);
10092
+ }
10093
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
10094
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
10095
+ throw new Error("Invalid throttle expiry or command timeout.");
10096
+ }
10097
+ let timer;
10098
+ try {
10099
+ const result = await Promise.race([
10100
+ client.send("EVAL", [
10101
+ CONSUME_THROTTLE,
10102
+ "1",
10103
+ key,
10104
+ String(Math.max(1, Math.ceil(decaySeconds * 1000)))
10105
+ ]),
10106
+ new Promise((_resolve, reject) => {
10107
+ timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
10108
+ })
10109
+ ]);
10110
+ const count = Number(result);
10111
+ if (!Number.isSafeInteger(count) || count < 1)
10112
+ throw new Error("Invalid throttle store response.");
10113
+ return count;
10114
+ } finally {
10115
+ clearTimeout(timer);
10116
+ }
10117
+ }
10118
+ function createRedisThrottleConsumer(options) {
10119
+ let ownedClient;
10120
+ return async (key, decaySeconds) => {
10121
+ if (!options.redisClient && !ownedClient) {
10122
+ ownedClient = new RedisClient2(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
10123
+ }
10124
+ const client = options.redisClient ?? ownedClient;
10125
+ if (!client)
10126
+ throw new Error("Throttle client is required.");
10127
+ try {
10128
+ return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
10129
+ } catch (error) {
10130
+ if (client === ownedClient) {
10131
+ ownedClient.close();
10132
+ ownedClient = undefined;
10133
+ }
10134
+ throw error;
10135
+ }
10136
+ };
10137
+ }
10138
+ function throttleUnavailableResponse() {
10139
+ return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
10140
+ status: 503,
10141
+ headers: { "retry-after": "1" }
10142
+ });
10143
+ }
10144
+ function createThrottleMiddleware(options) {
10145
+ const consume = createRedisThrottleConsumer(options);
10146
+ const prefix = options.keyPrefix ?? "throttle";
10147
+ return async (request, next) => {
10148
+ const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
10149
+ let attempts;
10150
+ try {
10151
+ attempts = await consume(key, options.decaySeconds);
10152
+ } catch {
10153
+ return throttleUnavailableResponse();
10154
+ }
10155
+ const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
10156
+ if (attempts > maxAttempts) {
10157
+ return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10158
+ }
10159
+ return await next();
10160
+ };
10161
+ }
10162
+
9830
10163
  // ../../src/core/http/loginThrottleMiddleware.ts
9831
10164
  var memoryLoginBuckets = new Map;
9832
10165
  function resolveLoginIdentity(request) {
@@ -9870,15 +10203,17 @@ function createMemoryLoginThrottleMiddleware(options) {
9870
10203
  };
9871
10204
  }
9872
10205
  function createRedisLoginThrottleMiddleware(options) {
9873
- const client = new RedisClient2(options.redisUrl);
9874
- const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
10206
+ const consume = createRedisThrottleConsumer(options);
10207
+ const prefix = options.keyPrefix ?? "login-throttle";
9875
10208
  return async (request, next) => {
9876
10209
  const identity = resolveLoginIdentity(request);
9877
10210
  const email = await resolveLoginEmail(request);
9878
- const throttleKey = `${prefix}${identity}:${email}`;
9879
- const attempts = Number(await client.incr(throttleKey));
9880
- if (attempts === 1) {
9881
- await client.expire(throttleKey, options.decaySeconds);
10211
+ const throttleKey = redisThrottleKey(request, prefix, JSON.stringify([identity, email]));
10212
+ let attempts;
10213
+ try {
10214
+ attempts = await consume(throttleKey, options.decaySeconds);
10215
+ } catch {
10216
+ return throttleUnavailableResponse();
9882
10217
  }
9883
10218
  if (attempts > options.maxAttempts) {
9884
10219
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
@@ -9925,7 +10260,24 @@ function resetMemoryThrottleForTests() {
9925
10260
  }
9926
10261
  }
9927
10262
  // ../../src/core/metrics/prometheus.ts
10263
+ var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
10264
+ function emptyHistogram() {
10265
+ return {
10266
+ cumulative: HTTP_DURATION_BUCKETS_MS.map(() => 0),
10267
+ sum: 0,
10268
+ count: 0
10269
+ };
10270
+ }
10271
+
9928
10272
  class PrometheusRegistry {
10273
+ maxSeries;
10274
+ seriesLabels = new Map;
10275
+ constructor(maxSeries = 4096) {
10276
+ this.maxSeries = maxSeries;
10277
+ if (!Number.isSafeInteger(maxSeries) || maxSeries < 1) {
10278
+ throw new Error("Metrics maxSeries must be a positive safe integer.");
10279
+ }
10280
+ }
9929
10281
  httpRequestsTotal = new Map;
9930
10282
  httpRequestDurationMs = new Map;
9931
10283
  incrementHttpRequest(labels) {
@@ -9933,10 +10285,20 @@ class PrometheusRegistry {
9933
10285
  this.httpRequestsTotal.set(key, (this.httpRequestsTotal.get(key) ?? 0) + 1);
9934
10286
  }
9935
10287
  observeHttpDuration(labels, durationMs) {
10288
+ if (!Number.isFinite(durationMs) || durationMs < 0)
10289
+ return;
9936
10290
  const key = this.metricKey(labels);
9937
- const samples = this.httpRequestDurationMs.get(key) ?? [];
9938
- samples.push(durationMs);
9939
- this.httpRequestDurationMs.set(key, samples);
10291
+ const histogram = this.httpRequestDurationMs.get(key) ?? emptyHistogram();
10292
+ histogram.count += 1;
10293
+ histogram.sum += durationMs;
10294
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
10295
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
10296
+ if (bound !== undefined && durationMs <= bound) {
10297
+ const count = histogram.cumulative[index] ?? 0;
10298
+ histogram.cumulative[index] = count + 1;
10299
+ }
10300
+ }
10301
+ this.httpRequestDurationMs.set(key, histogram);
9940
10302
  }
9941
10303
  renderMetrics() {
9942
10304
  const lines = [
@@ -9946,16 +10308,22 @@ class PrometheusRegistry {
9946
10308
  for (const [key, value] of this.httpRequestsTotal) {
9947
10309
  lines.push(`http_requests_total{${key}} ${value}`);
9948
10310
  }
9949
- lines.push("# HELP http_request_duration_ms_sum Sum of HTTP request durations in milliseconds.", "# TYPE http_request_duration_ms_sum counter");
9950
- for (const [key, samples] of this.httpRequestDurationMs) {
9951
- const sum = samples.reduce((total, sample) => total + sample, 0);
9952
- lines.push(`http_request_duration_ms_sum{${key}} ${sum}`);
10311
+ lines.push("# HELP http_request_duration_ms HTTP request duration in milliseconds.", "# TYPE http_request_duration_ms histogram");
10312
+ for (const [key, histogram] of this.httpRequestDurationMs) {
10313
+ for (let index = 0;index < HTTP_DURATION_BUCKETS_MS.length; index += 1) {
10314
+ const bound = HTTP_DURATION_BUCKETS_MS[index];
10315
+ lines.push(`http_request_duration_ms_bucket{${key},le="${bound}"} ${histogram.cumulative[index] ?? 0}`);
10316
+ }
10317
+ lines.push(`http_request_duration_ms_bucket{${key},le="+Inf"} ${histogram.count}`);
10318
+ lines.push(`http_request_duration_ms_sum{${key}} ${histogram.sum}`);
10319
+ lines.push(`http_request_duration_ms_count{${key}} ${histogram.count}`);
9953
10320
  }
9954
10321
  return `${lines.join(`
9955
10322
  `)}
9956
10323
  `;
9957
10324
  }
9958
10325
  resetForTests() {
10326
+ this.seriesLabels.clear();
9959
10327
  this.httpRequestsTotal.clear();
9960
10328
  this.httpRequestDurationMs.clear();
9961
10329
  }
@@ -9965,9 +10333,10 @@ class PrometheusRegistry {
9965
10333
  let totalRequests = 0;
9966
10334
  for (const [key, count] of this.httpRequestsTotal) {
9967
10335
  totalRequests += count;
9968
- const method = key.match(/method="([^"]+)"/)?.[1] ?? "GET";
9969
- const path = key.match(/path="([^"]+)"/)?.[1] ?? "/";
9970
- const status = key.match(/status="([^"]+)"/)?.[1] ?? "200";
10336
+ const labels = this.seriesLabels.get(key);
10337
+ if (!labels)
10338
+ continue;
10339
+ const { method, path, status } = labels;
9971
10340
  byStatus[status] = (byStatus[status] ?? 0) + count;
9972
10341
  const pathKey = `${method} ${path}`;
9973
10342
  const existing = pathCounts.get(pathKey);
@@ -9984,30 +10353,54 @@ class PrometheusRegistry {
9984
10353
  topPaths
9985
10354
  };
9986
10355
  }
10356
+ getStorageStats() {
10357
+ return {
10358
+ series: this.seriesLabels.size,
10359
+ histograms: this.httpRequestDurationMs.size,
10360
+ bucketCounters: this.httpRequestDurationMs.size * HTTP_DURATION_BUCKETS_MS.length
10361
+ };
10362
+ }
9987
10363
  metricKey(labels) {
9988
- return `method="${labels.method}",path="${labels.path}",status="${labels.status}"`;
10364
+ const normalized = {
10365
+ method: /^(GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|OPTIONS|TRACE)$/.test(labels.method) ? labels.method : "OTHER",
10366
+ path: labels.path.length <= 512 ? labels.path : "__overflow__",
10367
+ status: /^[1-5][0-9]{2}$/.test(labels.status) ? labels.status : "000"
10368
+ };
10369
+ const escapeLabel = (value) => value.replaceAll("\\", "\\\\").replaceAll(`
10370
+ `, "\\n").replaceAll('"', "\\\"");
10371
+ const encode = (value) => `method="${value.method}",path="${escapeLabel(value.path)}",status="${value.status}"`;
10372
+ let key = encode(normalized);
10373
+ if (!this.seriesLabels.has(key)) {
10374
+ if (this.seriesLabels.size >= this.maxSeries) {
10375
+ const overflow = { method: "OTHER", path: "__overflow__", status: "000" };
10376
+ key = encode(overflow);
10377
+ this.seriesLabels.set(key, overflow);
10378
+ } else {
10379
+ this.seriesLabels.set(key, normalized);
10380
+ }
10381
+ }
10382
+ return key;
9989
10383
  }
9990
10384
  }
9991
10385
  var prometheusRegistry = new PrometheusRegistry;
9992
10386
 
9993
10387
  // ../../src/core/http/metricsMiddleware.ts
9994
- function normalizeMetricPath(pathname) {
9995
- return pathname.replace(/\/\d+/g, "/:id").replace(/\/[0-9a-f-]{36}/gi, "/:id");
9996
- }
9997
10388
  function createMetricsMiddleware() {
9998
10389
  return async (request, next) => {
9999
10390
  const startedAt = performance.now();
10000
- const response = await next();
10001
- const durationMs = performance.now() - startedAt;
10002
- const path = normalizeMetricPath(new URL(request.url).pathname);
10003
- const labels = {
10004
- method: request.method,
10005
- path,
10006
- status: String(response.status)
10007
- };
10008
- prometheusRegistry.incrementHttpRequest(labels);
10009
- prometheusRegistry.observeHttpDuration(labels, durationMs);
10010
- return response;
10391
+ let response;
10392
+ try {
10393
+ response = await next();
10394
+ return response;
10395
+ } finally {
10396
+ const labels = {
10397
+ method: request.method,
10398
+ path: currentRequestMeta().routeTemplate ?? "__unmatched__",
10399
+ status: String(response?.status ?? 500)
10400
+ };
10401
+ prometheusRegistry.incrementHttpRequest(labels);
10402
+ prometheusRegistry.observeHttpDuration(labels, performance.now() - startedAt);
10403
+ }
10011
10404
  };
10012
10405
  }
10013
10406
  // ../../src/core/http/requireAbilityMiddleware.ts
@@ -10133,14 +10526,14 @@ function createRequireWebAuthMiddleware(auth) {
10133
10526
  };
10134
10527
  }
10135
10528
  // ../../src/core/http/scimThrottleMiddleware.ts
10136
- import { createHash as createHash3 } from "crypto";
10529
+ import { createHash as createHash4 } from "crypto";
10137
10530
  var {RedisClient: RedisClient3 } = globalThis.Bun;
10138
10531
  var memoryBuckets = new Map;
10139
10532
  function resolveScimIdentity(request) {
10140
10533
  const authorization = request.headers.get("authorization") ?? "";
10141
10534
  const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
10142
10535
  if (presented.length > 0) {
10143
- return createHash3("sha256").update(presented).digest("hex");
10536
+ return createHash4("sha256").update(presented).digest("hex");
10144
10537
  }
10145
10538
  return readClientIp(request) ?? "unknown";
10146
10539
  }
@@ -10303,37 +10696,6 @@ function createValidateSignatureMiddleware() {
10303
10696
  return await next();
10304
10697
  };
10305
10698
  }
10306
- // ../../src/core/http/throttleMiddleware.ts
10307
- init_tenantContext();
10308
- var {RedisClient: RedisClient4 } = globalThis.Bun;
10309
- function resolveThrottleIdentity(request) {
10310
- const user = currentAuthUser();
10311
- if (user?.tokenId !== undefined) {
10312
- return `token:${user.tokenId}`;
10313
- }
10314
- if (user) {
10315
- return `user:${user.id}`;
10316
- }
10317
- return readClientIp(request) ?? "unknown";
10318
- }
10319
- function createThrottleMiddleware(options) {
10320
- const client = new RedisClient4(options.redisUrl);
10321
- const prefix = options.keyPrefix ?? namespacedRedisKey("throttle:");
10322
- return async (request, next) => {
10323
- const identity = resolveThrottleIdentity(request);
10324
- const path = new URL(request.url).pathname;
10325
- const throttleKey = `${prefix}${identity}:${path}`;
10326
- const attempts = Number(await client.incr(throttleKey));
10327
- if (attempts === 1) {
10328
- await client.expire(throttleKey, options.decaySeconds);
10329
- }
10330
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
10331
- if (attempts > maxAttempts) {
10332
- return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10333
- }
10334
- return await next();
10335
- };
10336
- }
10337
10699
  // ../../src/core/http/parseFormBody.ts
10338
10700
  async function parseFormBody(request) {
10339
10701
  const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
@@ -10790,7 +11152,7 @@ var queueConfig = {
10790
11152
  };
10791
11153
 
10792
11154
  // ../../src/core/queue/jobRunner.ts
10793
- async function runQueueJob(envelope, failedJobs) {
11155
+ async function runQueueJob(envelope, failedJobs, options = {}) {
10794
11156
  const job = jobRegistry.create(envelope.name);
10795
11157
  if (!job) {
10796
11158
  throw new Error(`Unknown job "${envelope.name}".`);
@@ -10807,7 +11169,7 @@ async function runQueueJob(envelope, failedJobs) {
10807
11169
  await runQueueJob({
10808
11170
  ...envelope,
10809
11171
  attempts: nextAttempt
10810
- }, failedJobs);
11172
+ }, failedJobs, options);
10811
11173
  return;
10812
11174
  }
10813
11175
  await failedJobs.recordFailure({
@@ -10815,12 +11177,13 @@ async function runQueueJob(envelope, failedJobs) {
10815
11177
  payload: envelope.payload,
10816
11178
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
10817
11179
  });
11180
+ options.onFailureRecorded?.();
10818
11181
  throw error;
10819
11182
  }
10820
11183
  }
10821
11184
 
10822
11185
  // ../../src/core/queue/redisQueue.ts
10823
- var {RedisClient: RedisClient5 } = globalThis.Bun;
11186
+ var {RedisClient: RedisClient4 } = globalThis.Bun;
10824
11187
  function queueListKey() {
10825
11188
  return namespacedRedisKey("queue:default");
10826
11189
  }
@@ -10834,6 +11197,20 @@ var QUEUE_LIST_KEY = queueListKey();
10834
11197
  var QUEUE_HIGH_KEY = queueHighKey();
10835
11198
  var QUEUE_LOW_KEY = queueLowKey();
10836
11199
  var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
11200
+ var DEFAULT_VISIBILITY_MS = 60000;
11201
+ function queueProcessingKey(queueKey) {
11202
+ return `${queueKey}:processing`;
11203
+ }
11204
+ function queueProcessingLeaseKey(queueKey) {
11205
+ return `${queueKey}:processing:leases`;
11206
+ }
11207
+ function readVisibilityMs() {
11208
+ const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
11209
+ if (!Number.isSafeInteger(parsed) || parsed < 30) {
11210
+ throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
11211
+ }
11212
+ return parsed;
11213
+ }
10837
11214
  function queueKeyForPriority(priority = "default") {
10838
11215
  switch (priority) {
10839
11216
  case "high":
@@ -10875,11 +11252,134 @@ function parseQueueJobEnvelope(rawPayload) {
10875
11252
  attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
10876
11253
  };
10877
11254
  }
11255
+ var SCRIPT_HEADER = `
11256
+ local function checkType(key, expected)
11257
+ local actual = redis.call('TYPE', key).ok
11258
+ if actual ~= 'none' and actual ~= expected then
11259
+ return redis.error_reply('Unexpected queue key type: ' .. key)
11260
+ end
11261
+ end
11262
+ local function nowMs()
11263
+ local time = redis.call('TIME')
11264
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
11265
+ end
11266
+ `;
11267
+ var RESERVE_JOB = `${SCRIPT_HEADER}
11268
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
11269
+ if err then return err end
11270
+ local payload = redis.call('LINDEX', KEYS[1], -1)
11271
+ if not payload then return nil end
11272
+ local record = cjson.encode({strataReservation=1, id=ARGV[1], owner=ARGV[2], payload=payload})
11273
+ local lease = cjson.encode({owner=ARGV[2], expiresAt=nowMs()+tonumber(ARGV[3])})
11274
+ redis.call('LPUSH', KEYS[2], record)
11275
+ redis.call('HSET', KEYS[3], ARGV[1], lease)
11276
+ redis.call('RPOP', KEYS[1])
11277
+ return record
11278
+ `;
11279
+ var RENEW_OR_ACK = `${SCRIPT_HEADER}
11280
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'hash') or checkType(KEYS[3], 'list')
11281
+ if err then return err end
11282
+ local raw = redis.call('HGET', KEYS[2], ARGV[1])
11283
+ if not raw then return 0 end
11284
+ local ok, lease = pcall(cjson.decode, raw)
11285
+ local now = nowMs()
11286
+ if not ok or type(lease) ~= 'table' or lease.owner ~= ARGV[2] or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now then
11287
+ return 0
11288
+ end
11289
+ if ARGV[4] == 'renew' then
11290
+ redis.call('HSET', KEYS[2], ARGV[1], cjson.encode({owner=ARGV[2], expiresAt=now+tonumber(ARGV[5])}))
11291
+ return 1
11292
+ end
11293
+ if ARGV[4] == 'quarantine' then redis.call('LPUSH', KEYS[3], ARGV[6]) end
11294
+ local removed = redis.call('LREM', KEYS[1], 1, ARGV[3])
11295
+ redis.call('HDEL', KEYS[2], ARGV[1])
11296
+ return removed
11297
+ `;
11298
+ var RECLAIM_JOBS = `${SCRIPT_HEADER}
11299
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
11300
+ if err then return err end
11301
+ local now = nowMs()
11302
+ local reclaimed = 0
11303
+ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
11304
+ local payload, field = record, record
11305
+ local modern = false
11306
+ local ok, reservation = pcall(cjson.decode, record)
11307
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
11308
+ payload, field, modern = reservation.payload, reservation.id, true
11309
+ end
11310
+ local raw = redis.call('HGET', KEYS[3], field)
11311
+ local expired = true
11312
+ if raw then
11313
+ if modern then
11314
+ local leaseOk, lease = pcall(cjson.decode, raw)
11315
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
11316
+ else
11317
+ local reservedAt = tonumber(raw)
11318
+ expired = not reservedAt or now - reservedAt >= tonumber(ARGV[1])
11319
+ end
11320
+ end
11321
+ if expired then
11322
+ redis.call('LPUSH', KEYS[1], payload)
11323
+ redis.call('LREM', KEYS[2], 1, record)
11324
+ redis.call('HDEL', KEYS[3], field)
11325
+ reclaimed = reclaimed + 1
11326
+ end
11327
+ end
11328
+ return reclaimed
11329
+ `;
11330
+ function queueInvalidKey(queueKey) {
11331
+ return `${queueKey}:invalid`;
11332
+ }
11333
+ async function reserveQueueJob(client, queueKey, owner) {
11334
+ const raw = await client.send("EVAL", [
11335
+ RESERVE_JOB,
11336
+ "3",
11337
+ queueKey,
11338
+ queueProcessingKey(queueKey),
11339
+ queueProcessingLeaseKey(queueKey),
11340
+ crypto.randomUUID(),
11341
+ owner,
11342
+ String(readVisibilityMs())
11343
+ ]);
11344
+ if (typeof raw !== "string")
11345
+ return null;
11346
+ const reservation = JSON.parse(raw);
11347
+ return { ...reservation, record: raw };
11348
+ }
11349
+ async function updateQueueReservation(client, queueKey, reservation, action) {
11350
+ return Number(await client.send("EVAL", [
11351
+ RENEW_OR_ACK,
11352
+ "3",
11353
+ queueProcessingKey(queueKey),
11354
+ queueProcessingLeaseKey(queueKey),
11355
+ queueInvalidKey(queueKey),
11356
+ reservation.id,
11357
+ reservation.owner,
11358
+ reservation.record,
11359
+ action,
11360
+ String(readVisibilityMs()),
11361
+ reservation.payload
11362
+ ])) === 1;
11363
+ }
11364
+ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
11365
+ let reclaimed = 0;
11366
+ for (const key of queueKeys) {
11367
+ reclaimed += Number(await client.send("EVAL", [
11368
+ RECLAIM_JOBS,
11369
+ "3",
11370
+ key,
11371
+ queueProcessingKey(key),
11372
+ queueProcessingLeaseKey(key),
11373
+ String(readVisibilityMs())
11374
+ ]));
11375
+ }
11376
+ return reclaimed;
11377
+ }
10878
11378
 
10879
11379
  class RedisQueue {
10880
11380
  client;
10881
11381
  constructor(redisUrl) {
10882
- this.client = new RedisClient5(redisUrl);
11382
+ this.client = new RedisClient4(redisUrl);
10883
11383
  }
10884
11384
  async dispatch(job, payload) {
10885
11385
  const name = jobRegistry.resolveName(job);
@@ -10899,13 +11399,16 @@ class RedisQueue {
10899
11399
  class QueueWorker {
10900
11400
  failedJobs;
10901
11401
  timeoutSeconds;
11402
+ queueKeys;
11403
+ owner = crypto.randomUUID();
10902
11404
  running = false;
10903
11405
  stopping = false;
10904
11406
  client;
10905
- constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
11407
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = QUEUE_KEYS) {
10906
11408
  this.failedJobs = failedJobs;
10907
11409
  this.timeoutSeconds = timeoutSeconds;
10908
- this.client = new RedisClient5(redisUrl);
11410
+ this.queueKeys = queueKeys;
11411
+ this.client = new RedisClient4(redisUrl);
10909
11412
  }
10910
11413
  requestStop() {
10911
11414
  this.stopping = true;
@@ -10913,38 +11416,81 @@ class QueueWorker {
10913
11416
  isRunning() {
10914
11417
  return this.running;
10915
11418
  }
10916
- async processNext() {
10917
- let result = null;
10918
- for (const queueKey of QUEUE_KEYS) {
10919
- result = await this.client.brpop(queueKey, 1);
10920
- if (result) {
10921
- break;
11419
+ async reserve() {
11420
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
11421
+ do {
11422
+ for (const key of this.queueKeys) {
11423
+ if (this.stopping)
11424
+ return null;
11425
+ const reservation = await reserveQueueJob(this.client, key, this.owner);
11426
+ if (reservation)
11427
+ return { queueKey: key, reservation };
10922
11428
  }
10923
- }
10924
- if (!result) {
10925
- result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
10926
- }
10927
- if (!result) {
11429
+ if (this.stopping || performance.now() >= deadline)
11430
+ return null;
11431
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
11432
+ } while (!this.stopping);
11433
+ return null;
11434
+ }
11435
+ async processNext() {
11436
+ if (this.stopping)
10928
11437
  return false;
10929
- }
10930
- const [, rawPayload] = result;
10931
- const envelope = parseQueueJobEnvelope(rawPayload);
11438
+ await reclaimExpiredQueueReservations(this.client, this.queueKeys);
11439
+ const reserved = await this.reserve();
11440
+ if (!reserved)
11441
+ return false;
11442
+ const { queueKey, reservation } = reserved;
11443
+ const envelope = parseQueueJobEnvelope(reservation.payload);
10932
11444
  if (!envelope) {
11445
+ await updateQueueReservation(this.client, queueKey, reservation, "quarantine");
10933
11446
  return true;
10934
11447
  }
11448
+ let acknowledge = false;
11449
+ let ownershipLost = false;
11450
+ let renewal;
11451
+ const heartbeat = setInterval(() => {
11452
+ if (renewal || ownershipLost)
11453
+ return;
11454
+ renewal = (async () => {
11455
+ try {
11456
+ if (!await updateQueueReservation(this.client, queueKey, reservation, "renew")) {
11457
+ ownershipLost = true;
11458
+ }
11459
+ } catch (error) {
11460
+ ownershipLost = true;
11461
+ console.error("[QueueWorker] Lease renewal failed:", error);
11462
+ }
11463
+ })().finally(() => {
11464
+ renewal = undefined;
11465
+ });
11466
+ }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
10935
11467
  try {
10936
- await runQueueJob(envelope, this.failedJobs);
11468
+ await runQueueJob(envelope, this.failedJobs, {
11469
+ onFailureRecorded: () => {
11470
+ acknowledge = true;
11471
+ }
11472
+ });
11473
+ acknowledge = true;
10937
11474
  } catch (error) {
10938
11475
  console.error("[QueueWorker] Job failed:", error);
11476
+ } finally {
11477
+ clearInterval(heartbeat);
11478
+ await renewal;
11479
+ }
11480
+ if (acknowledge && !ownershipLost) {
11481
+ await updateQueueReservation(this.client, queueKey, reservation, "ack");
10939
11482
  }
10940
11483
  return true;
10941
11484
  }
10942
11485
  async run() {
10943
11486
  this.running = true;
10944
- while (!this.stopping) {
10945
- await this.processNext();
11487
+ try {
11488
+ while (!this.stopping) {
11489
+ await this.processNext();
11490
+ }
11491
+ } finally {
11492
+ this.running = false;
10946
11493
  }
10947
- this.running = false;
10948
11494
  }
10949
11495
  close() {
10950
11496
  this.client.close();
@@ -11003,9 +11549,9 @@ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
11003
11549
  return new QueueWorker(redisUrl, failedJobs);
11004
11550
  }
11005
11551
  // ../../src/core/queue/queueMetrics.ts
11006
- var {RedisClient: RedisClient6 } = globalThis.Bun;
11552
+ var {RedisClient: RedisClient5 } = globalThis.Bun;
11007
11553
  async function readRedisQueueDepth(redisUrl) {
11008
- const client = new RedisClient6(redisUrl);
11554
+ const client = new RedisClient5(redisUrl);
11009
11555
  const [high, defaultQueue, low] = await Promise.all([
11010
11556
  client.llen(QUEUE_HIGH_KEY),
11011
11557
  client.llen(QUEUE_LIST_KEY),
@@ -11225,58 +11771,9 @@ init_tenantContext();
11225
11771
  init_tenantDatabaseScope();
11226
11772
 
11227
11773
  // ../../src/core/tenant/tenantMiddleware.ts
11228
- import { createHash as createHash4 } from "crypto";
11229
-
11230
- // ../../src/core/tenant/databaseTenantContext.ts
11231
- init_defaultConnection();
11232
- init_connectionContext();
11233
- init_deferredModelEvents();
11234
- init_tenancyConfig();
11235
- function stringifyBypassIdentifier(identifier) {
11236
- if (typeof identifier === "number") {
11237
- return String(identifier);
11238
- }
11239
- return identifier.trim();
11240
- }
11241
- async function applyIdentifierToTransaction(transaction, identifier) {
11242
- await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
11243
- stringifyBypassIdentifier(identifier)
11244
- ]);
11245
- }
11246
- function assertBypassIdentifier(identifier) {
11247
- if (typeof identifier === "number") {
11248
- if (!Number.isInteger(identifier) || identifier <= 0) {
11249
- throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
11250
- }
11251
- return;
11252
- }
11253
- if (typeof identifier === "string" && identifier.trim() !== "") {
11254
- return;
11255
- }
11256
- throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
11257
- }
11258
- async function runWithScopedTenantTransaction(apply, callback) {
11259
- if (!isRlsTenancy()) {
11260
- return await callback();
11261
- }
11262
- const pool = getDefaultDatabasePool();
11263
- const begin = pool.begin;
11264
- if (typeof begin !== "function") {
11265
- throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
11266
- }
11267
- return await runWithDeferredModelEvents(async () => {
11268
- return await begin(async (transaction) => {
11269
- await apply(transaction);
11270
- return await runWithDatabaseConnection(transaction, callback);
11271
- });
11272
- });
11273
- }
11274
- async function runWithMigrationBypassForIdentifier(identifier, callback) {
11275
- assertBypassIdentifier(identifier);
11276
- return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
11277
- }
11278
-
11279
- // ../../src/core/tenant/tenantMiddleware.ts
11774
+ import { createHash as createHash5 } from "crypto";
11775
+ init_repositoryConnection();
11776
+ init_databaseTenantContext();
11280
11777
  init_tenancyConfig();
11281
11778
  init_tenantContext();
11282
11779
  init_tenantDatabaseScope();
@@ -11301,7 +11798,7 @@ async function resolveUserTenantId(userId) {
11301
11798
  });
11302
11799
  }
11303
11800
  function auditChecksum(payload) {
11304
- return createHash4("sha256").update(JSON.stringify(payload)).digest("hex");
11801
+ return createHash5("sha256").update(JSON.stringify(payload)).digest("hex");
11305
11802
  }
11306
11803
  async function resolveTenantForRequest(request) {
11307
11804
  const user = currentAuthUser();
@@ -12129,7 +12626,6 @@ export {
12129
12626
  namespacedRedisKey,
12130
12627
  noContentResponse,
12131
12628
  normalizeFieldErrors,
12132
- normalizeMetricPath,
12133
12629
  notFoundHtmlResponse,
12134
12630
  notModifiedResponse,
12135
12631
  otelServiceName,
@@ -12177,6 +12673,7 @@ export {
12177
12673
  repositoryConnection,
12178
12674
  requestIdMiddleware,
12179
12675
  requestMetaContext,
12676
+ requestTransactionRollback,
12180
12677
  requireConfiguredSecret,
12181
12678
  required,
12182
12679
  resetBoundDatabaseConnection,