@getstrata/core 2.1.0 → 2.2.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 (74) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/core/cache/createCacheStore.d.ts +3 -1
  3. package/dist/core/cache/redisCacheStore.d.ts +21 -8
  4. package/dist/core/cache/repository.d.ts +3 -2
  5. package/dist/core/cache/store.d.ts +4 -1
  6. package/dist/core/cache/taggedCache.d.ts +1 -1
  7. package/dist/core/contracts/di.d.ts +4 -2
  8. package/dist/core/contracts/serviceTokens.d.ts +7 -1
  9. package/dist/core/database/baseRepository.d.ts +0 -1
  10. package/dist/core/database/index.d.ts +1 -1
  11. package/dist/core/database/model.d.ts +51 -22
  12. package/dist/core/database/repositoryQuery.d.ts +16 -1
  13. package/dist/core/database/types.d.ts +25 -2
  14. package/dist/core/database/whereBuilder.d.ts +5 -5
  15. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  16. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  17. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  18. package/dist/core/http/requestMetaContext.d.ts +2 -0
  19. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  20. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  21. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  22. package/dist/core/queue/createAppQueue.d.ts +2 -1
  23. package/dist/core/queue/failedJobService.d.ts +3 -1
  24. package/dist/core/queue/index.d.ts +17 -2
  25. package/dist/core/queue/jobRunner.d.ts +7 -1
  26. package/dist/core/queue/publicQueue.d.ts +2 -1
  27. package/dist/core/queue/queueConfig.d.ts +3 -0
  28. package/dist/core/queue/redisQueue.d.ts +6 -7
  29. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  30. package/dist/core/queue/redisStreams.d.ts +73 -0
  31. package/dist/core/queue/resilientQueue.d.ts +2 -2
  32. package/dist/core/queue/types.d.ts +1 -0
  33. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  34. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  35. package/dist/core/scheduler/leases.d.ts +40 -0
  36. package/dist/core/scheduler/schedule.d.ts +29 -5
  37. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  38. package/dist/core/tenant/tenantContext.d.ts +5 -4
  39. package/dist/core/tenant/tenantMiddleware.d.ts +11 -1
  40. package/dist/core/tracing/otel.d.ts +28 -26
  41. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  42. package/dist/entries/audit/exportAuditLogs.js +14 -2
  43. package/dist/entries/auth/basicAuthGuard.js +81 -0
  44. package/dist/entries/auth/jwtGuard.js +81 -0
  45. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  46. package/dist/entries/auth/sessionGuard.js +81 -0
  47. package/dist/entries/cache/createCacheStore.js +365 -119
  48. package/dist/entries/cache/repository.js +5 -0
  49. package/dist/entries/contracts/serviceTokens.js +85 -0
  50. package/dist/entries/database/factory.js +0 -3
  51. package/dist/entries/database/model.js +1 -2486
  52. package/dist/entries/database/repositoryQuery.js +67 -9
  53. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  54. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  55. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  56. package/dist/entries/http/throttleMiddleware.js +65 -16
  57. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  58. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  59. package/dist/entries/queue/createAppQueue.js +748 -72
  60. package/dist/entries/queue/failedJobRepository.js +1 -1
  61. package/dist/entries/queue/failedJobService.js +5 -1
  62. package/dist/entries/queue/jobRunner.js +67 -2
  63. package/dist/entries/queue/publicQueue.js +746 -71
  64. package/dist/entries/queue/queueMetrics.js +761 -78
  65. package/dist/entries/queue/redisQueue.js +768 -148
  66. package/dist/entries/queue.js +30 -5
  67. package/dist/entries/scheduler/schedule.js +1 -72
  68. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  69. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  70. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  71. package/dist/framework/public-api.d.ts +14 -10
  72. package/dist/index.js +2444 -688
  73. package/dist/types/services.d.ts +3 -3
  74. package/package.json +17 -8
package/dist/index.js CHANGED
@@ -364,16 +364,6 @@ function currentTenantId() {
364
364
  }
365
365
  return tenant.id;
366
366
  }
367
- function rateLimitMultiplierForPlan(plan) {
368
- switch (plan) {
369
- case "enterprise":
370
- return 4;
371
- case "pro":
372
- return 2;
373
- default:
374
- return 1;
375
- }
376
- }
377
367
  var tenantContext;
