@getstrata/core 2.1.0 → 2.2.0

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 (61) hide show
  1. package/CHANGELOG.md +33 -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 +6 -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/scimThrottleMiddleware.d.ts +12 -3
  19. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  20. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  21. package/dist/core/queue/index.d.ts +7 -0
  22. package/dist/core/queue/redisQueue.d.ts +1 -0
  23. package/dist/core/queue/resilientQueue.d.ts +2 -2
  24. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  25. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  26. package/dist/core/scheduler/leases.d.ts +40 -0
  27. package/dist/core/scheduler/schedule.d.ts +29 -5
  28. package/dist/core/tenant/tenantContext.d.ts +5 -4
  29. package/dist/core/tenant/tenantMiddleware.d.ts +7 -1
  30. package/dist/core/tracing/otel.d.ts +28 -26
  31. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  32. package/dist/entries/audit/exportAuditLogs.js +14 -2
  33. package/dist/entries/auth/basicAuthGuard.js +80 -0
  34. package/dist/entries/auth/jwtGuard.js +80 -0
  35. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  36. package/dist/entries/auth/sessionGuard.js +80 -0
  37. package/dist/entries/cache/createCacheStore.js +365 -119
  38. package/dist/entries/cache/repository.js +5 -0
  39. package/dist/entries/contracts/serviceTokens.js +83 -0
  40. package/dist/entries/database/factory.js +0 -3
  41. package/dist/entries/database/model.js +1 -2486
  42. package/dist/entries/database/repositoryQuery.js +67 -9
  43. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  44. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  45. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  46. package/dist/entries/http/throttleMiddleware.js +65 -16
  47. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  48. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  49. package/dist/entries/queue/createAppQueue.js +67 -6
  50. package/dist/entries/queue/publicQueue.js +67 -6
  51. package/dist/entries/queue/queueMetrics.js +67 -6
  52. package/dist/entries/queue/redisQueue.js +3 -0
  53. package/dist/entries/queue.js +30 -5
  54. package/dist/entries/scheduler/schedule.js +1 -72
  55. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  56. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  57. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  58. package/dist/framework/public-api.d.ts +13 -9
  59. package/dist/index.js +1745 -633
  60. package/dist/types/services.d.ts +3 -3
  61. 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,87 @@ 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_TENANT_RESOLVER_TOKEN = createServiceToken("core.tenantResolver");
1140
+ var CORE_HTTP_CLEANUP_TOKEN = createServiceToken("core.httpCleanup");
1056
1141
  var CORE_QUEUE_TOKEN = "core.queue";
1057
1142
  var CORE_EVENT_BUS_TOKEN = "core.eventBus";
1058
1143
  var CORE_POLICY_GATE_TOKEN = "core.policyGate";
@@ -2054,6 +2139,12 @@ function setActiveApplicationContext(context) {
2054
2139
  activeContext = context;
2055
2140
  globalThis[APPLICATION_CONTEXT_KEY] = context;
2056
2141
  }
2142
+ function clearActiveApplicationContext(context) {
2143
+ if (readStoredApplicationContext() !== context)
2144
+ return;
2145
+ activeContext = undefined;
2146
+ delete globalThis[APPLICATION_CONTEXT_KEY];
2147
+ }
2057
2148
  function requireActiveApplicationContext() {
2058
2149
  const context = readStoredApplicationContext();
2059
2150
  if (!context) {
@@ -3247,19 +3338,17 @@ async function resolveTenant(tenantId) {
3247
3338
  if (!isTenancyEnabled()) {
3248
3339
  return {
3249
3340
  id: tenantId,
3250
- slug: "default",
3251
- plan: "free",
3252
- region: "eu"
3341
+ slug: "default"
3253
3342
  };
3254
3343
  }
3255
3344
  const rows = await repositoryConnection`
3256
- SELECT id, slug, plan, region
3345
+ SELECT id, slug
3257
3346
  FROM tenant
3258
3347
  WHERE id = ${tenantId}
3259
3348
  LIMIT 1
3260
3349
  `;
3261
3350
  const row = rows[0];
3262
- return row ? { id: row.id, slug: row.slug, plan: row.plan, region: row.region ?? "eu" } : null;
3351
+ return row ? { id: row.id, slug: row.slug } : null;
3263
3352
  }
3264
3353
 
3265
3354
  // ../../src/core/auth/scimAuthMiddleware.ts
@@ -3319,151 +3408,397 @@ function createTokenAbilityChecker() {
3319
3408
  }
3320
3409
  // ../../src/core/cache/redisCacheStore.ts
3321
3410
  var {RedisClient } = globalThis.Bun;
3322
- function cacheKeyPrefix() {
3323
- return namespacedRedisKey("cache:");
3324
- }
3325
- function cacheTagPrefix() {
3326
- return namespacedRedisKey("cache:tag:");
3327
- }
3411
+ var CACHE_SCRIPT = `
3412
+ local expected = {'hash', 'zset', 'zset', 'hash', 'zset'}
3413
+ for i, key in ipairs(KEYS) do
3414
+ local kind = redis.call('TYPE', key).ok
3415
+ if kind ~= 'none' and kind ~= expected[i] then return redis.error_reply('Unexpected cache key type') end
3416
+ end
3417
+ -- Redis eviction can remove one container without its companions. Never read
3418
+ -- orphaned values or leases after losing the expiry/order/generation indexes.
3419
+ local entries = redis.call('HLEN', KEYS[1])
3420
+ local order = redis.call('ZCARD', KEYS[3])
3421
+ if redis.call('ZCARD', KEYS[2]) ~= order or entries ~= (order > 0 and order + 1 or 0) then
3422
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3423
+ end
3424
+ if redis.call('HLEN', KEYS[4]) ~= redis.call('ZCARD', KEYS[5]) then
3425
+ redis.call('DEL', KEYS[4], KEYS[5])
3426
+ end
3427
+ local r = cjson.decode(ARGV[1])
3428
+ local time = redis.call('TIME')
3429
+ local now = tonumber(time[1]) * 1000 + tonumber(time[2]) / 1000
3430
+ local function removeEntry(key)
3431
+ local removed = redis.call('HDEL', KEYS[1], 'e:' .. key)
3432
+ redis.call('ZREM', KEYS[2], key)
3433
+ redis.call('ZREM', KEYS[3], key)
3434
+ return removed
3435
+ end
3436
+ local function removeMeta(field)
3437
+ redis.call('HDEL', KEYS[4], field)
3438
+ redis.call('ZREM', KEYS[5], field)
3439
+ end
3440
+ for _, key in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, r.capacity)) do removeEntry(key) end
3441
+ for _, field in ipairs(redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, r.metadataLimit)) do removeMeta(field) end
3442
+ local function expireContainers()
3443
+ local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
3444
+ if #last == 0 then
3445
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3])
3446
+ elseif last[2] == 'inf' then
3447
+ for i = 1, 3 do redis.call('PERSIST', KEYS[i]) end
3448
+ else
3449
+ local ttl = math.max(1, math.ceil(tonumber(last[2]) - now))
3450
+ for i = 1, 3 do redis.call('PEXPIRE', KEYS[i], ttl) end
3451
+ end
3452
+ for i = 4, 5 do redis.call('PEXPIRE', KEYS[i], r.metadataTtlMs) end
3453
+ end
3454
+ local function response(value)
3455
+ expireContainers()
3456
+ return cjson.encode(value)
3457
+ end
3458
+ local function addTags(existing, added)
3459
+ local seen = {}
3460
+ local result = {}
3461
+ for _, tags in ipairs({existing, added}) do
3462
+ for _, tag in ipairs(tags) do
3463
+ if not seen[tag] then seen[tag] = true; table.insert(result, tag) end
3464
+ end
3465
+ end
3466
+ if #result > 32 then error('Cache entries support at most 32 tags') end
3467
+ return result
3468
+ end
3469
+ local function touch(key)
3470
+ local sequence = redis.call('HINCRBY', KEYS[1], '_sequence', 1)
3471
+ redis.call('ZADD', KEYS[3], sequence, key)
3472
+ end
3473
+ local function read(key, tags)
3474
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3475
+ if not raw then return nil end
3476
+ local entry = cjson.decode(raw)
3477
+ if #tags > 0 then
3478
+ entry.tags = addTags(entry.tags, tags)
3479
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3480
+ end
3481
+ touch(key)
3482
+ return entry.payload
3483
+ end
3484
+ local function write(key, payload, tags)
3485
+ local entry = {payload=payload, tags=tags}
3486
+ redis.call('HSET', KEYS[1], 'e:' .. key, cjson.encode(entry))
3487
+ redis.call('ZADD', KEYS[2], r.ttlMs == 0 and '+inf' or now + r.ttlMs, key)
3488
+ touch(key)
3489
+ local overflow = redis.call('ZCARD', KEYS[3]) - r.capacity
3490
+ if overflow > 0 then
3491
+ for _, victim in ipairs(redis.call('ZRANGE', KEYS[3], 0, overflow - 1)) do removeEntry(victim) end
3492
+ end
3493
+ end
3494
+ local leaseField = 'l:' .. r.key
3495
+ local function lease()
3496
+ local raw = redis.call('HGET', KEYS[4], leaseField)
3497
+ if not raw then return nil end
3498
+ local value = cjson.decode(raw)
3499
+ if value.expires <= now or value.deadline <= now then removeMeta(leaseField); return nil end
3500
+ return value
3501
+ end
3502
+ local function metadataSpace(count)
3503
+ if redis.call('HLEN', KEYS[4]) + count > r.metadataLimit then
3504
+ -- Pressure sheds generation/lease state. Every old owner is fenced; cached
3505
+ -- entries remain valid because invalidation removes matching entries atomically.
3506
+ redis.call('DEL', KEYS[4], KEYS[5])
3507
+ end
3508
+ end
3509
+ local function putMeta(field, value, expires)
3510
+ redis.call('HSET', KEYS[4], field, value)
3511
+ redis.call('ZADD', KEYS[5], expires, field)
3512
+ end
3513
+ local function scopes()
3514
+ local fields = {}
3515
+ for _, tag in ipairs(r.tags) do table.insert(fields, 'g:tag:' .. tag) end
3516
+ for _, prefix in ipairs(r.prefixes) do table.insert(fields, 'g:prefix:' .. prefix) end
3517
+ return fields
3518
+ end
3519
+ local function matches(key, tags)
3520
+ if r.op == 'invalidate' then return key == r.key end
3521
+ if r.op == 'prefix' then return key == r.key or string.sub(key, 1, #r.key + 1) == r.key .. '?' end
3522
+ if r.op == 'tags' then
3523
+ for _, existing in ipairs(tags) do
3524
+ for _, wanted in ipairs(r.tags) do if wanted == existing then return true end end
3525
+ end
3526
+ end
3527
+ return false
3528
+ end
3529
+ if r.op == 'clear' then
3530
+ redis.call('DEL', unpack(KEYS))
3531
+ return cjson.encode({removed=0})
3532
+ elseif r.op == 'get' then
3533
+ local payload = read(r.key, r.tags)
3534
+ return response(payload and {kind='value', payload=payload} or {kind='missing'})
3535
+ elseif r.op == 'set' then
3536
+ local old = redis.call('HGET', KEYS[1], 'e:' .. r.key)
3537
+ local tags = old and cjson.decode(old).tags or {}
3538
+ write(r.key, r.payload, tags)
3539
+ removeMeta(leaseField)
3540
+ return response({written=true})
3541
+ elseif r.op == 'attach' then
3542
+ read(r.key, r.tags)
3543
+ return response({written=true})
3544
+ elseif r.op == 'size' then
3545
+ return response({size=redis.call('ZCARD', KEYS[3])})
3546
+ elseif r.op == 'tags' or r.op == 'prefix' or r.op == 'invalidate' then
3547
+ local generationFields = {}
3548
+ if r.op == 'tags' then
3549
+ for _, tag in ipairs(r.tags) do table.insert(generationFields, 'g:tag:' .. tag) end
3550
+ elseif r.op == 'prefix' then table.insert(generationFields, 'g:prefix:' .. r.key) end
3551
+ metadataSpace(#generationFields)
3552
+ for _, field in ipairs(generationFields) do putMeta(field, r.owner, now + r.metadataTtlMs) end
3553
+ local removed = 0
3554
+ for _, key in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
3555
+ local raw = redis.call('HGET', KEYS[1], 'e:' .. key)
3556
+ if raw and matches(key, cjson.decode(raw).tags) then removed = removed + removeEntry(key) end
3557
+ end
3558
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do
3559
+ if string.sub(field, 1, 2) == 'l:' then
3560
+ local value = cjson.decode(redis.call('HGET', KEYS[4], field))
3561
+ if matches(string.sub(field, 3), value.tags) then removeMeta(field) end
3562
+ end
3563
+ end
3564
+ return response({removed=removed})
3565
+ elseif r.op == 'claim' then
3566
+ local payload = read(r.key, r.tags)
3567
+ if payload then return response({kind='value', payload=payload}) end
3568
+ local current = lease()
3569
+ if current then
3570
+ local shared = addTags(current.tags, r.tags)
3571
+ if #shared == #current.tags then return response({kind='wait'}) end
3572
+ -- A newly requested tag must not inherit a fill started before that tag's
3573
+ -- invalidation. Restart with the union of tags and a new owner.
3574
+ r.tags = shared
3575
+ removeMeta(leaseField)
3576
+ end
3577
+ local active = 0
3578
+ for _, field in ipairs(redis.call('HKEYS', KEYS[4])) do if string.sub(field, 1, 2) == 'l:' then active = active + 1 end end
3579
+ if active >= r.capacity then return response({kind='wait'}) end
3580
+ local fields = scopes()
3581
+ metadataSpace(#fields + 1)
3582
+ local generations = {}
3583
+ for _, field in ipairs(fields) do
3584
+ local token = redis.call('HGET', KEYS[4], field) or r.owner
3585
+ putMeta(field, token, now + r.metadataTtlMs)
3586
+ generations[field] = token
3587
+ end
3588
+ local value = {owner=r.owner, tags=r.tags, generations=generations, deadline=now + r.fillTimeoutMs, expires=now + r.leaseMs}
3589
+ putMeta(leaseField, cjson.encode(value), value.expires)
3590
+ return response({kind='owner'})
3591
+ elseif r.op == 'renew' or r.op == 'publish' or r.op == 'release' then
3592
+ local current = lease()
3593
+ if not current or current.owner ~= r.owner then return response({owned=false}) end
3594
+ if r.op == 'release' then removeMeta(leaseField); return response({owned=true}) end
3595
+ for field, token in pairs(current.generations) do
3596
+ if redis.call('HGET', KEYS[4], field) ~= token then removeMeta(leaseField); return response({owned=false}) end
3597
+ end
3598
+ if r.op == 'renew' then
3599
+ current.expires = math.min(current.deadline, now + r.leaseMs)
3600
+ putMeta(leaseField, cjson.encode(current), current.expires)
3601
+ else
3602
+ write(r.key, r.payload, current.tags)
3603
+ removeMeta(leaseField)
3604
+ end
3605
+ return response({owned=true})
3606
+ end
3607
+ return redis.error_reply('Unknown cache operation')
3608
+ `;
3328
3609
 
3329
3610
  class RedisCacheStore {
3330
3611
  ttlMs;
3331
3612
  maxEntries;
3332
3613
  client;
3333
- inflight = new Map;
3334
- keyTags = new Map;
3335
- constructor(redisUrl, ttlMs, maxEntries) {
3614
+ keys;
3615
+ options;
3616
+ fills = new Set;
3617
+ closed = false;
3618
+ constructor(redisUrl, ttlMs, maxEntries, options = {}) {
3336
3619
  this.ttlMs = ttlMs;
3337
3620
  this.maxEntries = maxEntries;
3338
- this.client = new RedisClient(redisUrl);
3621
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
3622
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
3623
+ if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 1e4)
3624
+ throw new RangeError("maxEntries must be between 1 and 10000.");
3625
+ this.options = {
3626
+ commandTimeoutMs: 5000,
3627
+ leaseMs: 1e4,
3628
+ fillTimeoutMs: 30000,
3629
+ waitTimeoutMs: 35000,
3630
+ pollMs: 25,
3631
+ metadataTtlMs: 60000,
3632
+ metadataLimit: 4096,
3633
+ ...options
3634
+ };
3635
+ for (const [key, value] of Object.entries(this.options))
3636
+ if (!Number.isSafeInteger(value) || value < 1)
3637
+ throw new RangeError(`${key} must be a positive safe integer.`);
3638
+ 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)
3639
+ throw new RangeError("Invalid Redis cache lease, deadline or metadata bounds.");
3640
+ const namespace = namespacedRedisKey("cache:v3:");
3641
+ const slot = new Bun.CryptoHasher("sha256").update(namespace).digest("hex");
3642
+ this.keys = ["entries", "expiry", "order", "metadata", "metadata-expiry"].map((name) => `${namespace}{${slot}}:${name}`);
3643
+ this.client = new RedisClient(redisUrl, {
3644
+ connectionTimeout: this.options.commandTimeoutMs,
3645
+ maxRetries: 1
3646
+ });
3647
+ }
3648
+ close() {
3649
+ this.closed = true;
3650
+ for (const fill of this.fills)
3651
+ fill.abort(new Error("Redis cache store is closed."));
3652
+ this.client.close();
3339
3653
  }
3340
3654
  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);
3655
+ const result = await this.execute("get", key);
3656
+ return result.kind === "value" ? JSON.parse(result.payload ?? "null") : undefined;
3346
3657
  }
3347
3658
  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();
3659
+ await this.execute("set", key, { payload: this.serialize(value), ttlMs: ttlMs ?? this.ttlMs });
3356
3660
  }
3357
3661
  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;
3662
+ return this.getOrSetTagged(key, loader, [], ttlMs);
3663
+ }
3664
+ async getOrSetTagged(key, loader, tags, ttlMs) {
3665
+ const owner = crypto.randomUUID();
3666
+ const end = performance.now() + this.options.waitTimeoutMs;
3667
+ while (!this.closed) {
3668
+ const claim = await this.execute("claim", key, { owner, tags, ttlMs: ttlMs ?? this.ttlMs });
3669
+ if (claim.kind === "value")
3670
+ return JSON.parse(claim.payload ?? "null");
3671
+ if (claim.kind === "owner")
3672
+ return this.fill(key, owner, loader, ttlMs);
3673
+ if (performance.now() >= end)
3674
+ throw new Error("Redis cache fill wait deadline exceeded.");
3675
+ await Bun.sleep(this.options.pollMs);
3676
+ }
3677
+ throw new Error("Redis cache store is closed.");
3374
3678
  }
3375
3679
  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
- }
3680
+ await this.execute("attach", key, { tags });
3388
3681
  }
3389
3682
  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;
3683
+ return (await this.execute("tags", "", { tags })).removed ?? 0;
3407
3684
  }
3408
3685
  async invalidate(key) {
3409
- const deleted = await this.client.del(this.storageKey(key));
3410
- await this.detachKeyFromTags(key);
3411
- return deleted > 0;
3686
+ return ((await this.execute("invalidate", key)).removed ?? 0) > 0;
3412
3687
  }
3413
3688
  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;
3689
+ return (await this.execute("prefix", prefix)).removed ?? 0;
3425
3690
  }
3426
3691
  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();
3692
+ await this.execute("clear", "");
3437
3693
  }
3438
3694
  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}`;
3695
+ return (await this.execute("size", "")).size ?? 0;
3447
3696
  }
3448
- async detachKeyFromTags(key) {
3449
- const tags = this.keyTags.get(key);
3450
- if (!tags) {
3451
- return;
3697
+ async fill(key, owner, loader, ttlMs) {
3698
+ if (this.fills.size >= this.maxEntries) {
3699
+ await this.execute("release", key, { owner });
3700
+ throw new Error("Redis cache local fill capacity exceeded.");
3452
3701
  }
3453
- for (const tag of tags) {
3454
- await this.client.srem(this.tagKey(tag), key);
3702
+ const controller = new AbortController;
3703
+ this.fills.add(controller);
3704
+ let done = false;
3705
+ let renewing = false;
3706
+ const renew = setInterval(() => {
3707
+ if (renewing || done)
3708
+ return;
3709
+ renewing = true;
3710
+ this.execute("renew", key, { owner }).then((result) => {
3711
+ if (!result.owned)
3712
+ clearInterval(renew);
3713
+ }).catch((error) => {
3714
+ if (!done)
3715
+ controller.abort(error);
3716
+ }).finally(() => {
3717
+ renewing = false;
3718
+ });
3719
+ }, Math.max(10, Math.floor(this.options.leaseMs / 3)));
3720
+ const timeout = setTimeout(() => controller.abort(new Error("Redis cache fill deadline exceeded.")), this.options.fillTimeoutMs);
3721
+ let rejectAbort;
3722
+ const aborted = new Promise((_, reject) => {
3723
+ rejectAbort = () => reject(controller.signal.reason);
3724
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
3725
+ });
3726
+ try {
3727
+ const value = await Promise.race([
3728
+ Promise.resolve().then(() => loader(controller.signal)),
3729
+ aborted
3730
+ ]);
3731
+ done = true;
3732
+ clearInterval(renew);
3733
+ await this.execute("publish", key, {
3734
+ owner,
3735
+ payload: this.serialize(value),
3736
+ ttlMs: ttlMs ?? this.ttlMs
3737
+ });
3738
+ return value;
3739
+ } finally {
3740
+ done = true;
3741
+ clearInterval(renew);
3742
+ clearTimeout(timeout);
3743
+ controller.signal.removeEventListener("abort", rejectAbort);
3744
+ this.fills.delete(controller);
3745
+ if (!this.closed)
3746
+ await this.execute("release", key, { owner }).catch(() => {});
3455
3747
  }
3456
- this.keyTags.delete(key);
3457
3748
  }
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);
3749
+ serialize(value) {
3750
+ const payload = JSON.stringify(value);
3751
+ if (typeof payload !== "string")
3752
+ throw new TypeError("Redis cache values must be JSON serializable.");
3753
+ return payload;
3754
+ }
3755
+ async execute(op, key, extra = {}) {
3756
+ if (this.closed)
3757
+ throw new Error("Redis cache store is closed.");
3758
+ if (Buffer.byteLength(key) > 4096)
3759
+ throw new RangeError("Invalid Redis cache identity.");
3760
+ const tags = Array.from(new Set(extra.tags ?? []));
3761
+ if (tags.length > 32 || tags.some((tag) => typeof tag !== "string" || Buffer.byteLength(tag) > 1024))
3762
+ throw new RangeError("Redis cache supports at most 32 tags of 1024 bytes each.");
3763
+ const prefixes = [key];
3764
+ for (let i = 0;i < key.length; i++)
3765
+ if (key[i] === "?")
3766
+ prefixes.push(key.slice(0, i));
3767
+ if (prefixes.length > 33)
3768
+ throw new RangeError("Redis cache identities support at most 32 query separators.");
3769
+ const ttlMs = extra.ttlMs ?? this.ttlMs;
3770
+ if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs) || ttlMs < 0)
3771
+ throw new RangeError("ttlMs must be a non-negative safe integer.");
3772
+ let timer;
3773
+ const timeout = new Promise((_, reject) => {
3774
+ timer = setTimeout(() => {
3775
+ reject(new Error("Redis cache command deadline exceeded; recreate the store."));
3776
+ this.close();
3777
+ }, this.options.commandTimeoutMs);
3778
+ });
3779
+ try {
3780
+ const raw = await Promise.race([
3781
+ this.client.send("EVAL", [
3782
+ CACHE_SCRIPT,
3783
+ String(this.keys.length),
3784
+ ...this.keys,
3785
+ JSON.stringify({
3786
+ op,
3787
+ key,
3788
+ capacity: this.maxEntries,
3789
+ owner: crypto.randomUUID(),
3790
+ ...this.options,
3791
+ ...extra,
3792
+ tags,
3793
+ prefixes,
3794
+ ttlMs
3795
+ })
3796
+ ]),
3797
+ timeout
3798
+ ]);
3799
+ return JSON.parse(String(raw));
3800
+ } finally {
3801
+ clearTimeout(timer);
3467
3802
  }
3468
3803
  }
3469
3804
  }
@@ -3689,7 +4024,7 @@ function createCacheStore(options) {
3689
4024
  if (!options.redisUrl) {
3690
4025
  throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
3691
4026
  }
3692
- return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries);
4027
+ return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries, options.redis);
3693
4028
  }
3694
4029
  return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
3695
4030
  }
@@ -3702,6 +4037,8 @@ class TaggedCache {
3702
4037
  this.tags = tags;
3703
4038
  }
3704
4039
  async remember(key, callback, ttlMs) {
4040
+ if (this.store.getOrSetTagged)
4041
+ return this.store.getOrSetTagged(key, callback, this.tags, ttlMs);
3705
4042
  const value = await this.store.getOrSet(key, callback, ttlMs);
3706
4043
  await this.store.attachTags(key, this.tags);
3707
4044
  return value;
@@ -3718,6 +4055,9 @@ class CacheRepository {
3718
4055
  constructor(store) {
3719
4056
  this.store = store;
3720
4057
  }
4058
+ async close() {
4059
+ await this.store.close?.();
4060
+ }
3721
4061
  async get(key) {
3722
4062
  return this.store.get(key);
3723
4063
  }
@@ -3765,90 +4105,14 @@ var CACHE_TAGS = {
3765
4105
  careers: "careers",
3766
4106
  offers: "offers"
3767
4107
  };
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}`;
4108
+ // ../../src/core/database/baseRepository.ts
4109
+ init_eventBus();
4110
+
4111
+ // ../../src/core/events/index.ts
4112
+ init_deferredModelEvents();
4113
+ init_eventBus();
4114
+ function modelEventName(tableName, action) {
4115
+ return `${tableName}.${action}`;
3852
4116
  }
3853
4117
 
3854
4118
  // ../../src/core/pagination/index.ts