378
368
  var init_tenantContext = __esm(() => {
379
369
  init_asyncContextStore();
@@ -656,9 +646,12 @@ async function runWithDeferredModelEvents(callback) {
656
646
  const existing = deferredModelEvents.getStore();
657
647
  if (existing) {
658
648
  existing.frames.push([]);
649
+ existing.rollbackFrames.push([]);
659
650
  try {
660
651
  const result = await callback();
661
652
  const nested = existing.frames.pop() ?? [];
653
+ const rollback = existing.rollbackFrames.pop() ?? [];
654
+ existing.rollbackFrames.at(-1)?.push(...rollback);
662
655
  const parent = existing.frames[existing.frames.length - 1];
663
656
  if (parent) {
664
657
  parent.push(...nested);
@@ -666,14 +659,26 @@ async function runWithDeferredModelEvents(callback) {
666
659
  return result;
667
660
  } catch (error) {
668
661
  existing.frames.pop();
662
+ for (const rollback of (existing.rollbackFrames.pop() ?? []).reverse())
663
+ rollback();
669
664
  throw error;
670
665
  }
671
666
  }
672
- const state = { frames: [[]] };
673
- const result = await deferredModelEvents.run(state, callback);
667
+ const state = { frames: [[]], rollbackFrames: [[]] };
668
+ let result;
669
+ try {
670
+ result = await deferredModelEvents.run(state, callback);
671
+ } catch (error) {
672
+ for (const rollback of (state.rollbackFrames[0] ?? []).reverse())
673
+ rollback();
674
+ throw error;
675
+ }
674
676
  await dispatchQueuedModelEvents(state.frames[0] ?? []);
675
677
  return result;
676
678
  }
679
+ function onDeferredModelRollback(callback) {
680
+ deferredModelEvents.getStore()?.rollbackFrames.at(-1)?.push(callback);
681
+ }
677
682
  var deferredModelEvents;
678
683
  var init_deferredModelEvents = __esm(() => {
679
684
  init_asyncContextStore();
@@ -1052,7 +1057,88 @@ function resolveUserId(user) {
1052
1057
  }
1053
1058
  return userId;
1054
1059
  }
1060
+ // ../../src/core/contracts/container.ts
1061
+ function createServiceToken(key) {
1062
+ if (!key.trim())
1063
+ throw new Error("Service token key must not be empty.");
1064
+ return key;
1065
+ }
1066
+
1067
+ class ServiceContainer {
1068
+ services = new Map;
1069
+ singletonFactories = new Map;
1070
+ bindings = new Map;
1071
+ set(key, value) {
1072
+ this.singletonFactories.delete(key);
1073
+ this.bindings.delete(key);
1074
+ this.services.set(key, value);
1075
+ return value;
1076
+ }
1077
+ singleton(key, factory) {
1078
+ this.bindings.delete(key);
1079
+ this.services.delete(key);
1080
+ this.singletonFactories.set(key, factory);
1081
+ }
1082
+ bind(key, factory) {
1083
+ this.singletonFactories.delete(key);
1084
+ this.services.delete(key);
1085
+ this.bindings.set(key, factory);
1086
+ }
1087
+ get(key) {
1088
+ if (this.services.has(key)) {
1089
+ return this.services.get(key);
1090
+ }
1091
+ const singletonFactory = this.singletonFactories.get(key);
1092
+ if (singletonFactory) {
1093
+ const value = singletonFactory(this);
1094
+ this.services.set(key, value);
1095
+ return value;
1096
+ }
1097
+ const binding = this.bindings.get(key);
1098
+ if (binding) {
1099
+ return binding(this);
1100
+ }
1101
+ throw new Error(`Service "${key}" is not registered.`);
1102
+ }
1103
+ resolve(key) {
1104
+ return this.get(key);
1105
+ }
1106
+ make(key) {
1107
+ return this.resolve(key);
1108
+ }
1109
+ instance(key, value) {
1110
+ return this.set(key, value);
1111
+ }
1112
+ has(key) {
1113
+ return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
1114
+ }
1115
+ }
1116
+
1117
+ class ConfigStore {
1118
+ values = new Map;
1119
+ set(key, value) {
1120
+ this.values.set(key, value);
1121
+ return value;
1122
+ }
1123
+ get(key) {
1124
+ return this.values.get(key);
1125
+ }
1126
+ require(key) {
1127
+ if (!this.values.has(key)) {
1128
+ throw new Error(`Config key "${key}" is not defined.`);
1129
+ }
1130
+ return this.values.get(key);
1131
+ }
1132
+ has(key) {
1133
+ return this.values.has(key);
1134
+ }
1135
+ }
1136
+
1055
1137
  // ../../src/core/contracts/serviceTokens.ts
1138
+ var CORE_THROTTLE_QUOTA_POLICY_TOKEN = createServiceToken("core.throttleQuotaPolicy");
1139
+ var CORE_PUBLIC_TENANCY_TOKEN = createServiceToken("core.publicTenancy");
1140
+ var CORE_TENANT_RESOLVER_TOKEN = createServiceToken("core.tenantResolver");
1141
+ var CORE_HTTP_CLEANUP_TOKEN = createServiceToken("core.httpCleanup");
1056
1142
  var CORE_QUEUE_TOKEN = "core.queue";
1057
1143
  var CORE_EVENT_BUS_TOKEN = "core.eventBus";
1058
1144
  var CORE_POLICY_GATE_TOKEN = "core.policyGate";
@@ -2054,6 +2140,12 @@ function setActiveApplicationContext(context) {
2054
2140
  activeContext = context;
2055
2141
  globalThis[APPLICATION_CONTEXT_KEY] = context;
2056
2142
  }
2143
+ function clearActiveApplicationContext(context) {
2144
+ if (readStoredApplicationContext() !== context)
2145
+ return;
2146
+ activeContext = undefined;
2147
+ delete globalThis[APPLICATION_CONTEXT_KEY];
2148
+ }
2057
2149
  function requireActiveApplicationContext() {
2058
2150
  const context = readStoredApplicationContext();
2059
2151
  if (!context) {
@@ -3247,19 +3339,17 @@ async function resolveTenant(tenantId) {
3247
3339
  if (!isTenancyEnabled()) {
3248
3340
  return {
3249
3341
  id: tenantId,
3250
- slug: "default",
3251
- plan: "free",
3252
- region: "eu"
3342
+ slug: "default"
3253
3343
  };
3254
3344
  }
3255
3345
  const rows = await repositoryConnection`
3256
- SELECT id, slug, plan, region
3346
+ SELECT id, slug
3257
3347
  FROM tenant
3258
3348
  WHERE id = ${tenantId}
3259
3349
  LIMIT 1
3260
3350
  `;
3261
3351
  const row = rows[0];
3262
- return row ? { id: row.id, slug: row.slug, plan: row.plan, region: row.region ?? "eu" } : null;
3352
+ return row ? { id: row.id, slug: row.slug } : null;
3263
3353
  }
3264
3354
 
3265
3355
  // ../../src/core/auth/scimAuthMiddleware.ts
@@ -3319,151 +3409,397 @@ function createTokenAbilityChecker() {
3319
3409
  }
3320
3410
  // ../../src/core/cache/redisCacheStore.ts
3321
3411
  var {RedisClient } = globalThis.Bun;
3322
- function cacheKeyPrefix() {
3323
- return namespacedRedisKey("cache:");
3324
- }
3325
- function cacheTagPrefix() {
3326
- return namespacedRedisKey("cache:tag:");
3327
- }
3412
+ var CACHE_SCRIPT = `
3413
+ local expected = {'hash', 'zset', 'zset', 'hash', 'zset'}
3414
+ for i, key in ipairs(KEYS) do
3415
+ local kind = redis.call('TYPE', key).ok
3416
+ if kind ~= 'none' and kind ~= expected[i] then return redis.error_reply('Unexpected cache key type') end
3417
+ end
3418
+ -- Redis eviction can remove one container without its companions. Never read
3419
+ -- orphaned values or leases after losing the expiry/order/generation indexes.
3420
+ local entries = redis.call('HLEN', KEYS[1])
3421
+ local order = redis.call('ZCARD', KEYS[3])
3422
+ if redis.call('ZCARD', KEYS[2]) ~= order or entries ~= (order > 0 and order + 1 or 0) then
3423
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3424
+ end
3425
+ if redis.call('HLEN', KEYS[4]) ~= redis.call('ZCARD', KEYS[5]) then
3426
+ redis.call('DEL', KEYS[4], KEYS[5])
3427
+ end
3428
+ local r = cjson.decode(ARGV[1])
3429
+ local time = redis.call('TIME')
3430
+ local now = tonumber(time[1]) * 1000 + tonumber(time[2]) / 1000
3431
+ local function removeEntry(key)
3432
+ local removed = redis.call('HDEL', KEYS[1], 'e:' .. key)
3433
+ redis.call('ZREM', KEYS[2], key)
3434
+ redis.call('ZREM', KEYS[3], key)
3435
+ return removed
3436
+ end
3437
+ local function removeMeta(field)
3438
+ redis.call('HDEL', KEYS[4], field)
3439
+ redis.call('ZREM', KEYS[5], field)
3440
+ end
3441
+ for _, key in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, r.capacity)) do removeEntry(key) end
3442
+ for _, field in ipairs(redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, r.metadataLimit)) do removeMeta(field) end
3443
+ local function expireContainers()
3444
+ local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
3445
+ if #last == 0 then
3446
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3447
+ elseif last[2] == 'inf' then
3448
+ for i = 1, 3 do redis.call('PERSIST', KEYS[i]) end
3449
+ else
3450
+ local ttl = math.max(1, math.ceil(tonumber(last[2]) - now))
3451
+ for i = 1, 3 do redis.call('PEXPIRE', KEYS[i], ttl) end
3452
+ end
3453
+ for i = 4, 5 do redis.call('PEXPIRE', KEYS[i], r.metadataTtlMs) end
3454
+ end
3455
+ local function response(value)
3456
+ expireContainers()
3457
+ return cjson.encode(value)
3458
+ end
3459
+ local function addTags(existing, added)
3460
+ local seen = {}
3461
+ local result = {}
3462
+ for _, tags in ipairs({existing, added}) do
3463
+ for _, tag in ipairs(tags) do
3464
+ if not seen[tag] then seen[tag] = true; table.insert(result, tag) end
3465
+ end
3466
+ end
3467
+ if #result > 32 then error('Cache entries support at most 32 tags') end
3468
+ return result
3469
+ end
3470
+ local function touch(key)
3471
+ local sequence = redis.call('HINCRBY', KEYS[1], '_sequence', 1)
3472
+ redis.call('ZADD', KEYS[3], sequence, key)
3473
+ end
3474
+ local function read(key, tags)
3475
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3476
+ if not raw then return nil end
3477
+ local entry = cjson.decode(raw)
3478
+ if #tags > 0 then
3479
+ entry.tags = addTags(entry.tags, tags)
3480
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3481
+ end
3482
+ touch(key)
3483
+ return entry.payload
3484
+ end
3485
+ local function write(key, payload, tags)
3486
+ local entry = {payload=payload, tags=tags}
3487
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3488
+ redis.call('ZADD', KEYS[2], r.ttlMs == 0 and '+inf' or now + r.ttlMs, key)
3489
+ touch(key)
3490
+ local overflow = redis.call('ZCARD', KEYS[3]) - r.capacity
3491
+ if overflow > 0 then
3492
+ for _, victim in ipairs(redis.call('ZRANGE', KEYS[3], 0, overflow - 1)) do removeEntry(victim) end
3493
+ end
3494
+ end
3495
+ local leaseField = 'l:' .. r.key
3496
+ local function lease()
3497
+ local raw = redis.call('HGET', KEYS[4], leaseField)
3498
+ if not raw then return nil end
3499
+ local value = cjson.decode(raw)
3500
+ if value.expires <= now or value.deadline <= now then removeMeta(leaseField); return nil end
3501
+ return value
3502
+ end
3503
+ local function metadataSpace(count)
3504
+ if redis.call('HLEN', KEYS[4]) + count > r.metadataLimit then
3505
+ -- Pressure sheds generation/lease state. Every old owner is fenced; cached
3506
+ -- entries remain valid because invalidation removes matching entries atomically.
3507
+ redis.call('DEL', KEYS[4], KEYS[5])
3508
+ end
3509
+ end
3510
+ local function putMeta(field, value, expires)
3511
+ redis.call('HSET', KEYS[4], field, value)
3512
+ redis.call('ZADD', KEYS[5], expires, field)
3513
+ end
3514
+ local function scopes()
3515
+ local fields = {}
3516
+ for _, tag in ipairs(r.tags) do table.insert(fields, 'g:tag:' .. tag) end
3517
+ for _, prefix in ipairs(r.prefixes) do table.insert(fields, 'g:prefix:' .. prefix) end
3518
+ return fields
3519
+ end
3520
+ local function matches(key, tags)
3521
+ if r.op == 'invalidate' then return key == r.key end
3522
+ if r.op == 'prefix' then return key == r.key or string.sub(key, 1, #r.key + 1) == r.key .. '?' end
3523
+ if r.op == 'tags' then
3524
+ for _, existing in ipairs(tags) do
3525
+ for _, wanted in ipairs(r.tags) do if wanted == existing then return true end end
3526
+ end
3527
+ end
3528
+ return false
3529
+ end
3530
+ if r.op == 'clear' then
3531
+ redis.call('DEL', unpack(KEYS))
3532
+ return cjson.encode({removed=0})
3533
+ elseif r.op == 'get' then
3534
+ local payload = read(r.key, r.tags)
3535
+ return response(payload and {kind='value', payload=payload} or {kind='missing'})
3536
+ elseif r.op == 'set' then
3537
+ local old = redis.call('HGET', KEYS[1], 'e:' .. r.key)
3538
+ local tags = old and cjson.decode(old).tags or {}
3539
+ write(r.key, r.payload, tags)
3540
+ removeMeta(leaseField)
3541
+ return response({written=true})
3542
+ elseif r.op == 'attach' then
3543
+ read(r.key, r.tags)
3544
+ return response({written=true})
3545
+ elseif r.op == 'size' then
3546
+ return response({size=redis.call('ZCARD', KEYS[3])})
3547
+ elseif r.op == 'tags' or r.op == 'prefix' or r.op == 'invalidate' then
3548
+ local generationFields = {}
3549
+ if r.op == 'tags' then
3550
+ for _, tag in ipairs(r.tags) do table.insert(generationFields, 'g:tag:' .. tag) end
3551
+ elseif r.op == 'prefix' then table.insert(generationFields, 'g:prefix:' .. r.key) end
3552
+ metadataSpace(#generationFields)
3553
+ for _, field in ipairs(generationFields) do putMeta(field, r.owner, now + r.metadataTtlMs) end
3554
+ local removed = 0
3555
+ for _, key in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
3556
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3557
+ if raw and matches(key, cjson.decode(raw).tags) then removed = removed + removeEntry(key) end
3558
+ end
3559
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do
3560
+ if string.sub(field, 1, 2) == 'l:' then
3561
+ local value = cjson.decode(redis.call('HGET', KEYS[4], field))
3562
+ if matches(string.sub(field, 3), value.tags) then removeMeta(field) end
3563
+ end
3564
+ end
3565
+ return response({removed=removed})
3566
+ elseif r.op == 'claim' then
3567
+ local payload = read(r.key, r.tags)
3568
+ if payload then return response({kind='value', payload=payload}) end
3569
+ local current = lease()
3570
+ if current then
3571
+ local shared = addTags(current.tags, r.tags)
3572
+ if #shared == #current.tags then return response({kind='wait'}) end
3573
+ -- A newly requested tag must not inherit a fill started before that tag's
3574
+ -- invalidation. Restart with the union of tags and a new owner.
3575
+ r.tags = shared
3576
+ removeMeta(leaseField)
3577
+ end
3578
+ local active = 0
3579
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do if string.sub(field, 1, 2) == 'l:' then active = active + 1 end end
3580
+ if active >= r.capacity then return response({kind='wait'}) end
3581
+ local fields = scopes()
3582
+ metadataSpace(#fields + 1)
3583
+ local generations = {}
3584
+ for _, field in ipairs(fields) do
3585
+ local token = redis.call('HGET', KEYS[4], field) or r.owner
3586
+ putMeta(field, token, now + r.metadataTtlMs)
3587
+ generations[field] = token
3588
+ end
3589
+ local value = {owner=r.owner, tags=r.tags, generations=generations, deadline=now + r.fillTimeoutMs, expires=now + r.leaseMs}
3590
+ putMeta(leaseField, cjson.encode(value), value.expires)
3591
+ return response({kind='owner'})
3592
+ elseif r.op == 'renew' or r.op == 'publish' or r.op == 'release' then
3593
+ local current = lease()
3594
+ if not current or current.owner ~= r.owner then return response({owned=false}) end
3595
+ if r.op == 'release' then removeMeta(leaseField); return response({owned=true}) end
3596
+ for field, token in pairs(current.generations) do
3597
+ if redis.call('HGET', KEYS[4], field) ~= token then removeMeta(leaseField); return response({owned=false}) end
3598
+ end
3599
+ if r.op == 'renew' then
3600
+ current.expires = math.min(current.deadline, now + r.leaseMs)
3601
+ putMeta(leaseField, cjson.encode(current), current.expires)
3602
+ else
3603
+ write(r.key, r.payload, current.tags)
3604
+ removeMeta(leaseField)
3605
+ end
3606
+ return response({owned=true})
3607
+ end
3608
+ return redis.error_reply('Unknown cache operation')
3609
+ `;
3328
3610
 
3329
3611
  class RedisCacheStore {
3330
3612
  ttlMs;
3331
3613
  maxEntries;
3332
3614
  client;
3333
- inflight = new Map;
3334
- keyTags = new Map;
3335
- constructor(redisUrl, ttlMs, maxEntries) {
3615
+ keys;
3616
+ options;
3617
+ fills = new Set;
3618
+ closed = false;
3619
+ constructor(redisUrl, ttlMs, maxEntries, options = {}) {
3336
3620
  this.ttlMs = ttlMs;
3337
3621
  this.maxEntries = maxEntries;
3338
- this.client = new RedisClient(redisUrl);
3622
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
3623
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
3624
+ if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 1e4)
3625
+ throw new RangeError("maxEntries must be between 1 and 10000.");
3626
+ this.options = {
3627
+ commandTimeoutMs: 5000,
3628
+ leaseMs: 1e4,
3629
+ fillTimeoutMs: 30000,
3630
+ waitTimeoutMs: 35000,
3631
+ pollMs: 25,
3632
+ metadataTtlMs: 60000,
3633
+ metadataLimit: 4096,
3634
+ ...options
3635
+ };
3636
+ for (const [key, value] of Object.entries(this.options))
3637
+ if (!Number.isSafeInteger(value) || value < 1)
3638
+ throw new RangeError(`${key} must be a positive safe integer.`);
3639
+ if (this.options.leaseMs < 30 || this.options.fillTimeoutMs < this.options.leaseMs || this.options.metadataTtlMs < this.options.fillTimeoutMs + this.options.leaseMs || this.options.metadataLimit < 66 || this.options.metadataLimit > 1e5)
3640
+ throw new RangeError("Invalid Redis cache lease, deadline or metadata bounds.");
3641
+ const namespace = namespacedRedisKey("cache:v3:");
3642
+ const slot = new Bun.CryptoHasher("sha256").update(namespace).digest("hex");
3643
+ this.keys = ["entries", "expiry", "order", "metadata", "metadata-expiry"].map((name) => `${namespace}{${slot}}:${name}`);
3644
+ this.client = new RedisClient(redisUrl, {
3645
+ connectionTimeout: this.options.commandTimeoutMs,
3646
+ maxRetries: 1
3647
+ });
3648
+ }
3649
+ close() {
3650
+ this.closed = true;
3651
+ for (const fill of this.fills)
3652
+ fill.abort(new Error("Redis cache store is closed."));
3653
+ this.client.close();
3339
3654
  }
3340
3655
  async get(key) {
3341
- const raw = await this.client.get(this.storageKey(key));
3342
- if (raw === null) {
3343
- return;
3344
- }
3345
- return JSON.parse(raw);
3656
+ const result = await this.execute("get", key);
3657
+ return result.kind === "value" ? JSON.parse(result.payload ?? "null") : undefined;
3346
3658
  }
3347
3659
  async set(key, value, ttlMs) {
3348
- const resolvedTtlMs = ttlMs ?? this.ttlMs;
3349
- const payload = JSON.stringify(value);
3350
- if (resolvedTtlMs > 0) {
3351
- await this.client.psetex(this.storageKey(key), resolvedTtlMs, payload);
3352
- } else {
3353
- await this.client.set(this.storageKey(key), payload);
3354
- }
3355
- await this.enforceMaxEntries();
3660
+ await this.execute("set", key, { payload: this.serialize(value), ttlMs: ttlMs ?? this.ttlMs });
3356
3661
  }
3357
3662
  async getOrSet(key, loader, ttlMs) {
3358
- const cached = await this.get(key);
3359
- if (cached !== undefined) {
3360
- return cached;
3361
- }
3362
- const inflightRequest = this.inflight.get(key);
3363
- if (inflightRequest) {
3364
- return inflightRequest;
3365
- }
3366
- const pendingRequest = loader().then(async (value) => {
3367
- await this.set(key, value, ttlMs);
3368
- return value;
3369
- }).finally(() => {
3370
- this.inflight.delete(key);
3371
- });
3372
- this.inflight.set(key, pendingRequest);
3373
- return pendingRequest;
3663
+ return this.getOrSetTagged(key, loader, [], ttlMs);
3664
+ }
3665
+ async getOrSetTagged(key, loader, tags, ttlMs) {
3666
+ const owner = crypto.randomUUID();
3667
+ const end = performance.now() + this.options.waitTimeoutMs;
3668
+ while (!this.closed) {
3669
+ const claim = await this.execute("claim", key, { owner, tags, ttlMs: ttlMs ?? this.ttlMs });
3670
+ if (claim.kind === "value")
3671
+ return JSON.parse(claim.payload ?? "null");
3672
+ if (claim.kind === "owner")
3673
+ return this.fill(key, owner, loader, ttlMs);
3674
+ if (performance.now() >= end)
3675
+ throw new Error("Redis cache fill wait deadline exceeded.");
3676
+ await Bun.sleep(this.options.pollMs);
3677
+ }
3678
+ throw new Error("Redis cache store is closed.");
3374
3679
  }
3375
3680
  async attachTags(key, tags) {
3376
- if (tags.length === 0) {
3377
- return;
3378
- }
3379
- let tagsForKey = this.keyTags.get(key);
3380
- if (!tagsForKey) {
3381
- tagsForKey = new Set;
3382
- this.keyTags.set(key, tagsForKey);
3383
- }
3384
- for (const tag of tags) {
3385
- tagsForKey.add(tag);
3386
- await this.client.sadd(this.tagKey(tag), key);
3387
- }
3681
+ await this.execute("attach", key, { tags });
3388
3682
  }
3389
3683
  async flushTags(tags) {
3390
- const keysToRemove = new Set;
3391
- for (const tag of tags) {
3392
- const members = await this.client.smembers(this.tagKey(tag));
3393
- for (const member of members) {
3394
- keysToRemove.add(member);
3395
- }
3396
- }
3397
- let removed = 0;
3398
- for (const key of keysToRemove) {
3399
- if (await this.invalidate(key)) {
3400
- removed += 1;
3401
- }
3402
- }
3403
- for (const tag of tags) {
3404
- await this.client.del(this.tagKey(tag));
3405
- }
3406
- return removed;
3684
+ return (await this.execute("tags", "", { tags })).removed ?? 0;
3407
3685
  }
3408
3686
  async invalidate(key) {
3409
- const deleted = await this.client.del(this.storageKey(key));
3410
- await this.detachKeyFromTags(key);
3411
- return deleted > 0;
3687
+ return ((await this.execute("invalidate", key)).removed ?? 0) > 0;
3412
3688
  }
3413
3689
  async invalidateByPrefix(prefix) {
3414
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3415
- let removed = 0;
3416
- for (const storageKey of keys) {
3417
- const key = storageKey.slice(cacheKeyPrefix().length);
3418
- if (key === prefix || key.startsWith(`${prefix}?`)) {
3419
- if (await this.invalidate(key)) {
3420
- removed += 1;
3421
- }
3422
- }
3423
- }
3424
- return removed;
3690
+ return (await this.execute("prefix", prefix)).removed ?? 0;
3425
3691
  }
3426
3692
  async clear() {
3427
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3428
- if (keys.length > 0) {
3429
- await this.client.del(...keys);
3430
- }
3431
- const tagKeys = await this.client.keys(`${cacheTagPrefix()}*`);
3432
- if (tagKeys.length > 0) {
3433
- await this.client.del(...tagKeys);
3434
- }
3435
- this.inflight.clear();
3436
- this.keyTags.clear();
3693
+ await this.execute("clear", "");
3437
3694
  }
3438
3695
  async size() {
3439
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3440
- return keys.length;
3441
- }
3442
- storageKey(key) {
3443
- return `${cacheKeyPrefix()}${key}`;
3444
- }
3445
- tagKey(tag) {
3446
- return `${cacheTagPrefix()}${tag}`;
3696
+ return (await this.execute("size", "")).size ?? 0;
3447
3697
  }
3448
- async detachKeyFromTags(key) {
3449
- const tags = this.keyTags.get(key);
3450
- if (!tags) {
3451
- return;
3698
+ async fill(key, owner, loader, ttlMs) {
3699
+ if (this.fills.size >= this.maxEntries) {
3700
+ await this.execute("release", key, { owner });
3701
+ throw new Error("Redis cache local fill capacity exceeded.");
3452
3702
  }
3453
- for (const tag of tags) {
3454
- await this.client.srem(this.tagKey(tag), key);
3703
+ const controller = new AbortController;
3704
+ this.fills.add(controller);
3705
+ let done = false;
3706
+ let renewing = false;
3707
+ const renew = setInterval(() => {
3708
+ if (renewing || done)
3709
+ return;
3710
+ renewing = true;
3711
+ this.execute("renew", key, { owner }).then((result) => {
3712
+ if (!result.owned)
3713
+ clearInterval(renew);
3714
+ }).catch((error) => {
3715
+ if (!done)
3716
+ controller.abort(error);
3717
+ }).finally(() => {
3718
+ renewing = false;
3719
+ });
3720
+ }, Math.max(10, Math.floor(this.options.leaseMs / 3)));
3721
+ const timeout = setTimeout(() => controller.abort(new Error("Redis cache fill deadline exceeded.")), this.options.fillTimeoutMs);
3722
+ let rejectAbort;
3723
+ const aborted = new Promise((_, reject) => {
3724
+ rejectAbort = () => reject(controller.signal.reason);
3725
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
3726
+ });
3727
+ try {
3728
+ const value = await Promise.race([
3729
+ Promise.resolve().then(() => loader(controller.signal)),
3730
+ aborted
3731
+ ]);
3732
+ done = true;
3733
+ clearInterval(renew);
3734
+ await this.execute("publish", key, {
3735
+ owner,
3736
+ payload: this.serialize(value),
3737
+ ttlMs: ttlMs ?? this.ttlMs
3738
+ });
3739
+ return value;
3740
+ } finally {
3741
+ done = true;
3742
+ clearInterval(renew);
3743
+ clearTimeout(timeout);
3744
+ controller.signal.removeEventListener("abort", rejectAbort);
3745
+ this.fills.delete(controller);
3746
+ if (!this.closed)
3747
+ await this.execute("release", key, { owner }).catch(() => {});
3455
3748
  }
3456
- this.keyTags.delete(key);
3457
3749
  }
3458
- async enforceMaxEntries() {
3459
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3460
- if (keys.length <= this.maxEntries) {
3461
- return;
3462
- }
3463
- const overflow = keys.length - this.maxEntries;
3464
- const keysToRemove = keys.slice(0, overflow);
3465
- if (keysToRemove.length > 0) {
3466
- await this.client.del(...keysToRemove);
3750
+ serialize(value) {
3751
+ const payload = JSON.stringify(value);
3752
+ if (typeof payload !== "string")
3753
+ throw new TypeError("Redis cache values must be JSON serializable.");
3754
+ return payload;
3755
+ }
3756
+ async execute(op, key, extra = {}) {
3757
+ if (this.closed)
3758
+ throw new Error("Redis cache store is closed.");
3759
+ if (Buffer.byteLength(key) > 4096)
3760
+ throw new RangeError("Invalid Redis cache identity.");
3761
+ const tags = Array.from(new Set(extra.tags ?? []));
3762
+ if (tags.length > 32 || tags.some((tag) => typeof tag !== "string" || Buffer.byteLength(tag) > 1024))
3763
+ throw new RangeError("Redis cache supports at most 32 tags of 1024 bytes each.");
3764
+ const prefixes = [key];
3765
+ for (let i = 0;i < key.length; i++)
3766
+ if (key[i] === "?")
3767
+ prefixes.push(key.slice(0, i));
3768
+ if (prefixes.length > 33)
3769
+ throw new RangeError("Redis cache identities support at most 32 query separators.");
3770
+ const ttlMs = extra.ttlMs ?? this.ttlMs;
3771
+ if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs) || ttlMs < 0)
3772
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
3773
+ let timer;
3774
+ const timeout = new Promise((_, reject) => {
3775
+ timer = setTimeout(() => {
3776
+ reject(new Error("Redis cache command deadline exceeded; recreate the store."));
3777
+ this.close();
3778
+ }, this.options.commandTimeoutMs);
3779
+ });
3780
+ try {
3781
+ const raw = await Promise.race([
3782
+ this.client.send("EVAL", [
3783
+ CACHE_SCRIPT,
3784
+ String(this.keys.length),
3785
+ ...this.keys,
3786
+ JSON.stringify({
3787
+ op,
3788
+ key,
3789
+ capacity: this.maxEntries,
3790
+ owner: crypto.randomUUID(),
3791
+ ...this.options,
3792
+ ...extra,
3793
+ tags,
3794
+ prefixes,
3795
+ ttlMs
3796
+ })
3797
+ ]),
3798
+ timeout
3799
+ ]);
3800
+ return JSON.parse(String(raw));
3801
+ } finally {
3802
+ clearTimeout(timer);
3467
3803
  }
3468
3804
  }
3469
3805
  }
@@ -3689,7 +4025,7 @@ function createCacheStore(options) {
3689
4025
  if (!options.redisUrl) {
3690
4026
  throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
3691
4027
  }
3692
- return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries);
4028
+ return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries, options.redis);
3693
4029
  }
3694
4030
  return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
3695
4031
  }
@@ -3702,6 +4038,8 @@ class TaggedCache {
3702
4038
  this.tags = tags;
3703
4039
  }
3704
4040
  async remember(key, callback, ttlMs) {
4041
+ if (this.store.getOrSetTagged)
4042
+ return this.store.getOrSetTagged(key, callback, this.tags, ttlMs);
3705
4043
  const value = await this.store.getOrSet(key, callback, ttlMs);
3706
4044
  await this.store.attachTags(key, this.tags);
3707
4045
  return value;
@@ -3718,6 +4056,9 @@ class CacheRepository {
3718
4056
  constructor(store) {
3719
4057
  this.store = store;
3720
4058
  }
4059
+ async close() {
4060
+ await this.store.close?.();
4061
+ }
3721
4062
  async get(key) {
3722
4063
  return this.store.get(key);
3723
4064
  }
@@ -3765,90 +4106,14 @@ var CACHE_TAGS = {
3765
4106
  careers: "careers",
3766
4107
  offers: "offers"
3767
4108
  };
3768
- // ../../src/core/contracts/container.ts
3769
- function createServiceToken(key) {
3770
- if (!key.trim())
3771
- throw new Error("Service token key must not be empty.");
3772
- return key;
3773
- }
3774
-
3775
- class ServiceContainer {
3776
- services = new Map;
3777
- singletonFactories = new Map;
3778
- bindings = new Map;
3779
- set(key, value) {
3780
- this.singletonFactories.delete(key);
3781
- this.bindings.delete(key);
3782
- this.services.set(key, value);
3783
- return value;
3784
- }
3785
- singleton(key, factory) {
3786
- this.bindings.delete(key);
3787
- this.services.delete(key);
3788
- this.singletonFactories.set(key, factory);
3789
- }
3790
- bind(key, factory) {
3791
- this.singletonFactories.delete(key);
3792
- this.services.delete(key);
3793
- this.bindings.set(key, factory);
3794
- }
3795
- get(key) {
3796
- if (this.services.has(key)) {
3797
- return this.services.get(key);
3798
- }
3799
- const singletonFactory = this.singletonFactories.get(key);
3800
- if (singletonFactory) {
3801
- const value = singletonFactory(this);
3802
- this.services.set(key, value);
3803
- return value;
3804
- }
3805
- const binding = this.bindings.get(key);
3806
- if (binding) {
3807
- return binding(this);
3808
- }
3809
- throw new Error(`Service "${key}" is not registered.`);
3810
- }
3811
- resolve(key) {
3812
- return this.get(key);
3813
- }
3814
- make(key) {
3815
- return this.resolve(key);
3816
- }
3817
- instance(key, value) {
3818
- return this.set(key, value);
3819
- }
3820
- has(key) {
3821
- return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
3822
- }
3823
- }
3824
-
3825
- class ConfigStore {
3826
- values = new Map;
3827
- set(key, value) {
3828
- this.values.set(key, value);
3829
- return value;
3830
- }
3831
- get(key) {
3832
- return this.values.get(key);
3833
- }
3834
- require(key) {
3835
- if (!this.values.has(key)) {
3836
- throw new Error(`Config key "${key}" is not defined.`);
3837
- }
3838
- return this.values.get(key);
3839
- }
3840
- has(key) {
3841
- return this.values.has(key);
3842
- }
3843
- }
3844
- // ../../src/core/database/baseRepository.ts
3845
- init_eventBus();
3846
-
3847
- // ../../src/core/events/index.ts
3848
- init_deferredModelEvents();
3849
- init_eventBus();
3850
- function modelEventName(tableName, action) {
3851
- return `${tableName}.${action}`;
4109
+ // ../../src/core/database/baseRepository.ts
4110
+ init_eventBus();
4111
+
4112
+ // ../../src/core/events/index.ts
4113
+ init_deferredModelEvents();
4114
+ init_eventBus();
4115
+ function modelEventName(tableName, action) {
4116
+ return `${tableName}.${action}`;
3852
4117
  }
3853
4118
 
3854
4119
  // ../../src/core/pagination/index.ts
@@ -3864,6 +4129,7 @@ function buildPaginationMeta(input) {
3864
4129
 
3865
4130
  // ../../src/core/database/baseRepository.ts
3866
4131
  init_connectionContext();
4132
+ init_dialect();
3867
4133
 
3868
4134
  // ../../src/core/database/query.ts
3869
4135
  init_dialect();
@@ -4684,6 +4950,7 @@ class RepositoryQuery {
4684
4950
  queryOptions;
4685
4951
  eagerLoads = [];
4686
4952
  whereNodes = [];
4953
+ protectedWhere = [];
4687
4954
  constructor(repository, whereClause = {}, queryOptions = {}) {
4688
4955
  this.repository = repository;
4689
4956
  this.whereClause = whereClause;
@@ -4693,10 +4960,23 @@ class RepositoryQuery {
4693
4960
  if (typeof input === "function") {
4694
4961
  const builder = new WhereBuilder;
4695
4962
  input(builder);
4696
- this.whereNodes.push(...builder.nodes);
4963
+ if (builder.nodes.length > 0)
4964
+ this.whereNodes.push({ kind: "and", group: builder.nodes });
4697
4965
  return this;
4698
4966
  }
4699
- this.whereClause = { ...this.whereClause, ...input };
4967
+ this.whereNodes.push({ kind: "and", where: { ...input } });
4968
+ return this;
4969
+ }
4970
+ protectWhere() {
4971
+ const nodes = [];
4972
+ if (Object.keys(this.whereClause).length > 0) {
4973
+ nodes.push({ kind: "and", where: this.whereClause });
4974
+ }
4975
+ nodes.push(...this.whereNodes);
4976
+ if (nodes.length > 0)
4977
+ this.protectedWhere.push({ kind: "and", group: nodes });
4978
+ this.whereClause = {};
4979
+ this.whereNodes.length = 0;
4700
4980
  return this;
4701
4981
  }
4702
4982
  orWhere(input) {
@@ -4810,9 +5090,10 @@ class RepositoryQuery {
4810
5090
  });
4811
5091
  return this;
4812
5092
  }
4813
- withMorphTo(as, relation, repositoriesByType, options = {}) {
5093
+ withMorphTo(as, relation, repositoriesByType, options = {}, optionsByType) {
4814
5094
  this.eagerLoads.push({
4815
5095
  kind: "morphTo",
5096
+ morphOptions: optionsByType,
4816
5097
  as,
4817
5098
  relation,
4818
5099
  repository: this.repository,
@@ -4883,6 +5164,19 @@ class RepositoryQuery {
4883
5164
  }
4884
5165
  return row[column];
4885
5166
  }
5167
+ async project(columns, limit) {
5168
+ if (columns.length === 0)
5169
+ throw new RangeError("A projection requires at least one column.");
5170
+ const table = this.repository.getTable();
5171
+ for (const column of columns)
5172
+ if (!table.columns.includes(column))
5173
+ throw new TypeError(`Unknown projection column: ${column}`);
5174
+ return await this.repository.findAll({
5175
+ ...this.buildOptions(),
5176
+ ...limit === undefined ? {} : { limit },
5177
+ select: uniqueColumnSelect(table.name, columns)
5178
+ });
5179
+ }
4886
5180
  async attachToRows(rows) {
4887
5181
  return await this.attach(rows);
4888
5182
  }
@@ -4893,11 +5187,24 @@ class RepositoryQuery {
4893
5187
  perPage: options.perPage
4894
5188
  });
4895
5189
  }
5190
+ async chunk(count, callback) {
5191
+ await this.repository.chunk(count, async (rows) => callback(await this.attach(rows)), this.buildOptions());
5192
+ }
5193
+ async cursorPaginate(options) {
5194
+ const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
5195
+ return { ...page, data: await this.attach(page.data) };
5196
+ }
5197
+ getOptions() {
5198
+ return this.buildOptions();
5199
+ }
4896
5200
  buildOptions() {
5201
+ if (this.protectedWhere.length === 0) {
5202
+ return { ...this.queryOptions, where: this.whereClause, whereNodes: this.whereNodes };
5203
+ }
4897
5204
  return {
4898
5205
  ...this.queryOptions,
4899
- where: this.whereClause,
4900
- whereNodes: this.whereNodes
5206
+ where: {},
5207
+ whereNodes: [...this.protectedWhere, { kind: "and", group: [...this.whereNodes] }]
4901
5208
  };
4902
5209
  }
4903
5210
  addJoin(type, left, right) {
@@ -4974,10 +5281,27 @@ class RepositoryQuery {
4974
5281
  }
4975
5282
  if (load.kind === "morphTo") {
4976
5283
  const relation = load.relation;
4977
- const grouped = await this.repository.loadMorphToForChildren(rows, relation, load.morphRepositories ?? new Map, load.options);
4978
- for (const row of result) {
4979
- row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
4980
- }
5284
+ const repositories = load.morphRepositories ?? new Map;
5285
+ const partitions = new Map;
5286
+ rows.forEach((row, index) => {
5287
+ const type = String(row[relation.morphTypeKey]);
5288
+ const target = result[index];
5289
+ if (!target || !repositories.has(type))
5290
+ return;
5291
+ const partition = partitions.get(type) ?? { rows: [], targets: [] };
5292
+ partition.rows.push(row);
5293
+ partition.targets.push(target);
5294
+ partitions.set(type, partition);
5295
+ });
5296
+ await Promise.all([...partitions.entries()].map(async ([type, partition]) => {
5297
+ const repository = repositories.get(type);
5298
+ if (!repository)
5299
+ return;
5300
+ const grouped = await this.repository.loadMorphToForChildren(partition.rows, relation, new Map([[type, repository]]), load.morphOptions?.get(type) ?? load.options);
5301
+ for (const row of partition.targets) {
5302
+ row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
5303
+ }
5304
+ }));
4981
5305
  return;
4982
5306
  }
4983
5307
  const relation = load.relation;
@@ -5099,14 +5423,20 @@ class BaseRepository {
5099
5423
  if (!Number.isInteger(perPage) || perPage <= 0) {
5100
5424
  throw new Error("Cursor page size must be a positive integer.");
5101
5425
  }
5102
- const cursorWhere = { ...where };
5426
+ const filters = [];
5427
+ if (Object.keys(where).length > 0)
5428
+ filters.push({ kind: "and", where });
5429
+ filters.push(...whereNodes ?? []);
5430
+ const cursorFilters = [{ kind: "and", group: filters }];
5103
5431
  if (cursor !== undefined) {
5432
+ const cursorWhere = {};
5104
5433
  cursorWhere[cursorColumn] = direction === "asc" ? { gt: cursor } : { lt: cursor };
5434
+ cursorFilters.push({ kind: "and", where: cursorWhere });
5105
5435
  }
5106
5436
  const rows = await this.findAll({
5107
5437
  ...queryOptions,
5108
- where: cursorWhere,
5109
- whereNodes,
5438
+ where: {},
5439
+ whereNodes: cursorFilters,
5110
5440
  orderBy: { [cursorColumn]: direction },
5111
5441
  limit: perPage + 1
5112
5442
  });
@@ -5330,46 +5660,29 @@ class BaseRepository {
5330
5660
  if (parents.length === 0) {
5331
5661
  return indexHasManyThroughRelation(parents, [], relation);
5332
5662
  }
5663
+ if (options.joins?.some((join) => join.table === relation.throughTable)) {
5664
+ throw new Error("A related model scope joins the through table twice; use an explicitly aliased correlated predicate instead.");
5665
+ }
5333
5666
  const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5334
5667
  const throughParentKey = relation.throughParentKey ?? "__through_parent_id";
5335
5668
  const farTable = this.table.name;
5336
5669
  const columns = this.table.columns.map((column) => `${qualifyColumn(farTable, column)}`).join(", ");
5337
- const placeholders = parentIds.map((_, index) => `$${index + 1}`).join(", ");
5338
- const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds.length);
5339
- const softDelete = this.throughSoftDeleteClause(options);
5340
- const sql = `SELECT ${columns}, ${qualifyColumn(relation.throughTable, relation.firstKey)} AS ${throughParentKey} FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(relation.throughTable)} ON ${qualifyColumn(relation.throughTable, relation.secondLocalKey)} = ${qualifyColumn(farTable, relation.secondKey)} WHERE ${qualifyColumn(relation.throughTable, relation.firstKey)} IN (${placeholders})${softDelete}${extraWhere}`;
5670
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5671
+ const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds);
5672
+ const sql = `SELECT ${columns}, ${qualifyColumn(relation.throughTable, relation.firstKey)} AS ${throughParentKey} FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(relation.throughTable)} ON ${qualifyColumn(relation.throughTable, relation.secondLocalKey)} = ${qualifyColumn(farTable, relation.secondKey)}${buildJoinClause(options.joins)} WHERE ${qualifyColumn(relation.throughTable, relation.firstKey)} IN (${placeholders})${extraWhere}`;
5341
5673
  const children = await this.connection.unsafe(sql, [
5342
5674
  ...parentIds,
5343
5675
  ...extraParams
5344
5676
  ]);
5345
5677
  return indexHasManyThroughRelation(parents, children, relation);
5346
5678
  }
5347
- throughSoftDeleteClause(options) {
5348
- const column = resolveSoftDeleteColumn(this.table);
5349
- if (!column) {
5350
- return "";
5351
- }
5352
- const qualified = qualifyColumn(this.table.name, column);
5353
- if (options.onlyTrashed) {
5354
- return ` AND ${qualified} IS NOT NULL`;
5355
- }
5356
- if (options.withTrashed) {
5357
- return "";
5358
- }
5359
- return ` AND ${qualified} IS NULL`;
5360
- }
5361
- buildThroughWhere(options, paramOffset = 1) {
5362
- const where = options.where ?? {};
5363
- const entries = Object.entries(where);
5364
- if (entries.length === 0) {
5365
- return { text: "", params: [] };
5366
- }
5367
- const params = [];
5368
- const clauses = entries.map(([column, value], index) => {
5369
- params.push(value);
5370
- return `${qualifyColumn(this.table.name, column)} = $${paramOffset + index + 1}`;
5371
- });
5372
- return { text: ` AND ${clauses.join(" AND ")}`, params };
5679
+ buildThroughWhere(options, parentIds) {
5680
+ const parameters = [...parentIds];
5681
+ const { clause } = buildQueryWhereClause(this.table, options, options.whereNodes, parameters);
5682
+ return {
5683
+ text: clause ? ` AND (${clause.replace(/^ WHERE /, "")})` : "",
5684
+ params: parameters.slice(parentIds.length)
5685
+ };
5373
5686
  }
5374
5687
  async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
5375
5688
  if (children.length === 0) {
@@ -5436,7 +5749,7 @@ class BaseRepository {
5436
5749
  return indexBelongsToManyRelation(parents, [], [], relation);
5437
5750
  }
5438
5751
  const parentIds = [...new Set(parents.map((parent) => parent[relation.parentKey]))];
5439
- const placeholders = parentIds.map((_, index) => `$${index + 1}`).join(", ");
5752
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5440
5753
  const pivotRows = await this.connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} IN (${placeholders})`, parentIds);
5441
5754
  if (pivotRows.length === 0) {
5442
5755
  return indexBelongsToManyRelation(parents, [], [], relation);
@@ -5794,7 +6107,12 @@ async function freshDatabase(db, migrations, options = {}) {
5794
6107
  }
5795
6108
  await runFresh();
5796
6109
  }
6110
+ // ../../src/core/database/model.ts
6111
+ import { isDeepStrictEqual } from "util";
6112
+ init_deferredModelEvents();
6113
+
5797
6114
  // ../../src/core/database/relationQuery.ts
6115
+ init_dialect();
5798
6116
  function asWhere(where) {
5799
6117
  return where;
5800
6118
  }
@@ -5816,13 +6134,48 @@ function pluckFromQuery(query, column, keyBy) {
5816
6134
  }
5817
6135
  return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
5818
6136
  }
6137
+ function scopedRelatedQuery(model, repository = model.repository()) {
6138
+ const query = repository.query();
6139
+ const scopedModel = model;
6140
+ return scopedModel.scopeQuery?.(query) ?? query;
6141
+ }
6142
+ function relatedOptions(model, filters, options = {}) {
6143
+ const query = scopedRelatedQuery(model);
6144
+ for (const filter of filters)
6145
+ query.where(filter);
6146
+ return { ...query.getOptions(), ...options };
6147
+ }
6148
+ function relatedRead(model, connection, binding, filters, options = {}) {
6149
+ const query = scopedRelatedQuery(model, model.repository().withConnection(connection));
6150
+ query.where(binding).protectWhere();
6151
+ for (const filter of filters)
6152
+ query.where(filter);
6153
+ if (options.orderBy)
6154
+ query.orderBy(options.orderBy);
6155
+ if (options.limit !== undefined)
6156
+ query.limit(options.limit);
6157
+ return query;
6158
+ }
6159
+ function relatedConditions(model, filters, options = {}, parameters = [], reservedTables = []) {
6160
+ const compiled = relatedOptions(model, filters, options);
6161
+ if (compiled.joins?.some((join) => reservedTables.includes(join.table))) {
6162
+ throw new Error("A related model scope joins a reserved relation table; use an explicitly aliased correlated predicate instead.");
6163
+ }
6164
+ const extra = buildQueryWhereClause(model.repository().getTable(), compiled, compiled.whereNodes, parameters);
6165
+ const predicate = extra.clause.replace(/^ WHERE /, "");
6166
+ return {
6167
+ sql: predicate ? ` AND (${predicate})` : "",
6168
+ joins: buildJoinClause(compiled.joins),
6169
+ params: extra.params
6170
+ };
6171
+ }
5819
6172
 
5820
6173
  class HasManyRelationQuery {
5821
6174
  parent;
5822
6175
  related;
5823
6176
  relation;
5824
6177
  kind = "hasMany";
5825
- extraWhere = {};
6178
+ extraWhere = [];
5826
6179
  extraOptions = {};
5827
6180
  constructor(parent, related, relation) {
5828
6181
  this.parent = parent;
@@ -5830,7 +6183,7 @@ class HasManyRelationQuery {
5830
6183
  this.relation = relation;
5831
6184
  }
5832
6185
  where(where) {
5833
- this.extraWhere = { ...this.extraWhere, ...where };
6186
+ this.extraWhere.push({ ...where });
5834
6187
  return this;
5835
6188
  }
5836
6189
  orderBy(orderBy) {
@@ -5842,7 +6195,7 @@ class HasManyRelationQuery {
5842
6195
  return this;
5843
6196
  }
5844
6197
  applyEagerLoad(query, alias) {
5845
- query.withHasMany(alias, this.relation, this.related.repository(), this.extraOptions);
6198
+ query.withHasMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
5846
6199
  }
5847
6200
  hydrateEager(row, alias) {
5848
6201
  const value = row[alias] ?? [];
@@ -5851,24 +6204,12 @@ class HasManyRelationQuery {
5851
6204
  }
5852
6205
  toExistsClause(parentTable) {
5853
6206
  const childTable = this.related.repository().getTable().name;
5854
- const extra = buildAdvancedWhereClause(childTable, this.extraWhere, [], []);
5855
- const extraSql = extra.clause.replace(/^ WHERE /, "");
5856
- const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)} WHERE ${qualifyColumn(childTable, this.relation.foreignKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
6207
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable]);
6208
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)}${extra.joins} WHERE ${qualifyColumn(childTable, this.relation.foreignKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extra.sql}`;
5857
6209
  return { sql, params: extra.params };
5858
6210
  }
5859
6211
  scopedQuery() {
5860
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
5861
- let query = repository.query(asWhere({
5862
- [this.relation.foreignKey]: this.parent.get(this.relation.localKey),
5863
- ...this.extraWhere
5864
- }));
5865
- if (this.extraOptions.orderBy) {
5866
- query = query.orderBy(this.extraOptions.orderBy);
5867
- }
5868
- if (this.extraOptions.limit !== undefined) {
5869
- query = query.limit(this.extraOptions.limit);
5870
- }
5871
- return query;
6212
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({ [this.relation.foreignKey]: this.parent.get(this.relation.localKey) }), this.extraWhere, this.extraOptions);
5872
6213
  }
5873
6214
  async get() {
5874
6215
  const rows = await this.scopedQuery().get();
@@ -5934,7 +6275,7 @@ class HasOneRelationQuery {
5934
6275
  return this;
5935
6276
  }
5936
6277
  applyEagerLoad(query, alias) {
5937
- this.inner.limit(1).applyEagerLoad(query, alias);
6278
+ this.inner.applyEagerLoad(query, alias);
5938
6279
  }
5939
6280
  hydrateEager(row, alias) {
5940
6281
  const hydrated = this.inner.hydrateEager(row, alias);
@@ -5975,7 +6316,7 @@ class BelongsToRelationQuery {
5975
6316
  related;
5976
6317
  relation;
5977
6318
  kind = "belongsTo";
5978
- extraWhere = {};
6319
+ extraWhere = [];
5979
6320
  extraOptions = {};
5980
6321
  constructor(parent, related, relation) {
5981
6322
  this.parent = parent;
@@ -5983,7 +6324,7 @@ class BelongsToRelationQuery {
5983
6324
  this.relation = relation;
5984
6325
  }
5985
6326
  where(where) {
5986
- this.extraWhere = { ...this.extraWhere, ...where };
6327
+ this.extraWhere.push({ ...where });
5987
6328
  return this;
5988
6329
  }
5989
6330
  orderBy(orderBy) {
@@ -5991,7 +6332,7 @@ class BelongsToRelationQuery {
5991
6332
  return this;
5992
6333
  }
5993
6334
  applyEagerLoad(query, alias) {
5994
- query.withBelongsTo(alias, this.relation, this.related.repository(), this.extraOptions);
6335
+ query.withBelongsTo(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
5995
6336
  }
5996
6337
  hydrateEager(row, alias) {
5997
6338
  const value = row[alias];
@@ -5999,9 +6340,8 @@ class BelongsToRelationQuery {
5999
6340
  }
6000
6341
  toExistsClause(parentTable) {
6001
6342
  const relatedTable = this.related.repository().getTable().name;
6002
- const extra = buildAdvancedWhereClause(relatedTable, this.extraWhere, [], []);
6003
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6004
- const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)} WHERE ${qualifyColumn(relatedTable, this.relation.ownerKey)} = ${qualifyColumn(parentTable, this.relation.foreignKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
6343
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable]);
6344
+ const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)}${extra.joins} WHERE ${qualifyColumn(relatedTable, this.relation.ownerKey)} = ${qualifyColumn(parentTable, this.relation.foreignKey)}${extra.sql}`;
6005
6345
  return { sql, params: extra.params };
6006
6346
  }
6007
6347
  async get() {
@@ -6026,12 +6366,7 @@ class BelongsToRelationQuery {
6026
6366
  if (foreign === null || foreign === undefined) {
6027
6367
  return null;
6028
6368
  }
6029
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
6030
- let query = repository.query(asWhere({ [this.relation.ownerKey]: foreign, ...this.extraWhere }));
6031
- if (this.extraOptions.orderBy) {
6032
- query = query.orderBy(this.extraOptions.orderBy);
6033
- }
6034
- return query;
6369
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({ [this.relation.ownerKey]: foreign }), this.extraWhere, this.extraOptions);
6035
6370
  }
6036
6371
  then(onfulfilled, onrejected) {
6037
6372
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6053,7 +6388,7 @@ class BelongsToManyRelationQuery {
6053
6388
  related;
6054
6389
  relation;
6055
6390
  kind = "belongsToMany";
6056
- extraWhere = {};
6391
+ extraWhere = [];
6057
6392
  extraOptions = {};
6058
6393
  pivotValues = {};
6059
6394
  constructor(parent, related, relation) {
@@ -6062,7 +6397,7 @@ class BelongsToManyRelationQuery {
6062
6397
  this.relation = relation;
6063
6398
  }
6064
6399
  where(where) {
6065
- this.extraWhere = { ...this.extraWhere, ...where };
6400
+ this.extraWhere.push({ ...where });
6066
6401
  return this;
6067
6402
  }
6068
6403
  orderBy(orderBy) {
@@ -6070,7 +6405,7 @@ class BelongsToManyRelationQuery {
6070
6405
  return this;
6071
6406
  }
6072
6407
  applyEagerLoad(query, alias) {
6073
- query.withBelongsToMany(alias, this.relation, this.related.repository(), this.extraOptions);
6408
+ query.withBelongsToMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6074
6409
  }
6075
6410
  withPivotValues(values) {
6076
6411
  this.pivotValues = { ...this.pivotValues, ...values };
@@ -6083,31 +6418,16 @@ class BelongsToManyRelationQuery {
6083
6418
  }
6084
6419
  toExistsClause(parentTable) {
6085
6420
  const relatedTable = this.related.repository().getTable().name;
6086
- const extra = buildAdvancedWhereClause(relatedTable, this.extraWhere, [], []);
6087
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6088
- const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)} INNER JOIN ${quoteIdentifier(this.relation.pivotTable)} ON ${qualifyColumn(this.relation.pivotTable, this.relation.relatedPivotKey)} = ${qualifyColumn(relatedTable, this.relation.relatedKey)} WHERE ${qualifyColumn(this.relation.pivotTable, this.relation.foreignPivotKey)} = ${qualifyColumn(parentTable, this.relation.parentKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
6421
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable, this.relation.pivotTable]);
6422
+ const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)} INNER JOIN ${quoteIdentifier(this.relation.pivotTable)} ON ${qualifyColumn(this.relation.pivotTable, this.relation.relatedPivotKey)} = ${qualifyColumn(relatedTable, this.relation.relatedKey)}${extra.joins} WHERE ${qualifyColumn(this.relation.pivotTable, this.relation.foreignPivotKey)} = ${qualifyColumn(parentTable, this.relation.parentKey)}${extra.sql}`;
6089
6423
  return { sql, params: extra.params };
6090
6424
  }
6091
6425
  connection() {
6092
6426
  return this.parent.getRepository().getConnection();
6093
6427
  }
6094
6428
  async get() {
6095
- const parentId = this.parent.get(this.relation.parentKey);
6096
- const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
6097
- if (pivotRows.length === 0) {
6098
- return [];
6099
- }
6100
- const relatedIds = [
6101
- ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
6102
- ];
6103
- const repository = this.related.repository().withConnection(this.connection());
6104
- const rows = await repository.findAll({
6105
- ...this.extraOptions,
6106
- where: asWhere({
6107
- [this.relation.relatedKey]: relatedIds,
6108
- ...this.extraWhere
6109
- })
6110
- });
6429
+ const query = await this.relatedQuery();
6430
+ const rows = query ? await query.get() : [];
6111
6431
  return rows.map((row) => this.related.newFromRecord(row));
6112
6432
  }
6113
6433
  async first() {
@@ -6115,9 +6435,8 @@ class BelongsToManyRelationQuery {
6115
6435
  return rows[0] ?? null;
6116
6436
  }
6117
6437
  async count() {
6118
- const parentId = this.parent.get(this.relation.parentKey);
6119
- const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
6120
- return Number(rows[0]?.count ?? 0);
6438
+ const query = await this.relatedQuery();
6439
+ return query ? query.count() : 0;
6121
6440
  }
6122
6441
  async pluck(column, keyBy) {
6123
6442
  return pluckFromQuery(await this.relatedQuery(), column, keyBy);
@@ -6128,21 +6447,14 @@ class BelongsToManyRelationQuery {
6128
6447
  }
6129
6448
  async relatedQuery() {
6130
6449
  const parentId = this.parent.get(this.relation.parentKey);
6131
- const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
6450
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = ${currentSqlDialect().placeholder(1)}`, [parentId]);
6132
6451
  if (pivotRows.length === 0) {
6133
6452
  return null;
6134
6453
  }
6135
6454
  const relatedIds = [
6136
6455
  ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
6137
6456
  ];
6138
- let query = this.related.repository().withConnection(this.connection()).query(asWhere({
6139
- [this.relation.relatedKey]: relatedIds,
6140
- ...this.extraWhere
6141
- }));
6142
- if (this.extraOptions.orderBy) {
6143
- query = query.orderBy(this.extraOptions.orderBy);
6144
- }
6145
- return query;
6457
+ return relatedRead(this.related, this.connection(), asWhere({ [this.relation.relatedKey]: relatedIds }), this.extraWhere, this.extraOptions);
6146
6458
  }
6147
6459
  then(onfulfilled, onrejected) {
6148
6460
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6198,7 +6510,7 @@ class MorphManyRelationQuery {
6198
6510
  related;
6199
6511
  relation;
6200
6512
  kind = "morphMany";
6201
- extraWhere = {};
6513
+ extraWhere = [];
6202
6514
  extraOptions = {};
6203
6515
  constructor(parent, related, relation) {
6204
6516
  this.parent = parent;
@@ -6206,11 +6518,11 @@ class MorphManyRelationQuery {
6206
6518
  this.relation = relation;
6207
6519
  }
6208
6520
  where(where) {
6209
- this.extraWhere = { ...this.extraWhere, ...where };
6521
+ this.extraWhere.push({ ...where });
6210
6522
  return this;
6211
6523
  }
6212
6524
  applyEagerLoad(query, alias) {
6213
- query.withMorphMany(alias, this.relation, this.related.repository(), this.extraOptions);
6525
+ query.withMorphMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6214
6526
  }
6215
6527
  hydrateEager(row, alias) {
6216
6528
  const value = row[alias] ?? [];
@@ -6219,20 +6531,18 @@ class MorphManyRelationQuery {
6219
6531
  }
6220
6532
  toExistsClause(parentTable) {
6221
6533
  const childTable = this.related.repository().getTable().name;
6222
- const extra = buildAdvancedWhereClause(childTable, {
6223
- [this.relation.morphTypeKey]: this.relation.morphType,
6534
+ const extra = relatedConditions(this.related, [
6535
+ asWhere({ [this.relation.morphTypeKey]: this.relation.morphType }),
6224
6536
  ...this.extraWhere
6225
- }, [], []);
6226
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6227
- const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)} WHERE ${qualifyColumn(childTable, this.relation.morphIdKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
6537
+ ], this.extraOptions, [], [parentTable]);
6538
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)}${extra.joins} WHERE ${qualifyColumn(childTable, this.relation.morphIdKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extra.sql}`;
6228
6539
  return { sql, params: extra.params };
6229
6540
  }
6230
6541
  scopedQuery() {
6231
- return this.related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({
6542
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({
6232
6543
  [this.relation.morphTypeKey]: this.relation.morphType,
6233
- [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
6234
- ...this.extraWhere
6235
- }));
6544
+ [this.relation.morphIdKey]: this.parent.get(this.relation.localKey)
6545
+ }), this.extraWhere);
6236
6546
  }
6237
6547
  async get() {
6238
6548
  const rows = await this.scopedQuery().get();
@@ -6319,14 +6629,14 @@ class MorphToRelationQuery {
6319
6629
  relatedByType;
6320
6630
  relation;
6321
6631
  kind = "morphTo";
6322
- extraWhere = {};
6632
+ extraWhere = [];
6323
6633
  constructor(parent, relatedByType, relation) {
6324
6634
  this.parent = parent;
6325
6635
  this.relatedByType = relatedByType;
6326
6636
  this.relation = relation;
6327
6637
  }
6328
6638
  where(where) {
6329
- this.extraWhere = { ...this.extraWhere, ...where };
6639
+ this.extraWhere.push({ ...where });
6330
6640
  return this;
6331
6641
  }
6332
6642
  applyEagerLoad(query, alias) {
@@ -6334,23 +6644,30 @@ class MorphToRelationQuery {
6334
6644
  type,
6335
6645
  model.repository()
6336
6646
  ]));
6337
- query.withMorphTo(alias, this.relation, repositories);
6647
+ query.withMorphTo(alias, this.relation, repositories, {}, new Map(Object.entries(this.relatedByType).map(([type, model]) => [
6648
+ type,
6649
+ relatedOptions(model, this.extraWhere)
6650
+ ])));
6338
6651
  }
6339
6652
  hydrateEager(row, alias) {
6340
6653
  return row[alias];
6341
6654
  }
6342
6655
  toExistsClause(parentTable) {
6343
- const type = String(this.parent.get(this.relation.morphTypeKey) ?? "");
6344
- const related = this.relatedByType[type];
6345
- if (!related) {
6346
- return { sql: "SELECT 1 WHERE 1 = 0", params: [] };
6347
- }
6348
- const relatedTable = related.repository().getTable();
6349
- const extra = buildAdvancedWhereClause(relatedTable.name, this.extraWhere, [], []);
6350
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6656
+ const params = [];
6657
+ const type = this.parent.get(this.relation.morphTypeKey);
6658
+ if (type !== undefined && type !== null && !this.relatedByType[String(type)]) {
6659
+ return { sql: "SELECT 1 WHERE 1 = 0", params };
6660
+ }
6661
+ const branches = Object.entries(this.relatedByType).map(([type, related]) => {
6662
+ params.push(type);
6663
+ const discriminator = currentSqlDialect().placeholder(params.length);
6664
+ const table = related.repository().getTable();
6665
+ const extra = relatedConditions(related, this.extraWhere, {}, params, [parentTable]);
6666
+ return `(${qualifyColumn(parentTable, this.relation.morphTypeKey)} = ${discriminator} AND EXISTS (SELECT 1 FROM ${quoteIdentifier(table.name)}${extra.joins} WHERE ${qualifyColumn(table.name, table.primaryKey)} = ${qualifyColumn(parentTable, this.relation.morphIdKey)}${extra.sql}))`;
6667
+ });
6351
6668
  return {
6352
- sql: `SELECT 1 FROM ${quoteIdentifier(relatedTable.name)} WHERE ${qualifyColumn(relatedTable.name, relatedTable.primaryKey)} = ${qualifyColumn(parentTable, this.relation.morphIdKey)}${extraSql ? ` AND ${extraSql}` : ""}`,
6353
- params: extra.params
6669
+ sql: `SELECT 1 WHERE ${branches.length > 0 ? branches.join(" OR ") : "1 = 0"}`,
6670
+ params
6354
6671
  };
6355
6672
  }
6356
6673
  async get() {
@@ -6378,7 +6695,7 @@ class MorphToRelationQuery {
6378
6695
  return null;
6379
6696
  }
6380
6697
  const table = related.repository().getTable();
6381
- return related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({ [table.primaryKey]: id, ...this.extraWhere }));
6698
+ return relatedRead(related, this.parent.getRepository().getConnection(), asWhere({ [table.primaryKey]: id }), this.extraWhere);
6382
6699
  }
6383
6700
  then(onfulfilled, onrejected) {
6384
6701
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6390,7 +6707,7 @@ class HasManyThroughRelationQuery {
6390
6707
  related;
6391
6708
  relation;
6392
6709
  kind = "hasManyThrough";
6393
- extraWhere = {};
6710
+ extraWhere = [];
6394
6711
  extraOptions = {};
6395
6712
  constructor(parent, related, relation) {
6396
6713
  this.parent = parent;
@@ -6398,7 +6715,7 @@ class HasManyThroughRelationQuery {
6398
6715
  this.relation = relation;
6399
6716
  }
6400
6717
  where(where) {
6401
- this.extraWhere = { ...this.extraWhere, ...where };
6718
+ this.extraWhere.push({ ...where });
6402
6719
  return this;
6403
6720
  }
6404
6721
  orderBy(orderBy) {
@@ -6410,7 +6727,7 @@ class HasManyThroughRelationQuery {
6410
6727
  return this;
6411
6728
  }
6412
6729
  applyEagerLoad(query, alias) {
6413
- query.withHasManyThrough(alias, this.relation, this.related.repository(), this.extraOptions);
6730
+ query.withHasManyThrough(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6414
6731
  }
6415
6732
  hydrateEager(row, alias) {
6416
6733
  const value = row[alias] ?? [];
@@ -6419,9 +6736,8 @@ class HasManyThroughRelationQuery {
6419
6736
  }
6420
6737
  toExistsClause(parentTable) {
6421
6738
  const farTable = this.related.repository().getTable().name;
6422
- const extra = buildAdvancedWhereClause(farTable, this.extraWhere, [], []);
6423
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6424
- const sql = `SELECT 1 FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(this.relation.throughTable)} ON ${qualifyColumn(this.relation.throughTable, this.relation.secondLocalKey)} = ${qualifyColumn(farTable, this.relation.secondKey)} WHERE ${qualifyColumn(this.relation.throughTable, this.relation.firstKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
6739
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable, this.relation.throughTable]);
6740
+ const sql = `SELECT 1 FROM ${quoteIdentifier(farTable)} INNER JOIN ${quoteIdentifier(this.relation.throughTable)} ON ${qualifyColumn(this.relation.throughTable, this.relation.secondLocalKey)} = ${qualifyColumn(farTable, this.relation.secondKey)}${extra.joins} WHERE ${qualifyColumn(this.relation.throughTable, this.relation.firstKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extra.sql}`;
6425
6741
  return { sql, params: extra.params };
6426
6742
  }
6427
6743
  async get() {
@@ -6446,8 +6762,7 @@ class HasManyThroughRelationQuery {
6446
6762
  }
6447
6763
  async farRows() {
6448
6764
  return this.related.repository().withConnection(this.parent.getRepository().getConnection()).findHasManyThrough(this.parent.get(this.relation.localKey), this.relation, {
6449
- ...this.extraOptions,
6450
- where: this.extraWhere
6765
+ ...relatedOptions(this.related, this.extraWhere, this.extraOptions)
6451
6766
  });
6452
6767
  }
6453
6768
  then(onfulfilled, onrejected) {
@@ -6456,7 +6771,17 @@ class HasManyThroughRelationQuery {
6456
6771
  }
6457
6772
 
6458
6773
  // ../../src/core/database/model.ts
6774
+ function snapshotAttributes(values) {
6775
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [
6776
+ key,
6777
+ Buffer.isBuffer(value) ? Buffer.from(value) : structuredClone(value)
6778
+ ]));
6779
+ }
6780
+ function changedAttributes(values, original) {
6781
+ return Object.fromEntries(Object.entries(values).filter(([key, value]) => !(key in original) || !isDeepStrictEqual(value, original[key])));
6782
+ }
6459
6783
  var modelRepositories = new WeakMap;
6784
+ var modelRepositoryFactories = new WeakMap;
6460
6785
  var namedModels = new Map;
6461
6786
  var modelGlobalScopes = new WeakMap;
6462
6787
  var modelObservers = new WeakMap;
@@ -6547,12 +6872,54 @@ async function eagerLoadOnModels(models, paths) {
6547
6872
  async function loadNested(model, path) {
6548
6873
  await eagerLoadOnModels([model], [path]);
6549
6874
  }
6875
+ function nameModel(model) {
6876
+ const { name, $morphClass } = model;
6877
+ if (name)
6878
+ namedModels.set(name, model);
6879
+ if ($morphClass)
6880
+ namedModels.set($morphClass, model);
6881
+ }
6550
6882
  function resolveModelRepository(model) {
6551
- const repository = modelRepositories.get(model);
6552
- if (!repository) {
6553
- throw new Error(`${model.name}.repository() is not implemented.`);
6883
+ const existing = modelRepositories.get(model);
6884
+ if (existing)
6885
+ return existing;
6886
+ for (let current = model;current; current = Object.getPrototypeOf(current)) {
6887
+ const factory = modelRepositoryFactories.get(current);
6888
+ if (!factory)
6889
+ continue;
6890
+ const repository = factory();
6891
+ modelRepositories.set(model, repository);
6892
+ nameModel(model);
6893
+ return repository;
6894
+ }
6895
+ throw new Error(`${model.name}.repository() is not implemented.`);
6896
+ }
6897
+ function defineModel(table) {
6898
+
6899
+ class TableModel extends Model {
6900
+ }
6901
+ modelRepositoryFactories.set(TableModel, () => new baseRepository_default(table));
6902
+ return TableModel;
6903
+ }
6904
+ function bootModels(models) {
6905
+ const names = new Map;
6906
+ for (const model of models) {
6907
+ const { name, $morphClass } = model;
6908
+ for (const alias of [name, $morphClass]) {
6909
+ if (!alias)
6910
+ continue;
6911
+ const existing = names.get(alias);
6912
+ if (existing && existing !== model)
6913
+ throw new Error(`Duplicate model name [${alias}].`);
6914
+ names.set(alias, model);
6915
+ }
6916
+ }
6917
+ for (const model of models)
6918
+ nameModel(model);
6919
+ for (const model of models) {
6920
+ resolveModelRepository(model);
6921
+ ensureBooted(model);
6554
6922
  }
6555
- return repository;
6556
6923
  }
6557
6924
  function registerModelClass(name, model) {
6558
6925
  namedModels.set(name, model);
@@ -6603,10 +6970,26 @@ function ensureBooted(model) {
6603
6970
  if (modelBooted.has(model)) {
6604
6971
  return;
6605
6972
  }
6973
+ nameModel(model);
6606
6974
  modelBooted.add(model);
6975
+ const scopes = modelGlobalScopes.get(model);
6976
+ const observers = modelObservers.get(model);
6607
6977
  const boot = model.boot;
6608
6978
  if (typeof boot === "function") {
6609
- boot.call(model);
6979
+ try {
6980
+ boot.call(model);
6981
+ } catch (error) {
6982
+ modelBooted.delete(model);
6983
+ if (scopes)
6984
+ modelGlobalScopes.set(model, scopes);
6985
+ else
6986
+ modelGlobalScopes.delete(model);
6987
+ if (observers)
6988
+ modelObservers.set(model, observers);
6989
+ else
6990
+ modelObservers.delete(model);
6991
+ throw error;
6992
+ }
6610
6993
  }
6611
6994
  }
6612
6995
  function getGlobalScopes(model) {
@@ -6734,6 +7117,19 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
6734
7117
  return result;
6735
7118
  }
6736
7119
 
7120
+ class ModelProjection {
7121
+ load;
7122
+ constructor(load) {
7123
+ this.load = load;
7124
+ }
7125
+ get() {
7126
+ return this.load();
7127
+ }
7128
+ async first() {
7129
+ return (await this.load(1))[0] ?? null;
7130
+ }
7131
+ }
7132
+
6737
7133
  class ModelQuery {
6738
7134
  modelClass;
6739
7135
  query;
@@ -6766,6 +7162,16 @@ class ModelQuery {
6766
7162
  this.query.orWhere(input);
6767
7163
  return this;
6768
7164
  }
7165
+ whereDynamic(input) {
7166
+ this.query.where(input);
7167
+ return this;
7168
+ }
7169
+ select(...columns) {
7170
+ return new ModelProjection(async (limit) => {
7171
+ const rows = await this.query.project(columns, limit);
7172
+ return rows.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"));
7173
+ });
7174
+ }
6769
7175
  orderBy(orderBy) {
6770
7176
  this.query.orderBy(orderBy);
6771
7177
  return this;
@@ -6914,7 +7320,7 @@ class ModelQuery {
6914
7320
  }
6915
7321
  async find(id) {
6916
7322
  const primaryKey = resolveModelRepository(this.modelClass).getTable().primaryKey;
6917
- return this.where({ [primaryKey]: id }).first();
7323
+ return this.whereDynamic({ [primaryKey]: id }).first();
6918
7324
  }
6919
7325
  async findOrFail(id, errorFactory) {
6920
7326
  const model = await this.find(id);
@@ -6971,6 +7377,7 @@ class Model {
6971
7377
  static $appends;
6972
7378
  static $morphClass;
6973
7379
  _exists;
7380
+ originalAttributes;
6974
7381
  loadedRelations = {};
6975
7382
  hiddenOverrides = [];
6976
7383
  visibleOverrides = [];
@@ -6980,6 +7387,24 @@ class Model {
6980
7387
  this.repository = repository;
6981
7388
  this._exists = exists;
6982
7389
  this.attributes = modelStatics(this.constructor).hydrateAttributes(attributes);
7390
+ this.originalAttributes = snapshotAttributes(this.attributes);
7391
+ }
7392
+ acceptPersistedAttributes(record) {
7393
+ const original = this.originalAttributes;
7394
+ const beforeWrite = snapshotAttributes(this.attributes);
7395
+ const existed = this._exists;
7396
+ this.attributes = modelStatics(this.constructor).hydrateAttributes(record);
7397
+ this.originalAttributes = snapshotAttributes(this.attributes);
7398
+ const written = this.originalAttributes;
7399
+ this._exists = true;
7400
+ onDeferredModelRollback(() => {
7401
+ this.attributes = {
7402
+ ...beforeWrite,
7403
+ ...changedAttributes(this.attributes, written)
7404
+ };
7405
+ this.originalAttributes = original;
7406
+ this._exists = existed;
7407
+ });
6983
7408
  }
6984
7409
  getRepository() {
6985
7410
  return this.repository;
@@ -7072,21 +7497,30 @@ class Model {
7072
7497
  ]);
7073
7498
  }
7074
7499
  static repository() {
7075
- return resolveModelRepository(this);
7500
+ const repository = resolveModelRepository(this);
7501
+ ensureBooted(this);
7502
+ return repository;
7503
+ }
7504
+ static scopeQuery(query) {
7505
+ ensureBooted(this);
7506
+ let scoped = query;
7507
+ for (const scope of getGlobalScopes(this))
7508
+ scoped = scope(scoped).protectWhere();
7509
+ return scoped;
7076
7510
  }
7077
7511
  static query() {
7078
7512
  ensureBooted(this);
7079
7513
  const repository = resolveModelRepository(this);
7080
- let query = repository.query();
7081
- for (const scope of getGlobalScopes(this)) {
7082
- query = scope(query);
7083
- }
7084
- return new ModelQuery(this, query);
7514
+ const scopeQuery = Model.scopeQuery;
7515
+ return new ModelQuery(this, scopeQuery.call(this, repository.query()));
7085
7516
  }
7086
7517
  static newFromRecord(record, exists = true) {
7087
7518
  const repository = resolveModelRepository(this);
7088
7519
  return modelStatics(this).fromRecord(record, repository, exists);
7089
7520
  }
7521
+ static newFromTrustedRecord(record, exists = true) {
7522
+ return modelStatics(this).newFromRecord(record, exists);
7523
+ }
7090
7524
  static async create(attributes, forced = {}) {
7091
7525
  const statics = modelStatics(this);
7092
7526
  ensureBooted(this);
@@ -7124,14 +7558,14 @@ class Model {
7124
7558
  static async chunk(count, callback) {
7125
7559
  const statics = modelStatics(this);
7126
7560
  ensureBooted(this);
7127
- await resolveModelRepository(this).chunk(count, async (rows) => {
7561
+ await Model.query.call(this).query.chunk(count, async (rows) => {
7128
7562
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7129
7563
  });
7130
7564
  }
7131
7565
  static async cursorPaginate(options) {
7132
7566
  const statics = modelStatics(this);
7133
7567
  ensureBooted(this);
7134
- const page = await resolveModelRepository(this).cursorPaginate({
7568
+ const page = await Model.query.call(this).query.cursorPaginate({
7135
7569
  perPage: options.perPage,
7136
7570
  cursor: options.cursor
7137
7571
  });
@@ -7239,9 +7673,14 @@ class Model {
7239
7673
  return this;
7240
7674
  }
7241
7675
  if (updating) {
7242
- const changes = applyTimestampsOnUpdate(table.columns, await applyCasts(this.attributes, casts, "dehydrate"), timestamps);
7676
+ const dirty = changedAttributes(this.attributes, this.originalAttributes);
7677
+ if (Object.keys(dirty).length === 0) {
7678
+ await runObservers(this, "saved");
7679
+ return this;
7680
+ }
7681
+ const changes = applyTimestampsOnUpdate(table.columns, await applyCasts(dirty, casts, "dehydrate"), timestamps);
7243
7682
  const record = await this.repository.updateByIdOrThrow(this.id, changes);
7244
- this.attributes = ModelClass.hydrateAttributes(record);
7683
+ this.acceptPersistedAttributes(record);
7245
7684
  await runObservers(this, "updated");
7246
7685
  await runObservers(this, "saved");
7247
7686
  return this;
@@ -7250,8 +7689,7 @@ class Model {
7250
7689
  const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
7251
7690
  const payload = await ModelClass.dehydrateAttributes(withTimestamps);
7252
7691
  const record = await this.repository.create(payload);
7253
- this.attributes = ModelClass.hydrateAttributes(record);
7254
- this._exists = true;
7692
+ this.acceptPersistedAttributes(record);
7255
7693
  await runObservers(this, "created");
7256
7694
  await runObservers(this, "saved");
7257
7695
  return this;
@@ -7276,12 +7714,11 @@ class Model {
7276
7714
  return await this.repository.forceDeleteById(this.id);
7277
7715
  }
7278
7716
  async restore() {
7279
- const ModelClass = modelStatics(this.constructor);
7280
7717
  const record = await this.repository.restoreById(this.id);
7281
7718
  if (!record) {
7282
7719
  return null;
7283
7720
  }
7284
- this.attributes = ModelClass.hydrateAttributes(record);
7721
+ this.acceptPersistedAttributes(record);
7285
7722
  return this;
7286
7723
  }
7287
7724
  async loadHasMany(as, relation, childRepository, options = {}) {
@@ -7436,14 +7873,7 @@ class Model {
7436
7873
  }
7437
7874
  function registerModelRepository(model, repository) {
7438
7875
  modelRepositories.set(model, repository);
7439
- const name = model.name;
7440
- if (name) {
7441
- namedModels.set(name, model);
7442
- }
7443
- const morphClass = model.$morphClass;
7444
- if (morphClass) {
7445
- namedModels.set(morphClass, model);
7446
- }
7876
+ nameModel(model);
7447
7877
  ensureBooted(model);
7448
7878
  return model;
7449
7879
  }
@@ -9168,29 +9598,95 @@ function storageFacade() {
9168
9598
  return storage();
9169
9599
  }
9170
9600
  // ../../src/core/http/bodySizeLimitMiddleware.ts
9601
+ var guards = new WeakMap;
9602
+ function guardBody(request, maxBytes) {
9603
+ const existing = guards.get(request);
9604
+ if (existing && existing.maxBytes <= maxBytes)
9605
+ return existing;
9606
+ const state = { maxBytes, exceeded: false };
9607
+ const body = request.body;
9608
+ if (!body)
9609
+ return state;
9610
+ let consumed = 0;
9611
+ const stream = body.pipeThrough(new TransformStream({
9612
+ transform(chunk, controller) {
9613
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
9614
+ consumed += bytes.byteLength;
9615
+ if (consumed > maxBytes) {
9616
+ state.exceeded = true;
9617
+ throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9618
+ }
9619
+ controller.enqueue(bytes);
9620
+ }
9621
+ }));
9622
+ const bounded = new Request(request, { body: stream });
9623
+ const decorateClone = (clone) => {
9624
+ for (const field of ["params", "cookies"]) {
9625
+ if (field in request)
9626
+ Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
9627
+ }
9628
+ const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
9629
+ Object.defineProperty(clone, "clone", {
9630
+ value: () => decorateClone(nativeClone())
9631
+ });
9632
+ guards.set(clone, state);
9633
+ return clone;
9634
+ };
9635
+ const descriptors = {
9636
+ body: { configurable: true, get: () => bounded.body },
9637
+ bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
9638
+ clone: {
9639
+ configurable: true,
9640
+ value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
9641
+ }
9642
+ };
9643
+ for (const method of [
9644
+ "text",
9645
+ "json",
9646
+ "formData",
9647
+ "arrayBuffer",
9648
+ "blob",
9649
+ "bytes",
9650
+ "textStream"
9651
+ ]) {
9652
+ descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
9653
+ }
9654
+ Object.defineProperties(request, descriptors);
9655
+ guards.set(request, state);
9656
+ return state;
9657
+ }
9171
9658
  var DEFAULT_MAX_BODY_BYTES = 1048576;
9172
9659
  function resolveMaxBodyBytes() {
9173
9660
  const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
9174
9661
  if (!raw) {
9175
9662
  return DEFAULT_MAX_BODY_BYTES;
9176
9663
  }
9177
- const parsed = Number.parseInt(raw, 10);
9178
- if (!Number.isInteger(parsed) || parsed <= 0) {
9664
+ const parsed = Number(raw);
9665
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
9179
9666
  return DEFAULT_MAX_BODY_BYTES;
9180
9667
  }
9181
9668
  return parsed;
9182
9669
  }
9183
9670
  function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
9671
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
9672
+ throw new RangeError("Body limit must be a positive safe integer.");
9184
9673
  return async (request, next) => {
9185
- const contentLength = request.headers.get("content-length");
9186
- if (contentLength) {
9187
- const bytes = Number.parseInt(contentLength, 10);
9188
- if (Number.isInteger(bytes) && bytes > maxBytes) {
9189
- const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9190
- return Response.json({ error: error.message }, { status: error.status });
9191
- }
9674
+ const tooLarge = () => {
9675
+ const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9676
+ return Response.json({ error: error.message }, { status: error.status });
9677
+ };
9678
+ const length = Number(request.headers.get("content-length"));
9679
+ if (Number.isFinite(length) && length > maxBytes)
9680
+ return tooLarge();
9681
+ const state = guardBody(request, maxBytes);
9682
+ try {
9683
+ const response = await next();
9684
+ return state.exceeded ? tooLarge() : response;
9685
+ } catch (error) {
9686
+ if (state.exceeded || error instanceof PayloadTooLargeError)
9687
+ return tooLarge();
9688
+ throw error;
9192
9689
  }
9193
- return await next();
9194
9690
  };
9195
9691
  }
9196
9692
  // ../../src/core/http/requestMetaContext.ts
@@ -10718,6 +11214,67 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
10718
11214
  return response;
10719
11215
  };
10720
11216
  }
11217
+ // ../../src/core/runtime/boundedThrottleStore.ts
11218
+ class BoundedThrottleStore {
11219
+ decayMs;
11220
+ now;
11221
+ buckets = new Map;
11222
+ maxBuckets;
11223
+ pruneBatchSize;
11224
+ disposed = false;
11225
+ pruned = 0;
11226
+ constructor(decayMs, options = {}, now = () => performance.now()) {
11227
+ this.decayMs = decayMs;
11228
+ this.now = now;
11229
+ this.maxBuckets = options.maxBuckets ?? 1e4;
11230
+ this.pruneBatchSize = options.pruneBatchSize ?? 32;
11231
+ if (!Number.isFinite(decayMs) || decayMs < 0 || decayMs > 2147483647) {
11232
+ throw new Error("Invalid memory throttle window.");
11233
+ }
11234
+ if (!Number.isSafeInteger(this.maxBuckets) || this.maxBuckets < 1 || this.maxBuckets > 1e6 || !Number.isSafeInteger(this.pruneBatchSize) || this.pruneBatchSize < 1 || this.pruneBatchSize > 1024) {
11235
+ throw new Error("Invalid memory throttle storage bounds.");
11236
+ }
11237
+ }
11238
+ consume(key) {
11239
+ this.pruned = 0;
11240
+ if (this.disposed)
11241
+ return null;
11242
+ const now = this.now();
11243
+ const entries = this.buckets.entries();
11244
+ for (let i = 0;i < this.pruneBatchSize; i++) {
11245
+ const oldest = entries.next().value;
11246
+ if (!oldest || oldest[1].resetAt > now)
11247
+ break;
11248
+ this.buckets.delete(oldest[0]);
11249
+ this.pruned++;
11250
+ }
11251
+ const existing = this.buckets.get(key);
11252
+ if (existing && existing.resetAt > now) {
11253
+ existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + 1);
11254
+ return existing.count;
11255
+ }
11256
+ this.buckets.delete(key);
11257
+ if (this.buckets.size >= this.maxBuckets)
11258
+ return null;
11259
+ this.buckets.set(key, { count: 1, resetAt: now + this.decayMs });
11260
+ return 1;
11261
+ }
11262
+ clear() {
11263
+ this.buckets.clear();
11264
+ }
11265
+ dispose() {
11266
+ this.disposed = true;
11267
+ this.clear();
11268
+ }
11269
+ stats() {
11270
+ return {
11271
+ retainedBuckets: this.buckets.size,
11272
+ prunedOnLastConsume: this.pruned,
11273
+ disposed: this.disposed
11274
+ };
11275
+ }
11276
+ }
11277
+
10721
11278
  // ../../src/core/http/throttleMiddleware.ts
10722
11279
  import { createHash as createHash3 } from "crypto";
10723
11280
  init_tenantContext();
@@ -10762,28 +11319,32 @@ if ttl < 0 then
10762
11319
  end
10763
11320
  return count
10764
11321
  `;
10765
- function redisThrottleKey(request, prefix, identity) {
11322
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
10766
11323
  const bucket = JSON.stringify([
10767
- currentTenant()?.id ?? null,
11324
+ tenantId,
10768
11325
  request.method,
10769
11326
  currentRequestMeta().routeTemplate ?? "__unmatched__",
10770
11327
  identity
10771
11328
  ]);
10772
11329
  return namespacedRedisKey(`${prefix}:${createHash3("sha256").update(bucket).digest("hex")}`);
10773
11330
  }
10774
- async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
10775
- if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
11331
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
11332
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
11333
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
10776
11334
  throw new Error("Invalid throttle expiry or command timeout.");
10777
11335
  }
11336
+ if (signal?.aborted)
11337
+ throw new Error("Throttle consumer is disposed.");
11338
+ let onAbort;
10778
11339
  let timer;
10779
11340
  try {
11341
+ const cancelled = new Promise((_resolve, reject) => {
11342
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
11343
+ signal?.addEventListener("abort", onAbort, { once: true });
11344
+ });
10780
11345
  const result = await Promise.race([
10781
- client.send("EVAL", [
10782
- CONSUME_THROTTLE,
10783
- "1",
10784
- key,
10785
- String(Math.max(1, Math.ceil(decaySeconds * 1000)))
10786
- ]),
11346
+ cancelled,
11347
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
10787
11348
  new Promise((_resolve, reject) => {
10788
11349
  timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
10789
11350
  })
@@ -10794,11 +11355,16 @@ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000)
10794
11355
  return count;
10795
11356
  } finally {
10796
11357
  clearTimeout(timer);
11358
+ if (onAbort)
11359
+ signal?.removeEventListener("abort", onAbort);
10797
11360
  }
10798
11361
  }
10799
11362
  function createRedisThrottleConsumer(options) {
10800
11363
  let ownedClient;
10801
- return async (key, decaySeconds) => {
11364
+ const controller = new AbortController;
11365
+ const consume = async (key, decaySeconds) => {
11366
+ if (controller.signal.aborted)
11367
+ throw new Error("Throttle consumer is disposed.");
10802
11368
  if (!options.redisClient && !ownedClient) {
10803
11369
  ownedClient = new RedisClient2(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
10804
11370
  }
@@ -10806,7 +11372,10 @@ function createRedisThrottleConsumer(options) {
10806
11372
  if (!client)
10807
11373
  throw new Error("Throttle client is required.");
10808
11374
  try {
10809
- return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
11375
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
11376
+ if (controller.signal.aborted)
11377
+ throw new Error("Throttle consumer is disposed.");
11378
+ return attempts;
10810
11379
  } catch (error) {
10811
11380
  if (client === ownedClient) {
10812
11381
  ownedClient.close();
@@ -10815,6 +11384,17 @@ function createRedisThrottleConsumer(options) {
10815
11384
  throw error;
10816
11385
  }
10817
11386
  };
11387
+ return Object.assign(consume, {
11388
+ isDisposed: () => controller.signal.aborted,
11389
+ dispose() {
11390
+ if (controller.signal.aborted)
11391
+ return;
11392
+ controller.abort();
11393
+ const client = ownedClient;
11394
+ ownedClient = undefined;
11395
+ client?.close();
11396
+ }
11397
+ });
10818
11398
  }
10819
11399
  function throttleUnavailableResponse() {
10820
11400
  return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
@@ -10822,27 +11402,52 @@ function throttleUnavailableResponse() {
10822
11402
  headers: { "retry-after": "1" }
10823
11403
  });
10824
11404
  }
11405
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
11406
+ const limit = options.quotaPolicy ? options.quotaPolicy({
11407
+ request,
11408
+ tenant: currentTenant(),
11409
+ identity,
11410
+ routeTemplate: currentRequestMeta().routeTemplate ?? "__unmatched__",
11411
+ maxAttempts: options.maxAttempts
11412
+ }) : options.maxAttempts;
11413
+ if (!Number.isSafeInteger(limit) || limit < 0) {
11414
+ throw new Error("Invalid application quota.");
11415
+ }
11416
+ return limit;
11417
+ }
10825
11418
  function createThrottleMiddleware(options) {
11419
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
11420
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
11421
+ }
10826
11422
  const consume = createRedisThrottleConsumer(options);
10827
11423
  const prefix = options.keyPrefix ?? "throttle";
10828
- return async (request, next) => {
10829
- const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
11424
+ const middleware = async (request, next) => {
11425
+ const identity = resolveThrottleIdentity(request);
11426
+ let maxAttempts;
11427
+ try {
11428
+ maxAttempts = resolveThrottleQuota(request, options, identity);
11429
+ } catch {
11430
+ return throttleUnavailableResponse();
11431
+ }
11432
+ const key = redisThrottleKey(request, prefix, identity);
10830
11433
  let attempts;
10831
11434
  try {
10832
11435
  attempts = await consume(key, options.decaySeconds);
10833
11436
  } catch {
10834
11437
  return throttleUnavailableResponse();
10835
11438
  }
10836
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
11439
+ if (consume.isDisposed())
11440
+ return throttleUnavailableResponse();
10837
11441
  if (attempts > maxAttempts) {
10838
11442
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10839
11443
  }
10840
11444
  return await next();
10841
11445
  };
11446
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10842
11447
  }
10843
11448
 
10844
11449
  // ../../src/core/http/loginThrottleMiddleware.ts
10845
- var memoryLoginBuckets = new Map;
11450
+ var resetGeneration = 0;
10846
11451
  function resolveLoginIdentity(request) {
10847
11452
  return readClientIp(request) ?? "unknown";
10848
11453
  }
@@ -10860,33 +11465,31 @@ async function resolveLoginEmail(request) {
10860
11465
  return "unknown";
10861
11466
  }
10862
11467
  }
10863
- function consumeMemoryAttempt(key, decaySeconds) {
10864
- const now = Date.now();
10865
- const existing = memoryLoginBuckets.get(key);
10866
- if (!existing || existing.resetAt <= now) {
10867
- memoryLoginBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
10868
- return 1;
10869
- }
10870
- existing.count += 1;
10871
- return existing.count;
10872
- }
10873
11468
  function createMemoryLoginThrottleMiddleware(options) {
10874
- const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
10875
- return async (request, next) => {
10876
- const identity = resolveLoginIdentity(request);
11469
+ const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
11470
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11471
+ throw new Error("Invalid memory throttle limit.");
11472
+ let generation = resetGeneration;
11473
+ const middleware = async (request, next) => {
11474
+ if (generation !== resetGeneration) {
11475
+ store.clear();
11476
+ generation = resetGeneration;
11477
+ }
10877
11478
  const email = await resolveLoginEmail(request);
10878
- const throttleKey = `${prefix}${identity}:${email}`;
10879
- const attempts = consumeMemoryAttempt(throttleKey, options.decaySeconds);
10880
- if (attempts > options.maxAttempts) {
11479
+ const key = redisThrottleKey(request, options.keyPrefix ?? "login-throttle", JSON.stringify([resolveLoginIdentity(request), email]));
11480
+ const attempts = store.consume(key);
11481
+ if (attempts === null)
11482
+ return throttleUnavailableResponse();
11483
+ if (attempts > options.maxAttempts)
10881
11484
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10882
- }
10883
11485
  return await next();
10884
11486
  };
11487
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10885
11488
  }
10886
11489
  function createRedisLoginThrottleMiddleware(options) {
10887
11490
  const consume = createRedisThrottleConsumer(options);
10888
11491
  const prefix = options.keyPrefix ?? "login-throttle";
10889
- return async (request, next) => {
11492
+ const middleware = async (request, next) => {
10890
11493
  const identity = resolveLoginIdentity(request);
10891
11494
  const email = await resolveLoginEmail(request);
10892
11495
  const throttleKey = redisThrottleKey(request, prefix, JSON.stringify([identity, email]));
@@ -10896,11 +11499,14 @@ function createRedisLoginThrottleMiddleware(options) {
10896
11499
  } catch {
10897
11500
  return throttleUnavailableResponse();
10898
11501
  }
11502
+ if (consume.isDisposed())
11503
+ return throttleUnavailableResponse();
10899
11504
  if (attempts > options.maxAttempts) {
10900
11505
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10901
11506
  }
10902
11507
  return await next();
10903
11508
  };
11509
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10904
11510
  }
10905
11511
  function createLoginThrottleMiddleware(options) {
10906
11512
  const redisUrl = options.redisUrl?.trim() ?? "";
@@ -10910,35 +11516,37 @@ function createLoginThrottleMiddleware(options) {
10910
11516
  return createMemoryLoginThrottleMiddleware(options);
10911
11517
  }
10912
11518
  function resetMemoryLoginThrottleForTests() {
10913
- memoryLoginBuckets.clear();
11519
+ resetGeneration++;
10914
11520
  }
10915
11521
  // ../../src/core/http/memoryThrottleMiddleware.ts
10916
- var throttleBucketRegistries = new Set;
11522
+ var resetGeneration2 = 0;
10917
11523
  function createMemoryThrottleMiddleware(options) {
10918
- const prefix = options.keyPrefix ?? namespacedRedisKey("memory-throttle:");
10919
- const buckets = new Map;
10920
- throttleBucketRegistries.add(buckets);
10921
- return async (request, next) => {
10922
- const path = new URL(request.url).pathname;
10923
- const identity = readClientIp(request) ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
10924
- const key = `${prefix}${identity}:${path}`;
10925
- const now = Date.now();
10926
- const existing = buckets.get(key);
10927
- if (!existing || existing.resetAt <= now) {
10928
- buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
10929
- return await next();
11524
+ const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
11525
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11526
+ throw new Error("Invalid memory throttle limit.");
11527
+ let generation = resetGeneration2;
11528
+ const middleware = async (request, next) => {
11529
+ if (generation !== resetGeneration2) {
11530
+ store.clear();
11531
+ generation = resetGeneration2;
11532
+ }
11533
+ let maxAttempts;
11534
+ try {
11535
+ maxAttempts = resolveThrottleQuota(request, options);
11536
+ } catch {
11537
+ return throttleUnavailableResponse();
10930
11538
  }
10931
- existing.count += 1;
10932
- if (existing.count > options.maxAttempts) {
11539
+ const attempts = store.consume(redisThrottleKey(request, options.keyPrefix ?? "memory-throttle", resolveThrottleIdentity(request)));
11540
+ if (attempts === null)
11541
+ return throttleUnavailableResponse();
11542
+ if (attempts > maxAttempts)
10933
11543
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10934
- }
10935
11544
  return await next();
10936
11545
  };
11546
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10937
11547
  }
10938
11548
  function resetMemoryThrottleForTests() {
10939
- for (const buckets of throttleBucketRegistries) {
10940
- buckets.clear();
10941
- }
11549
+ resetGeneration2++;
10942
11550
  }
10943
11551
  // ../../src/core/metrics/prometheus.ts
10944
11552
  var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
@@ -11208,8 +11816,6 @@ function createRequireWebAuthMiddleware(auth) {
11208
11816
  }
11209
11817
  // ../../src/core/http/scimThrottleMiddleware.ts
11210
11818
  import { createHash as createHash4 } from "crypto";
11211
- var {RedisClient: RedisClient3 } = globalThis.Bun;
11212
- var memoryBuckets = new Map;
11213
11819
  function resolveScimIdentity(request) {
11214
11820
  const authorization = request.headers.get("authorization") ?? "";
11215
11821
  const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
@@ -11218,40 +11824,44 @@ function resolveScimIdentity(request) {
11218
11824
  }
11219
11825
  return readClientIp(request) ?? "unknown";
11220
11826
  }
11221
- function throttleFromMemory(key, maxAttempts, decaySeconds) {
11222
- const now = Date.now();
11223
- const existing = memoryBuckets.get(key);
11224
- if (!existing || existing.resetAt <= now) {
11225
- memoryBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
11226
- return null;
11227
- }
11228
- existing.count += 1;
11229
- if (existing.count > maxAttempts) {
11230
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(decaySeconds) } });
11231
- }
11232
- return null;
11233
- }
11234
11827
  function createScimThrottleMiddleware(options) {
11235
- const client = options.redisUrl ? new RedisClient3(options.redisUrl) : null;
11236
- return async (request, next) => {
11828
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11829
+ throw new Error("Invalid SCIM throttle limit.");
11830
+ const redisUrl = options.redisUrl?.trim() ?? "";
11831
+ const distributed = Boolean(redisUrl || options.redisClient);
11832
+ const store = distributed ? null : new BoundedThrottleStore(options.decaySeconds * 1000, options);
11833
+ const consume = distributed ? createRedisThrottleConsumer({ ...options, redisUrl }) : null;
11834
+ let disposed = false;
11835
+ const middleware = async (request, next) => {
11836
+ if (disposed)
11837
+ return throttleUnavailableResponse();
11237
11838
  const identity = resolveScimIdentity(request);
11238
- const key = `${namespacedRedisKey("scim-throttle:")}${identity}`;
11239
- if (client) {
11240
- const attempts = Number(await client.incr(key));
11241
- if (attempts === 1) {
11242
- await client.expire(key, options.decaySeconds);
11243
- }
11244
- if (attempts > options.maxAttempts) {
11245
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
11246
- }
11247
- return await next();
11248
- }
11249
- const limited = throttleFromMemory(key, options.maxAttempts, options.decaySeconds);
11250
- if (limited) {
11251
- return limited;
11839
+ const token = request.headers.get("authorization")?.startsWith("Bearer ") ? request.headers.get("authorization")?.slice("Bearer ".length).trim() ?? "" : "";
11840
+ const key = redisThrottleKey(request, options.keyPrefix ?? "scim-throttle:v2", identity, resolveScimTenantFromToken(token) ?? undefined);
11841
+ let attempts;
11842
+ try {
11843
+ attempts = consume ? await consume(key, options.decaySeconds) : store?.consume(key);
11844
+ } catch {
11845
+ return throttleUnavailableResponse();
11252
11846
  }
11847
+ if (disposed)
11848
+ return throttleUnavailableResponse();
11849
+ if (attempts === null || attempts === undefined)
11850
+ return throttleUnavailableResponse();
11851
+ if (attempts > options.maxAttempts)
11852
+ return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
11253
11853
  return await next();
11254
11854
  };
11855
+ return Object.assign(middleware, {
11856
+ dispose: () => {
11857
+ if (disposed)
11858
+ return;
11859
+ disposed = true;
11860
+ store?.dispose();
11861
+ consume?.dispose();
11862
+ },
11863
+ stats: () => store?.stats()
11864
+ });
11255
11865
  }
11256
11866
  // ../../src/core/http/securityHeadersMiddleware.ts
11257
11867
  function createSecurityHeadersMiddleware(options = {}) {
@@ -11419,47 +12029,150 @@ class WebFormRequest {
11419
12029
  }
11420
12030
  }
11421
12031
  // ../../src/core/lifecycle/gracefulShutdown.ts
11422
- var shutdownHandlers = new Map;
11423
- var shutdownInstalled = false;
11424
- var shuttingDown = false;
11425
- function registerShutdownHandler(name, handler) {
11426
- shutdownHandlers.set(name, handler);
11427
- return () => {
11428
- shutdownHandlers.delete(name);
11429
- };
12032
+ function resolveShutdownTimeoutMs() {
12033
+ const configured = process.env.SHUTDOWN_TIMEOUT_MS;
12034
+ const value = configured === undefined ? 30000 : Number(configured);
12035
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2147483647)
12036
+ throw new RangeError("SHUTDOWN_TIMEOUT_MS must be an integer between 1 and 2147483647.");
12037
+ return value;
11430
12038
  }
11431
- async function runGracefulShutdown(signal) {
11432
- if (shuttingDown) {
11433
- return;
11434
- }
11435
- shuttingDown = true;
11436
- console.log(`[shutdown] Received ${signal}, draining ${shutdownHandlers.size} handler(s)...`);
11437
- for (const [name, handler] of shutdownHandlers) {
11438
- try {
11439
- await handler();
11440
- console.log(`[shutdown] Completed ${name}`);
11441
- } catch (error) {
11442
- console.error(`[shutdown] Failed ${name}:`, error);
11443
- }
12039
+
12040
+ class LifecycleCoordinator {
12041
+ handlers = new Map;
12042
+ running;
12043
+ timeoutMs;
12044
+ constructor(options = {}) {
12045
+ this.timeoutMs = options.timeoutMs ?? resolveShutdownTimeoutMs();
12046
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2147483647)
12047
+ throw new RangeError("Shutdown timeout must be an integer between 1 and 2147483647.");
12048
+ }
12049
+ get isShuttingDown() {
12050
+ return this.running !== undefined;
12051
+ }
12052
+ register(name, handler, phase = "close") {
12053
+ if (this.isShuttingDown)
12054
+ throw new Error("Cannot register resources during shutdown.");
12055
+ if (!["stop", "drain", "flush", "close", "force"].includes(phase))
12056
+ throw new TypeError("Unknown shutdown phase.");
12057
+ const registration = { name, handler, phase };
12058
+ this.handlers.set(name, registration);
12059
+ return () => {
12060
+ if (this.handlers.get(name) === registration)
12061
+ this.handlers.delete(name);
12062
+ };
11444
12063
  }
11445
- }
11446
- function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
11447
- if (shutdownInstalled) {
11448
- return;
12064
+ shutdown(reason = "MANUAL") {
12065
+ if (this.running)
12066
+ return this.running;
12067
+ this.running = Promise.resolve().then(() => this.execute(reason));
12068
+ return this.running;
11449
12069
  }
11450
- shutdownInstalled = true;
11451
- for (const signal of signals) {
11452
- process.on(signal, () => {
11453
- runGracefulShutdown(signal).finally(() => {
11454
- process.exit(0);
11455
- });
12070
+ async execute(reason) {
12071
+ const registrations = [...this.handlers.values()];
12072
+ const controller = new AbortController;
12073
+ const context = { signal: controller.signal, reason };
12074
+ const errors = [];
12075
+ let timedOut = false;
12076
+ let forced = false;
12077
+ const record = (entry, error) => {
12078
+ errors.push({ name: entry.name, phase: entry.phase, error });
12079
+ console.error(`[shutdown] Failed ${entry.phase}:${entry.name}:`, error);
12080
+ };
12081
+ const force = () => {
12082
+ if (forced)
12083
+ return;
12084
+ forced = true;
12085
+ for (const entry of registrations.filter((entry) => entry.phase === "force")) {
12086
+ try {
12087
+ Promise.resolve(entry.handler(context)).catch((error) => record(entry, error));
12088
+ } catch (error) {
12089
+ record(entry, error);
12090
+ }
12091
+ }
12092
+ };
12093
+ let timer;
12094
+ const deadline = new Promise((resolve) => {
12095
+ timer = setTimeout(() => {
12096
+ timedOut = true;
12097
+ const error = new Error(`Shutdown deadline exceeded after ${this.timeoutMs}ms.`);
12098
+ errors.push({ name: "deadline", phase: "force", error });
12099
+ controller.abort(error);
12100
+ force();
12101
+ resolve();
12102
+ }, this.timeoutMs);
11456
12103
  });
12104
+ const sequence = (async () => {
12105
+ let unsafeToClose = false;
12106
+ for (const phase of ["stop", "drain", "flush", "close"]) {
12107
+ if (controller.signal.aborted)
12108
+ return;
12109
+ if (unsafeToClose && (phase === "flush" || phase === "close")) {
12110
+ controller.abort(new Error("Admission or drain failed; infrastructure remains open."));
12111
+ force();
12112
+ return;
12113
+ }
12114
+ for (const entry of registrations.filter((entry) => entry.phase === phase)) {
12115
+ if (controller.signal.aborted)
12116
+ return;
12117
+ try {
12118
+ await entry.handler(context);
12119
+ } catch (error) {
12120
+ record(entry, error);
12121
+ if (phase === "stop" || phase === "drain")
12122
+ unsafeToClose = true;
12123
+ }
12124
+ }
12125
+ }
12126
+ })();
12127
+ try {
12128
+ await Promise.race([sequence, deadline]);
12129
+ } finally {
12130
+ clearTimeout(timer);
12131
+ this.handlers.clear();
12132
+ }
12133
+ return { successful: errors.length === 0 && !timedOut, timedOut, errors: [...errors] };
11457
12134
  }
11458
12135
  }
12136
+ var defaultLifecycle = new LifecycleCoordinator;
12137
+ var installations = new Map;
12138
+ function registerShutdownHandler(name, handler, phase = "close") {
12139
+ return defaultLifecycle.register(name, handler, phase);
12140
+ }
12141
+ function runGracefulShutdown(signal) {
12142
+ return defaultLifecycle.shutdown(signal);
12143
+ }
12144
+ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"], lifecycle = defaultLifecycle) {
12145
+ const existing = installations.get(lifecycle);
12146
+ if (existing)
12147
+ return existing;
12148
+ let exiting = false;
12149
+ const handler = (signal) => {
12150
+ if (exiting)
12151
+ return;
12152
+ exiting = true;
12153
+ lifecycle.shutdown(signal).then((result) => process.exit(result.successful ? 0 : 1), (error) => {
12154
+ console.error("[shutdown] Coordinator failed:", error);
12155
+ process.exit(1);
12156
+ });
12157
+ };
12158
+ const callbacks = signals.map((signal) => {
12159
+ const callback = () => handler(signal);
12160
+ process.on(signal, callback);
12161
+ return () => process.off(signal, callback);
12162
+ });
12163
+ const uninstall = () => {
12164
+ for (const remove of callbacks)
12165
+ remove();
12166
+ if (installations.get(lifecycle) === uninstall)
12167
+ installations.delete(lifecycle);
12168
+ };
12169
+ installations.set(lifecycle, uninstall);
12170
+ return uninstall;
12171
+ }
11459
12172
  function resetGracefulShutdownForTests() {
11460
- shutdownHandlers.clear();
11461
- shutdownInstalled = false;
11462
- shuttingDown = false;
12173
+ for (const remove of [...installations.values()])
12174
+ remove();
12175
+ defaultLifecycle = new LifecycleCoordinator;
11463
12176
  }
11464
12177
  // ../../src/core/logging/requestLoggingMiddleware.ts
11465
12178
  function createRequestLoggingMiddleware() {
@@ -11738,12 +12451,37 @@ class SyncQueue {
11738
12451
  }
11739
12452
 
11740
12453
  class AsyncQueue {
12454
+ active = new Set;
12455
+ closing;
12456
+ closed = false;
12457
+ assertOpen() {
12458
+ if (this.closed)
12459
+ throw new Error("Async queue is closed.");
12460
+ }
12461
+ enqueue(task, onError) {
12462
+ this.assertOpen();
12463
+ const running = new Promise((resolve) => {
12464
+ setTimeout(() => {
12465
+ Promise.resolve().then(task).catch(onError).finally(resolve);
12466
+ }, 0);
12467
+ });
12468
+ this.active.add(running);
12469
+ running.then(() => {
12470
+ this.active.delete(running);
12471
+ });
12472
+ }
11741
12473
  async dispatch(job, payload) {
11742
- setTimeout(() => {
11743
- job.handle(payload).catch((error) => {
11744
- console.error("[AsyncQueue] Job failed:", error);
11745
- });
11746
- }, 0);
12474
+ this.enqueue(() => job.handle(payload), (error) => {
12475
+ console.error("[AsyncQueue] Job failed:", error);
12476
+ });
12477
+ }
12478
+ close() {
12479
+ this.closing ??= (async () => {
12480
+ while (this.active.size)
12481
+ await Promise.all([...this.active]);
12482
+ this.closed = true;
12483
+ })();
12484
+ return this.closing;
11747
12485
  }
11748
12486
  }
11749
12487
  function createQueue(driver) {
@@ -11753,7 +12491,7 @@ function createQueue(driver) {
11753
12491
  var failedJobTable = defineTable({
11754
12492
  name: "failed_job",
11755
12493
  primaryKey: "id",
11756
- columns: ["id", "job_name", "payload", "exception", "failed_at"],
12494
+ columns: ["id", "job_name", "job_id", "payload", "exception", "failed_at"],
11757
12495
  defaultOrderBy: { column: "failed_at", direction: "DESC" }
11758
12496
  });
11759
12497
 
@@ -11774,6 +12512,7 @@ class FailedJobService {
11774
12512
  async recordFailure(input) {
11775
12513
  return await this.repository.create({
11776
12514
  job_name: input.jobName,
12515
+ job_id: input.jobId ?? null,
11777
12516
  payload: input.payload,
11778
12517
  exception: input.exception,
11779
12518
  failed_at: new Date
@@ -11785,8 +12524,11 @@ class FailedJobService {
11785
12524
  orderBy: { column: "failed_at", direction: "DESC" }
11786
12525
  });
11787
12526
  }
11788
- async retry(id) {
12527
+ async retry(id, enqueue) {
12528
+ if (typeof enqueue !== "function")
12529
+ throw new Error("Failed-job retry requires an admission callback.");
11789
12530
  const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
12531
+ await enqueue(failedJob);
11790
12532
  await this.repository.deleteById(id);
11791
12533
  return failedJob;
11792
12534
  }
@@ -11809,6 +12551,9 @@ class FailedJobService {
11809
12551
  }
11810
12552
  var failedJobService_default = FailedJobService;
11811
12553
 
12554
+ // ../../src/core/queue/jobRunner.ts
12555
+ import { setTimeout as delay } from "timers/promises";
12556
+
11812
12557
  // ../../src/core/queue/queueConfig.ts
11813
12558
  function resolveQueueConfig() {
11814
12559
  const driver = process.env.QUEUE_DRIVER;
@@ -11831,22 +12576,82 @@ var queueConfig = {
11831
12576
  return resolveQueueConfig().backoffMs;
11832
12577
  }
11833
12578
  };
12579
+ function resolveRedisQueueTransport() {
12580
+ const transport = process.env.QUEUE_REDIS_TRANSPORT ?? "lists";
12581
+ if (transport !== "lists" && transport !== "streams")
12582
+ throw new Error('QUEUE_REDIS_TRANSPORT must be "lists" or "streams".');
12583
+ return transport;
12584
+ }
12585
+ function readQueueVisibilityMs() {
12586
+ const value = Number(process.env.QUEUE_VISIBILITY_MS ?? "60000");
12587
+ if (!Number.isSafeInteger(value) || value < 30)
12588
+ throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
12589
+ return value;
12590
+ }
11834
12591
 
11835
12592
  // ../../src/core/queue/jobRunner.ts
12593
+ function parseQueueJobEnvelope(rawPayload) {
12594
+ let parsed;
12595
+ try {
12596
+ parsed = JSON.parse(rawPayload);
12597
+ } catch {
12598
+ console.error("[QueueWorker] Ignoring malformed queue payload");
12599
+ return null;
12600
+ }
12601
+ if (!parsed || typeof parsed !== "object") {
12602
+ console.error("[QueueWorker] Ignoring non-object queue payload");
12603
+ return null;
12604
+ }
12605
+ const envelope = parsed;
12606
+ if (typeof envelope.name !== "string" || envelope.name.length === 0) {
12607
+ console.error("[QueueWorker] Ignoring queue payload without job name");
12608
+ return null;
12609
+ }
12610
+ if (!jobRegistry.create(envelope.name)) {
12611
+ console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
12612
+ return null;
12613
+ }
12614
+ if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
12615
+ console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
12616
+ return null;
12617
+ }
12618
+ if (envelope.jobId !== undefined && (typeof envelope.jobId !== "string" || !envelope.jobId.length))
12619
+ return null;
12620
+ if (envelope.attempts !== undefined && (!Number.isSafeInteger(envelope.attempts) || envelope.attempts < 0))
12621
+ return null;
12622
+ if (envelope.deadlineAtMs !== undefined && (!Number.isSafeInteger(envelope.deadlineAtMs) || envelope.deadlineAtMs <= 0))
12623
+ return null;
12624
+ if (envelope.cancellable !== undefined && (typeof envelope.cancellable !== "boolean" || envelope.cancellable && !envelope.jobId))
12625
+ return null;
12626
+ return {
12627
+ ...envelope.cancellable ? { cancellable: true } : {},
12628
+ ...envelope.deadlineAtMs === undefined ? {} : { deadlineAtMs: envelope.deadlineAtMs },
12629
+ jobId: envelope.jobId,
12630
+ name: envelope.name,
12631
+ payload: envelope.payload ?? {},
12632
+ attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
12633
+ };
12634
+ }
11836
12635
  async function runQueueJob(envelope, failedJobs, options = {}) {
12636
+ options.context?.signal.throwIfAborted();
11837
12637
  const job = jobRegistry.create(envelope.name);
11838
12638
  if (!job) {
11839
12639
  throw new Error(`Unknown job "${envelope.name}".`);
11840
12640
  }
11841
12641
  const attempts = envelope.attempts ?? 0;
11842
12642
  try {
11843
- await job.handle(envelope.payload);
12643
+ await job.handle(envelope.payload, options.context);
11844
12644
  } catch (error) {
12645
+ options.context?.signal.throwIfAborted();
11845
12646
  const nextAttempt = attempts + 1;
11846
12647
  const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
11847
12648
  if (nextAttempt < maxAttempts) {
11848
12649
  const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
11849
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
12650
+ if (options.deferRetry) {
12651
+ await options.deferRetry({ ...envelope, attempts: nextAttempt }, backoffMs * nextAttempt);
12652
+ return;
12653
+ }
12654
+ await delay(backoffMs * nextAttempt, undefined, { signal: options.context?.signal });
11850
12655
  await runQueueJob({
11851
12656
  ...envelope,
11852
12657
  attempts: nextAttempt
@@ -11855,6 +12660,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
11855
12660
  }
11856
12661
  await failedJobs.recordFailure({
11857
12662
  jobName: envelope.name,
12663
+ jobId: options.context?.jobId ?? envelope.jobId,
11858
12664
  payload: envelope.payload,
11859
12665
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
11860
12666
  });
@@ -11865,6 +12671,8 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
11865
12671
 
11866
12672
  // ../../src/core/queue/redisQueue.ts
11867
12673
  var {RedisClient: RedisClient4 } = globalThis.Bun;
12674
+
12675
+ // ../../src/core/queue/redisQueueKeys.ts
11868
12676
  function queueListKey() {
11869
12677
  return namespacedRedisKey("queue:default");
11870
12678
  }
@@ -11877,21 +12685,10 @@ function queueLowKey() {
11877
12685
  var QUEUE_LIST_KEY = queueListKey();
11878
12686
  var QUEUE_HIGH_KEY = queueHighKey();
11879
12687
  var QUEUE_LOW_KEY = queueLowKey();
11880
- var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
11881
- var DEFAULT_VISIBILITY_MS = 60000;
11882
- function queueProcessingKey(queueKey) {
11883
- return `${queueKey}:processing`;
11884
- }
11885
- function queueProcessingLeaseKey(queueKey) {
11886
- return `${queueKey}:processing:leases`;
11887
- }
11888
- function readVisibilityMs() {
11889
- const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
11890
- if (!Number.isSafeInteger(parsed) || parsed < 30) {
11891
- throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
11892
- }
11893
- return parsed;
12688
+ function defaultQueueKeys() {
12689
+ return [queueHighKey(), queueListKey(), queueLowKey()];
11894
12690
  }
12691
+ var QUEUE_KEYS = defaultQueueKeys();
11895
12692
  function queueKeyForPriority(priority = "default") {
11896
12693
  switch (priority) {
11897
12694
  case "high":
@@ -11902,36 +12699,557 @@ function queueKeyForPriority(priority = "default") {
11902
12699
  return queueListKey();
11903
12700
  }
11904
12701
  }
11905
- function parseQueueJobEnvelope(rawPayload) {
11906
- let parsed;
12702
+
12703
+ // ../../src/core/queue/redisStreams.ts
12704
+ var {RedisClient: RedisClient3 } = globalThis.Bun;
12705
+ var STREAM_GROUP = "strata-workers-v1";
12706
+ function streamQueueKey(queueKey) {
12707
+ return `${queueKey}:stream`;
12708
+ }
12709
+ function streamDeadLetterKey(queueKey) {
12710
+ return `${streamQueueKey(queueKey)}:invalid`;
12711
+ }
12712
+ function streamRetryKey(queueKey) {
12713
+ return `${streamQueueKey(queueKey)}:retries`;
12714
+ }
12715
+ function streamControlKey(key, jobId) {
12716
+ const prefix = `${streamQueueKey(key)}/`;
12717
+ if (!jobId.startsWith(prefix) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(jobId.slice(prefix.length)))
12718
+ throw new Error("Invalid cancellable Streams job ID");
12719
+ return `${streamQueueKey(key)}:control:${jobId.slice(prefix.length)}`;
12720
+ }
12721
+ var ENQUEUE_CONTROLLED = `
12722
+ local kind = redis.call('TYPE', KEYS[1]).ok
12723
+ if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream admission key type') end
12724
+ if redis.call('EXISTS', KEYS[2]) ~= 0 then return redis.error_reply('Streams control identity already exists') end
12725
+ redis.call('XADD', KEYS[1], '*', 'payload', ARGV[1])
12726
+ redis.call('HSET', KEYS[2], 'state', 'active')
12727
+ return 1
12728
+ `;
12729
+ var CANCEL_STREAM = `
12730
+ local state = redis.call('HGET', KEYS[1], 'state')
12731
+ if not state then return 0 end
12732
+ local kind = redis.call('TYPE', KEYS[2]).ok
12733
+ if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
12734
+ local payload = redis.call('HGET', KEYS[1], 'retryPayload')
12735
+ if payload and redis.call('ZSCORE', KEYS[2], payload) then redis.call('ZADD', KEYS[2], 0, payload) end
12736
+ redis.call('HSET', KEYS[1], 'state', 'cancelled')
12737
+ return 1
12738
+ `;
12739
+ async function cancelStreamJob(client, key, jobId) {
12740
+ return Number(await client.send("EVAL", [
12741
+ CANCEL_STREAM,
12742
+ "2",
12743
+ streamControlKey(key, jobId),
12744
+ streamRetryKey(key)
12745
+ ])) === 1;
12746
+ }
12747
+ async function readStreamCancellation(client, controlKey) {
12748
+ const state = await client.send("HGET", [controlKey, "state"]);
12749
+ if (state !== "active" && state !== "cancelled")
12750
+ throw new Error("Missing or invalid Streams control state");
12751
+ return state === "cancelled";
12752
+ }
12753
+ var MAX_TIMER_MS = 2147483647;
12754
+ async function readStreamTime(client) {
12755
+ const value = await client.send("TIME", []);
12756
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1]))
12757
+ throw new Error("Invalid Redis TIME response");
12758
+ const seconds = Number(value[0]);
12759
+ const micros = Number(value[1]);
12760
+ const now = seconds * 1000 + Math.floor(micros / 1000);
12761
+ if (!Number.isSafeInteger(seconds) || seconds < 0 || !Number.isSafeInteger(micros) || micros < 0 || micros >= 1e6 || !Number.isSafeInteger(now))
12762
+ throw new Error("Invalid Redis TIME response");
12763
+ return now;
12764
+ }
12765
+ function watchStreamDeadline(client, deadlineAtMs, remainingMs, expire, fail) {
12766
+ let stopped = false;
12767
+ let timer;
12768
+ const schedule = (remaining) => {
12769
+ timer = setTimeout(() => {
12770
+ (async () => {
12771
+ try {
12772
+ const remaining = deadlineAtMs - await readStreamTime(client);
12773
+ if (stopped)
12774
+ return;
12775
+ if (remaining <= 0)
12776
+ expire();
12777
+ else
12778
+ schedule(remaining);
12779
+ } catch (error) {
12780
+ if (!stopped)
12781
+ fail(error);
12782
+ }
12783
+ })();
12784
+ }, Math.max(1, Math.min(MAX_TIMER_MS, remaining)));
12785
+ };
12786
+ schedule(remainingMs);
12787
+ return () => {
12788
+ stopped = true;
12789
+ clearTimeout(timer);
12790
+ };
12791
+ }
12792
+ var SCHEDULE_RETRY = `
12793
+ local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
12794
+ if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
12795
+ local kind = redis.call('TYPE', KEYS[2]).ok
12796
+ if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
12797
+ if ARGV[8] == '1' then
12798
+ local state = redis.call('HGET', KEYS[3], 'state')
12799
+ if state == 'cancelled' then return redis.error_reply('Streams job cancelled') end
12800
+ if state ~= 'active' then return redis.error_reply('Missing or invalid Streams control state') end
12801
+ end
12802
+ local now = redis.call('TIME')
12803
+ local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000) + tonumber(ARGV[5])
12804
+ if ARGV[7] ~= '' then due = math.min(due, tonumber(ARGV[7])) end
12805
+ redis.call('ZADD', KEYS[2], due, ARGV[6])
12806
+ if ARGV[8] == '1' then redis.call('HSET', KEYS[3], 'retryPayload', ARGV[6]) end
12807
+ redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
12808
+ redis.call('XDEL', KEYS[1], ARGV[2])
12809
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
12810
+ return 1
12811
+ `;
12812
+ var PROMOTE_RETRIES = `
12813
+ local kinds = {'stream','zset'}
12814
+ for i=1,2 do
12815
+ local kind = redis.call('TYPE', KEYS[i]).ok
12816
+ if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
12817
+ end
12818
+ local now = redis.call('TIME')
12819
+ local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
12820
+ local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
12821
+ for _, payload in ipairs(entries) do
12822
+ redis.call('XADD', KEYS[1], '*', 'payload', payload)
12823
+ redis.call('ZREM', KEYS[2], payload)
12824
+ end
12825
+ return #entries
12826
+ `;
12827
+ async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
12828
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
12829
+ throw new Error("Invalid Streams retry delay");
12830
+ const scheduled = await client.send("EVAL", [
12831
+ SCHEDULE_RETRY,
12832
+ "3",
12833
+ streamQueueKey(key),
12834
+ streamRetryKey(key),
12835
+ envelope.cancellable ? streamControlKey(key, envelope.jobId ?? "") : streamQueueKey(key),
12836
+ STREAM_GROUP,
12837
+ reservation.id,
12838
+ reservation.owner,
12839
+ String(readQueueVisibilityMs()),
12840
+ String(delayMs),
12841
+ JSON.stringify(envelope),
12842
+ envelope.deadlineAtMs === undefined ? "" : String(envelope.deadlineAtMs),
12843
+ envelope.cancellable ? "1" : "0"
12844
+ ]);
12845
+ if (Number(scheduled) !== 1)
12846
+ throw new Error("Queue lease lost while scheduling retry");
12847
+ }
12848
+ async function promoteStreamRetries(client, key) {
12849
+ return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
12850
+ }
12851
+ async function readStreamQueueDepth(client, key) {
12852
+ return Number(await client.send("EVAL", [
12853
+ "return redis.call('XLEN', KEYS[1]) + redis.call('ZCARD', KEYS[2])",
12854
+ "2",
12855
+ streamQueueKey(key),
12856
+ streamRetryKey(key)
12857
+ ]));
12858
+ }
12859
+ var ASSERT_DRAINED = `
12860
+ if redis.call('LLEN', KEYS[1]) > 0 or redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then
12861
+ return redis.error_reply('Legacy queue state requires maintenance conversion before Streams activation')
12862
+ end
12863
+ return 1
12864
+ `;
12865
+ async function assertLegacyDrained(client, key) {
12866
+ await client.send("EVAL", [
12867
+ ASSERT_DRAINED,
12868
+ "3",
12869
+ key,
12870
+ `${key}:processing`,
12871
+ `${key}:processing:leases`
12872
+ ]);
12873
+ }
12874
+ var UPDATE_STREAM = `
12875
+ local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
12876
+ if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
12877
+ if ARGV[7] == '1' then
12878
+ local kind = redis.call('TYPE', KEYS[3]).ok
12879
+ if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('Invalid stream control key type') end
12880
+ end
12881
+ if ARGV[5] == 'renew' then
12882
+ local claimed = redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[3], 0, ARGV[2], 'JUSTID')
12883
+ return #claimed
12884
+ end
12885
+ if ARGV[5] == 'quarantine' then
12886
+ local kind = redis.call('TYPE', KEYS[2]).ok
12887
+ if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream quarantine key type') end
12888
+ redis.call('XADD', KEYS[2], '*', 'sourceId', ARGV[2], 'payload', ARGV[6])
12889
+ end
12890
+ local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
12891
+ if acknowledged == 1 then
12892
+ redis.call('XDEL', KEYS[1], ARGV[2])
12893
+ if ARGV[7] == '1' then redis.call('DEL', KEYS[3]) end
12894
+ end
12895
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
12896
+ return acknowledged
12897
+ `;
12898
+ var CLAIM_STREAM = `
12899
+ local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
12900
+ local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
12901
+ for _, entry in ipairs(window) do
12902
+ if entry[2] ~= ARGV[2] and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, entry[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], entry[2]) end
12903
+ end
12904
+ if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
12905
+ return claimed
12906
+ `;
12907
+ var READ_STREAM = `
12908
+ local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
12909
+ if not entries and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
12910
+ return entries
12911
+ `;
12912
+ async function ensureStreamGroup(client, key) {
12913
+ await assertLegacyDrained(client, key);
11907
12914
  try {
11908
- parsed = JSON.parse(rawPayload);
11909
- } catch {
11910
- console.error("[QueueWorker] Ignoring malformed queue payload");
11911
- return null;
12915
+ await client.send("XGROUP", ["CREATE", streamQueueKey(key), STREAM_GROUP, "0", "MKSTREAM"]);
12916
+ } catch (error) {
12917
+ if (!(error instanceof Error) || !error.message.includes("BUSYGROUP"))
12918
+ throw error;
11912
12919
  }
11913
- if (!parsed || typeof parsed !== "object") {
11914
- console.error("[QueueWorker] Ignoring non-object queue payload");
11915
- return null;
12920
+ }
12921
+ function readStreamEntry(value, owner) {
12922
+ if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
12923
+ throw new Error("Invalid Redis stream entry response");
12924
+ const fields = value[1];
12925
+ let payload;
12926
+ for (let index = 0;index < fields.length; index += 2) {
12927
+ if (fields[index] === "payload" && typeof fields[index + 1] === "string")
12928
+ payload = fields[index + 1];
11916
12929
  }
11917
- const envelope = parsed;
11918
- if (typeof envelope.name !== "string" || envelope.name.length === 0) {
11919
- console.error("[QueueWorker] Ignoring queue payload without job name");
11920
- return null;
12930
+ return { id: value[0], owner, payload: payload ?? "" };
12931
+ }
12932
+ async function updateStreamReservation(client, key, reservation, action, controlKey) {
12933
+ return Number(await client.send("EVAL", [
12934
+ UPDATE_STREAM,
12935
+ "3",
12936
+ streamQueueKey(key),
12937
+ streamDeadLetterKey(key),
12938
+ controlKey ?? streamQueueKey(key),
12939
+ STREAM_GROUP,
12940
+ reservation.id,
12941
+ reservation.owner,
12942
+ String(readQueueVisibilityMs()),
12943
+ action,
12944
+ reservation.payload,
12945
+ controlKey ? "1" : "0"
12946
+ ])) === 1;
12947
+ }
12948
+ async function reserveStreamJob(client, key, owner, cursor = "0-0") {
12949
+ const claimed = await client.send("EVAL", [
12950
+ CLAIM_STREAM,
12951
+ "1",
12952
+ streamQueueKey(key),
12953
+ STREAM_GROUP,
12954
+ owner,
12955
+ String(readQueueVisibilityMs()),
12956
+ cursor
12957
+ ]);
12958
+ if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
12959
+ throw new Error("Invalid Redis XAUTOCLAIM response");
12960
+ const nextCursor = claimed[0];
12961
+ if (claimed[1].length > 0)
12962
+ return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
12963
+ const fresh = await client.send("EVAL", [
12964
+ READ_STREAM,
12965
+ "1",
12966
+ streamQueueKey(key),
12967
+ STREAM_GROUP,
12968
+ owner
12969
+ ]);
12970
+ if (fresh === null)
12971
+ return { reservation: null, cursor: nextCursor };
12972
+ if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
12973
+ throw new Error("Invalid Redis XREADGROUP response");
12974
+ return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
12975
+ }
12976
+
12977
+ class RedisStreamsQueue {
12978
+ client;
12979
+ constructor(redisUrl) {
12980
+ this.client = new RedisClient3(redisUrl);
11921
12981
  }
11922
- if (!jobRegistry.create(envelope.name)) {
11923
- console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
11924
- return null;
12982
+ close() {
12983
+ this.client.close();
11925
12984
  }
11926
- if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
11927
- console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
11928
- return null;
12985
+ async dispatch(job, payload) {
12986
+ await this.enqueue(job, payload);
12987
+ }
12988
+ async enqueue(job, payload, options = {}) {
12989
+ return this.admit(job, payload, options);
12990
+ }
12991
+ async replay(job, payload, jobId) {
12992
+ const prefix = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
12993
+ if (!prefix || !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9]+-[0-9]+)$/.test(jobId.slice(streamQueueKey(prefix).length + 1)))
12994
+ throw new Error("Replay identity does not belong to this Streams namespace");
12995
+ await this.admit(job, payload, {}, jobId);
12996
+ }
12997
+ async admit(job, payload, options, replayId) {
12998
+ if (options.cancellable !== undefined && typeof options.cancellable !== "boolean")
12999
+ throw new Error("cancellable must be a boolean");
13000
+ if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > MAX_TIMER_MS))
13001
+ throw new Error("Streams timeoutMs must be an integer between 1 and 2147483647");
13002
+ const name = jobRegistry.resolveName(job);
13003
+ if (!name)
13004
+ throw new Error("Job is not registered with the queue worker registry.");
13005
+ const key = queueKeyForPriority(job.priority);
13006
+ await assertLegacyDrained(this.client, key);
13007
+ const jobId = replayId ?? `${streamQueueKey(key)}/${crypto.randomUUID()}`;
13008
+ const deadlineAtMs = options.timeoutMs === undefined ? undefined : await readStreamTime(this.client) + options.timeoutMs;
13009
+ const envelope = JSON.stringify({
13010
+ name,
13011
+ payload,
13012
+ attempts: 0,
13013
+ jobId,
13014
+ deadlineAtMs,
13015
+ ...options.cancellable ? { cancellable: true } : {}
13016
+ });
13017
+ if (options.cancellable)
13018
+ await this.client.send("EVAL", [
13019
+ ENQUEUE_CONTROLLED,
13020
+ "2",
13021
+ streamQueueKey(key),
13022
+ streamControlKey(key, jobId),
13023
+ envelope
13024
+ ]);
13025
+ else
13026
+ await this.client.send("XADD", [streamQueueKey(key), "*", "payload", envelope]);
13027
+ return jobId;
13028
+ }
13029
+ async cancel(jobId) {
13030
+ const key = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
13031
+ if (!key)
13032
+ throw new Error("Invalid cancellable Streams job ID");
13033
+ return cancelStreamJob(this.client, key, jobId);
11929
13034
  }
11930
- return {
11931
- name: envelope.name,
11932
- payload: envelope.payload ?? {},
11933
- attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
11934
- };
13035
+ }
13036
+
13037
+ class RedisStreamsWorker {
13038
+ failedJobs;
13039
+ timeoutSeconds;
13040
+ queueKeys;
13041
+ client;
13042
+ owner = crypto.randomUUID();
13043
+ cursors = new Map;
13044
+ processing = false;
13045
+ initialized = false;
13046
+ running = false;
13047
+ stopping = false;
13048
+ active;
13049
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = defaultQueueKeys()) {
13050
+ this.failedJobs = failedJobs;
13051
+ this.timeoutSeconds = timeoutSeconds;
13052
+ this.queueKeys = queueKeys;
13053
+ readQueueVisibilityMs();
13054
+ this.client = new RedisClient3(redisUrl);
13055
+ }
13056
+ requestStop() {
13057
+ this.stopping = true;
13058
+ }
13059
+ isRunning() {
13060
+ return this.running;
13061
+ }
13062
+ close() {
13063
+ this.stopping = true;
13064
+ this.active?.abort(new Error("Queue worker closed"));
13065
+ this.client.close();
13066
+ }
13067
+ async initialize() {
13068
+ if (this.initialized)
13069
+ return;
13070
+ for (const key of this.queueKeys)
13071
+ await ensureStreamGroup(this.client, key);
13072
+ this.initialized = true;
13073
+ }
13074
+ async reserve(key) {
13075
+ await promoteStreamRetries(this.client, key);
13076
+ const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
13077
+ this.cursors.set(key, result.cursor);
13078
+ return result.reservation;
13079
+ }
13080
+ async processNext() {
13081
+ if (this.processing)
13082
+ throw new Error("Queue worker already processing a job.");
13083
+ this.processing = true;
13084
+ try {
13085
+ return await this.processOne();
13086
+ } finally {
13087
+ this.processing = false;
13088
+ }
13089
+ }
13090
+ async processOne() {
13091
+ if (this.stopping)
13092
+ return false;
13093
+ await this.initialize();
13094
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
13095
+ do {
13096
+ for (const key of this.queueKeys) {
13097
+ if (this.stopping)
13098
+ return false;
13099
+ const reservation = await this.reserve(key);
13100
+ if (!reservation)
13101
+ continue;
13102
+ await this.handle(key, reservation);
13103
+ return true;
13104
+ }
13105
+ if (performance.now() >= deadline || this.stopping)
13106
+ return false;
13107
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
13108
+ } while (!this.stopping);
13109
+ return false;
13110
+ }
13111
+ async handle(key, reservation) {
13112
+ const envelope = parseQueueJobEnvelope(reservation.payload);
13113
+ if (!envelope) {
13114
+ await updateStreamReservation(this.client, key, reservation, "quarantine");
13115
+ return;
13116
+ }
13117
+ envelope.jobId ??= `${streamQueueKey(key)}/${reservation.id}`;
13118
+ let controlKey;
13119
+ if (envelope.cancellable) {
13120
+ try {
13121
+ controlKey = streamControlKey(key, envelope.jobId);
13122
+ } catch {
13123
+ await updateStreamReservation(this.client, key, reservation, "quarantine");
13124
+ return;
13125
+ }
13126
+ }
13127
+ const cancellationError = new Error("Streams job cancelled");
13128
+ if (controlKey && await readStreamCancellation(this.client, controlKey)) {
13129
+ await this.failedJobs.recordFailure({
13130
+ jobName: envelope.name,
13131
+ jobId: envelope.jobId,
13132
+ payload: envelope.payload,
13133
+ exception: cancellationError.message
13134
+ });
13135
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
13136
+ return;
13137
+ }
13138
+ const remainingMs = envelope.deadlineAtMs === undefined ? undefined : envelope.deadlineAtMs - await readStreamTime(this.client);
13139
+ const deadlineError = new Error("Streams job deadline exceeded");
13140
+ if (remainingMs !== undefined && remainingMs <= 0) {
13141
+ await this.failedJobs.recordFailure({
13142
+ jobName: envelope.name,
13143
+ jobId: envelope.jobId,
13144
+ payload: envelope.payload,
13145
+ exception: deadlineError.message
13146
+ });
13147
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
13148
+ return;
13149
+ }
13150
+ const controller = new AbortController;
13151
+ this.active = controller;
13152
+ let acknowledge = false;
13153
+ let released = false;
13154
+ let ownershipLost = false;
13155
+ let renewal;
13156
+ let deadlineExpired = false;
13157
+ let cancellationRequested = false;
13158
+ let failureRecorded = false;
13159
+ const loseOwnership = (error) => {
13160
+ ownershipLost = true;
13161
+ controller.abort(error);
13162
+ };
13163
+ const stopDeadlineWatch = remainingMs === undefined || envelope.deadlineAtMs === undefined ? undefined : watchStreamDeadline(this.client, envelope.deadlineAtMs, remainingMs, () => {
13164
+ deadlineExpired = true;
13165
+ controller.abort(deadlineError);
13166
+ }, loseOwnership);
13167
+ const heartbeat = setInterval(() => {
13168
+ if (renewal || ownershipLost || released)
13169
+ return;
13170
+ renewal = (async () => {
13171
+ try {
13172
+ if (!await updateStreamReservation(this.client, key, reservation, "renew", controlKey))
13173
+ loseOwnership(new Error("Queue lease lost"));
13174
+ else if (controlKey && await readStreamCancellation(this.client, controlKey)) {
13175
+ cancellationRequested = true;
13176
+ controller.abort(cancellationError);
13177
+ }
13178
+ } catch (error) {
13179
+ loseOwnership(error);
13180
+ console.error("[StreamsWorker] Lease renewal failed:", error);
13181
+ }
13182
+ })().finally(() => {
13183
+ renewal = undefined;
13184
+ });
13185
+ }, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
13186
+ try {
13187
+ await runQueueJob(envelope, this.failedJobs, {
13188
+ context: {
13189
+ jobId: envelope.jobId,
13190
+ signal: controller.signal,
13191
+ deadlineAtMs: envelope.deadlineAtMs
13192
+ },
13193
+ deferRetry: async (next, delayMs) => {
13194
+ controller.signal.throwIfAborted();
13195
+ await scheduleStreamRetry(this.client, key, reservation, next, delayMs);
13196
+ released = true;
13197
+ },
13198
+ onFailureRecorded: () => {
13199
+ acknowledge = true;
13200
+ failureRecorded = true;
13201
+ }
13202
+ });
13203
+ acknowledge = true;
13204
+ } catch (error) {
13205
+ console.error("[StreamsWorker] Job failed:", error);
13206
+ } finally {
13207
+ stopDeadlineWatch?.();
13208
+ clearInterval(heartbeat);
13209
+ await renewal;
13210
+ this.active = undefined;
13211
+ }
13212
+ if (!ownershipLost && !released && controlKey && (cancellationRequested || await readStreamCancellation(this.client, controlKey))) {
13213
+ if (!failureRecorded)
13214
+ await this.failedJobs.recordFailure({
13215
+ jobName: envelope.name,
13216
+ jobId: envelope.jobId,
13217
+ payload: envelope.payload,
13218
+ exception: cancellationError.message
13219
+ });
13220
+ failureRecorded = true;
13221
+ acknowledge = true;
13222
+ }
13223
+ if (!ownershipLost && !released && envelope.deadlineAtMs !== undefined && (deadlineExpired || envelope.deadlineAtMs <= await readStreamTime(this.client))) {
13224
+ if (!failureRecorded)
13225
+ await this.failedJobs.recordFailure({
13226
+ jobName: envelope.name,
13227
+ jobId: envelope.jobId,
13228
+ payload: envelope.payload,
13229
+ exception: deadlineError.message
13230
+ });
13231
+ acknowledge = true;
13232
+ }
13233
+ if (acknowledge && !ownershipLost && !released)
13234
+ await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
13235
+ }
13236
+ async run() {
13237
+ this.running = true;
13238
+ try {
13239
+ while (!this.stopping)
13240
+ await this.processNext();
13241
+ } finally {
13242
+ this.running = false;
13243
+ }
13244
+ }
13245
+ }
13246
+
13247
+ // ../../src/core/queue/redisQueue.ts
13248
+ function queueProcessingKey(queueKey) {
13249
+ return `${queueKey}:processing`;
13250
+ }
13251
+ function queueProcessingLeaseKey(queueKey) {
13252
+ return `${queueKey}:processing:leases`;
11935
13253
  }
11936
13254
  var SCRIPT_HEADER = `
11937
13255
  local function checkType(key, expected)
@@ -12020,7 +13338,7 @@ async function reserveQueueJob(client, queueKey, owner) {
12020
13338
  queueProcessingLeaseKey(queueKey),
12021
13339
  crypto.randomUUID(),
12022
13340
  owner,
12023
- String(readVisibilityMs())
13341
+ String(readQueueVisibilityMs())
12024
13342
  ]);
12025
13343
  if (typeof raw !== "string")
12026
13344
  return null;
@@ -12038,7 +13356,7 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
12038
13356
  reservation.owner,
12039
13357
  reservation.record,
12040
13358
  action,
12041
- String(readVisibilityMs()),
13359
+ String(readQueueVisibilityMs()),
12042
13360
  reservation.payload
12043
13361
  ])) === 1;
12044
13362
  }
@@ -12051,7 +13369,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
12051
13369
  key,
12052
13370
  queueProcessingKey(key),
12053
13371
  queueProcessingLeaseKey(key),
12054
- String(readVisibilityMs())
13372
+ String(readQueueVisibilityMs())
12055
13373
  ]));
12056
13374
  }
12057
13375
  return reclaimed;
@@ -12062,6 +13380,9 @@ class RedisQueue {
12062
13380
  constructor(redisUrl) {
12063
13381
  this.client = new RedisClient4(redisUrl);
12064
13382
  }
13383
+ close() {
13384
+ this.client.close();
13385
+ }
12065
13386
  async dispatch(job, payload) {
12066
13387
  const name = jobRegistry.resolveName(job);
12067
13388
  if (!name) {
@@ -12144,7 +13465,7 @@ class QueueWorker {
12144
13465
  })().finally(() => {
12145
13466
  renewal = undefined;
12146
13467
  });
12147
- }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
13468
+ }, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
12148
13469
  try {
12149
13470
  await runQueueJob(envelope, this.failedJobs, {
12150
13471
  onFailureRecorded: () => {
@@ -12179,14 +13500,16 @@ class QueueWorker {
12179
13500
  }
12180
13501
 
12181
13502
  // ../../src/core/queue/resilientQueue.ts
12182
- class ResilientQueue {
13503
+ class ResilientQueue extends AsyncQueue {
12183
13504
  failedJobs;
12184
13505
  asyncDispatch;
12185
13506
  constructor(failedJobs, asyncDispatch = false) {
13507
+ super();
12186
13508
  this.failedJobs = failedJobs;
12187
13509
  this.asyncDispatch = asyncDispatch;
12188
13510
  }
12189
13511
  async dispatch(job, payload) {
13512
+ this.assertOpen();
12190
13513
  const name = jobRegistry.resolveName(job);
12191
13514
  if (!name) {
12192
13515
  throw new Error("Job is not registered with the queue worker registry.");
@@ -12197,11 +13520,9 @@ class ResilientQueue {
12197
13520
  attempts: 0
12198
13521
  };
12199
13522
  if (this.asyncDispatch) {
12200
- setTimeout(() => {
12201
- runQueueJob(envelope, this.failedJobs).catch((error) => {
12202
- console.error("[ResilientQueue] Job failed:", error);
12203
- });
12204
- }, 0);
13523
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
13524
+ console.error("[ResilientQueue] Job failed:", error);
13525
+ });
12205
13526
  return;
12206
13527
  }
12207
13528
  await runQueueJob(envelope, this.failedJobs);
@@ -12222,22 +13543,30 @@ function createProductionQueue(driver, options = {}) {
12222
13543
  if (!options.redisUrl) {
12223
13544
  throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
12224
13545
  }
12225
- return new RedisQueue(options.redisUrl);
13546
+ return resolveRedisQueueTransport() === "streams" ? new RedisStreamsQueue(options.redisUrl) : new RedisQueue(options.redisUrl);
12226
13547
  }
12227
13548
  return new ResilientQueue(failedJobs, driver === "async");
12228
13549
  }
12229
13550
  function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
12230
- return new QueueWorker(redisUrl, failedJobs);
13551
+ return resolveRedisQueueTransport() === "streams" ? new RedisStreamsWorker(redisUrl, failedJobs) : new QueueWorker(redisUrl, failedJobs);
12231
13552
  }
12232
13553
  // ../../src/core/queue/queueMetrics.ts
12233
13554
  var {RedisClient: RedisClient5 } = globalThis.Bun;
12234
13555
  async function readRedisQueueDepth(redisUrl) {
12235
13556
  const client = new RedisClient5(redisUrl);
12236
- const [high, defaultQueue, low] = await Promise.all([
12237
- client.llen(QUEUE_HIGH_KEY),
12238
- client.llen(QUEUE_LIST_KEY),
12239
- client.llen(QUEUE_LOW_KEY)
12240
- ]);
13557
+ let high;
13558
+ let defaultQueue;
13559
+ let low;
13560
+ try {
13561
+ const readDepth = async (key) => resolveRedisQueueTransport() === "streams" ? readStreamQueueDepth(client, key) : client.llen(key);
13562
+ [high, defaultQueue, low] = await Promise.all([
13563
+ readDepth(defaultQueueKeys()[0]),
13564
+ readDepth(defaultQueueKeys()[1]),
13565
+ readDepth(defaultQueueKeys()[2])
13566
+ ]);
13567
+ } finally {
13568
+ client.close();
13569
+ }
12241
13570
  return {
12242
13571
  high: Number(high ?? 0),
12243
13572
  default: Number(defaultQueue ?? 0),
@@ -12280,6 +13609,149 @@ async function collectQueueMetrics() {
12280
13609
  failedCount
12281
13610
  };
12282
13611
  }
13612
+ // ../../src/core/scheduler/schedule.ts
13613
+ import { createHash as createHash6 } from "crypto";
13614
+
13615
+ // ../../src/core/scheduler/leases.ts
13616
+ import { createHash as createHash5 } from "crypto";
13617
+ var {RedisClient: RedisClient6 } = globalThis.Bun;
13618
+ var LEASE_SCRIPT = `
13619
+ for _, key in ipairs(KEYS) do
13620
+ local kind = redis.call('TYPE', key).ok
13621
+ if kind ~= 'none' and kind ~= 'string' then return redis.error_reply('Unexpected scheduler key type') end
13622
+ end
13623
+ local action = ARGV[1]
13624
+ local token = ARGV[2]
13625
+ local ttl = tonumber(ARGV[3])
13626
+ if action == 'acquire' then
13627
+ if redis.call('EXISTS', KEYS[3]) == 1 or redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
13628
+ redis.call('SET', KEYS[1], token, 'PX', ttl)
13629
+ redis.call('SET', KEYS[2], token, 'PX', ttl)
13630
+ return 1
13631
+ end
13632
+ if redis.call('GET', KEYS[1]) ~= token or redis.call('GET', KEYS[2]) ~= token then return 0 end
13633
+ if action == 'renew' then
13634
+ redis.call('PEXPIRE', KEYS[1], ttl)
13635
+ redis.call('PEXPIRE', KEYS[2], ttl)
13636
+ elseif action == 'complete' then
13637
+ redis.call('SET', KEYS[3], 'completed', 'PX', ttl)
13638
+ redis.call('DEL', KEYS[1], KEYS[2])
13639
+ elseif action == 'release' then
13640
+ redis.call('DEL', KEYS[1], KEYS[2])
13641
+ else return redis.error_reply('Unexpected scheduler action') end
13642
+ return 1
13643
+ `;
13644
+
13645
+ class RedisSchedulerLeaseStore {
13646
+ client;
13647
+ owned;
13648
+ namespace;
13649
+ timeoutMs;
13650
+ constructor(options) {
13651
+ this.namespace = options.namespace.trim();
13652
+ if (!this.namespace || this.namespace.length > 512)
13653
+ throw new TypeError("Invalid scheduler namespace.");
13654
+ this.timeoutMs = options.commandTimeoutMs ?? 1000;
13655
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 60000)
13656
+ throw new RangeError("Invalid scheduler command timeout.");
13657
+ if (options.client)
13658
+ this.client = options.client;
13659
+ else {
13660
+ const client = new RedisClient6(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
13661
+ this.owned = client;
13662
+ this.client = client;
13663
+ }
13664
+ }
13665
+ keys(lease) {
13666
+ const task = createHash5("sha256").update(JSON.stringify([this.namespace, lease.taskId])).digest("hex");
13667
+ const occurrence = createHash5("sha256").update(lease.occurrenceId).digest("hex");
13668
+ const prefix = `scheduler:{${task}}`;
13669
+ return [
13670
+ `${prefix}:active`,
13671
+ `${prefix}:occurrence:${occurrence}`,
13672
+ `${prefix}:completed:${occurrence}`
13673
+ ];
13674
+ }
13675
+ async execute(action, lease, ttl) {
13676
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > 2147483647)
13677
+ throw new RangeError("Invalid scheduler TTL.");
13678
+ let timer;
13679
+ try {
13680
+ const result = await Promise.race([
13681
+ this.client.send("EVAL", [
13682
+ LEASE_SCRIPT,
13683
+ "3",
13684
+ ...this.keys(lease),
13685
+ action,
13686
+ lease.token,
13687
+ String(ttl)
13688
+ ]),
13689
+ new Promise((_, reject) => {
13690
+ timer = setTimeout(() => reject(new Error("Scheduler store deadline exceeded.")), this.timeoutMs);
13691
+ })
13692
+ ]);
13693
+ if (result !== 0 && result !== 1)
13694
+ throw new Error("Invalid scheduler store response.");
13695
+ return result === 1;
13696
+ } catch (error) {
13697
+ this.owned?.close();
13698
+ throw error;
13699
+ } finally {
13700
+ clearTimeout(timer);
13701
+ }
13702
+ }
13703
+ acquire(lease, leaseMs) {
13704
+ return this.execute("acquire", lease, leaseMs);
13705
+ }
13706
+ renew(lease, leaseMs) {
13707
+ return this.execute("renew", lease, leaseMs);
13708
+ }
13709
+ complete(lease, retentionMs) {
13710
+ return this.execute("complete", lease, retentionMs);
13711
+ }
13712
+ async release(lease) {
13713
+ await this.execute("release", lease, 1);
13714
+ }
13715
+ close() {
13716
+ this.owned?.close();
13717
+ }
13718
+ }
13719
+
13720
+ class SingleRunnerLeaseStore {
13721
+ tasks = new Map;
13722
+ async acquire(lease, leaseMs) {
13723
+ const task = this.tasks.get(lease.taskId) ?? { expires: 0 };
13724
+ if (task.active && task.expires > performance.now())
13725
+ return false;
13726
+ if ((task.completed ?? -Infinity) >= Number(lease.occurrenceId))
13727
+ return false;
13728
+ task.active = lease;
13729
+ task.expires = performance.now() + leaseMs;
13730
+ this.tasks.set(lease.taskId, task);
13731
+ return true;
13732
+ }
13733
+ async renew(lease, leaseMs) {
13734
+ const task = this.tasks.get(lease.taskId);
13735
+ if (!task || task.active?.token !== lease.token || task.expires <= performance.now())
13736
+ return false;
13737
+ task.expires = performance.now() + leaseMs;
13738
+ return true;
13739
+ }
13740
+ async complete(lease) {
13741
+ const task = this.tasks.get(lease.taskId);
13742
+ if (!task || task.active?.token !== lease.token || task.expires <= performance.now())
13743
+ return false;
13744
+ task.completed = Number(lease.occurrenceId);
13745
+ task.active = undefined;
13746
+ return true;
13747
+ }
13748
+ async release(lease) {
13749
+ const task = this.tasks.get(lease.taskId);
13750
+ if (task?.active?.token === lease.token)
13751
+ task.active = undefined;
13752
+ }
13753
+ }
13754
+
12283
13755
  // ../../src/core/scheduler/osCron.ts
12284
13756
  function parseScheduleExpression(expression, from = new Date) {
12285
13757
  return Bun.cron.parse(expression, from);
@@ -12310,11 +13782,25 @@ function assertScheduleExpression(expression) {
12310
13782
  }
12311
13783
 
12312
13784
  // ../../src/core/scheduler/schedule.ts
13785
+ class SchedulerLeaseLostError extends Error {
13786
+ constructor(taskName, options) {
13787
+ super(`Scheduler lease lost for task ${taskName}.`, options);
13788
+ this.name = "SchedulerLeaseLostError";
13789
+ }
13790
+ }
13791
+
12313
13792
  class Schedule {
12314
13793
  tasks = [];
12315
13794
  command(expression, name, run) {
12316
13795
  assertScheduleExpression(expression);
12317
- this.tasks.push({ expression, name, run });
13796
+ if (typeof run !== "function")
13797
+ throw new TypeError("Scheduled task handler must be a function.");
13798
+ const normalized = name.trim();
13799
+ if (!normalized || normalized.length > 200 || this.tasks.some((task) => task.name === normalized))
13800
+ throw new TypeError("Scheduled task names must be nonempty and unique (at most 200 characters).");
13801
+ if (this.tasks.length >= 4096)
13802
+ throw new RangeError("A schedule supports at most 4096 tasks.");
13803
+ this.tasks.push({ expression, name: normalized, run });
12318
13804
  return this;
12319
13805
  }
12320
13806
  dueTasks(now = new Date) {
@@ -12325,12 +13811,125 @@ class Schedule {
12325
13811
  }
12326
13812
  }
12327
13813
  var appSchedule = new Schedule;
12328
- async function runDueScheduledTasks(schedule = appSchedule, now = new Date) {
13814
+ var localStores = new WeakMap;
13815
+ function bounded(value, min, max, name) {
13816
+ if (!Number.isSafeInteger(value) || value < min || value > max)
13817
+ throw new RangeError(`Invalid scheduler ${name}.`);
13818
+ return value;
13819
+ }
13820
+ async function runDueScheduledTasks(schedule = appSchedule, now = new Date, options = {}) {
13821
+ const timestamp = now.getTime();
13822
+ if (!Number.isFinite(timestamp))
13823
+ throw new TypeError("Invalid scheduler date.");
12329
13824
  const due = schedule.dueTasks(now);
12330
- for (const task of due) {
12331
- await task.run();
13825
+ if (!due.length || options.signal?.aborted)
13826
+ return 0;
13827
+ const coordination = options.coordination ?? process.env.SCHEDULER_COORDINATION ?? (isProductionEnv() ? "redis" : "single-runner");
13828
+ if (coordination !== "redis" && coordination !== "single-runner")
13829
+ throw new TypeError("Unsupported scheduler coordination.");
13830
+ const leaseMs = bounded(options.leaseMs ?? 30000, 100, 3600000, "leaseMs");
13831
+ const renewalMs = bounded(options.renewalMs ?? Math.floor(leaseMs / 3), 1, Math.floor(leaseMs / 3), "renewalMs");
13832
+ const timeoutMs = bounded(options.commandTimeoutMs ?? Math.min(1000, Math.floor(leaseMs / 4)), 1, Math.floor(leaseMs / 3), "commandTimeoutMs");
13833
+ const retention = bounded(options.completedRetentionMs ?? 7 * 86400000, leaseMs, 2147483647, "completedRetentionMs");
13834
+ const namespace = (options.namespace ?? namespacedRedisKey("schedule")).trim();
13835
+ if (!namespace || namespace.length > 512)
13836
+ throw new TypeError("Invalid scheduler namespace.");
13837
+ let owned;
13838
+ let store = options.leaseStore;
13839
+ if (!store && coordination === "redis") {
13840
+ const redisUrl = options.redisUrl ?? process.env.REDIS_URL?.trim();
13841
+ if (!redisUrl)
13842
+ throw new Error("Redis scheduler coordination requires REDIS_URL.");
13843
+ owned = new RedisSchedulerLeaseStore({
13844
+ redisUrl,
13845
+ namespace,
13846
+ commandTimeoutMs: timeoutMs
13847
+ });
13848
+ store = owned;
13849
+ }
13850
+ if (!store) {
13851
+ let local = localStores.get(schedule);
13852
+ if (!local) {
13853
+ local = new SingleRunnerLeaseStore;
13854
+ localStores.set(schedule, local);
13855
+ }
13856
+ store = local;
13857
+ }
13858
+ const occurrenceId = String(Math.floor(timestamp / 60000) * 60000);
13859
+ let completed = 0;
13860
+ try {
13861
+ for (const task of due) {
13862
+ if (options.signal?.aborted)
13863
+ break;
13864
+ const lease = { taskId: task.name, occurrenceId, token: crypto.randomUUID() };
13865
+ if (!await store.acquire(lease, leaseMs))
13866
+ continue;
13867
+ if (options.signal?.aborted) {
13868
+ await store.release(lease);
13869
+ break;
13870
+ }
13871
+ const controller = new AbortController;
13872
+ let lost;
13873
+ const lose = (cause) => {
13874
+ lost ??= new SchedulerLeaseLostError(task.name, { cause });
13875
+ controller.abort(lost);
13876
+ return lost;
13877
+ };
13878
+ let timer;
13879
+ let renewal;
13880
+ let stopped = false;
13881
+ const renew = async () => {
13882
+ try {
13883
+ if (!await store.renew(lease, leaseMs))
13884
+ throw lose();
13885
+ } catch (error) {
13886
+ throw lose(error);
13887
+ }
13888
+ };
13889
+ const tick = () => {
13890
+ renewal = renew().catch(() => {}).finally(() => {
13891
+ if (!stopped && !lost)
13892
+ timer = setTimeout(tick, renewalMs);
13893
+ });
13894
+ };
13895
+ timer = setTimeout(tick, renewalMs);
13896
+ try {
13897
+ await task.run({
13898
+ taskName: task.name,
13899
+ occurrenceId: createHash6("sha256").update(JSON.stringify([namespace, task.name, occurrenceId])).digest("hex"),
13900
+ scheduledAt: new Date(Number(occurrenceId)),
13901
+ signal: controller.signal,
13902
+ async assertOwnership() {
13903
+ if (lost)
13904
+ throw lost;
13905
+ await renew();
13906
+ }
13907
+ });
13908
+ stopped = true;
13909
+ clearTimeout(timer);
13910
+ await renewal;
13911
+ if (lost || !await store.complete(lease, retention))
13912
+ throw lost ?? lose();
13913
+ completed++;
13914
+ } catch (error) {
13915
+ stopped = true;
13916
+ clearTimeout(timer);
13917
+ await renewal;
13918
+ try {
13919
+ await store.release(lease);
13920
+ } catch (releaseError) {
13921
+ throw new AggregateError([error, releaseError], "Scheduled task and lease release failed.");
13922
+ }
13923
+ throw error;
13924
+ } finally {
13925
+ stopped = true;
13926
+ clearTimeout(timer);
13927
+ }
13928
+ }
13929
+ return completed;
13930
+ } finally {
13931
+ owned?.close();
12332
13932
  }
12333
- return due.length;
12334
13933
  }
12335
13934
  // ../../src/core/security/publicReads.ts
12336
13935
  function isPublicReadsEnabled() {
@@ -12452,17 +14051,62 @@ init_tenantContext();
12452
14051
  init_tenantDatabaseScope();
12453
14052
 
12454
14053
  // ../../src/core/tenant/tenantMiddleware.ts
12455
- import { createHash as createHash5 } from "crypto";
14054
+ import { createHash as createHash7 } from "crypto";
12456
14055
  init_repositoryConnection();
12457
14056
  init_databaseTenantContext();
14057
+
14058
+ // ../../src/core/tenant/publicTenantHost.ts
14059
+ import { BlockList, isIP } from "net";
14060
+ function normalizeTenantHostname(authority) {
14061
+ if (!authority || authority !== authority.trim() || /[\s\\/@?#%,]/.test(authority)) {
14062
+ throw new ForbiddenError("Invalid tenant hostname.");
14063
+ }
14064
+ let url;
14065
+ try {
14066
+ url = new URL(`http://${authority}`);
14067
+ } catch {
14068
+ throw new ForbiddenError("Invalid tenant hostname.");
14069
+ }
14070
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
14071
+ const ip = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname;
14072
+ if (!isIP(ip) && (hostname.length > 253 || !hostname.split(".").every((label) => label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)))) {
14073
+ throw new ForbiddenError("Invalid tenant hostname.");
14074
+ }
14075
+ return hostname;
14076
+ }
14077
+ function createPublicTenantSelector(options) {
14078
+ const addresses = options.trustedProxyAddresses ?? [];
14079
+ if (addresses.length > 128)
14080
+ throw new Error("Public tenancy supports at most 128 trusted proxy addresses.");
14081
+ const proxies = new BlockList;
14082
+ for (const address of addresses) {
14083
+ const version = isIP(address);
14084
+ if (!version)
14085
+ throw new Error("Trusted proxy addresses must be literal IP addresses.");
14086
+ proxies.addAddress(address, version === 6 ? "ipv6" : "ipv4");
14087
+ }
14088
+ return async (request) => {
14089
+ const peer = currentRequestMeta().peerAddress;
14090
+ const version = peer ? isIP(peer) : 0;
14091
+ const trusted = peer && version && proxies.check(peer, version === 6 ? "ipv6" : "ipv4");
14092
+ const forwarded = trusted ? request.headers.get("x-forwarded-host") : null;
14093
+ const authority = forwarded ?? request.headers.get("host") ?? new URL(request.url).host;
14094
+ const id = await options.resolveTenantId(normalizeTenantHostname(authority));
14095
+ if (id === null)
14096
+ throw new ForbiddenError("Tenant host not found.");
14097
+ if (!Number.isSafeInteger(id) || id <= 0)
14098
+ throw new Error("Public tenant resolver returned an invalid identity.");
14099
+ return id;
14100
+ };
14101
+ }
14102
+
14103
+ // ../../src/core/tenant/tenantMiddleware.ts
12458
14104
  init_tenancyConfig();
12459
14105
  init_tenantContext();
12460
14106
  init_tenantDatabaseScope();
12461
14107
  var DEFAULT_TENANT = {
12462
14108
  id: 1,
12463
- slug: "default",
12464
- plan: "free",
12465
- region: "eu"
14109
+ slug: "default"
12466
14110
  };
12467
14111
  async function resolveUserTenantId(userId) {
12468
14112
  if (!isTenancyEnabled()) {
@@ -12479,49 +14123,57 @@ async function resolveUserTenantId(userId) {
12479
14123
  });
12480
14124
  }
12481
14125
  function auditChecksum(payload) {
12482
- return createHash5("sha256").update(JSON.stringify(payload)).digest("hex");
14126
+ return createHash7("sha256").update(JSON.stringify(payload)).digest("hex");
12483
14127
  }
12484
- async function resolveTenantForRequest(request) {
14128
+ async function resolveTenantForRequest(request, resolver, selectPublicTenant) {
14129
+ const hostTenantId = selectPublicTenant ? await selectPublicTenant(request) : null;
12485
14130
  const user = currentAuthUser();
12486
14131
  const headerValue = request.headers.get("x-tenant-id")?.trim();
12487
- const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number.parseInt(headerValue, 10) : Number.NaN;
14132
+ const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number(headerValue) : Number.NaN;
12488
14133
  if (user) {
12489
14134
  const userId = typeof user.id === "number" ? user.id : Number.parseInt(String(user.id), 10);
12490
14135
  if (Number.isInteger(userId) && userId > 0) {
12491
14136
  const userTenantId = await resolveUserTenantId(userId);
12492
14137
  if (!isGlobalAdmin(user)) {
12493
- if (Number.isInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
14138
+ if (Number.isSafeInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
12494
14139
  throw new ForbiddenError("Tenant header does not match your account.");
12495
14140
  }
12496
- const memberTenant = await resolveTenant(userTenantId);
14141
+ const memberTenant = await resolver(userTenantId);
12497
14142
  if (memberTenant) {
12498
14143
  return memberTenant;
12499
14144
  }
12500
14145
  return DEFAULT_TENANT;
12501
14146
  }
12502
- if (Number.isInteger(parsedHeader) && parsedHeader > 0) {
12503
- const headerTenant = await resolveTenant(parsedHeader);
14147
+ if (Number.isSafeInteger(parsedHeader) && parsedHeader > 0) {
14148
+ const headerTenant = await resolver(parsedHeader);
12504
14149
  if (headerTenant) {
12505
14150
  return headerTenant;
12506
14151
  }
12507
14152
  return DEFAULT_TENANT;
12508
14153
  }
12509
- const adminTenant = await resolveTenant(userTenantId);
14154
+ const adminTenant = await resolver(userTenantId);
12510
14155
  if (adminTenant) {
12511
14156
  return adminTenant;
12512
14157
  }
12513
14158
  return DEFAULT_TENANT;
12514
14159
  }
12515
14160
  }
12516
- const headerTenantId = Number.isInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
12517
- const tenantId = isPublicReadsEnabled() && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
12518
- const guestTenant = await resolveTenant(tenantId);
14161
+ const headerTenantId = Number.isSafeInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
14162
+ const tenantId = hostTenantId !== null ? hostTenantId : !isProductionEnv() && process.env.TENANT_DEV_HEADERS === "true" && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
14163
+ if (isProductionEnv() && !selectPublicTenant) {
14164
+ throw new ForbiddenError("Public tenant host resolver is required.");
14165
+ }
14166
+ const guestTenant = await resolver(tenantId);
14167
+ if (selectPublicTenant && !guestTenant)
14168
+ throw new ForbiddenError("Tenant not found.");
12519
14169
  if (guestTenant) {
12520
14170
  return guestTenant;
12521
14171
  }
12522
14172
  return DEFAULT_TENANT;
12523
14173
  }
12524
- function createTenantMiddleware() {
14174
+ function createTenantMiddleware(options = {}) {
14175
+ const resolver = options.resolveTenant ?? resolveTenant;
14176
+ const selectPublicTenant = options.publicTenancy ? createPublicTenantSelector(options.publicTenancy) : undefined;
12525
14177
  return async (request, next) => {
12526
14178
  const pathname = new URL(request.url).pathname;
12527
14179
  if (pathname.startsWith("/scim/")) {
@@ -12531,7 +14183,16 @@ function createTenantMiddleware() {
12531
14183
  return await runWithTenant(DEFAULT_TENANT, next);
12532
14184
  }
12533
14185
  try {
12534
- const tenant = await resolveTenantForRequest(request);
14186
+ const tenant = await resolveTenantForRequest(request, async (id) => {
14187
+ const tenant = await resolver(id);
14188
+ if (options.resolveTenant && tenant === null) {
14189
+ throw new ForbiddenError("Tenant not found.");
14190
+ }
14191
+ if (tenant && tenant.id !== id) {
14192
+ throw new Error("Tenant resolver returned a different tenant identity.");
14193
+ }
14194
+ return tenant;
14195
+ }, selectPublicTenant);
12535
14196
  return await runWithTenantDatabase(tenant, async () => {
12536
14197
  return await next();
12537
14198
  });
@@ -12553,99 +14214,185 @@ function runWithTraceContext(context, callback) {
12553
14214
  function currentTraceId() {
12554
14215
  return traceContextStorage.getStore()?.traceId ?? null;
12555
14216
  }
14217
+ // ../../src/core/tracing/tracingMiddleware.ts
14218
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
14219
+
12556
14220
  // ../../src/core/tracing/otel.ts
12557
- import { randomBytes as randomBytes5 } from "crypto";
12558
- function randomHex(bytes) {
12559
- return randomBytes5(bytes).toString("hex");
12560
- }
12561
- function createSpan(input) {
12562
- const spanId = randomHex(8);
12563
- return {
12564
- traceId: input.traceId,
12565
- spanId,
12566
- name: input.name,
12567
- startTimeUnixNano: String(Math.floor(input.startedAt * 1e6)),
12568
- endTimeUnixNano: String(Math.floor(input.endedAt * 1e6)),
12569
- attributes: Object.entries(input.attributes ?? {}).map(([key, value]) => ({
12570
- key,
12571
- value: { stringValue: value }
12572
- })),
12573
- status: { code: 1 }
12574
- };
14221
+ import { context } from "@opentelemetry/api";
14222
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
14223
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
14224
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
14225
+ import { resourceFromAttributes } from "@opentelemetry/resources";
14226
+ import {
14227
+ AlwaysOffSampler,
14228
+ AlwaysOnSampler,
14229
+ BatchSpanProcessor,
14230
+ ParentBasedSampler,
14231
+ TraceIdRatioBasedSampler
14232
+ } from "@opentelemetry/sdk-trace-base";
14233
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
14234
+ function numberOption(value, fallback, name, max = 1e6) {
14235
+ const result = value ?? fallback;
14236
+ if (!Number.isInteger(result) || result < 1 || result > max)
14237
+ throw new TypeError(`Invalid tracing ${name}.`);
14238
+ return result;
12575
14239
  }
12576
- async function exportOtelSpan(span) {
12577
- const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
12578
- if (!endpoint) {
14240
+ function ensureContextManager() {
14241
+ const key = Symbol.for("@getstrata/otel-context-installed");
14242
+ const state = globalThis;
14243
+ if (state[key])
12579
14244
  return;
14245
+ const manager = new AsyncLocalStorageContextManager().enable();
14246
+ if (!context.setGlobalContextManager(manager))
14247
+ manager.disable();
14248
+ state[key] = true;
14249
+ }
14250
+ function createTracingRuntime(options = {}) {
14251
+ ensureContextManager();
14252
+ const disabled = process.env.OTEL_SDK_DISABLED === "true";
14253
+ const samplerName = options.sampleRatio === undefined ? process.env.OTEL_TRACES_SAMPLER ?? "parentbased_traceidratio" : "parentbased_traceidratio";
14254
+ const rootName = samplerName.replace(/^parentbased_/, "");
14255
+ if (!["always_on", "always_off", "traceidratio"].includes(rootName))
14256
+ throw new TypeError("Unsupported tracing sampler.");
14257
+ const ratio = options.sampleRatio ?? Number(process.env.OTEL_TRACES_SAMPLER_ARG ?? "1");
14258
+ if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1)
14259
+ throw new TypeError("Invalid tracing sampleRatio.");
14260
+ const rootSampler = rootName === "always_on" ? new AlwaysOnSampler : rootName === "always_off" ? new AlwaysOffSampler : new TraceIdRatioBasedSampler(ratio);
14261
+ const sampler = disabled ? new AlwaysOffSampler : samplerName.startsWith("parentbased_") ? new ParentBasedSampler({ root: rootSampler }) : rootSampler;
14262
+ const queueSize = numberOption(options.maxQueueSize, 2048, "maxQueueSize");
14263
+ const batchSize = numberOption(options.maxExportBatchSize, Math.min(512, queueSize), "maxExportBatchSize", queueSize);
14264
+ const timeout = numberOption(options.exportTimeoutMillis, 5000, "exportTimeoutMillis", 60000);
14265
+ const delay = numberOption(options.scheduledDelayMillis, 5000, "scheduledDelayMillis", 60000);
14266
+ let endpoint = options.endpoint ?? process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim();
14267
+ if (!endpoint) {
14268
+ const base = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
14269
+ if (base)
14270
+ endpoint = base.endsWith("/v1/traces") ? base : `${base.replace(/\/$/, "")}/v1/traces`;
12580
14271
  }
12581
- const serviceName = otelServiceName();
12582
- const url = endpoint.endsWith("/v1/traces") ? endpoint : `${endpoint.replace(/\/$/, "")}/v1/traces`;
12583
- await safeFetch(url, {
12584
- method: "POST",
12585
- headers: { "content-type": "application/json" },
12586
- body: JSON.stringify({
12587
- resourceSpans: [
12588
- {
12589
- resource: {
12590
- attributes: [{ key: "service.name", value: { stringValue: serviceName } }]
12591
- },
12592
- scopeSpans: [{ spans: [span] }]
12593
- }
12594
- ]
12595
- })
12596
- }, {
12597
- allowHttp: !isProductionEnv(),
12598
- allowPrivate: !isProductionEnv(),
12599
- timeoutMs: 5000,
12600
- maxRedirects: 0
14272
+ if (endpoint) {
14273
+ let url;
14274
+ try {
14275
+ url = new URL(endpoint);
14276
+ } catch {
14277
+ throw new TypeError("Invalid tracing collector endpoint.");
14278
+ }
14279
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
14280
+ throw new TypeError("Invalid tracing collector endpoint.");
14281
+ }
14282
+ const exporter = disabled ? undefined : options.exporter ?? (endpoint ? new OTLPTraceExporter({ url: endpoint, timeoutMillis: timeout, concurrencyLimit: 1 }) : undefined);
14283
+ const provider = new NodeTracerProvider({
14284
+ resource: resourceFromAttributes({ "service.name": options.serviceName ?? otelServiceName() }),
14285
+ sampler,
14286
+ spanProcessors: exporter ? [
14287
+ new BatchSpanProcessor(exporter, {
14288
+ maxQueueSize: queueSize,
14289
+ maxExportBatchSize: batchSize,
14290
+ scheduledDelayMillis: delay,
14291
+ exportTimeoutMillis: timeout
14292
+ })
14293
+ ] : [],
14294
+ spanLimits: {
14295
+ attributeCountLimit: 16,
14296
+ attributeValueLengthLimit: 256,
14297
+ eventCountLimit: 8,
14298
+ linkCountLimit: 8
14299
+ },
14300
+ forceFlushTimeoutMillis: timeout
12601
14301
  });
14302
+ let shutdown;
14303
+ return {
14304
+ tracer: provider.getTracer("@getstrata/http"),
14305
+ propagator: new W3CTraceContextPropagator,
14306
+ run: (parent, callback) => context.with(parent, callback),
14307
+ forceFlush: () => provider.forceFlush(),
14308
+ shutdown: () => shutdown ??= provider.shutdown()
14309
+ };
14310
+ }
14311
+ var runtimeKey = Symbol.for("@getstrata/otel-runtime");
14312
+ function runtimeState() {
14313
+ const state = globalThis;
14314
+ state[runtimeKey] ??= { owners: 0 };
14315
+ return state[runtimeKey];
14316
+ }
14317
+ function getTracingRuntime() {
14318
+ const state = runtimeState();
14319
+ state.runtime ??= createTracingRuntime();
14320
+ return state.runtime;
14321
+ }
14322
+ function acquireTracingRuntime() {
14323
+ const state = runtimeState();
14324
+ const runtime = getTracingRuntime();
14325
+ state.owners++;
14326
+ let released = false;
14327
+ return {
14328
+ flush: () => runtime.forceFlush(),
14329
+ async release() {
14330
+ if (released)
14331
+ return;
14332
+ released = true;
14333
+ if (--state.owners === 0) {
14334
+ if (state.runtime === runtime)
14335
+ state.runtime = undefined;
14336
+ await runtime.shutdown();
14337
+ }
14338
+ }
14339
+ };
12602
14340
  }
12603
14341
 
12604
14342
  // ../../src/core/tracing/tracingMiddleware.ts
12605
- function readClientTraceId(request) {
12606
- if (!envFlagEnabled(process.env.APP_DEBUG)) {
12607
- return null;
12608
- }
12609
- const raw = request.headers.get("x-trace-id")?.trim() ?? "";
12610
- if (!raw) {
12611
- return null;
12612
- }
12613
- const compact = raw.replace(/-/g, "");
12614
- return /^[0-9a-f]{32}$/iu.test(compact) ? compact.toLowerCase() : null;
12615
- }
12616
- function createTracingMiddleware() {
14343
+ var headerGetter = {
14344
+ keys: (headers) => [...headers.keys()],
14345
+ get: (headers, key) => headers.get(key) ?? undefined
14346
+ };
14347
+ var methods = new Set([
14348
+ "GET",
14349
+ "POST",
14350
+ "PUT",
14351
+ "PATCH",
14352
+ "DELETE",
14353
+ "HEAD",
14354
+ "OPTIONS",
14355
+ "CONNECT",
14356
+ "TRACE"
14357
+ ]);
14358
+ function createTracingMiddleware(runtime) {
12617
14359
  return async (request, next) => {
12618
- const traceId = readClientTraceId(request) ?? crypto.randomUUID().replace(/-/g, "");
12619
- const spanId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
12620
- const startedAt = performance.now();
12621
- const path = new URL(request.url).pathname;
12622
- return await runWithTraceContext({ traceId, spanId }, async () => {
12623
- const response = await next();
12624
- const endedAt = performance.now();
12625
- const headers = new Headers(response.headers);
12626
- headers.set("x-trace-id", traceId);
12627
- headers.set("x-span-id", spanId);
12628
- headers.set("traceparent", `00-${traceId}-${spanId}-01`);
12629
- headers.set("server-timing", `app;dur=${(endedAt - startedAt).toFixed(2)}`);
12630
- exportOtelSpan(createSpan({
12631
- traceId,
12632
- name: `${request.method} ${path}`,
12633
- startedAt,
12634
- endedAt,
12635
- attributes: {
12636
- "http.method": request.method,
12637
- "http.route": path,
12638
- "http.status_code": String(response.status)
12639
- }
12640
- })).catch(() => {
12641
- return;
12642
- });
12643
- return new Response(response.body, {
12644
- status: response.status,
12645
- statusText: response.statusText,
12646
- headers
12647
- });
12648
- });
14360
+ const telemetry = runtime ?? getTracingRuntime();
14361
+ const method = methods.has(request.method) ? request.method : "_OTHER";
14362
+ const route = currentRequestMeta().routeTemplate ?? "__unmatched__";
14363
+ const parent = telemetry.propagator.extract(ROOT_CONTEXT, request.headers, headerGetter);
14364
+ const span = telemetry.tracer.startSpan(`${method} ${route}`, {
14365
+ kind: SpanKind.SERVER,
14366
+ attributes: { "http.request.method": method, "http.route": route }
14367
+ }, parent);
14368
+ const identity = span.spanContext();
14369
+ const started = performance.now();
14370
+ return await telemetry.run(trace.setSpan(parent, span), () => runWithTraceContext({ traceId: identity.traceId, spanId: identity.spanId }, async () => {
14371
+ try {
14372
+ const response = await next();
14373
+ span.setAttribute("http.response.status_code", response.status);
14374
+ if (response.status >= 500)
14375
+ span.setStatus({ code: SpanStatusCode.ERROR });
14376
+ const headers = new Headers(response.headers);
14377
+ headers.delete("tracestate");
14378
+ headers.set("x-trace-id", identity.traceId);
14379
+ headers.set("x-span-id", identity.spanId);
14380
+ telemetry.propagator.inject(trace.setSpan(parent, span), headers, {
14381
+ set: (carrier, key, value) => carrier.set(key, value)
14382
+ });
14383
+ headers.set("server-timing", `app;dur=${(performance.now() - started).toFixed(2)}`);
14384
+ return new Response(response.body, {
14385
+ status: response.status,
14386
+ statusText: response.statusText,
14387
+ headers
14388
+ });
14389
+ } catch (error) {
14390
+ span.setStatus({ code: SpanStatusCode.ERROR });
14391
+ throw error;
14392
+ } finally {
14393
+ span.end();
14394
+ }
14395
+ }));
12649
14396
  };
12650
14397
  }
12651
14398
  // ../../src/core/validation/rules.ts
@@ -13056,6 +14803,7 @@ export {
13056
14803
  JobRegistry,
13057
14804
  JsonResource,
13058
14805
  JwtGuard,
14806
+ LifecycleCoordinator,
13059
14807
  LocalStorageDriver,
13060
14808
  LogMailDriver,
13061
14809
  MAX_PER_PAGE,
@@ -13081,12 +14829,14 @@ export {
13081
14829
  QueueWorker,
13082
14830
  RLS_HELPER_SQL,
13083
14831
  RedisQueue,
14832
+ RedisSchedulerLeaseStore,
13084
14833
  RepositoryQuery,
13085
14834
  ResilientQueue,
13086
14835
  ResourceCollection,
13087
14836
  SamlProvider,
13088
14837
  SamlServiceProvider,
13089
14838
  Schedule,
14839
+ SchedulerLeaseLostError,
13090
14840
  Schema,
13091
14841
  ServiceContainer,
13092
14842
  SqlOutbox,
@@ -13100,6 +14850,7 @@ export {
13100
14850
  WebFormRequest,
13101
14851
  WhereBuilder,
13102
14852
  absoluteTemporarySignedUrl,
14853
+ acquireTracingRuntime,
13103
14854
  apiPrefix,
13104
14855
  appCookieName,
13105
14856
  appDevSecret,
@@ -13130,6 +14881,7 @@ export {
13130
14881
  bindBunSql,
13131
14882
  bindDatabaseConnection,
13132
14883
  bindRouteModel,
14884
+ bootModels,
13133
14885
  buildAdvancedWhereClause,
13134
14886
  buildCountQuery,
13135
14887
  buildDeleteByIdQuery,
@@ -13149,6 +14901,7 @@ export {
13149
14901
  buildUpdateQuery,
13150
14902
  buildWhereClause,
13151
14903
  cache,
14904
+ clearActiveApplicationContext,
13152
14905
  collectQueueMetrics,
13153
14906
  compileBlueprint,
13154
14907
  completePasswordLogin,
@@ -13205,6 +14958,7 @@ export {
13205
14958
  createThrottleMiddleware,
13206
14959
  createTokenAbilityChecker,
13207
14960
  createTracingMiddleware,
14961
+ createTracingRuntime,
13208
14962
  createTrackedJob,
13209
14963
  createValidateSignatureMiddleware,
13210
14964
  createdResponse,
@@ -13217,6 +14971,7 @@ export {
13217
14971
  currentTenant,
13218
14972
  currentTenantId,
13219
14973
  currentTraceId,
14974
+ defineModel,
13220
14975
  defineTable,
13221
14976
  dehydrateValue,
13222
14977
  dialectFor,
@@ -13248,6 +15003,7 @@ export {
13248
15003
  getNamedConnection,
13249
15004
  getQueryParams,
13250
15005
  getRouteParams,
15006
+ getTracingRuntime,
13251
15007
  grammarForDriver,
13252
15008
  guestCanViewResource,
13253
15009
  hasActiveDatabaseConnection,
@@ -13311,6 +15067,7 @@ export {
13311
15067
  namespacedRedisKey,
13312
15068
  noContentResponse,
13313
15069
  normalizeFieldErrors,
15070
+ normalizeTenantHostname,
13314
15071
  notFoundHtmlResponse,
13315
15072
  notModifiedResponse,
13316
15073
  otelServiceName,
@@ -13331,7 +15088,6 @@ export {
13331
15088
  qualifyColumn,
13332
15089
  queue,
13333
15090
  quoteIdentifier,
13334
- rateLimitMultiplierForPlan,
13335
15091
  readBunRequestCookie,
13336
15092
  readClientIp,
13337
15093
  readOptionalEnum,