@@ -3864,6 +4128,7 @@ function buildPaginationMeta(input) {
3864
4128
 
3865
4129
  // ../../src/core/database/baseRepository.ts
3866
4130
  init_connectionContext();
4131
+ init_dialect();
3867
4132
 
3868
4133
  // ../../src/core/database/query.ts
3869
4134
  init_dialect();
@@ -4684,6 +4949,7 @@ class RepositoryQuery {
4684
4949
  queryOptions;
4685
4950
  eagerLoads = [];
4686
4951
  whereNodes = [];
4952
+ protectedWhere = [];
4687
4953
  constructor(repository, whereClause = {}, queryOptions = {}) {
4688
4954
  this.repository = repository;
4689
4955
  this.whereClause = whereClause;
@@ -4693,10 +4959,23 @@ class RepositoryQuery {
4693
4959
  if (typeof input === "function") {
4694
4960
  const builder = new WhereBuilder;
4695
4961
  input(builder);
4696
- this.whereNodes.push(...builder.nodes);
4962
+ if (builder.nodes.length > 0)
4963
+ this.whereNodes.push({ kind: "and", group: builder.nodes });
4697
4964
  return this;
4698
4965
  }
4699
- this.whereClause = { ...this.whereClause, ...input };
4966
+ this.whereNodes.push({ kind: "and", where: { ...input } });
4967
+ return this;
4968
+ }
4969
+ protectWhere() {
4970
+ const nodes = [];
4971
+ if (Object.keys(this.whereClause).length > 0) {
4972
+ nodes.push({ kind: "and", where: this.whereClause });
4973
+ }
4974
+ nodes.push(...this.whereNodes);
4975
+ if (nodes.length > 0)
4976
+ this.protectedWhere.push({ kind: "and", group: nodes });
4977
+ this.whereClause = {};
4978
+ this.whereNodes.length = 0;
4700
4979
  return this;
4701
4980
  }
4702
4981
  orWhere(input) {
@@ -4810,9 +5089,10 @@ class RepositoryQuery {
4810
5089
  });
4811
5090
  return this;
4812
5091
  }
4813
- withMorphTo(as, relation, repositoriesByType, options = {}) {
5092
+ withMorphTo(as, relation, repositoriesByType, options = {}, optionsByType) {
4814
5093
  this.eagerLoads.push({
4815
5094
  kind: "morphTo",
5095
+ morphOptions: optionsByType,
4816
5096
  as,
4817
5097
  relation,
4818
5098
  repository: this.repository,
@@ -4883,6 +5163,19 @@ class RepositoryQuery {
4883
5163
  }
4884
5164
  return row[column];
4885
5165
  }
5166
+ async project(columns, limit) {
5167
+ if (columns.length === 0)
5168
+ throw new RangeError("A projection requires at least one column.");
5169
+ const table = this.repository.getTable();
5170
+ for (const column of columns)
5171
+ if (!table.columns.includes(column))
5172
+ throw new TypeError(`Unknown projection column: ${column}`);
5173
+ return await this.repository.findAll({
5174
+ ...this.buildOptions(),
5175
+ ...limit === undefined ? {} : { limit },
5176
+ select: uniqueColumnSelect(table.name, columns)
5177
+ });
5178
+ }
4886
5179
  async attachToRows(rows) {
4887
5180
  return await this.attach(rows);
4888
5181
  }
@@ -4893,11 +5186,24 @@ class RepositoryQuery {
4893
5186
  perPage: options.perPage
4894
5187
  });
4895
5188
  }
5189
+ async chunk(count, callback) {
5190
+ await this.repository.chunk(count, async (rows) => callback(await this.attach(rows)), this.buildOptions());
5191
+ }
5192
+ async cursorPaginate(options) {
5193
+ const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
5194
+ return { ...page, data: await this.attach(page.data) };
5195
+ }
5196
+ getOptions() {
5197
+ return this.buildOptions();
5198
+ }
4896
5199
  buildOptions() {
5200
+ if (this.protectedWhere.length === 0) {
5201
+ return { ...this.queryOptions, where: this.whereClause, whereNodes: this.whereNodes };
5202
+ }
4897
5203
  return {
4898
5204
  ...this.queryOptions,
4899
- where: this.whereClause,
4900
- whereNodes: this.whereNodes
5205
+ where: {},
5206
+ whereNodes: [...this.protectedWhere, { kind: "and", group: [...this.whereNodes] }]
4901
5207
  };
4902
5208
  }
4903
5209
  addJoin(type, left, right) {
@@ -4974,10 +5280,27 @@ class RepositoryQuery {
4974
5280
  }
4975
5281
  if (load.kind === "morphTo") {
4976
5282
  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
- }
5283
+ const repositories = load.morphRepositories ?? new Map;
5284
+ const partitions = new Map;
5285
+ rows.forEach((row, index) => {
5286
+ const type = String(row[relation.morphTypeKey]);
5287
+ const target = result[index];
5288
+ if (!target || !repositories.has(type))
5289
+ return;
5290
+ const partition = partitions.get(type) ?? { rows: [], targets: [] };
5291
+ partition.rows.push(row);
5292
+ partition.targets.push(target);
5293
+ partitions.set(type, partition);
5294
+ });
5295
+ await Promise.all([...partitions.entries()].map(async ([type, partition]) => {
5296
+ const repository = repositories.get(type);
5297
+ if (!repository)
5298
+ return;
5299
+ const grouped = await this.repository.loadMorphToForChildren(partition.rows, relation, new Map([[type, repository]]), load.morphOptions?.get(type) ?? load.options);
5300
+ for (const row of partition.targets) {
5301
+ row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
5302
+ }
5303
+ }));
4981
5304
  return;
4982
5305
  }
4983
5306
  const relation = load.relation;
@@ -5099,14 +5422,20 @@ class BaseRepository {
5099
5422
  if (!Number.isInteger(perPage) || perPage <= 0) {
5100
5423
  throw new Error("Cursor page size must be a positive integer.");
5101
5424
  }
5102
- const cursorWhere = { ...where };
5425
+ const filters = [];
5426
+ if (Object.keys(where).length > 0)
5427
+ filters.push({ kind: "and", where });
5428
+ filters.push(...whereNodes ?? []);
5429
+ const cursorFilters = [{ kind: "and", group: filters }];
5103
5430
  if (cursor !== undefined) {
5431
+ const cursorWhere = {};
5104
5432
  cursorWhere[cursorColumn] = direction === "asc" ? { gt: cursor } : { lt: cursor };
5433
+ cursorFilters.push({ kind: "and", where: cursorWhere });
5105
5434
  }
5106
5435
  const rows = await this.findAll({
5107
5436
  ...queryOptions,
5108
- where: cursorWhere,
5109
- whereNodes,
5437
+ where: {},
5438
+ whereNodes: cursorFilters,
5110
5439
  orderBy: { [cursorColumn]: direction },
5111
5440
  limit: perPage + 1
5112
5441
  });
@@ -5330,46 +5659,29 @@ class BaseRepository {
5330
5659
  if (parents.length === 0) {
5331
5660
  return indexHasManyThroughRelation(parents, [], relation);
5332
5661
  }
5662
+ if (options.joins?.some((join) => join.table === relation.throughTable)) {
5663
+ throw new Error("A related model scope joins the through table twice; use an explicitly aliased correlated predicate instead.");
5664
+ }
5333
5665
  const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5334
5666
  const throughParentKey = relation.throughParentKey ?? "__through_parent_id";
5335
5667
  const farTable = this.table.name;
5336
5668
  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}`;
5669
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5670
+ const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds);
5671
+ 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
5672
  const children = await this.connection.unsafe(sql, [
5342
5673
  ...parentIds,
5343
5674
  ...extraParams
5344
5675
  ]);
5345
5676
  return indexHasManyThroughRelation(parents, children, relation);
5346
5677
  }
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 };
5678
+ buildThroughWhere(options, parentIds) {
5679
+ const parameters = [...parentIds];
5680
+ const { clause } = buildQueryWhereClause(this.table, options, options.whereNodes, parameters);
5681
+ return {
5682
+ text: clause ? ` AND (${clause.replace(/^ WHERE /, "")})` : "",
5683
+ params: parameters.slice(parentIds.length)
5684
+ };
5373
5685
  }
5374
5686
  async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
5375
5687
  if (children.length === 0) {
@@ -5436,7 +5748,7 @@ class BaseRepository {
5436
5748
  return indexBelongsToManyRelation(parents, [], [], relation);
5437
5749
  }
5438
5750
  const parentIds = [...new Set(parents.map((parent) => parent[relation.parentKey]))];
5439
- const placeholders = parentIds.map((_, index) => `$${index + 1}`).join(", ");
5751
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5440
5752
  const pivotRows = await this.connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} IN (${placeholders})`, parentIds);
5441
5753
  if (pivotRows.length === 0) {
5442
5754
  return indexBelongsToManyRelation(parents, [], [], relation);
@@ -5794,7 +6106,12 @@ async function freshDatabase(db, migrations, options = {}) {
5794
6106
  }
5795
6107
  await runFresh();
5796
6108
  }
6109
+ // ../../src/core/database/model.ts
6110
+ import { isDeepStrictEqual } from "util";
6111
+ init_deferredModelEvents();
6112
+
5797
6113
  // ../../src/core/database/relationQuery.ts
6114
+ init_dialect();
5798
6115
  function asWhere(where) {
5799
6116
  return where;
5800
6117
  }
@@ -5816,13 +6133,48 @@ function pluckFromQuery(query, column, keyBy) {
5816
6133
  }
5817
6134
  return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
5818
6135
  }
6136
+ function scopedRelatedQuery(model, repository = model.repository()) {
6137
+ const query = repository.query();
6138
+ const scopedModel = model;
6139
+ return scopedModel.scopeQuery?.(query) ?? query;
6140
+ }
6141
+ function relatedOptions(model, filters, options = {}) {
6142
+ const query = scopedRelatedQuery(model);
6143
+ for (const filter of filters)
6144
+ query.where(filter);
6145
+ return { ...query.getOptions(), ...options };
6146
+ }
6147
+ function relatedRead(model, connection, binding, filters, options = {}) {
6148
+ const query = scopedRelatedQuery(model, model.repository().withConnection(connection));
6149
+ query.where(binding).protectWhere();
6150
+ for (const filter of filters)
6151
+ query.where(filter);
6152
+ if (options.orderBy)
6153
+ query.orderBy(options.orderBy);
6154
+ if (options.limit !== undefined)
6155
+ query.limit(options.limit);
6156
+ return query;
6157
+ }
6158
+ function relatedConditions(model, filters, options = {}, parameters = [], reservedTables = []) {
6159
+ const compiled = relatedOptions(model, filters, options);
6160
+ if (compiled.joins?.some((join) => reservedTables.includes(join.table))) {
6161
+ throw new Error("A related model scope joins a reserved relation table; use an explicitly aliased correlated predicate instead.");
6162
+ }
6163
+ const extra = buildQueryWhereClause(model.repository().getTable(), compiled, compiled.whereNodes, parameters);
6164
+ const predicate = extra.clause.replace(/^ WHERE /, "");
6165
+ return {
6166
+ sql: predicate ? ` AND (${predicate})` : "",
6167
+ joins: buildJoinClause(compiled.joins),
6168
+ params: extra.params
6169
+ };
6170
+ }
5819
6171
 
5820
6172
  class HasManyRelationQuery {
5821
6173
  parent;
5822
6174
  related;
5823
6175
  relation;
5824
6176
  kind = "hasMany";
5825
- extraWhere = {};
6177
+ extraWhere = [];
5826
6178
  extraOptions = {};
5827
6179
  constructor(parent, related, relation) {
5828
6180
  this.parent = parent;
@@ -5830,7 +6182,7 @@ class HasManyRelationQuery {
5830
6182
  this.relation = relation;
5831
6183
  }
5832
6184
  where(where) {
5833
- this.extraWhere = { ...this.extraWhere, ...where };
6185
+ this.extraWhere.push({ ...where });
5834
6186
  return this;
5835
6187
  }
5836
6188
  orderBy(orderBy) {
@@ -5842,7 +6194,7 @@ class HasManyRelationQuery {
5842
6194
  return this;
5843
6195
  }
5844
6196
  applyEagerLoad(query, alias) {
5845
- query.withHasMany(alias, this.relation, this.related.repository(), this.extraOptions);
6197
+ query.withHasMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
5846
6198
  }
5847
6199
  hydrateEager(row, alias) {
5848
6200
  const value = row[alias] ?? [];
@@ -5851,24 +6203,12 @@ class HasManyRelationQuery {
5851
6203
  }
5852
6204
  toExistsClause(parentTable) {
5853
6205
  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}` : ""}`;
6206
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable]);
6207
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)}${extra.joins} WHERE ${qualifyColumn(childTable, this.relation.foreignKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extra.sql}`;
5857
6208
  return { sql, params: extra.params };
5858
6209
  }
5859
6210
  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;
6211
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({ [this.relation.foreignKey]: this.parent.get(this.relation.localKey) }), this.extraWhere, this.extraOptions);
5872
6212
  }
5873
6213
  async get() {
5874
6214
  const rows = await this.scopedQuery().get();
@@ -5934,7 +6274,7 @@ class HasOneRelationQuery {
5934
6274
  return this;
5935
6275
  }
5936
6276
  applyEagerLoad(query, alias) {
5937
- this.inner.limit(1).applyEagerLoad(query, alias);
6277
+ this.inner.applyEagerLoad(query, alias);
5938
6278
  }
5939
6279
  hydrateEager(row, alias) {
5940
6280
  const hydrated = this.inner.hydrateEager(row, alias);
@@ -5975,7 +6315,7 @@ class BelongsToRelationQuery {
5975
6315
  related;
5976
6316
  relation;
5977
6317
  kind = "belongsTo";
5978
- extraWhere = {};
6318
+ extraWhere = [];
5979
6319
  extraOptions = {};
5980
6320
  constructor(parent, related, relation) {
5981
6321
  this.parent = parent;
@@ -5983,7 +6323,7 @@ class BelongsToRelationQuery {
5983
6323
  this.relation = relation;
5984
6324
  }
5985
6325
  where(where) {
5986
- this.extraWhere = { ...this.extraWhere, ...where };
6326
+ this.extraWhere.push({ ...where });
5987
6327
  return this;
5988
6328
  }
5989
6329
  orderBy(orderBy) {
@@ -5991,7 +6331,7 @@ class BelongsToRelationQuery {
5991
6331
  return this;
5992
6332
  }
5993
6333
  applyEagerLoad(query, alias) {
5994
- query.withBelongsTo(alias, this.relation, this.related.repository(), this.extraOptions);
6334
+ query.withBelongsTo(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
5995
6335
  }
5996
6336
  hydrateEager(row, alias) {
5997
6337
  const value = row[alias];
@@ -5999,9 +6339,8 @@ class BelongsToRelationQuery {
5999
6339
  }
6000
6340
  toExistsClause(parentTable) {
6001
6341
  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}` : ""}`;
6342
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable]);
6343
+ const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)}${extra.joins} WHERE ${qualifyColumn(relatedTable, this.relation.ownerKey)} = ${qualifyColumn(parentTable, this.relation.foreignKey)}${extra.sql}`;
6005
6344
  return { sql, params: extra.params };
6006
6345
  }
6007
6346
  async get() {
@@ -6026,12 +6365,7 @@ class BelongsToRelationQuery {
6026
6365
  if (foreign === null || foreign === undefined) {
6027
6366
  return null;
6028
6367
  }
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;
6368
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({ [this.relation.ownerKey]: foreign }), this.extraWhere, this.extraOptions);
6035
6369
  }
6036
6370
  then(onfulfilled, onrejected) {
6037
6371
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6053,7 +6387,7 @@ class BelongsToManyRelationQuery {
6053
6387
  related;
6054
6388
  relation;
6055
6389
  kind = "belongsToMany";
6056
- extraWhere = {};
6390
+ extraWhere = [];
6057
6391
  extraOptions = {};
6058
6392
  pivotValues = {};
6059
6393
  constructor(parent, related, relation) {
@@ -6062,7 +6396,7 @@ class BelongsToManyRelationQuery {
6062
6396
  this.relation = relation;
6063
6397
  }
6064
6398
  where(where) {
6065
- this.extraWhere = { ...this.extraWhere, ...where };
6399
+ this.extraWhere.push({ ...where });
6066
6400
  return this;
6067
6401
  }
6068
6402
  orderBy(orderBy) {
@@ -6070,7 +6404,7 @@ class BelongsToManyRelationQuery {
6070
6404
  return this;
6071
6405
  }
6072
6406
  applyEagerLoad(query, alias) {
6073
- query.withBelongsToMany(alias, this.relation, this.related.repository(), this.extraOptions);
6407
+ query.withBelongsToMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6074
6408
  }
6075
6409
  withPivotValues(values) {
6076
6410
  this.pivotValues = { ...this.pivotValues, ...values };
@@ -6083,31 +6417,16 @@ class BelongsToManyRelationQuery {
6083
6417
  }
6084
6418
  toExistsClause(parentTable) {
6085
6419
  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}` : ""}`;
6420
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable, this.relation.pivotTable]);
6421
+ 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
6422
  return { sql, params: extra.params };
6090
6423
  }
6091
6424
  connection() {
6092
6425
  return this.parent.getRepository().getConnection();
6093
6426
  }
6094
6427
  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
- });
6428
+ const query = await this.relatedQuery();
6429
+ const rows = query ? await query.get() : [];
6111
6430
  return rows.map((row) => this.related.newFromRecord(row));
6112
6431
  }
6113
6432
  async first() {
@@ -6115,9 +6434,8 @@ class BelongsToManyRelationQuery {
6115
6434
  return rows[0] ?? null;
6116
6435
  }
6117
6436
  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);
6437
+ const query = await this.relatedQuery();
6438
+ return query ? query.count() : 0;
6121
6439
  }
6122
6440
  async pluck(column, keyBy) {
6123
6441
  return pluckFromQuery(await this.relatedQuery(), column, keyBy);
@@ -6128,21 +6446,14 @@ class BelongsToManyRelationQuery {
6128
6446
  }
6129
6447
  async relatedQuery() {
6130
6448
  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]);
6449
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = ${currentSqlDialect().placeholder(1)}`, [parentId]);
6132
6450
  if (pivotRows.length === 0) {
6133
6451
  return null;
6134
6452
  }
6135
6453
  const relatedIds = [
6136
6454
  ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
6137
6455
  ];
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;
6456
+ return relatedRead(this.related, this.connection(), asWhere({ [this.relation.relatedKey]: relatedIds }), this.extraWhere, this.extraOptions);
6146
6457
  }
6147
6458
  then(onfulfilled, onrejected) {
6148
6459
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6198,7 +6509,7 @@ class MorphManyRelationQuery {
6198
6509
  related;
6199
6510
  relation;
6200
6511
  kind = "morphMany";
6201
- extraWhere = {};
6512
+ extraWhere = [];
6202
6513
  extraOptions = {};
6203
6514
  constructor(parent, related, relation) {
6204
6515
  this.parent = parent;
@@ -6206,11 +6517,11 @@ class MorphManyRelationQuery {
6206
6517
  this.relation = relation;
6207
6518
  }
6208
6519
  where(where) {
6209
- this.extraWhere = { ...this.extraWhere, ...where };
6520
+ this.extraWhere.push({ ...where });
6210
6521
  return this;
6211
6522
  }
6212
6523
  applyEagerLoad(query, alias) {
6213
- query.withMorphMany(alias, this.relation, this.related.repository(), this.extraOptions);
6524
+ query.withMorphMany(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6214
6525
  }
6215
6526
  hydrateEager(row, alias) {
6216
6527
  const value = row[alias] ?? [];
@@ -6219,20 +6530,18 @@ class MorphManyRelationQuery {
6219
6530
  }
6220
6531
  toExistsClause(parentTable) {
6221
6532
  const childTable = this.related.repository().getTable().name;
6222
- const extra = buildAdvancedWhereClause(childTable, {
6223
- [this.relation.morphTypeKey]: this.relation.morphType,
6533
+ const extra = relatedConditions(this.related, [
6534
+ asWhere({ [this.relation.morphTypeKey]: this.relation.morphType }),
6224
6535
  ...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}` : ""}`;
6536
+ ], this.extraOptions, [], [parentTable]);
6537
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)}${extra.joins} WHERE ${qualifyColumn(childTable, this.relation.morphIdKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extra.sql}`;
6228
6538
  return { sql, params: extra.params };
6229
6539
  }
6230
6540
  scopedQuery() {
6231
- return this.related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({
6541
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({
6232
6542
  [this.relation.morphTypeKey]: this.relation.morphType,
6233
- [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
6234
- ...this.extraWhere
6235
- }));
6543
+ [this.relation.morphIdKey]: this.parent.get(this.relation.localKey)
6544
+ }), this.extraWhere);
6236
6545
  }
6237
6546
  async get() {
6238
6547
  const rows = await this.scopedQuery().get();
@@ -6319,14 +6628,14 @@ class MorphToRelationQuery {
6319
6628
  relatedByType;
6320
6629
  relation;
6321
6630
  kind = "morphTo";
6322
- extraWhere = {};
6631
+ extraWhere = [];
6323
6632
  constructor(parent, relatedByType, relation) {
6324
6633
  this.parent = parent;
6325
6634
  this.relatedByType = relatedByType;
6326
6635
  this.relation = relation;
6327
6636
  }
6328
6637
  where(where) {
6329
- this.extraWhere = { ...this.extraWhere, ...where };
6638
+ this.extraWhere.push({ ...where });
6330
6639
  return this;
6331
6640
  }
6332
6641
  applyEagerLoad(query, alias) {
@@ -6334,23 +6643,30 @@ class MorphToRelationQuery {
6334
6643
  type,
6335
6644
  model.repository()
6336
6645
  ]));
6337
- query.withMorphTo(alias, this.relation, repositories);
6646
+ query.withMorphTo(alias, this.relation, repositories, {}, new Map(Object.entries(this.relatedByType).map(([type, model]) => [
6647
+ type,
6648
+ relatedOptions(model, this.extraWhere)
6649
+ ])));
6338
6650
  }
6339
6651
  hydrateEager(row, alias) {
6340
6652
  return row[alias];
6341
6653
  }
6342
6654
  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 /, "");
6655
+ const params = [];
6656
+ const type = this.parent.get(this.relation.morphTypeKey);
6657
+ if (type !== undefined && type !== null && !this.relatedByType[String(type)]) {
6658
+ return { sql: "SELECT 1 WHERE 1 = 0", params };
6659
+ }
6660
+ const branches = Object.entries(this.relatedByType).map(([type, related]) => {
6661
+ params.push(type);
6662
+ const discriminator = currentSqlDialect().placeholder(params.length);
6663
+ const table = related.repository().getTable();
6664
+ const extra = relatedConditions(related, this.extraWhere, {}, params, [parentTable]);
6665
+ 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}))`;
6666
+ });
6351
6667
  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
6668
+ sql: `SELECT 1 WHERE ${branches.length > 0 ? branches.join(" OR ") : "1 = 0"}`,
6669
+ params
6354
6670
  };
6355
6671
  }
6356
6672
  async get() {
@@ -6378,7 +6694,7 @@ class MorphToRelationQuery {
6378
6694
  return null;
6379
6695
  }
6380
6696
  const table = related.repository().getTable();
6381
- return related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({ [table.primaryKey]: id, ...this.extraWhere }));
6697
+ return relatedRead(related, this.parent.getRepository().getConnection(), asWhere({ [table.primaryKey]: id }), this.extraWhere);
6382
6698
  }
6383
6699
  then(onfulfilled, onrejected) {
6384
6700
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6390,7 +6706,7 @@ class HasManyThroughRelationQuery {
6390
6706
  related;
6391
6707
  relation;
6392
6708
  kind = "hasManyThrough";
6393
- extraWhere = {};
6709
+ extraWhere = [];
6394
6710
  extraOptions = {};
6395
6711
  constructor(parent, related, relation) {
6396
6712
  this.parent = parent;
@@ -6398,7 +6714,7 @@ class HasManyThroughRelationQuery {
6398
6714
  this.relation = relation;
6399
6715
  }
6400
6716
  where(where) {
6401
- this.extraWhere = { ...this.extraWhere, ...where };
6717
+ this.extraWhere.push({ ...where });
6402
6718
  return this;
6403
6719
  }
6404
6720
  orderBy(orderBy) {
@@ -6410,7 +6726,7 @@ class HasManyThroughRelationQuery {
6410
6726
  return this;
6411
6727
  }
6412
6728
  applyEagerLoad(query, alias) {
6413
- query.withHasManyThrough(alias, this.relation, this.related.repository(), this.extraOptions);
6729
+ query.withHasManyThrough(alias, this.relation, this.related.repository(), relatedOptions(this.related, this.extraWhere, this.extraOptions));
6414
6730
  }
6415
6731
  hydrateEager(row, alias) {
6416
6732
  const value = row[alias] ?? [];
@@ -6419,9 +6735,8 @@ class HasManyThroughRelationQuery {
6419
6735
  }
6420
6736
  toExistsClause(parentTable) {
6421
6737
  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}` : ""}`;
6738
+ const extra = relatedConditions(this.related, this.extraWhere, this.extraOptions, [], [parentTable, this.relation.throughTable]);
6739
+ 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
6740
  return { sql, params: extra.params };
6426
6741
  }
6427
6742
  async get() {
@@ -6446,8 +6761,7 @@ class HasManyThroughRelationQuery {
6446
6761
  }
6447
6762
  async farRows() {
6448
6763
  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
6764
+ ...relatedOptions(this.related, this.extraWhere, this.extraOptions)
6451
6765
  });
6452
6766
  }
6453
6767
  then(onfulfilled, onrejected) {
@@ -6456,7 +6770,17 @@ class HasManyThroughRelationQuery {
6456
6770
  }
6457
6771
 
6458
6772
  // ../../src/core/database/model.ts
6773
+ function snapshotAttributes(values) {
6774
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [
6775
+ key,
6776
+ Buffer.isBuffer(value) ? Buffer.from(value) : structuredClone(value)
6777
+ ]));
6778
+ }
6779
+ function changedAttributes(values, original) {
6780
+ return Object.fromEntries(Object.entries(values).filter(([key, value]) => !(key in original) || !isDeepStrictEqual(value, original[key])));
6781
+ }
6459
6782
  var modelRepositories = new WeakMap;
6783
+ var modelRepositoryFactories = new WeakMap;
6460
6784
  var namedModels = new Map;
6461
6785
  var modelGlobalScopes = new WeakMap;
6462
6786
  var modelObservers = new WeakMap;
@@ -6547,12 +6871,54 @@ async function eagerLoadOnModels(models, paths) {
6547
6871
  async function loadNested(model, path) {
6548
6872
  await eagerLoadOnModels([model], [path]);
6549
6873
  }
6874
+ function nameModel(model) {
6875
+ const { name, $morphClass } = model;
6876
+ if (name)
6877
+ namedModels.set(name, model);
6878
+ if ($morphClass)
6879
+ namedModels.set($morphClass, model);
6880
+ }
6550
6881
  function resolveModelRepository(model) {
6551
- const repository = modelRepositories.get(model);
6552
- if (!repository) {
6553
- throw new Error(`${model.name}.repository() is not implemented.`);
6882
+ const existing = modelRepositories.get(model);
6883
+ if (existing)
6884
+ return existing;
6885
+ for (let current = model;current; current = Object.getPrototypeOf(current)) {
6886
+ const factory = modelRepositoryFactories.get(current);
6887
+ if (!factory)
6888
+ continue;
6889
+ const repository = factory();
6890
+ modelRepositories.set(model, repository);
6891
+ nameModel(model);
6892
+ return repository;
6893
+ }
6894
+ throw new Error(`${model.name}.repository() is not implemented.`);
6895
+ }
6896
+ function defineModel(table) {
6897
+
6898
+ class TableModel extends Model {
6899
+ }
6900
+ modelRepositoryFactories.set(TableModel, () => new baseRepository_default(table));
6901
+ return TableModel;
6902
+ }
6903
+ function bootModels(models) {
6904
+ const names = new Map;
6905
+ for (const model of models) {
6906
+ const { name, $morphClass } = model;
6907
+ for (const alias of [name, $morphClass]) {
6908
+ if (!alias)
6909
+ continue;
6910
+ const existing = names.get(alias);
6911
+ if (existing && existing !== model)
6912
+ throw new Error(`Duplicate model name [${alias}].`);
6913
+ names.set(alias, model);
6914
+ }
6915
+ }
6916
+ for (const model of models)
6917
+ nameModel(model);
6918
+ for (const model of models) {
6919
+ resolveModelRepository(model);
6920
+ ensureBooted(model);
6554
6921
  }
6555
- return repository;
6556
6922
  }
6557
6923
  function registerModelClass(name, model) {
6558
6924
  namedModels.set(name, model);
@@ -6603,10 +6969,26 @@ function ensureBooted(model) {
6603
6969
  if (modelBooted.has(model)) {
6604
6970
  return;
6605
6971
  }
6972
+ nameModel(model);
6606
6973
  modelBooted.add(model);
6974
+ const scopes = modelGlobalScopes.get(model);
6975
+ const observers = modelObservers.get(model);
6607
6976
  const boot = model.boot;
6608
6977
  if (typeof boot === "function") {
6609
- boot.call(model);
6978
+ try {
6979
+ boot.call(model);
6980
+ } catch (error) {
6981
+ modelBooted.delete(model);
6982
+ if (scopes)
6983
+ modelGlobalScopes.set(model, scopes);
6984
+ else
6985
+ modelGlobalScopes.delete(model);
6986
+ if (observers)
6987
+ modelObservers.set(model, observers);
6988
+ else
6989
+ modelObservers.delete(model);
6990
+ throw error;
6991
+ }
6610
6992
  }
6611
6993
  }
6612
6994
  function getGlobalScopes(model) {
@@ -6734,6 +7116,19 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
6734
7116
  return result;
6735
7117
  }
6736
7118
 
7119
+ class ModelProjection {
7120
+ load;
7121
+ constructor(load) {
7122
+ this.load = load;
7123
+ }
7124
+ get() {
7125
+ return this.load();
7126
+ }
7127
+ async first() {
7128
+ return (await this.load(1))[0] ?? null;
7129
+ }
7130
+ }
7131
+
6737
7132
  class ModelQuery {
6738
7133
  modelClass;
6739
7134
  query;
@@ -6766,6 +7161,16 @@ class ModelQuery {
6766
7161
  this.query.orWhere(input);
6767
7162
  return this;
6768
7163
  }
7164
+ whereDynamic(input) {
7165
+ this.query.where(input);
7166
+ return this;
7167
+ }
7168
+ select(...columns) {
7169
+ return new ModelProjection(async (limit) => {
7170
+ const rows = await this.query.project(columns, limit);
7171
+ return rows.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"));
7172
+ });
7173
+ }
6769
7174
  orderBy(orderBy) {
6770
7175
  this.query.orderBy(orderBy);
6771
7176
  return this;
@@ -6914,7 +7319,7 @@ class ModelQuery {
6914
7319
  }
6915
7320
  async find(id) {
6916
7321
  const primaryKey = resolveModelRepository(this.modelClass).getTable().primaryKey;
6917
- return this.where({ [primaryKey]: id }).first();
7322
+ return this.whereDynamic({ [primaryKey]: id }).first();
6918
7323
  }
6919
7324
  async findOrFail(id, errorFactory) {
6920
7325
  const model = await this.find(id);
@@ -6971,6 +7376,7 @@ class Model {
6971
7376
  static $appends;
6972
7377
  static $morphClass;
6973
7378
  _exists;
7379
+ originalAttributes;
6974
7380
  loadedRelations = {};
6975
7381
  hiddenOverrides = [];
6976
7382
  visibleOverrides = [];
@@ -6980,6 +7386,24 @@ class Model {
6980
7386
  this.repository = repository;
6981
7387
  this._exists = exists;
6982
7388
  this.attributes = modelStatics(this.constructor).hydrateAttributes(attributes);
7389
+ this.originalAttributes = snapshotAttributes(this.attributes);
7390
+ }
7391
+ acceptPersistedAttributes(record) {
7392
+ const original = this.originalAttributes;
7393
+ const beforeWrite = snapshotAttributes(this.attributes);
7394
+ const existed = this._exists;
7395
+ this.attributes = modelStatics(this.constructor).hydrateAttributes(record);
7396
+ this.originalAttributes = snapshotAttributes(this.attributes);
7397
+ const written = this.originalAttributes;
7398
+ this._exists = true;
7399
+ onDeferredModelRollback(() => {
7400
+ this.attributes = {
7401
+ ...beforeWrite,
7402
+ ...changedAttributes(this.attributes, written)
7403
+ };
7404
+ this.originalAttributes = original;
7405
+ this._exists = existed;
7406
+ });
6983
7407
  }
6984
7408
  getRepository() {
6985
7409
  return this.repository;
@@ -7072,21 +7496,30 @@ class Model {
7072
7496
  ]);
7073
7497
  }
7074
7498
  static repository() {
7075
- return resolveModelRepository(this);
7499
+ const repository = resolveModelRepository(this);
7500
+ ensureBooted(this);
7501
+ return repository;
7502
+ }
7503
+ static scopeQuery(query) {
7504
+ ensureBooted(this);
7505
+ let scoped = query;
7506
+ for (const scope of getGlobalScopes(this))
7507
+ scoped = scope(scoped).protectWhere();
7508
+ return scoped;
7076
7509
  }
7077
7510
  static query() {
7078
7511
  ensureBooted(this);
7079
7512
  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);
7513
+ const scopeQuery = Model.scopeQuery;
7514
+ return new ModelQuery(this, scopeQuery.call(this, repository.query()));
7085
7515
  }
7086
7516
  static newFromRecord(record, exists = true) {
7087
7517
  const repository = resolveModelRepository(this);
7088
7518
  return modelStatics(this).fromRecord(record, repository, exists);
7089
7519
  }
7520
+ static newFromTrustedRecord(record, exists = true) {
7521
+ return modelStatics(this).newFromRecord(record, exists);
7522
+ }
7090
7523
  static async create(attributes, forced = {}) {
7091
7524
  const statics = modelStatics(this);
7092
7525
  ensureBooted(this);
@@ -7124,14 +7557,14 @@ class Model {
7124
7557
  static async chunk(count, callback) {
7125
7558
  const statics = modelStatics(this);
7126
7559
  ensureBooted(this);
7127
- await resolveModelRepository(this).chunk(count, async (rows) => {
7560
+ await Model.query.call(this).query.chunk(count, async (rows) => {
7128
7561
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7129
7562
  });
7130
7563
  }
7131
7564
  static async cursorPaginate(options) {
7132
7565
  const statics = modelStatics(this);
7133
7566
  ensureBooted(this);
7134
- const page = await resolveModelRepository(this).cursorPaginate({
7567
+ const page = await Model.query.call(this).query.cursorPaginate({
7135
7568
  perPage: options.perPage,
7136
7569
  cursor: options.cursor
7137
7570
  });
@@ -7239,9 +7672,14 @@ class Model {
7239
7672
  return this;
7240
7673
  }
7241
7674
  if (updating) {
7242
- const changes = applyTimestampsOnUpdate(table.columns, await applyCasts(this.attributes, casts, "dehydrate"), timestamps);
7675
+ const dirty = changedAttributes(this.attributes, this.originalAttributes);
7676
+ if (Object.keys(dirty).length === 0) {
7677
+ await runObservers(this, "saved");
7678
+ return this;
7679
+ }
7680
+ const changes = applyTimestampsOnUpdate(table.columns, await applyCasts(dirty, casts, "dehydrate"), timestamps);
7243
7681
  const record = await this.repository.updateByIdOrThrow(this.id, changes);
7244
- this.attributes = ModelClass.hydrateAttributes(record);
7682
+ this.acceptPersistedAttributes(record);
7245
7683
  await runObservers(this, "updated");
7246
7684
  await runObservers(this, "saved");
7247
7685
  return this;
@@ -7250,8 +7688,7 @@ class Model {
7250
7688
  const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
7251
7689
  const payload = await ModelClass.dehydrateAttributes(withTimestamps);
7252
7690
  const record = await this.repository.create(payload);
7253
- this.attributes = ModelClass.hydrateAttributes(record);
7254
- this._exists = true;
7691
+ this.acceptPersistedAttributes(record);
7255
7692
  await runObservers(this, "created");
7256
7693
  await runObservers(this, "saved");
7257
7694
  return this;
@@ -7276,12 +7713,11 @@ class Model {
7276
7713
  return await this.repository.forceDeleteById(this.id);
7277
7714
  }
7278
7715
  async restore() {
7279
- const ModelClass = modelStatics(this.constructor);
7280
7716
  const record = await this.repository.restoreById(this.id);
7281
7717
  if (!record) {
7282
7718
  return null;
7283
7719
  }
7284
- this.attributes = ModelClass.hydrateAttributes(record);
7720
+ this.acceptPersistedAttributes(record);
7285
7721
  return this;
7286
7722
  }
7287
7723
  async loadHasMany(as, relation, childRepository, options = {}) {
@@ -7436,14 +7872,7 @@ class Model {
7436
7872
  }
7437
7873
  function registerModelRepository(model, repository) {
7438
7874
  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
- }
7875
+ nameModel(model);
7447
7876
  ensureBooted(model);
7448
7877
  return model;
7449
7878
  }
@@ -9168,29 +9597,95 @@ function storageFacade() {
9168
9597
  return storage();
9169
9598
  }
9170
9599
  // ../../src/core/http/bodySizeLimitMiddleware.ts
9600
+ var guards = new WeakMap;
9601
+ function guardBody(request, maxBytes) {
9602
+ const existing = guards.get(request);
9603
+ if (existing && existing.maxBytes <= maxBytes)
9604
+ return existing;
9605
+ const state = { maxBytes, exceeded: false };
9606
+ const body = request.body;
9607
+ if (!body)
9608
+ return state;
9609
+ let consumed = 0;
9610
+ const stream = body.pipeThrough(new TransformStream({
9611
+ transform(chunk, controller) {
9612
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
9613
+ consumed += bytes.byteLength;
9614
+ if (consumed > maxBytes) {
9615
+ state.exceeded = true;
9616
+ throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9617
+ }
9618
+ controller.enqueue(bytes);
9619
+ }
9620
+ }));
9621
+ const bounded = new Request(request, { body: stream });
9622
+ const decorateClone = (clone) => {
9623
+ for (const field of ["params", "cookies"]) {
9624
+ if (field in request)
9625
+ Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
9626
+ }
9627
+ const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
9628
+ Object.defineProperty(clone, "clone", {
9629
+ value: () => decorateClone(nativeClone())
9630
+ });
9631
+ guards.set(clone, state);
9632
+ return clone;
9633
+ };
9634
+ const descriptors = {
9635
+ body: { configurable: true, get: () => bounded.body },
9636
+ bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
9637
+ clone: {
9638
+ configurable: true,
9639
+ value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
9640
+ }
9641
+ };
9642
+ for (const method of [
9643
+ "text",
9644
+ "json",
9645
+ "formData",
9646
+ "arrayBuffer",
9647
+ "blob",
9648
+ "bytes",
9649
+ "textStream"
9650
+ ]) {
9651
+ descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
9652
+ }
9653
+ Object.defineProperties(request, descriptors);
9654
+ guards.set(request, state);
9655
+ return state;
9656
+ }
9171
9657
  var DEFAULT_MAX_BODY_BYTES = 1048576;
9172
9658
  function resolveMaxBodyBytes() {
9173
9659
  const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
9174
9660
  if (!raw) {
9175
9661
  return DEFAULT_MAX_BODY_BYTES;
9176
9662
  }
9177
- const parsed = Number.parseInt(raw, 10);
9178
- if (!Number.isInteger(parsed) || parsed <= 0) {
9663
+ const parsed = Number(raw);
9664
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
9179
9665
  return DEFAULT_MAX_BODY_BYTES;
9180
9666
  }
9181
9667
  return parsed;
9182
9668
  }
9183
9669
  function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
9670
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
9671
+ throw new RangeError("Body limit must be a positive safe integer.");
9184
9672
  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
- }
9673
+ const tooLarge = () => {
9674
+ const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9675
+ return Response.json({ error: error.message }, { status: error.status });
9676
+ };
9677
+ const length = Number(request.headers.get("content-length"));
9678
+ if (Number.isFinite(length) && length > maxBytes)
9679
+ return tooLarge();
9680
+ const state = guardBody(request, maxBytes);
9681
+ try {
9682
+ const response = await next();
9683
+ return state.exceeded ? tooLarge() : response;
9684
+ } catch (error) {
9685
+ if (state.exceeded || error instanceof PayloadTooLargeError)
9686
+ return tooLarge();
9687
+ throw error;
9192
9688
  }
9193
- return await next();
9194
9689
  };
9195
9690
  }
9196
9691
  // ../../src/core/http/requestMetaContext.ts
@@ -10718,6 +11213,67 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
10718
11213
  return response;
10719
11214
  };
10720
11215
  }
11216
+ // ../../src/core/runtime/boundedThrottleStore.ts
11217
+ class BoundedThrottleStore {
11218
+ decayMs;
11219
+ now;
11220
+ buckets = new Map;
11221
+ maxBuckets;
11222
+ pruneBatchSize;
11223
+ disposed = false;
11224
+ pruned = 0;
11225
+ constructor(decayMs, options = {}, now = () => performance.now()) {
11226
+ this.decayMs = decayMs;
11227
+ this.now = now;
11228
+ this.maxBuckets = options.maxBuckets ?? 1e4;
11229
+ this.pruneBatchSize = options.pruneBatchSize ?? 32;
11230
+ if (!Number.isFinite(decayMs) || decayMs < 0 || decayMs > 2147483647) {
11231
+ throw new Error("Invalid memory throttle window.");
11232
+ }
11233
+ if (!Number.isSafeInteger(this.maxBuckets) || this.maxBuckets < 1 || this.maxBuckets > 1e6 || !Number.isSafeInteger(this.pruneBatchSize) || this.pruneBatchSize < 1 || this.pruneBatchSize > 1024) {
11234
+ throw new Error("Invalid memory throttle storage bounds.");
11235
+ }
11236
+ }
11237
+ consume(key) {
11238
+ this.pruned = 0;
11239
+ if (this.disposed)
11240
+ return null;
11241
+ const now = this.now();
11242
+ const entries = this.buckets.entries();
11243
+ for (let i = 0;i < this.pruneBatchSize; i++) {
11244
+ const oldest = entries.next().value;
11245
+ if (!oldest || oldest[1].resetAt > now)
11246
+ break;
11247
+ this.buckets.delete(oldest[0]);
11248
+ this.pruned++;
11249
+ }
11250
+ const existing = this.buckets.get(key);
11251
+ if (existing && existing.resetAt > now) {
11252
+ existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + 1);
11253
+ return existing.count;
11254
+ }
11255
+ this.buckets.delete(key);
11256
+ if (this.buckets.size >= this.maxBuckets)
11257
+ return null;
11258
+ this.buckets.set(key, { count: 1, resetAt: now + this.decayMs });
11259
+ return 1;
11260
+ }
11261
+ clear() {
11262
+ this.buckets.clear();
11263
+ }
11264
+ dispose() {
11265
+ this.disposed = true;
11266
+ this.clear();
11267
+ }
11268
+ stats() {
11269
+ return {
11270
+ retainedBuckets: this.buckets.size,
11271
+ prunedOnLastConsume: this.pruned,
11272
+ disposed: this.disposed
11273
+ };
11274
+ }
11275
+ }
11276
+
10721
11277
  // ../../src/core/http/throttleMiddleware.ts
10722
11278
  import { createHash as createHash3 } from "crypto";
10723
11279
  init_tenantContext();
@@ -10762,28 +11318,32 @@ if ttl < 0 then
10762
11318
  end
10763
11319
  return count
10764
11320
  `;
10765
- function redisThrottleKey(request, prefix, identity) {
11321
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
10766
11322
  const bucket = JSON.stringify([
10767
- currentTenant()?.id ?? null,
11323
+ tenantId,
10768
11324
  request.method,
10769
11325
  currentRequestMeta().routeTemplate ?? "__unmatched__",
10770
11326
  identity
10771
11327
  ]);
10772
11328
  return namespacedRedisKey(`${prefix}:${createHash3("sha256").update(bucket).digest("hex")}`);
10773
11329
  }
10774
- async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
10775
- if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
11330
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
11331
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
11332
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
10776
11333
  throw new Error("Invalid throttle expiry or command timeout.");
10777
11334
  }
11335
+ if (signal?.aborted)
11336
+ throw new Error("Throttle consumer is disposed.");
11337
+ let onAbort;
10778
11338
  let timer;
10779
11339
  try {
11340
+ const cancelled = new Promise((_resolve, reject) => {
11341
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
11342
+ signal?.addEventListener("abort", onAbort, { once: true });
11343
+ });
10780
11344
  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
- ]),
11345
+ cancelled,
11346
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
10787
11347
  new Promise((_resolve, reject) => {
10788
11348
  timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
10789
11349
  })
@@ -10794,11 +11354,16 @@ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000)
10794
11354
  return count;
10795
11355
  } finally {
10796
11356
  clearTimeout(timer);
11357
+ if (onAbort)
11358
+ signal?.removeEventListener("abort", onAbort);
10797
11359
  }
10798
11360
  }
10799
11361
  function createRedisThrottleConsumer(options) {
10800
11362
  let ownedClient;
10801
- return async (key, decaySeconds) => {
11363
+ const controller = new AbortController;
11364
+ const consume = async (key, decaySeconds) => {
11365
+ if (controller.signal.aborted)
11366
+ throw new Error("Throttle consumer is disposed.");
10802
11367
  if (!options.redisClient && !ownedClient) {
10803
11368
  ownedClient = new RedisClient2(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
10804
11369
  }
@@ -10806,7 +11371,10 @@ function createRedisThrottleConsumer(options) {
10806
11371
  if (!client)
10807
11372
  throw new Error("Throttle client is required.");
10808
11373
  try {
10809
- return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
11374
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
11375
+ if (controller.signal.aborted)
11376
+ throw new Error("Throttle consumer is disposed.");
11377
+ return attempts;
10810
11378
  } catch (error) {
10811
11379
  if (client === ownedClient) {
10812
11380
  ownedClient.close();
@@ -10815,6 +11383,17 @@ function createRedisThrottleConsumer(options) {
10815
11383
  throw error;
10816
11384
  }
10817
11385
  };
11386
+ return Object.assign(consume, {
11387
+ isDisposed: () => controller.signal.aborted,
11388
+ dispose() {
11389
+ if (controller.signal.aborted)
11390
+ return;
11391
+ controller.abort();
11392
+ const client = ownedClient;
11393
+ ownedClient = undefined;
11394
+ client?.close();
11395
+ }
11396
+ });
10818
11397
  }
10819
11398
  function throttleUnavailableResponse() {
10820
11399
  return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
@@ -10822,27 +11401,52 @@ function throttleUnavailableResponse() {
10822
11401
  headers: { "retry-after": "1" }
10823
11402
  });
10824
11403
  }
11404
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
11405
+ const limit = options.quotaPolicy ? options.quotaPolicy({
11406
+ request,
11407
+ tenant: currentTenant(),
11408
+ identity,
11409
+ routeTemplate: currentRequestMeta().routeTemplate ?? "__unmatched__",
11410
+ maxAttempts: options.maxAttempts
11411
+ }) : options.maxAttempts;
11412
+ if (!Number.isSafeInteger(limit) || limit < 0) {
11413
+ throw new Error("Invalid application quota.");
11414
+ }
11415
+ return limit;
11416
+ }
10825
11417
  function createThrottleMiddleware(options) {
11418
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
11419
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
11420
+ }
10826
11421
  const consume = createRedisThrottleConsumer(options);
10827
11422
  const prefix = options.keyPrefix ?? "throttle";
10828
- return async (request, next) => {
10829
- const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
11423
+ const middleware = async (request, next) => {
11424
+ const identity = resolveThrottleIdentity(request);
11425
+ let maxAttempts;
11426
+ try {
11427
+ maxAttempts = resolveThrottleQuota(request, options, identity);
11428
+ } catch {
11429
+ return throttleUnavailableResponse();
11430
+ }
11431
+ const key = redisThrottleKey(request, prefix, identity);
10830
11432
  let attempts;
10831
11433
  try {
10832
11434
  attempts = await consume(key, options.decaySeconds);
10833
11435
  } catch {
10834
11436
  return throttleUnavailableResponse();
10835
11437
  }
10836
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
11438
+ if (consume.isDisposed())
11439
+ return throttleUnavailableResponse();
10837
11440
  if (attempts > maxAttempts) {
10838
11441
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10839
11442
  }
10840
11443
  return await next();
10841
11444
  };
11445
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10842
11446
  }
10843
11447
 
10844
11448
  // ../../src/core/http/loginThrottleMiddleware.ts
10845
- var memoryLoginBuckets = new Map;
11449
+ var resetGeneration = 0;
10846
11450
  function resolveLoginIdentity(request) {
10847
11451
  return readClientIp(request) ?? "unknown";
10848
11452
  }
@@ -10860,33 +11464,31 @@ async function resolveLoginEmail(request) {
10860
11464
  return "unknown";
10861
11465
  }
10862
11466
  }
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
11467
  function createMemoryLoginThrottleMiddleware(options) {
10874
- const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
10875
- return async (request, next) => {
10876
- const identity = resolveLoginIdentity(request);
11468
+ const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
11469
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11470
+ throw new Error("Invalid memory throttle limit.");
11471
+ let generation = resetGeneration;
11472
+ const middleware = async (request, next) => {
11473
+ if (generation !== resetGeneration) {
11474
+ store.clear();
11475
+ generation = resetGeneration;
11476
+ }
10877
11477
  const email = await resolveLoginEmail(request);
10878
- const throttleKey = `${prefix}${identity}:${email}`;
10879
- const attempts = consumeMemoryAttempt(throttleKey, options.decaySeconds);
10880
- if (attempts > options.maxAttempts) {
11478
+ const key = redisThrottleKey(request, options.keyPrefix ?? "login-throttle", JSON.stringify([resolveLoginIdentity(request), email]));
11479
+ const attempts = store.consume(key);
11480
+ if (attempts === null)
11481
+ return throttleUnavailableResponse();
11482
+ if (attempts > options.maxAttempts)
10881
11483
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10882
- }
10883
11484
  return await next();
10884
11485
  };
11486
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10885
11487
  }
10886
11488
  function createRedisLoginThrottleMiddleware(options) {
10887
11489
  const consume = createRedisThrottleConsumer(options);
10888
11490
  const prefix = options.keyPrefix ?? "login-throttle";
10889
- return async (request, next) => {
11491
+ const middleware = async (request, next) => {
10890
11492
  const identity = resolveLoginIdentity(request);
10891
11493
  const email = await resolveLoginEmail(request);
10892
11494
  const throttleKey = redisThrottleKey(request, prefix, JSON.stringify([identity, email]));
@@ -10896,11 +11498,14 @@ function createRedisLoginThrottleMiddleware(options) {
10896
11498
  } catch {
10897
11499
  return throttleUnavailableResponse();
10898
11500
  }
11501
+ if (consume.isDisposed())
11502
+ return throttleUnavailableResponse();
10899
11503
  if (attempts > options.maxAttempts) {
10900
11504
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10901
11505
  }
10902
11506
  return await next();
10903
11507
  };
11508
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10904
11509
  }
10905
11510
  function createLoginThrottleMiddleware(options) {
10906
11511
  const redisUrl = options.redisUrl?.trim() ?? "";
@@ -10910,35 +11515,37 @@ function createLoginThrottleMiddleware(options) {
10910
11515
  return createMemoryLoginThrottleMiddleware(options);
10911
11516
  }
10912
11517
  function resetMemoryLoginThrottleForTests() {
10913
- memoryLoginBuckets.clear();
11518
+ resetGeneration++;
10914
11519
  }
10915
11520
  // ../../src/core/http/memoryThrottleMiddleware.ts
10916
- var throttleBucketRegistries = new Set;
11521
+ var resetGeneration2 = 0;
10917
11522
  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();
11523
+ const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
11524
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11525
+ throw new Error("Invalid memory throttle limit.");
11526
+ let generation = resetGeneration2;
11527
+ const middleware = async (request, next) => {
11528
+ if (generation !== resetGeneration2) {
11529
+ store.clear();
11530
+ generation = resetGeneration2;
11531
+ }
11532
+ let maxAttempts;
11533
+ try {
11534
+ maxAttempts = resolveThrottleQuota(request, options);
11535
+ } catch {
11536
+ return throttleUnavailableResponse();
10930
11537
  }
10931
- existing.count += 1;
10932
- if (existing.count > options.maxAttempts) {
11538
+ const attempts = store.consume(redisThrottleKey(request, options.keyPrefix ?? "memory-throttle", resolveThrottleIdentity(request)));
11539
+ if (attempts === null)
11540
+ return throttleUnavailableResponse();
11541
+ if (attempts > maxAttempts)
10933
11542
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10934
- }
10935
11543
  return await next();
10936
11544
  };
11545
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10937
11546
  }
10938
11547
  function resetMemoryThrottleForTests() {
10939
- for (const buckets of throttleBucketRegistries) {
10940
- buckets.clear();
10941
- }
11548
+ resetGeneration2++;
10942
11549
  }
10943
11550
  // ../../src/core/metrics/prometheus.ts
10944
11551
  var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
@@ -11208,8 +11815,6 @@ function createRequireWebAuthMiddleware(auth) {
11208
11815
  }
11209
11816
  // ../../src/core/http/scimThrottleMiddleware.ts
11210
11817
  import { createHash as createHash4 } from "crypto";
11211
- var {RedisClient: RedisClient3 } = globalThis.Bun;
11212
- var memoryBuckets = new Map;
11213
11818
  function resolveScimIdentity(request) {
11214
11819
  const authorization = request.headers.get("authorization") ?? "";
11215
11820
  const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
@@ -11218,40 +11823,44 @@ function resolveScimIdentity(request) {
11218
11823
  }
11219
11824
  return readClientIp(request) ?? "unknown";
11220
11825
  }
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
11826
  function createScimThrottleMiddleware(options) {
11235
- const client = options.redisUrl ? new RedisClient3(options.redisUrl) : null;
11236
- return async (request, next) => {
11827
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
11828
+ throw new Error("Invalid SCIM throttle limit.");
11829
+ const redisUrl = options.redisUrl?.trim() ?? "";
11830
+ const distributed = Boolean(redisUrl || options.redisClient);
11831
+ const store = distributed ? null : new BoundedThrottleStore(options.decaySeconds * 1000, options);
11832
+ const consume = distributed ? createRedisThrottleConsumer({ ...options, redisUrl }) : null;
11833
+ let disposed = false;
11834
+ const middleware = async (request, next) => {
11835
+ if (disposed)
11836
+ return throttleUnavailableResponse();
11237
11837
  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;
11838
+ const token = request.headers.get("authorization")?.startsWith("Bearer ") ? request.headers.get("authorization")?.slice("Bearer ".length).trim() ?? "" : "";
11839
+ const key = redisThrottleKey(request, options.keyPrefix ?? "scim-throttle:v2", identity, resolveScimTenantFromToken(token) ?? undefined);
11840
+ let attempts;
11841
+ try {
11842
+ attempts = consume ? await consume(key, options.decaySeconds) : store?.consume(key);
11843
+ } catch {
11844
+ return throttleUnavailableResponse();
11252
11845
  }
11846
+ if (disposed)
11847
+ return throttleUnavailableResponse();
11848
+ if (attempts === null || attempts === undefined)
11849
+ return throttleUnavailableResponse();
11850
+ if (attempts > options.maxAttempts)
11851
+ return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
11253
11852
  return await next();
11254
11853
  };
11854
+ return Object.assign(middleware, {
11855
+ dispose: () => {
11856
+ if (disposed)
11857
+ return;
11858
+ disposed = true;
11859
+ store?.dispose();
11860
+ consume?.dispose();
11861
+ },
11862
+ stats: () => store?.stats()
11863
+ });
11255
11864
  }
11256
11865
  // ../../src/core/http/securityHeadersMiddleware.ts
11257
11866
  function createSecurityHeadersMiddleware(options = {}) {
@@ -11419,47 +12028,150 @@ class WebFormRequest {
11419
12028
  }
11420
12029
  }
11421
12030
  // ../../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
- };
12031
+ function resolveShutdownTimeoutMs() {
12032
+ const configured = process.env.SHUTDOWN_TIMEOUT_MS;
12033
+ const value = configured === undefined ? 30000 : Number(configured);
12034
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2147483647)
12035
+ throw new RangeError("SHUTDOWN_TIMEOUT_MS must be an integer between 1 and 2147483647.");
12036
+ return value;
11430
12037
  }
11431
- async function runGracefulShutdown(signal) {
11432
- if (shuttingDown) {
11433
- return;
12038
+
12039
+ class LifecycleCoordinator {
12040
+ handlers = new Map;
12041
+ running;
12042
+ timeoutMs;
12043
+ constructor(options = {}) {
12044
+ this.timeoutMs = options.timeoutMs ?? resolveShutdownTimeoutMs();
12045
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2147483647)
12046
+ throw new RangeError("Shutdown timeout must be an integer between 1 and 2147483647.");
12047
+ }
12048
+ get isShuttingDown() {
12049
+ return this.running !== undefined;
12050
+ }
12051
+ register(name, handler, phase = "close") {
12052
+ if (this.isShuttingDown)
12053
+ throw new Error("Cannot register resources during shutdown.");
12054
+ if (!["stop", "drain", "flush", "close", "force"].includes(phase))
12055
+ throw new TypeError("Unknown shutdown phase.");
12056
+ const registration = { name, handler, phase };
12057
+ this.handlers.set(name, registration);
12058
+ return () => {
12059
+ if (this.handlers.get(name) === registration)
12060
+ this.handlers.delete(name);
12061
+ };
11434
12062
  }
11435
- shuttingDown = true;
11436
- console.log(`[shutdown] Received ${signal}, draining ${shutdownHandlers.size} handler(s)...`);
11437
- for (const [name, handler] of shutdownHandlers) {
12063
+ shutdown(reason = "MANUAL") {
12064
+ if (this.running)
12065
+ return this.running;
12066
+ this.running = Promise.resolve().then(() => this.execute(reason));
12067
+ return this.running;
12068
+ }
12069
+ async execute(reason) {
12070
+ const registrations = [...this.handlers.values()];
12071
+ const controller = new AbortController;
12072
+ const context = { signal: controller.signal, reason };
12073
+ const errors = [];
12074
+ let timedOut = false;
12075
+ let forced = false;
12076
+ const record = (entry, error) => {
12077
+ errors.push({ name: entry.name, phase: entry.phase, error });
12078
+ console.error(`[shutdown] Failed ${entry.phase}:${entry.name}:`, error);
12079
+ };
12080
+ const force = () => {
12081
+ if (forced)
12082
+ return;
12083
+ forced = true;
12084
+ for (const entry of registrations.filter((entry) => entry.phase === "force")) {
12085
+ try {
12086
+ Promise.resolve(entry.handler(context)).catch((error) => record(entry, error));
12087
+ } catch (error) {
12088
+ record(entry, error);
12089
+ }
12090
+ }
12091
+ };
12092
+ let timer;
12093
+ const deadline = new Promise((resolve) => {
12094
+ timer = setTimeout(() => {
12095
+ timedOut = true;
12096
+ const error = new Error(`Shutdown deadline exceeded after ${this.timeoutMs}ms.`);
12097
+ errors.push({ name: "deadline", phase: "force", error });
12098
+ controller.abort(error);
12099
+ force();
12100
+ resolve();
12101
+ }, this.timeoutMs);
12102
+ });
12103
+ const sequence = (async () => {
12104
+ let unsafeToClose = false;
12105
+ for (const phase of ["stop", "drain", "flush", "close"]) {
12106
+ if (controller.signal.aborted)
12107
+ return;
12108
+ if (unsafeToClose && (phase === "flush" || phase === "close")) {
12109
+ controller.abort(new Error("Admission or drain failed; infrastructure remains open."));
12110
+ force();
12111
+ return;
12112
+ }
12113
+ for (const entry of registrations.filter((entry) => entry.phase === phase)) {
12114
+ if (controller.signal.aborted)
12115
+ return;
12116
+ try {
12117
+ await entry.handler(context);
12118
+ } catch (error) {
12119
+ record(entry, error);
12120
+ if (phase === "stop" || phase === "drain")
12121
+ unsafeToClose = true;
12122
+ }
12123
+ }
12124
+ }
12125
+ })();
11438
12126
  try {
11439
- await handler();
11440
- console.log(`[shutdown] Completed ${name}`);
11441
- } catch (error) {
11442
- console.error(`[shutdown] Failed ${name}:`, error);
12127
+ await Promise.race([sequence, deadline]);
12128
+ } finally {
12129
+ clearTimeout(timer);
12130
+ this.handlers.clear();
11443
12131
  }
12132
+ return { successful: errors.length === 0 && !timedOut, timedOut, errors: [...errors] };
11444
12133
  }
11445
12134
  }
11446
- function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
11447
- if (shutdownInstalled) {
11448
- return;
11449
- }
11450
- shutdownInstalled = true;
11451
- for (const signal of signals) {
11452
- process.on(signal, () => {
11453
- runGracefulShutdown(signal).finally(() => {
11454
- process.exit(0);
11455
- });
12135
+ var defaultLifecycle = new LifecycleCoordinator;
12136
+ var installations = new Map;
12137
+ function registerShutdownHandler(name, handler, phase = "close") {
12138
+ return defaultLifecycle.register(name, handler, phase);
12139
+ }
12140
+ function runGracefulShutdown(signal) {
12141
+ return defaultLifecycle.shutdown(signal);
12142
+ }
12143
+ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"], lifecycle = defaultLifecycle) {
12144
+ const existing = installations.get(lifecycle);
12145
+ if (existing)
12146
+ return existing;
12147
+ let exiting = false;
12148
+ const handler = (signal) => {
12149
+ if (exiting)
12150
+ return;
12151
+ exiting = true;
12152
+ lifecycle.shutdown(signal).then((result) => process.exit(result.successful ? 0 : 1), (error) => {
12153
+ console.error("[shutdown] Coordinator failed:", error);
12154
+ process.exit(1);
11456
12155
  });
11457
- }
12156
+ };
12157
+ const callbacks = signals.map((signal) => {
12158
+ const callback = () => handler(signal);
12159
+ process.on(signal, callback);
12160
+ return () => process.off(signal, callback);
12161
+ });
12162
+ const uninstall = () => {
12163
+ for (const remove of callbacks)
12164
+ remove();
12165
+ if (installations.get(lifecycle) === uninstall)
12166
+ installations.delete(lifecycle);
12167
+ };
12168
+ installations.set(lifecycle, uninstall);
12169
+ return uninstall;
11458
12170
  }
11459
12171
  function resetGracefulShutdownForTests() {
11460
- shutdownHandlers.clear();
11461
- shutdownInstalled = false;
11462
- shuttingDown = false;
12172
+ for (const remove of [...installations.values()])
12173
+ remove();
12174
+ defaultLifecycle = new LifecycleCoordinator;
11463
12175
  }
11464
12176
  // ../../src/core/logging/requestLoggingMiddleware.ts
11465
12177
  function createRequestLoggingMiddleware() {
@@ -11738,12 +12450,37 @@ class SyncQueue {
11738
12450
  }
11739
12451
 
11740
12452
  class AsyncQueue {
12453
+ active = new Set;
12454
+ closing;
12455
+ closed = false;
12456
+ assertOpen() {
12457
+ if (this.closed)
12458
+ throw new Error("Async queue is closed.");
12459
+ }
12460
+ enqueue(task, onError) {
12461
+ this.assertOpen();
12462
+ const running = new Promise((resolve) => {
12463
+ setTimeout(() => {
12464
+ Promise.resolve().then(task).catch(onError).finally(resolve);
12465
+ }, 0);
12466
+ });
12467
+ this.active.add(running);
12468
+ running.then(() => {
12469
+ this.active.delete(running);
12470
+ });
12471
+ }
11741
12472
  async dispatch(job, payload) {
11742
- setTimeout(() => {
11743
- job.handle(payload).catch((error) => {
11744
- console.error("[AsyncQueue] Job failed:", error);
11745
- });
11746
- }, 0);
12473
+ this.enqueue(() => job.handle(payload), (error) => {
12474
+ console.error("[AsyncQueue] Job failed:", error);
12475
+ });
12476
+ }
12477
+ close() {
12478
+ this.closing ??= (async () => {
12479
+ while (this.active.size)
12480
+ await Promise.all([...this.active]);
12481
+ this.closed = true;
12482
+ })();
12483
+ return this.closing;
11747
12484
  }
11748
12485
  }
11749
12486
  function createQueue(driver) {
@@ -11864,7 +12601,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
11864
12601
  }
11865
12602
 
11866
12603
  // ../../src/core/queue/redisQueue.ts
11867
- var {RedisClient: RedisClient4 } = globalThis.Bun;
12604
+ var {RedisClient: RedisClient3 } = globalThis.Bun;
11868
12605
  function queueListKey() {
11869
12606
  return namespacedRedisKey("queue:default");
11870
12607
  }
@@ -12060,7 +12797,10 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
12060
12797
  class RedisQueue {
12061
12798
  client;
12062
12799
  constructor(redisUrl) {
12063
- this.client = new RedisClient4(redisUrl);
12800
+ this.client = new RedisClient3(redisUrl);
12801
+ }
12802
+ close() {
12803
+ this.client.close();
12064
12804
  }
12065
12805
  async dispatch(job, payload) {
12066
12806
  const name = jobRegistry.resolveName(job);
@@ -12089,7 +12829,7 @@ class QueueWorker {
12089
12829
  this.failedJobs = failedJobs;
12090
12830
  this.timeoutSeconds = timeoutSeconds;
12091
12831
  this.queueKeys = queueKeys;
12092
- this.client = new RedisClient4(redisUrl);
12832
+ this.client = new RedisClient3(redisUrl);
12093
12833
  }
12094
12834
  requestStop() {
12095
12835
  this.stopping = true;
@@ -12179,14 +12919,16 @@ class QueueWorker {
12179
12919
  }
12180
12920
 
12181
12921
  // ../../src/core/queue/resilientQueue.ts
12182
- class ResilientQueue {
12922
+ class ResilientQueue extends AsyncQueue {
12183
12923
  failedJobs;
12184
12924
  asyncDispatch;
12185
12925
  constructor(failedJobs, asyncDispatch = false) {
12926
+ super();
12186
12927
  this.failedJobs = failedJobs;
12187
12928
  this.asyncDispatch = asyncDispatch;
12188
12929
  }
12189
12930
  async dispatch(job, payload) {
12931
+ this.assertOpen();
12190
12932
  const name = jobRegistry.resolveName(job);
12191
12933
  if (!name) {
12192
12934
  throw new Error("Job is not registered with the queue worker registry.");
@@ -12197,11 +12939,9 @@ class ResilientQueue {
12197
12939
  attempts: 0
12198
12940
  };
12199
12941
  if (this.asyncDispatch) {
12200
- setTimeout(() => {
12201
- runQueueJob(envelope, this.failedJobs).catch((error) => {
12202
- console.error("[ResilientQueue] Job failed:", error);
12203
- });
12204
- }, 0);
12942
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
12943
+ console.error("[ResilientQueue] Job failed:", error);
12944
+ });
12205
12945
  return;
12206
12946
  }
12207
12947
  await runQueueJob(envelope, this.failedJobs);
@@ -12230,9 +12970,9 @@ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
12230
12970
  return new QueueWorker(redisUrl, failedJobs);
12231
12971
  }
12232
12972
  // ../../src/core/queue/queueMetrics.ts
12233
- var {RedisClient: RedisClient5 } = globalThis.Bun;
12973
+ var {RedisClient: RedisClient4 } = globalThis.Bun;
12234
12974
  async function readRedisQueueDepth(redisUrl) {
12235
- const client = new RedisClient5(redisUrl);
12975
+ const client = new RedisClient4(redisUrl);
12236
12976
  const [high, defaultQueue, low] = await Promise.all([
12237
12977
  client.llen(QUEUE_HIGH_KEY),
12238
12978
  client.llen(QUEUE_LIST_KEY),
@@ -12280,6 +13020,149 @@ async function collectQueueMetrics() {
12280
13020
  failedCount
12281
13021
  };
12282
13022
  }
13023
+ // ../../src/core/scheduler/schedule.ts
13024
+ import { createHash as createHash6 } from "crypto";
13025
+
13026
+ // ../../src/core/scheduler/leases.ts
13027
+ import { createHash as createHash5 } from "crypto";
13028
+ var {RedisClient: RedisClient5 } = globalThis.Bun;
13029
+ var LEASE_SCRIPT = `
13030
+ for _, key in ipairs(KEYS) do
13031
+ local kind = redis.call('TYPE', key).ok
13032
+ if kind ~= 'none' and kind ~= 'string' then return redis.error_reply('Unexpected scheduler key type') end
13033
+ end
13034
+ local action = ARGV[1]
13035
+ local token = ARGV[2]
13036
+ local ttl = tonumber(ARGV[3])
13037
+ if action == 'acquire' then
13038
+ 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
13039
+ redis.call('SET', KEYS[1], token, 'PX', ttl)
13040
+ redis.call('SET', KEYS[2], token, 'PX', ttl)
13041
+ return 1
13042
+ end
13043
+ if redis.call('GET', KEYS[1]) ~= token or redis.call('GET', KEYS[2]) ~= token then return 0 end
13044
+ if action == 'renew' then
13045
+ redis.call('PEXPIRE', KEYS[1], ttl)
13046
+ redis.call('PEXPIRE', KEYS[2], ttl)
13047
+ elseif action == 'complete' then
13048
+ redis.call('SET', KEYS[3], 'completed', 'PX', ttl)
13049
+ redis.call('DEL', KEYS[1], KEYS[2])
13050
+ elseif action == 'release' then
13051
+ redis.call('DEL', KEYS[1], KEYS[2])
13052
+ else return redis.error_reply('Unexpected scheduler action') end
13053
+ return 1
13054
+ `;
13055
+
13056
+ class RedisSchedulerLeaseStore {
13057
+ client;
13058
+ owned;
13059
+ namespace;
13060
+ timeoutMs;
13061
+ constructor(options) {
13062
+ this.namespace = options.namespace.trim();
13063
+ if (!this.namespace || this.namespace.length > 512)
13064
+ throw new TypeError("Invalid scheduler namespace.");
13065
+ this.timeoutMs = options.commandTimeoutMs ?? 1000;
13066
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 60000)
13067
+ throw new RangeError("Invalid scheduler command timeout.");
13068
+ if (options.client)
13069
+ this.client = options.client;
13070
+ else {
13071
+ const client = new RedisClient5(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
13072
+ this.owned = client;
13073
+ this.client = client;
13074
+ }
13075
+ }
13076
+ keys(lease) {
13077
+ const task = createHash5("sha256").update(JSON.stringify([this.namespace, lease.taskId])).digest("hex");
13078
+ const occurrence = createHash5("sha256").update(lease.occurrenceId).digest("hex");
13079
+ const prefix = `scheduler:{${task}}`;
13080
+ return [
13081
+ `${prefix}:active`,
13082
+ `${prefix}:occurrence:${occurrence}`,
13083
+ `${prefix}:completed:${occurrence}`
13084
+ ];
13085
+ }
13086
+ async execute(action, lease, ttl) {
13087
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > 2147483647)
13088
+ throw new RangeError("Invalid scheduler TTL.");
13089
+ let timer;
13090
+ try {
13091
+ const result = await Promise.race([
13092
+ this.client.send("EVAL", [
13093
+ LEASE_SCRIPT,
13094
+ "3",
13095
+ ...this.keys(lease),
13096
+ action,
13097
+ lease.token,
13098
+ String(ttl)
13099
+ ]),
13100
+ new Promise((_, reject) => {
13101
+ timer = setTimeout(() => reject(new Error("Scheduler store deadline exceeded.")), this.timeoutMs);
13102
+ })
13103
+ ]);
13104
+ if (result !== 0 && result !== 1)
13105
+ throw new Error("Invalid scheduler store response.");
13106
+ return result === 1;
13107
+ } catch (error) {
13108
+ this.owned?.close();
13109
+ throw error;
13110
+ } finally {
13111
+ clearTimeout(timer);
13112
+ }
13113
+ }
13114
+ acquire(lease, leaseMs) {
13115
+ return this.execute("acquire", lease, leaseMs);
13116
+ }
13117
+ renew(lease, leaseMs) {
13118
+ return this.execute("renew", lease, leaseMs);
13119
+ }
13120
+ complete(lease, retentionMs) {
13121
+ return this.execute("complete", lease, retentionMs);
13122
+ }
13123
+ async release(lease) {
13124
+ await this.execute("release", lease, 1);
13125
+ }
13126
+ close() {
13127
+ this.owned?.close();
13128
+ }
13129
+ }
13130
+
13131
+ class SingleRunnerLeaseStore {
13132
+ tasks = new Map;
13133
+ async acquire(lease, leaseMs) {
13134
+ const task = this.tasks.get(lease.taskId) ?? { expires: 0 };
13135
+ if (task.active && task.expires > performance.now())
13136
+ return false;
13137
+ if ((task.completed ?? -Infinity) >= Number(lease.occurrenceId))
13138
+ return false;
13139
+ task.active = lease;
13140
+ task.expires = performance.now() + leaseMs;
13141
+ this.tasks.set(lease.taskId, task);
13142
+ return true;
13143
+ }
13144
+ async renew(lease, leaseMs) {
13145
+ const task = this.tasks.get(lease.taskId);
13146
+ if (!task || task.active?.token !== lease.token || task.expires <= performance.now())
13147
+ return false;
13148
+ task.expires = performance.now() + leaseMs;
13149
+ return true;
13150
+ }
13151
+ async complete(lease) {
13152
+ const task = this.tasks.get(lease.taskId);
13153
+ if (!task || task.active?.token !== lease.token || task.expires <= performance.now())
13154
+ return false;
13155
+ task.completed = Number(lease.occurrenceId);
13156
+ task.active = undefined;
13157
+ return true;
13158
+ }
13159
+ async release(lease) {
13160
+ const task = this.tasks.get(lease.taskId);
13161
+ if (task?.active?.token === lease.token)
13162
+ task.active = undefined;
13163
+ }
13164
+ }
13165
+
12283
13166
  // ../../src/core/scheduler/osCron.ts
12284
13167
  function parseScheduleExpression(expression, from = new Date) {
12285
13168
  return Bun.cron.parse(expression, from);
@@ -12310,11 +13193,25 @@ function assertScheduleExpression(expression) {
12310
13193
  }
12311
13194
 
12312
13195
  // ../../src/core/scheduler/schedule.ts
13196
+ class SchedulerLeaseLostError extends Error {
13197
+ constructor(taskName, options) {
13198
+ super(`Scheduler lease lost for task ${taskName}.`, options);
13199
+ this.name = "SchedulerLeaseLostError";
13200
+ }
13201
+ }
13202
+
12313
13203
  class Schedule {
12314
13204
  tasks = [];
12315
13205
  command(expression, name, run) {
12316
13206
  assertScheduleExpression(expression);
12317
- this.tasks.push({ expression, name, run });
13207
+ if (typeof run !== "function")
13208
+ throw new TypeError("Scheduled task handler must be a function.");
13209
+ const normalized = name.trim();
13210
+ if (!normalized || normalized.length > 200 || this.tasks.some((task) => task.name === normalized))
13211
+ throw new TypeError("Scheduled task names must be nonempty and unique (at most 200 characters).");
13212
+ if (this.tasks.length >= 4096)
13213
+ throw new RangeError("A schedule supports at most 4096 tasks.");
13214
+ this.tasks.push({ expression, name: normalized, run });
12318
13215
  return this;
12319
13216
  }
12320
13217
  dueTasks(now = new Date) {
@@ -12325,12 +13222,125 @@ class Schedule {
12325
13222
  }
12326
13223
  }
12327
13224
  var appSchedule = new Schedule;
12328
- async function runDueScheduledTasks(schedule = appSchedule, now = new Date) {
13225
+ var localStores = new WeakMap;
13226
+ function bounded(value, min, max, name) {
13227
+ if (!Number.isSafeInteger(value) || value < min || value > max)
13228
+ throw new RangeError(`Invalid scheduler ${name}.`);
13229
+ return value;
13230
+ }
13231
+ async function runDueScheduledTasks(schedule = appSchedule, now = new Date, options = {}) {
13232
+ const timestamp = now.getTime();
13233
+ if (!Number.isFinite(timestamp))
13234
+ throw new TypeError("Invalid scheduler date.");
12329
13235
  const due = schedule.dueTasks(now);
12330
- for (const task of due) {
12331
- await task.run();
13236
+ if (!due.length || options.signal?.aborted)
13237
+ return 0;
13238
+ const coordination = options.coordination ?? process.env.SCHEDULER_COORDINATION ?? (isProductionEnv() ? "redis" : "single-runner");
13239
+ if (coordination !== "redis" && coordination !== "single-runner")
13240
+ throw new TypeError("Unsupported scheduler coordination.");
13241
+ const leaseMs = bounded(options.leaseMs ?? 30000, 100, 3600000, "leaseMs");
13242
+ const renewalMs = bounded(options.renewalMs ?? Math.floor(leaseMs / 3), 1, Math.floor(leaseMs / 3), "renewalMs");
13243
+ const timeoutMs = bounded(options.commandTimeoutMs ?? Math.min(1000, Math.floor(leaseMs / 4)), 1, Math.floor(leaseMs / 3), "commandTimeoutMs");
13244
+ const retention = bounded(options.completedRetentionMs ?? 7 * 86400000, leaseMs, 2147483647, "completedRetentionMs");
13245
+ const namespace = (options.namespace ?? namespacedRedisKey("schedule")).trim();
13246
+ if (!namespace || namespace.length > 512)
13247
+ throw new TypeError("Invalid scheduler namespace.");
13248
+ let owned;
13249
+ let store = options.leaseStore;
13250
+ if (!store && coordination === "redis") {
13251
+ const redisUrl = options.redisUrl ?? process.env.REDIS_URL?.trim();
13252
+ if (!redisUrl)
13253
+ throw new Error("Redis scheduler coordination requires REDIS_URL.");
13254
+ owned = new RedisSchedulerLeaseStore({
13255
+ redisUrl,
13256
+ namespace,
13257
+ commandTimeoutMs: timeoutMs
13258
+ });
13259
+ store = owned;
13260
+ }
13261
+ if (!store) {
13262
+ let local = localStores.get(schedule);
13263
+ if (!local) {
13264
+ local = new SingleRunnerLeaseStore;
13265
+ localStores.set(schedule, local);
13266
+ }
13267
+ store = local;
13268
+ }
13269
+ const occurrenceId = String(Math.floor(timestamp / 60000) * 60000);
13270
+ let completed = 0;
13271
+ try {
13272
+ for (const task of due) {
13273
+ if (options.signal?.aborted)
13274
+ break;
13275
+ const lease = { taskId: task.name, occurrenceId, token: crypto.randomUUID() };
13276
+ if (!await store.acquire(lease, leaseMs))
13277
+ continue;
13278
+ if (options.signal?.aborted) {
13279
+ await store.release(lease);
13280
+ break;
13281
+ }
13282
+ const controller = new AbortController;
13283
+ let lost;
13284
+ const lose = (cause) => {
13285
+ lost ??= new SchedulerLeaseLostError(task.name, { cause });
13286
+ controller.abort(lost);
13287
+ return lost;
13288
+ };
13289
+ let timer;
13290
+ let renewal;
13291
+ let stopped = false;
13292
+ const renew = async () => {
13293
+ try {
13294
+ if (!await store.renew(lease, leaseMs))
13295
+ throw lose();
13296
+ } catch (error) {
13297
+ throw lose(error);
13298
+ }
13299
+ };
13300
+ const tick = () => {
13301
+ renewal = renew().catch(() => {}).finally(() => {
13302
+ if (!stopped && !lost)
13303
+ timer = setTimeout(tick, renewalMs);
13304
+ });
13305
+ };
13306
+ timer = setTimeout(tick, renewalMs);
13307
+ try {
13308
+ await task.run({
13309
+ taskName: task.name,
13310
+ occurrenceId: createHash6("sha256").update(JSON.stringify([namespace, task.name, occurrenceId])).digest("hex"),
13311
+ scheduledAt: new Date(Number(occurrenceId)),
13312
+ signal: controller.signal,
13313
+ async assertOwnership() {
13314
+ if (lost)
13315
+ throw lost;
13316
+ await renew();
13317
+ }
13318
+ });
13319
+ stopped = true;
13320
+ clearTimeout(timer);
13321
+ await renewal;
13322
+ if (lost || !await store.complete(lease, retention))
13323
+ throw lost ?? lose();
13324
+ completed++;
13325
+ } catch (error) {
13326
+ stopped = true;
13327
+ clearTimeout(timer);
13328
+ await renewal;
13329
+ try {
13330
+ await store.release(lease);
13331
+ } catch (releaseError) {
13332
+ throw new AggregateError([error, releaseError], "Scheduled task and lease release failed.");
13333
+ }
13334
+ throw error;
13335
+ } finally {
13336
+ stopped = true;
13337
+ clearTimeout(timer);
13338
+ }
13339
+ }
13340
+ return completed;
13341
+ } finally {
13342
+ owned?.close();
12332
13343
  }
12333
- return due.length;
12334
13344
  }
12335
13345
  // ../../src/core/security/publicReads.ts
12336
13346
  function isPublicReadsEnabled() {
@@ -12452,7 +13462,7 @@ init_tenantContext();
12452
13462
  init_tenantDatabaseScope();
12453
13463
 
12454
13464
  // ../../src/core/tenant/tenantMiddleware.ts
12455
- import { createHash as createHash5 } from "crypto";
13465
+ import { createHash as createHash7 } from "crypto";
12456
13466
  init_repositoryConnection();
12457
13467
  init_databaseTenantContext();
12458
13468
  init_tenancyConfig();
@@ -12460,9 +13470,7 @@ init_tenantContext();
12460
13470
  init_tenantDatabaseScope();
12461
13471
  var DEFAULT_TENANT = {
12462
13472
  id: 1,
12463
- slug: "default",
12464
- plan: "free",
12465
- region: "eu"
13473
+ slug: "default"
12466
13474
  };
12467
13475
  async function resolveUserTenantId(userId) {
12468
13476
  if (!isTenancyEnabled()) {
@@ -12479,9 +13487,9 @@ async function resolveUserTenantId(userId) {
12479
13487
  });
12480
13488
  }
12481
13489
  function auditChecksum(payload) {
12482
- return createHash5("sha256").update(JSON.stringify(payload)).digest("hex");
13490
+ return createHash7("sha256").update(JSON.stringify(payload)).digest("hex");
12483
13491
  }
12484
- async function resolveTenantForRequest(request) {
13492
+ async function resolveTenantForRequest(request, resolver) {
12485
13493
  const user = currentAuthUser();
12486
13494
  const headerValue = request.headers.get("x-tenant-id")?.trim();
12487
13495
  const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number.parseInt(headerValue, 10) : Number.NaN;
@@ -12493,20 +13501,20 @@ async function resolveTenantForRequest(request) {
12493
13501
  if (Number.isInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
12494
13502
  throw new ForbiddenError("Tenant header does not match your account.");
12495
13503
  }
12496
- const memberTenant = await resolveTenant(userTenantId);
13504
+ const memberTenant = await resolver(userTenantId);
12497
13505
  if (memberTenant) {
12498
13506
  return memberTenant;
12499
13507
  }
12500
13508
  return DEFAULT_TENANT;
12501
13509
  }
12502
13510
  if (Number.isInteger(parsedHeader) && parsedHeader > 0) {
12503
- const headerTenant = await resolveTenant(parsedHeader);
13511
+ const headerTenant = await resolver(parsedHeader);
12504
13512
  if (headerTenant) {
12505
13513
  return headerTenant;
12506
13514
  }
12507
13515
  return DEFAULT_TENANT;
12508
13516
  }
12509
- const adminTenant = await resolveTenant(userTenantId);
13517
+ const adminTenant = await resolver(userTenantId);
12510
13518
  if (adminTenant) {
12511
13519
  return adminTenant;
12512
13520
  }
@@ -12515,13 +13523,14 @@ async function resolveTenantForRequest(request) {
12515
13523
  }
12516
13524
  const headerTenantId = Number.isInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
12517
13525
  const tenantId = isPublicReadsEnabled() && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
12518
- const guestTenant = await resolveTenant(tenantId);
13526
+ const guestTenant = await resolver(tenantId);
12519
13527
  if (guestTenant) {
12520
13528
  return guestTenant;
12521
13529
  }
12522
13530
  return DEFAULT_TENANT;
12523
13531
  }
12524
- function createTenantMiddleware() {
13532
+ function createTenantMiddleware(options = {}) {
13533
+ const resolver = options.resolveTenant ?? resolveTenant;
12525
13534
  return async (request, next) => {
12526
13535
  const pathname = new URL(request.url).pathname;
12527
13536
  if (pathname.startsWith("/scim/")) {
@@ -12531,7 +13540,16 @@ function createTenantMiddleware() {
12531
13540
  return await runWithTenant(DEFAULT_TENANT, next);
12532
13541
  }
12533
13542
  try {
12534
- const tenant = await resolveTenantForRequest(request);
13543
+ const tenant = await resolveTenantForRequest(request, async (id) => {
13544
+ const tenant = await resolver(id);
13545
+ if (options.resolveTenant && tenant === null) {
13546
+ throw new ForbiddenError("Tenant not found.");
13547
+ }
13548
+ if (tenant && tenant.id !== id) {
13549
+ throw new Error("Tenant resolver returned a different tenant identity.");
13550
+ }
13551
+ return tenant;
13552
+ });
12535
13553
  return await runWithTenantDatabase(tenant, async () => {
12536
13554
  return await next();
12537
13555
  });
@@ -12553,99 +13571,185 @@ function runWithTraceContext(context, callback) {
12553
13571
  function currentTraceId() {
12554
13572
  return traceContextStorage.getStore()?.traceId ?? null;
12555
13573
  }
13574
+ // ../../src/core/tracing/tracingMiddleware.ts
13575
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
13576
+
12556
13577
  // ../../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
- };
13578
+ import { context } from "@opentelemetry/api";
13579
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
13580
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
13581
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
13582
+ import { resourceFromAttributes } from "@opentelemetry/resources";
13583
+ import {
13584
+ AlwaysOffSampler,
13585
+ AlwaysOnSampler,
13586
+ BatchSpanProcessor,
13587
+ ParentBasedSampler,
13588
+ TraceIdRatioBasedSampler
13589
+ } from "@opentelemetry/sdk-trace-base";
13590
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
13591
+ function numberOption(value, fallback, name, max = 1e6) {
13592
+ const result = value ?? fallback;
13593
+ if (!Number.isInteger(result) || result < 1 || result > max)
13594
+ throw new TypeError(`Invalid tracing ${name}.`);
13595
+ return result;
12575
13596
  }
12576
- async function exportOtelSpan(span) {
12577
- const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
12578
- if (!endpoint) {
13597
+ function ensureContextManager() {
13598
+ const key = Symbol.for("@getstrata/otel-context-installed");
13599
+ const state = globalThis;
13600
+ if (state[key])
12579
13601
  return;
13602
+ const manager = new AsyncLocalStorageContextManager().enable();
13603
+ if (!context.setGlobalContextManager(manager))
13604
+ manager.disable();
13605
+ state[key] = true;
13606
+ }
13607
+ function createTracingRuntime(options = {}) {
13608
+ ensureContextManager();
13609
+ const disabled = process.env.OTEL_SDK_DISABLED === "true";
13610
+ const samplerName = options.sampleRatio === undefined ? process.env.OTEL_TRACES_SAMPLER ?? "parentbased_traceidratio" : "parentbased_traceidratio";
13611
+ const rootName = samplerName.replace(/^parentbased_/, "");
13612
+ if (!["always_on", "always_off", "traceidratio"].includes(rootName))
13613
+ throw new TypeError("Unsupported tracing sampler.");
13614
+ const ratio = options.sampleRatio ?? Number(process.env.OTEL_TRACES_SAMPLER_ARG ?? "1");
13615
+ if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1)
13616
+ throw new TypeError("Invalid tracing sampleRatio.");
13617
+ const rootSampler = rootName === "always_on" ? new AlwaysOnSampler : rootName === "always_off" ? new AlwaysOffSampler : new TraceIdRatioBasedSampler(ratio);
13618
+ const sampler = disabled ? new AlwaysOffSampler : samplerName.startsWith("parentbased_") ? new ParentBasedSampler({ root: rootSampler }) : rootSampler;
13619
+ const queueSize = numberOption(options.maxQueueSize, 2048, "maxQueueSize");
13620
+ const batchSize = numberOption(options.maxExportBatchSize, Math.min(512, queueSize), "maxExportBatchSize", queueSize);
13621
+ const timeout = numberOption(options.exportTimeoutMillis, 5000, "exportTimeoutMillis", 60000);
13622
+ const delay = numberOption(options.scheduledDelayMillis, 5000, "scheduledDelayMillis", 60000);
13623
+ let endpoint = options.endpoint ?? process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim();
13624
+ if (!endpoint) {
13625
+ const base = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
13626
+ if (base)
13627
+ endpoint = base.endsWith("/v1/traces") ? base : `${base.replace(/\/$/, "")}/v1/traces`;
12580
13628
  }
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
13629
+ if (endpoint) {
13630
+ let url;
13631
+ try {
13632
+ url = new URL(endpoint);
13633
+ } catch {
13634
+ throw new TypeError("Invalid tracing collector endpoint.");
13635
+ }
13636
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
13637
+ throw new TypeError("Invalid tracing collector endpoint.");
13638
+ }
13639
+ const exporter = disabled ? undefined : options.exporter ?? (endpoint ? new OTLPTraceExporter({ url: endpoint, timeoutMillis: timeout, concurrencyLimit: 1 }) : undefined);
13640
+ const provider = new NodeTracerProvider({
13641
+ resource: resourceFromAttributes({ "service.name": options.serviceName ?? otelServiceName() }),
13642
+ sampler,
13643
+ spanProcessors: exporter ? [
13644
+ new BatchSpanProcessor(exporter, {
13645
+ maxQueueSize: queueSize,
13646
+ maxExportBatchSize: batchSize,
13647
+ scheduledDelayMillis: delay,
13648
+ exportTimeoutMillis: timeout
13649
+ })
13650
+ ] : [],
13651
+ spanLimits: {
13652
+ attributeCountLimit: 16,
13653
+ attributeValueLengthLimit: 256,
13654
+ eventCountLimit: 8,
13655
+ linkCountLimit: 8
13656
+ },
13657
+ forceFlushTimeoutMillis: timeout
12601
13658
  });
13659
+ let shutdown;
13660
+ return {
13661
+ tracer: provider.getTracer("@getstrata/http"),
13662
+ propagator: new W3CTraceContextPropagator,
13663
+ run: (parent, callback) => context.with(parent, callback),
13664
+ forceFlush: () => provider.forceFlush(),
13665
+ shutdown: () => shutdown ??= provider.shutdown()
13666
+ };
13667
+ }
13668
+ var runtimeKey = Symbol.for("@getstrata/otel-runtime");
13669
+ function runtimeState() {
13670
+ const state = globalThis;
13671
+ state[runtimeKey] ??= { owners: 0 };
13672
+ return state[runtimeKey];
13673
+ }
13674
+ function getTracingRuntime() {
13675
+ const state = runtimeState();
13676
+ state.runtime ??= createTracingRuntime();
13677
+ return state.runtime;
13678
+ }
13679
+ function acquireTracingRuntime() {
13680
+ const state = runtimeState();
13681
+ const runtime = getTracingRuntime();
13682
+ state.owners++;
13683
+ let released = false;
13684
+ return {
13685
+ flush: () => runtime.forceFlush(),
13686
+ async release() {
13687
+ if (released)
13688
+ return;
13689
+ released = true;
13690
+ if (--state.owners === 0) {
13691
+ if (state.runtime === runtime)
13692
+ state.runtime = undefined;
13693
+ await runtime.shutdown();
13694
+ }
13695
+ }
13696
+ };
12602
13697
  }
12603
13698
 
12604
13699
  // ../../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() {
13700
+ var headerGetter = {
13701
+ keys: (headers) => [...headers.keys()],
13702
+ get: (headers, key) => headers.get(key) ?? undefined
13703
+ };
13704
+ var methods = new Set([
13705
+ "GET",
13706
+ "POST",
13707
+ "PUT",
13708
+ "PATCH",
13709
+ "DELETE",
13710
+ "HEAD",
13711
+ "OPTIONS",
13712
+ "CONNECT",
13713
+ "TRACE"
13714
+ ]);
13715
+ function createTracingMiddleware(runtime) {
12617
13716
  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
- });
13717
+ const telemetry = runtime ?? getTracingRuntime();
13718
+ const method = methods.has(request.method) ? request.method : "_OTHER";
13719
+ const route = currentRequestMeta().routeTemplate ?? "__unmatched__";
13720
+ const parent = telemetry.propagator.extract(ROOT_CONTEXT, request.headers, headerGetter);
13721
+ const span = telemetry.tracer.startSpan(`${method} ${route}`, {
13722
+ kind: SpanKind.SERVER,
13723
+ attributes: { "http.request.method": method, "http.route": route }
13724
+ }, parent);
13725
+ const identity = span.spanContext();
13726
+ const started = performance.now();
13727
+ return await telemetry.run(trace.setSpan(parent, span), () => runWithTraceContext({ traceId: identity.traceId, spanId: identity.spanId }, async () => {
13728
+ try {
13729
+ const response = await next();
13730
+ span.setAttribute("http.response.status_code", response.status);
13731
+ if (response.status >= 500)
13732
+ span.setStatus({ code: SpanStatusCode.ERROR });
13733
+ const headers = new Headers(response.headers);
13734
+ headers.delete("tracestate");
13735
+ headers.set("x-trace-id", identity.traceId);
13736
+ headers.set("x-span-id", identity.spanId);
13737
+ telemetry.propagator.inject(trace.setSpan(parent, span), headers, {
13738
+ set: (carrier, key, value) => carrier.set(key, value)
13739
+ });
13740
+ headers.set("server-timing", `app;dur=${(performance.now() - started).toFixed(2)}`);
13741
+ return new Response(response.body, {
13742
+ status: response.status,
13743
+ statusText: response.statusText,
13744
+ headers
13745
+ });
13746
+ } catch (error) {
13747
+ span.setStatus({ code: SpanStatusCode.ERROR });
13748
+ throw error;
13749
+ } finally {
13750
+ span.end();
13751
+ }
13752
+ }));
12649
13753
  };
12650
13754
  }
12651
13755
  // ../../src/core/validation/rules.ts
@@ -13056,6 +14160,7 @@ export {
13056
14160
  JobRegistry,
13057
14161
  JsonResource,
13058
14162
  JwtGuard,
14163
+ LifecycleCoordinator,
13059
14164
  LocalStorageDriver,
13060
14165
  LogMailDriver,
13061
14166
  MAX_PER_PAGE,
@@ -13081,12 +14186,14 @@ export {
13081
14186
  QueueWorker,
13082
14187
  RLS_HELPER_SQL,
13083
14188
  RedisQueue,
14189
+ RedisSchedulerLeaseStore,
13084
14190
  RepositoryQuery,
13085
14191
  ResilientQueue,
13086
14192
  ResourceCollection,
13087
14193
  SamlProvider,
13088
14194
  SamlServiceProvider,
13089
14195
  Schedule,
14196
+ SchedulerLeaseLostError,
13090
14197
  Schema,
13091
14198
  ServiceContainer,
13092
14199
  SqlOutbox,
@@ -13100,6 +14207,7 @@ export {
13100
14207
  WebFormRequest,
13101
14208
  WhereBuilder,
13102
14209
  absoluteTemporarySignedUrl,
14210
+ acquireTracingRuntime,
13103
14211
  apiPrefix,
13104
14212
  appCookieName,
13105
14213
  appDevSecret,
@@ -13130,6 +14238,7 @@ export {
13130
14238
  bindBunSql,
13131
14239
  bindDatabaseConnection,
13132
14240
  bindRouteModel,
14241
+ bootModels,
13133
14242
  buildAdvancedWhereClause,
13134
14243
  buildCountQuery,
13135
14244
  buildDeleteByIdQuery,
@@ -13149,6 +14258,7 @@ export {
13149
14258
  buildUpdateQuery,
13150
14259
  buildWhereClause,
13151
14260
  cache,
14261
+ clearActiveApplicationContext,
13152
14262
  collectQueueMetrics,
13153
14263
  compileBlueprint,
13154
14264
  completePasswordLogin,
@@ -13205,6 +14315,7 @@ export {
13205
14315
  createThrottleMiddleware,
13206
14316
  createTokenAbilityChecker,
13207
14317
  createTracingMiddleware,
14318
+ createTracingRuntime,
13208
14319
  createTrackedJob,
13209
14320
  createValidateSignatureMiddleware,
13210
14321
  createdResponse,
@@ -13217,6 +14328,7 @@ export {
13217
14328
  currentTenant,
13218
14329
  currentTenantId,
13219
14330
  currentTraceId,
14331
+ defineModel,
13220
14332
  defineTable,
13221
14333
  dehydrateValue,
13222
14334
  dialectFor,
@@ -13248,6 +14360,7 @@ export {
13248
14360
  getNamedConnection,
13249
14361
  getQueryParams,
13250
14362
  getRouteParams,
14363
+ getTracingRuntime,
13251
14364
  grammarForDriver,
13252
14365
  guestCanViewResource,
13253
14366
  hasActiveDatabaseConnection,
@@ -13331,7 +14444,6 @@ export {
13331
14444
  qualifyColumn,
13332
14445
  queue,
13333
14446
  quoteIdentifier,
13334
- rateLimitMultiplierForPlan,
13335
14447
  readBunRequestCookie,
13336
14448
  readClientIp,
13337
14449
  readOptionalEnum,