@getstrata/core 2.0.9 → 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 (67) hide show
  1. package/CHANGELOG.md +37 -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/container.d.ts +28 -10
  8. package/dist/core/contracts/di.d.ts +7 -4
  9. package/dist/core/contracts/serviceTokens.d.ts +6 -1
  10. package/dist/core/database/baseRepository.d.ts +0 -1
  11. package/dist/core/database/index.d.ts +1 -1
  12. package/dist/core/database/model.d.ts +123 -42
  13. package/dist/core/database/repositoryQuery.d.ts +16 -1
  14. package/dist/core/database/types.d.ts +25 -2
  15. package/dist/core/database/whereBuilder.d.ts +5 -5
  16. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  17. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  18. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  19. package/dist/core/http/middleware.d.ts +2 -2
  20. package/dist/core/http/response.d.ts +2 -0
  21. package/dist/core/http/routeMiddleware.d.ts +3 -2
  22. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  23. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  24. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  25. package/dist/core/queue/index.d.ts +7 -0
  26. package/dist/core/queue/redisQueue.d.ts +1 -0
  27. package/dist/core/queue/resilientQueue.d.ts +2 -2
  28. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  29. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  30. package/dist/core/scheduler/leases.d.ts +40 -0
  31. package/dist/core/scheduler/schedule.d.ts +29 -5
  32. package/dist/core/tenant/tenantContext.d.ts +5 -4
  33. package/dist/core/tenant/tenantMiddleware.d.ts +7 -1
  34. package/dist/core/tracing/otel.d.ts +28 -26
  35. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  36. package/dist/entries/audit/exportAuditLogs.js +14 -2
  37. package/dist/entries/auth/basicAuthGuard.js +80 -0
  38. package/dist/entries/auth/jwtGuard.js +80 -0
  39. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  40. package/dist/entries/auth/sessionGuard.js +80 -0
  41. package/dist/entries/cache/createCacheStore.js +365 -119
  42. package/dist/entries/cache/repository.js +5 -0
  43. package/dist/entries/contracts/container.js +8 -1
  44. package/dist/entries/contracts/serviceTokens.js +83 -0
  45. package/dist/entries/database/factory.js +0 -3
  46. package/dist/entries/database/model.js +1 -2465
  47. package/dist/entries/database/repositoryQuery.js +67 -9
  48. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  49. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  50. package/dist/entries/http/routeMiddleware.js +1 -4
  51. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  52. package/dist/entries/http/throttleMiddleware.js +65 -16
  53. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  54. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  55. package/dist/entries/queue/createAppQueue.js +67 -6
  56. package/dist/entries/queue/publicQueue.js +67 -6
  57. package/dist/entries/queue/queueMetrics.js +67 -6
  58. package/dist/entries/queue/redisQueue.js +3 -0
  59. package/dist/entries/queue.js +30 -5
  60. package/dist/entries/scheduler/schedule.js +1 -72
  61. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  62. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  63. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  64. package/dist/framework/public-api.d.ts +14 -10
  65. package/dist/index.js +1770 -638
  66. package/dist/types/services.d.ts +3 -3
  67. 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";
@@ -1124,6 +1209,12 @@ function readBasicCredentials(request) {
1124
1209
  }
1125
1210
 
1126
1211
  // ../../src/core/auth/password.ts
1212
+ async function hashPassword(password) {
1213
+ return await Bun.password.hash(password, {
1214
+ algorithm: "bcrypt",
1215
+ cost: 12
1216
+ });
1217
+ }
1127
1218
  async function verifyPassword(password, passwordHash) {
1128
1219
  return await Bun.password.verify(password, passwordHash);
1129
1220
  }
@@ -2048,6 +2139,12 @@ function setActiveApplicationContext(context) {
2048
2139
  activeContext = context;
2049
2140
  globalThis[APPLICATION_CONTEXT_KEY] = context;
2050
2141
  }
2142
+ function clearActiveApplicationContext(context) {
2143
+ if (readStoredApplicationContext() !== context)
2144
+ return;
2145
+ activeContext = undefined;
2146
+ delete globalThis[APPLICATION_CONTEXT_KEY];
2147
+ }
2051
2148
  function requireActiveApplicationContext() {
2052
2149
  const context = readStoredApplicationContext();
2053
2150
  if (!context) {
@@ -3241,19 +3338,17 @@ async function resolveTenant(tenantId) {
3241
3338
  if (!isTenancyEnabled()) {
3242
3339
  return {
3243
3340
  id: tenantId,
3244
- slug: "default",
3245
- plan: "free",
3246
- region: "eu"
3341
+ slug: "default"
3247
3342
  };
3248
3343
  }
3249
3344
  const rows = await repositoryConnection`
3250
- SELECT id, slug, plan, region
3345
+ SELECT id, slug
3251
3346
  FROM tenant
3252
3347
  WHERE id = ${tenantId}
3253
3348
  LIMIT 1
3254
3349
  `;
3255
3350
  const row = rows[0];
3256
- 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;
3257
3352
  }
3258
3353
 
3259
3354
  // ../../src/core/auth/scimAuthMiddleware.ts
@@ -3313,151 +3408,397 @@ function createTokenAbilityChecker() {
3313
3408
  }
3314
3409
  // ../../src/core/cache/redisCacheStore.ts
3315
3410
  var {RedisClient } = globalThis.Bun;
3316
- function cacheKeyPrefix() {
3317
- return namespacedRedisKey("cache:");
3318
- }
3319
- function cacheTagPrefix() {
3320
- return namespacedRedisKey("cache:tag:");
3321
- }
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
+ `;
3322
3609
 
3323
3610
  class RedisCacheStore {
3324
3611
  ttlMs;
3325
3612
  maxEntries;
3326
3613
  client;
3327
- inflight = new Map;
3328
- keyTags = new Map;
3329
- constructor(redisUrl, ttlMs, maxEntries) {
3614
+ keys;
3615
+ options;
3616
+ fills = new Set;
3617
+ closed = false;
3618
+ constructor(redisUrl, ttlMs, maxEntries, options = {}) {
3330
3619
  this.ttlMs = ttlMs;
3331
3620
  this.maxEntries = maxEntries;
3332
- 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();
3333
3653
  }
3334
3654
  async get(key) {
3335
- const raw = await this.client.get(this.storageKey(key));
3336
- if (raw === null) {
3337
- return;
3338
- }
3339
- return JSON.parse(raw);
3655
+ const result = await this.execute("get", key);
3656
+ return result.kind === "value" ? JSON.parse(result.payload ?? "null") : undefined;
3340
3657
  }
3341
3658
  async set(key, value, ttlMs) {
3342
- const resolvedTtlMs = ttlMs ?? this.ttlMs;
3343
- const payload = JSON.stringify(value);
3344
- if (resolvedTtlMs > 0) {
3345
- await this.client.psetex(this.storageKey(key), resolvedTtlMs, payload);
3346
- } else {
3347
- await this.client.set(this.storageKey(key), payload);
3348
- }
3349
- await this.enforceMaxEntries();
3659
+ await this.execute("set", key, { payload: this.serialize(value), ttlMs: ttlMs ?? this.ttlMs });
3350
3660
  }
3351
3661
  async getOrSet(key, loader, ttlMs) {
3352
- const cached = await this.get(key);
3353
- if (cached !== undefined) {
3354
- return cached;
3355
- }
3356
- const inflightRequest = this.inflight.get(key);
3357
- if (inflightRequest) {
3358
- return inflightRequest;
3359
- }
3360
- const pendingRequest = loader().then(async (value) => {
3361
- await this.set(key, value, ttlMs);
3362
- return value;
3363
- }).finally(() => {
3364
- this.inflight.delete(key);
3365
- });
3366
- this.inflight.set(key, pendingRequest);
3367
- 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.");
3368
3678
  }
3369
3679
  async attachTags(key, tags) {
3370
- if (tags.length === 0) {
3371
- return;
3372
- }
3373
- let tagsForKey = this.keyTags.get(key);
3374
- if (!tagsForKey) {
3375
- tagsForKey = new Set;
3376
- this.keyTags.set(key, tagsForKey);
3377
- }
3378
- for (const tag of tags) {
3379
- tagsForKey.add(tag);
3380
- await this.client.sadd(this.tagKey(tag), key);
3381
- }
3680
+ await this.execute("attach", key, { tags });
3382
3681
  }
3383
3682
  async flushTags(tags) {
3384
- const keysToRemove = new Set;
3385
- for (const tag of tags) {
3386
- const members = await this.client.smembers(this.tagKey(tag));
3387
- for (const member of members) {
3388
- keysToRemove.add(member);
3389
- }
3390
- }
3391
- let removed = 0;
3392
- for (const key of keysToRemove) {
3393
- if (await this.invalidate(key)) {
3394
- removed += 1;
3395
- }
3396
- }
3397
- for (const tag of tags) {
3398
- await this.client.del(this.tagKey(tag));
3399
- }
3400
- return removed;
3683
+ return (await this.execute("tags", "", { tags })).removed ?? 0;
3401
3684
  }
3402
3685
  async invalidate(key) {
3403
- const deleted = await this.client.del(this.storageKey(key));
3404
- await this.detachKeyFromTags(key);
3405
- return deleted > 0;
3686
+ return ((await this.execute("invalidate", key)).removed ?? 0) > 0;
3406
3687
  }
3407
3688
  async invalidateByPrefix(prefix) {
3408
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3409
- let removed = 0;
3410
- for (const storageKey of keys) {
3411
- const key = storageKey.slice(cacheKeyPrefix().length);
3412
- if (key === prefix || key.startsWith(`${prefix}?`)) {
3413
- if (await this.invalidate(key)) {
3414
- removed += 1;
3415
- }
3416
- }
3417
- }
3418
- return removed;
3689
+ return (await this.execute("prefix", prefix)).removed ?? 0;
3419
3690
  }
3420
3691
  async clear() {
3421
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3422
- if (keys.length > 0) {
3423
- await this.client.del(...keys);
3424
- }
3425
- const tagKeys = await this.client.keys(`${cacheTagPrefix()}*`);
3426
- if (tagKeys.length > 0) {
3427
- await this.client.del(...tagKeys);
3428
- }
3429
- this.inflight.clear();
3430
- this.keyTags.clear();
3692
+ await this.execute("clear", "");
3431
3693
  }
3432
3694
  async size() {
3433
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3434
- return keys.length;
3695
+ return (await this.execute("size", "")).size ?? 0;
3435
3696
  }
3436
- storageKey(key) {
3437
- return `${cacheKeyPrefix()}${key}`;
3438
- }
3439
- tagKey(tag) {
3440
- return `${cacheTagPrefix()}${tag}`;
3441
- }
3442
- async detachKeyFromTags(key) {
3443
- const tags = this.keyTags.get(key);
3444
- if (!tags) {
3445
- 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.");
3446
3701
  }
3447
- for (const tag of tags) {
3448
- 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(() => {});
3449
3747
  }
3450
- this.keyTags.delete(key);
3451
3748
  }
3452
- async enforceMaxEntries() {
3453
- const keys = await this.client.keys(`${cacheKeyPrefix()}*`);
3454
- if (keys.length <= this.maxEntries) {
3455
- return;
3456
- }
3457
- const overflow = keys.length - this.maxEntries;
3458
- const keysToRemove = keys.slice(0, overflow);
3459
- if (keysToRemove.length > 0) {
3460
- 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);
3461
3802
  }
3462
3803
  }
3463
3804
  }
@@ -3683,7 +4024,7 @@ function createCacheStore(options) {
3683
4024
  if (!options.redisUrl) {
3684
4025
  throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
3685
4026
  }
3686
- return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries);
4027
+ return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries, options.redis);
3687
4028
  }
3688
4029
  return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
3689
4030
  }
@@ -3696,6 +4037,8 @@ class TaggedCache {
3696
4037
  this.tags = tags;
3697
4038
  }
3698
4039
  async remember(key, callback, ttlMs) {
4040
+ if (this.store.getOrSetTagged)
4041
+ return this.store.getOrSetTagged(key, callback, this.tags, ttlMs);
3699
4042
  const value = await this.store.getOrSet(key, callback, ttlMs);
3700
4043
  await this.store.attachTags(key, this.tags);
3701
4044
  return value;
@@ -3712,6 +4055,9 @@ class CacheRepository {
3712
4055
  constructor(store) {
3713
4056
  this.store = store;
3714
4057
  }
4058
+ async close() {
4059
+ await this.store.close?.();
4060
+ }
3715
4061
  async get(key) {
3716
4062
  return this.store.get(key);
3717
4063
  }
@@ -3759,78 +4105,8 @@ var CACHE_TAGS = {
3759
4105
  careers: "careers",
3760
4106
  offers: "offers"
3761
4107
  };
3762
- // ../../src/core/contracts/container.ts
3763
- class ServiceContainer {
3764
- services = new Map;
3765
- singletonFactories = new Map;
3766
- bindings = new Map;
3767
- set(key, value) {
3768
- this.singletonFactories.delete(key);
3769
- this.bindings.delete(key);
3770
- this.services.set(key, value);
3771
- return value;
3772
- }
3773
- singleton(key, factory) {
3774
- this.bindings.delete(key);
3775
- this.services.delete(key);
3776
- this.singletonFactories.set(key, factory);
3777
- }
3778
- bind(key, factory) {
3779
- this.singletonFactories.delete(key);
3780
- this.services.delete(key);
3781
- this.bindings.set(key, factory);
3782
- }
3783
- get(key) {
3784
- if (this.services.has(key)) {
3785
- return this.services.get(key);
3786
- }
3787
- const singletonFactory = this.singletonFactories.get(key);
3788
- if (singletonFactory) {
3789
- const value = singletonFactory(this);
3790
- this.services.set(key, value);
3791
- return value;
3792
- }
3793
- const binding = this.bindings.get(key);
3794
- if (binding) {
3795
- return binding(this);
3796
- }
3797
- throw new Error(`Service "${key}" is not registered.`);
3798
- }
3799
- resolve(key) {
3800
- return this.get(key);
3801
- }
3802
- make(key) {
3803
- return this.resolve(key);
3804
- }
3805
- instance(key, value) {
3806
- return this.set(key, value);
3807
- }
3808
- has(key) {
3809
- return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
3810
- }
3811
- }
3812
-
3813
- class ConfigStore {
3814
- values = new Map;
3815
- set(key, value) {
3816
- this.values.set(key, value);
3817
- return value;
3818
- }
3819
- get(key) {
3820
- return this.values.get(key);
3821
- }
3822
- require(key) {
3823
- if (!this.values.has(key)) {
3824
- throw new Error(`Config key "${key}" is not defined.`);
3825
- }
3826
- return this.values.get(key);
3827
- }
3828
- has(key) {
3829
- return this.values.has(key);
3830
- }
3831
- }
3832
- // ../../src/core/database/baseRepository.ts
3833
- init_eventBus();
4108
+ // ../../src/core/database/baseRepository.ts
4109
+ init_eventBus();
3834
4110
 
3835
4111
  // ../../src/core/events/index.ts
3836
4112
  init_deferredModelEvents();
@@ -3852,6 +4128,7 @@ function buildPaginationMeta(input) {
3852
4128
 
3853
4129
  // ../../src/core/database/baseRepository.ts
3854
4130
  init_connectionContext();
4131
+ init_dialect();
3855
4132
 
3856
4133
  // ../../src/core/database/query.ts
3857
4134
  init_dialect();
@@ -4672,6 +4949,7 @@ class RepositoryQuery {
4672
4949
  queryOptions;
4673
4950
  eagerLoads = [];
4674
4951
  whereNodes = [];
4952
+ protectedWhere = [];
4675
4953
  constructor(repository, whereClause = {}, queryOptions = {}) {
4676
4954
  this.repository = repository;
4677
4955
  this.whereClause = whereClause;
@@ -4681,10 +4959,23 @@ class RepositoryQuery {
4681
4959
  if (typeof input === "function") {
4682
4960
  const builder = new WhereBuilder;
4683
4961
  input(builder);
4684
- this.whereNodes.push(...builder.nodes);
4962
+ if (builder.nodes.length > 0)
4963
+ this.whereNodes.push({ kind: "and", group: builder.nodes });
4685
4964
  return this;
4686
4965
  }
4687
- 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;
4688
4979
  return this;
4689
4980
  }
4690
4981
  orWhere(input) {
@@ -4798,9 +5089,10 @@ class RepositoryQuery {
4798
5089
  });
4799
5090
  return this;
4800
5091
  }
4801
- withMorphTo(as, relation, repositoriesByType, options = {}) {
5092
+ withMorphTo(as, relation, repositoriesByType, options = {}, optionsByType) {
4802
5093
  this.eagerLoads.push({
4803
5094
  kind: "morphTo",
5095
+ morphOptions: optionsByType,
4804
5096
  as,
4805
5097
  relation,
4806
5098
  repository: this.repository,
@@ -4871,6 +5163,19 @@ class RepositoryQuery {
4871
5163
  }
4872
5164
  return row[column];
4873
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
+ }
4874
5179
  async attachToRows(rows) {
4875
5180
  return await this.attach(rows);
4876
5181
  }
@@ -4881,11 +5186,24 @@ class RepositoryQuery {
4881
5186
  perPage: options.perPage
4882
5187
  });
4883
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
+ }
4884
5199
  buildOptions() {
5200
+ if (this.protectedWhere.length === 0) {
5201
+ return { ...this.queryOptions, where: this.whereClause, whereNodes: this.whereNodes };
5202
+ }
4885
5203
  return {
4886
5204
  ...this.queryOptions,
4887
- where: this.whereClause,
4888
- whereNodes: this.whereNodes
5205
+ where: {},
5206
+ whereNodes: [...this.protectedWhere, { kind: "and", group: [...this.whereNodes] }]
4889
5207
  };
4890
5208
  }
4891
5209
  addJoin(type, left, right) {
@@ -4962,10 +5280,27 @@ class RepositoryQuery {
4962
5280
  }
4963
5281
  if (load.kind === "morphTo") {
4964
5282
  const relation = load.relation;
4965
- const grouped = await this.repository.loadMorphToForChildren(rows, relation, load.morphRepositories ?? new Map, load.options);
4966
- for (const row of result) {
4967
- row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
4968
- }
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
+ }));
4969
5304
  return;
4970
5305
  }
4971
5306
  const relation = load.relation;
@@ -5087,14 +5422,20 @@ class BaseRepository {
5087
5422
  if (!Number.isInteger(perPage) || perPage <= 0) {
5088
5423
  throw new Error("Cursor page size must be a positive integer.");
5089
5424
  }
5090
- 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 }];
5091
5430
  if (cursor !== undefined) {
5431
+ const cursorWhere = {};
5092
5432
  cursorWhere[cursorColumn] = direction === "asc" ? { gt: cursor } : { lt: cursor };
5433
+ cursorFilters.push({ kind: "and", where: cursorWhere });
5093
5434
  }
5094
5435
  const rows = await this.findAll({
5095
5436
  ...queryOptions,
5096
- where: cursorWhere,
5097
- whereNodes,
5437
+ where: {},
5438
+ whereNodes: cursorFilters,
5098
5439
  orderBy: { [cursorColumn]: direction },
5099
5440
  limit: perPage + 1
5100
5441
  });
@@ -5318,46 +5659,29 @@ class BaseRepository {
5318
5659
  if (parents.length === 0) {
5319
5660
  return indexHasManyThroughRelation(parents, [], relation);
5320
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
+ }
5321
5665
  const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
5322
5666
  const throughParentKey = relation.throughParentKey ?? "__through_parent_id";
5323
5667
  const farTable = this.table.name;
5324
5668
  const columns = this.table.columns.map((column) => `${qualifyColumn(farTable, column)}`).join(", ");
5325
- const placeholders = parentIds.map((_, index) => `$${index + 1}`).join(", ");
5326
- const { text: extraWhere, params: extraParams } = this.buildThroughWhere(options, parentIds.length);
5327
- const softDelete = this.throughSoftDeleteClause(options);
5328
- 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}`;
5329
5672
  const children = await this.connection.unsafe(sql, [
5330
5673
  ...parentIds,
5331
5674
  ...extraParams
5332
5675
  ]);
5333
5676
  return indexHasManyThroughRelation(parents, children, relation);
5334
5677
  }
5335
- throughSoftDeleteClause(options) {
5336
- const column = resolveSoftDeleteColumn(this.table);
5337
- if (!column) {
5338
- return "";
5339
- }
5340
- const qualified = qualifyColumn(this.table.name, column);
5341
- if (options.onlyTrashed) {
5342
- return ` AND ${qualified} IS NOT NULL`;
5343
- }
5344
- if (options.withTrashed) {
5345
- return "";
5346
- }
5347
- return ` AND ${qualified} IS NULL`;
5348
- }
5349
- buildThroughWhere(options, paramOffset = 1) {
5350
- const where = options.where ?? {};
5351
- const entries = Object.entries(where);
5352
- if (entries.length === 0) {
5353
- return { text: "", params: [] };
5354
- }
5355
- const params = [];
5356
- const clauses = entries.map(([column, value], index) => {
5357
- params.push(value);
5358
- return `${qualifyColumn(this.table.name, column)} = $${paramOffset + index + 1}`;
5359
- });
5360
- 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
+ };
5361
5685
  }
5362
5686
  async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
5363
5687
  if (children.length === 0) {
@@ -5424,7 +5748,7 @@ class BaseRepository {
5424
5748
  return indexBelongsToManyRelation(parents, [], [], relation);
5425
5749
  }
5426
5750
  const parentIds = [...new Set(parents.map((parent) => parent[relation.parentKey]))];
5427
- const placeholders = parentIds.map((_, index) => `$${index + 1}`).join(", ");
5751
+ const placeholders = parentIds.map((_, index) => currentSqlDialect().placeholder(index + 1)).join(", ");
5428
5752
  const pivotRows = await this.connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} IN (${placeholders})`, parentIds);
5429
5753
  if (pivotRows.length === 0) {
5430
5754
  return indexBelongsToManyRelation(parents, [], [], relation);
@@ -5782,7 +6106,12 @@ async function freshDatabase(db, migrations, options = {}) {
5782
6106
  }
5783
6107
  await runFresh();
5784
6108
  }
6109
+ // ../../src/core/database/model.ts
6110
+ import { isDeepStrictEqual } from "util";
6111
+ init_deferredModelEvents();
6112
+
5785
6113
  // ../../src/core/database/relationQuery.ts
6114
+ init_dialect();
5786
6115
  function asWhere(where) {
5787
6116
  return where;
5788
6117
  }
@@ -5804,13 +6133,48 @@ function pluckFromQuery(query, column, keyBy) {
5804
6133
  }
5805
6134
  return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
5806
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
+ }
5807
6171
 
5808
6172
  class HasManyRelationQuery {
5809
6173
  parent;
5810
6174
  related;
5811
6175
  relation;
5812
6176
  kind = "hasMany";
5813
- extraWhere = {};
6177
+ extraWhere = [];
5814
6178
  extraOptions = {};
5815
6179
  constructor(parent, related, relation) {
5816
6180
  this.parent = parent;
@@ -5818,7 +6182,7 @@ class HasManyRelationQuery {
5818
6182
  this.relation = relation;
5819
6183
  }
5820
6184
  where(where) {
5821
- this.extraWhere = { ...this.extraWhere, ...where };
6185
+ this.extraWhere.push({ ...where });
5822
6186
  return this;
5823
6187
  }
5824
6188
  orderBy(orderBy) {
@@ -5830,7 +6194,7 @@ class HasManyRelationQuery {
5830
6194
  return this;
5831
6195
  }
5832
6196
  applyEagerLoad(query, alias) {
5833
- 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));
5834
6198
  }
5835
6199
  hydrateEager(row, alias) {
5836
6200
  const value = row[alias] ?? [];
@@ -5839,24 +6203,12 @@ class HasManyRelationQuery {
5839
6203
  }
5840
6204
  toExistsClause(parentTable) {
5841
6205
  const childTable = this.related.repository().getTable().name;
5842
- const extra = buildAdvancedWhereClause(childTable, this.extraWhere, [], []);
5843
- const extraSql = extra.clause.replace(/^ WHERE /, "");
5844
- 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}`;
5845
6208
  return { sql, params: extra.params };
5846
6209
  }
5847
6210
  scopedQuery() {
5848
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
5849
- let query = repository.query(asWhere({
5850
- [this.relation.foreignKey]: this.parent.get(this.relation.localKey),
5851
- ...this.extraWhere
5852
- }));
5853
- if (this.extraOptions.orderBy) {
5854
- query = query.orderBy(this.extraOptions.orderBy);
5855
- }
5856
- if (this.extraOptions.limit !== undefined) {
5857
- query = query.limit(this.extraOptions.limit);
5858
- }
5859
- 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);
5860
6212
  }
5861
6213
  async get() {
5862
6214
  const rows = await this.scopedQuery().get();
@@ -5922,7 +6274,7 @@ class HasOneRelationQuery {
5922
6274
  return this;
5923
6275
  }
5924
6276
  applyEagerLoad(query, alias) {
5925
- this.inner.limit(1).applyEagerLoad(query, alias);
6277
+ this.inner.applyEagerLoad(query, alias);
5926
6278
  }
5927
6279
  hydrateEager(row, alias) {
5928
6280
  const hydrated = this.inner.hydrateEager(row, alias);
@@ -5963,7 +6315,7 @@ class BelongsToRelationQuery {
5963
6315
  related;
5964
6316
  relation;
5965
6317
  kind = "belongsTo";
5966
- extraWhere = {};
6318
+ extraWhere = [];
5967
6319
  extraOptions = {};
5968
6320
  constructor(parent, related, relation) {
5969
6321
  this.parent = parent;
@@ -5971,7 +6323,7 @@ class BelongsToRelationQuery {
5971
6323
  this.relation = relation;
5972
6324
  }
5973
6325
  where(where) {
5974
- this.extraWhere = { ...this.extraWhere, ...where };
6326
+ this.extraWhere.push({ ...where });
5975
6327
  return this;
5976
6328
  }
5977
6329
  orderBy(orderBy) {
@@ -5979,7 +6331,7 @@ class BelongsToRelationQuery {
5979
6331
  return this;
5980
6332
  }
5981
6333
  applyEagerLoad(query, alias) {
5982
- 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));
5983
6335
  }
5984
6336
  hydrateEager(row, alias) {
5985
6337
  const value = row[alias];
@@ -5987,9 +6339,8 @@ class BelongsToRelationQuery {
5987
6339
  }
5988
6340
  toExistsClause(parentTable) {
5989
6341
  const relatedTable = this.related.repository().getTable().name;
5990
- const extra = buildAdvancedWhereClause(relatedTable, this.extraWhere, [], []);
5991
- const extraSql = extra.clause.replace(/^ WHERE /, "");
5992
- 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}`;
5993
6344
  return { sql, params: extra.params };
5994
6345
  }
5995
6346
  async get() {
@@ -6014,12 +6365,7 @@ class BelongsToRelationQuery {
6014
6365
  if (foreign === null || foreign === undefined) {
6015
6366
  return null;
6016
6367
  }
6017
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
6018
- let query = repository.query(asWhere({ [this.relation.ownerKey]: foreign, ...this.extraWhere }));
6019
- if (this.extraOptions.orderBy) {
6020
- query = query.orderBy(this.extraOptions.orderBy);
6021
- }
6022
- return query;
6368
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({ [this.relation.ownerKey]: foreign }), this.extraWhere, this.extraOptions);
6023
6369
  }
6024
6370
  then(onfulfilled, onrejected) {
6025
6371
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6041,7 +6387,7 @@ class BelongsToManyRelationQuery {
6041
6387
  related;
6042
6388
  relation;
6043
6389
  kind = "belongsToMany";
6044
- extraWhere = {};
6390
+ extraWhere = [];
6045
6391
  extraOptions = {};
6046
6392
  pivotValues = {};
6047
6393
  constructor(parent, related, relation) {
@@ -6050,7 +6396,7 @@ class BelongsToManyRelationQuery {
6050
6396
  this.relation = relation;
6051
6397
  }
6052
6398
  where(where) {
6053
- this.extraWhere = { ...this.extraWhere, ...where };
6399
+ this.extraWhere.push({ ...where });
6054
6400
  return this;
6055
6401
  }
6056
6402
  orderBy(orderBy) {
@@ -6058,7 +6404,7 @@ class BelongsToManyRelationQuery {
6058
6404
  return this;
6059
6405
  }
6060
6406
  applyEagerLoad(query, alias) {
6061
- 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));
6062
6408
  }
6063
6409
  withPivotValues(values) {
6064
6410
  this.pivotValues = { ...this.pivotValues, ...values };
@@ -6071,31 +6417,16 @@ class BelongsToManyRelationQuery {
6071
6417
  }
6072
6418
  toExistsClause(parentTable) {
6073
6419
  const relatedTable = this.related.repository().getTable().name;
6074
- const extra = buildAdvancedWhereClause(relatedTable, this.extraWhere, [], []);
6075
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6076
- 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}`;
6077
6422
  return { sql, params: extra.params };
6078
6423
  }
6079
6424
  connection() {
6080
6425
  return this.parent.getRepository().getConnection();
6081
6426
  }
6082
6427
  async get() {
6083
- const parentId = this.parent.get(this.relation.parentKey);
6084
- const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
6085
- if (pivotRows.length === 0) {
6086
- return [];
6087
- }
6088
- const relatedIds = [
6089
- ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
6090
- ];
6091
- const repository = this.related.repository().withConnection(this.connection());
6092
- const rows = await repository.findAll({
6093
- ...this.extraOptions,
6094
- where: asWhere({
6095
- [this.relation.relatedKey]: relatedIds,
6096
- ...this.extraWhere
6097
- })
6098
- });
6428
+ const query = await this.relatedQuery();
6429
+ const rows = query ? await query.get() : [];
6099
6430
  return rows.map((row) => this.related.newFromRecord(row));
6100
6431
  }
6101
6432
  async first() {
@@ -6103,9 +6434,8 @@ class BelongsToManyRelationQuery {
6103
6434
  return rows[0] ?? null;
6104
6435
  }
6105
6436
  async count() {
6106
- const parentId = this.parent.get(this.relation.parentKey);
6107
- const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
6108
- return Number(rows[0]?.count ?? 0);
6437
+ const query = await this.relatedQuery();
6438
+ return query ? query.count() : 0;
6109
6439
  }
6110
6440
  async pluck(column, keyBy) {
6111
6441
  return pluckFromQuery(await this.relatedQuery(), column, keyBy);
@@ -6116,21 +6446,14 @@ class BelongsToManyRelationQuery {
6116
6446
  }
6117
6447
  async relatedQuery() {
6118
6448
  const parentId = this.parent.get(this.relation.parentKey);
6119
- 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]);
6120
6450
  if (pivotRows.length === 0) {
6121
6451
  return null;
6122
6452
  }
6123
6453
  const relatedIds = [
6124
6454
  ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
6125
6455
  ];
6126
- let query = this.related.repository().withConnection(this.connection()).query(asWhere({
6127
- [this.relation.relatedKey]: relatedIds,
6128
- ...this.extraWhere
6129
- }));
6130
- if (this.extraOptions.orderBy) {
6131
- query = query.orderBy(this.extraOptions.orderBy);
6132
- }
6133
- return query;
6456
+ return relatedRead(this.related, this.connection(), asWhere({ [this.relation.relatedKey]: relatedIds }), this.extraWhere, this.extraOptions);
6134
6457
  }
6135
6458
  then(onfulfilled, onrejected) {
6136
6459
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6186,7 +6509,7 @@ class MorphManyRelationQuery {
6186
6509
  related;
6187
6510
  relation;
6188
6511
  kind = "morphMany";
6189
- extraWhere = {};
6512
+ extraWhere = [];
6190
6513
  extraOptions = {};
6191
6514
  constructor(parent, related, relation) {
6192
6515
  this.parent = parent;
@@ -6194,11 +6517,11 @@ class MorphManyRelationQuery {
6194
6517
  this.relation = relation;
6195
6518
  }
6196
6519
  where(where) {
6197
- this.extraWhere = { ...this.extraWhere, ...where };
6520
+ this.extraWhere.push({ ...where });
6198
6521
  return this;
6199
6522
  }
6200
6523
  applyEagerLoad(query, alias) {
6201
- 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));
6202
6525
  }
6203
6526
  hydrateEager(row, alias) {
6204
6527
  const value = row[alias] ?? [];
@@ -6207,20 +6530,18 @@ class MorphManyRelationQuery {
6207
6530
  }
6208
6531
  toExistsClause(parentTable) {
6209
6532
  const childTable = this.related.repository().getTable().name;
6210
- const extra = buildAdvancedWhereClause(childTable, {
6211
- [this.relation.morphTypeKey]: this.relation.morphType,
6533
+ const extra = relatedConditions(this.related, [
6534
+ asWhere({ [this.relation.morphTypeKey]: this.relation.morphType }),
6212
6535
  ...this.extraWhere
6213
- }, [], []);
6214
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6215
- 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}`;
6216
6538
  return { sql, params: extra.params };
6217
6539
  }
6218
6540
  scopedQuery() {
6219
- return this.related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({
6541
+ return relatedRead(this.related, this.parent.getRepository().getConnection(), asWhere({
6220
6542
  [this.relation.morphTypeKey]: this.relation.morphType,
6221
- [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
6222
- ...this.extraWhere
6223
- }));
6543
+ [this.relation.morphIdKey]: this.parent.get(this.relation.localKey)
6544
+ }), this.extraWhere);
6224
6545
  }
6225
6546
  async get() {
6226
6547
  const rows = await this.scopedQuery().get();
@@ -6307,14 +6628,14 @@ class MorphToRelationQuery {
6307
6628
  relatedByType;
6308
6629
  relation;
6309
6630
  kind = "morphTo";
6310
- extraWhere = {};
6631
+ extraWhere = [];
6311
6632
  constructor(parent, relatedByType, relation) {
6312
6633
  this.parent = parent;
6313
6634
  this.relatedByType = relatedByType;
6314
6635
  this.relation = relation;
6315
6636
  }
6316
6637
  where(where) {
6317
- this.extraWhere = { ...this.extraWhere, ...where };
6638
+ this.extraWhere.push({ ...where });
6318
6639
  return this;
6319
6640
  }
6320
6641
  applyEagerLoad(query, alias) {
@@ -6322,23 +6643,30 @@ class MorphToRelationQuery {
6322
6643
  type,
6323
6644
  model.repository()
6324
6645
  ]));
6325
- 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
+ ])));
6326
6650
  }
6327
6651
  hydrateEager(row, alias) {
6328
6652
  return row[alias];
6329
6653
  }
6330
6654
  toExistsClause(parentTable) {
6331
- const type = String(this.parent.get(this.relation.morphTypeKey) ?? "");
6332
- const related = this.relatedByType[type];
6333
- if (!related) {
6334
- return { sql: "SELECT 1 WHERE 1 = 0", params: [] };
6335
- }
6336
- const relatedTable = related.repository().getTable();
6337
- const extra = buildAdvancedWhereClause(relatedTable.name, this.extraWhere, [], []);
6338
- 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
+ });
6339
6667
  return {
6340
- sql: `SELECT 1 FROM ${quoteIdentifier(relatedTable.name)} WHERE ${qualifyColumn(relatedTable.name, relatedTable.primaryKey)} = ${qualifyColumn(parentTable, this.relation.morphIdKey)}${extraSql ? ` AND ${extraSql}` : ""}`,
6341
- params: extra.params
6668
+ sql: `SELECT 1 WHERE ${branches.length > 0 ? branches.join(" OR ") : "1 = 0"}`,
6669
+ params
6342
6670
  };
6343
6671
  }
6344
6672
  async get() {
@@ -6366,7 +6694,7 @@ class MorphToRelationQuery {
6366
6694
  return null;
6367
6695
  }
6368
6696
  const table = related.repository().getTable();
6369
- 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);
6370
6698
  }
6371
6699
  then(onfulfilled, onrejected) {
6372
6700
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -6378,7 +6706,7 @@ class HasManyThroughRelationQuery {
6378
6706
  related;
6379
6707
  relation;
6380
6708
  kind = "hasManyThrough";
6381
- extraWhere = {};
6709
+ extraWhere = [];
6382
6710
  extraOptions = {};
6383
6711
  constructor(parent, related, relation) {
6384
6712
  this.parent = parent;
@@ -6386,7 +6714,7 @@ class HasManyThroughRelationQuery {
6386
6714
  this.relation = relation;
6387
6715
  }
6388
6716
  where(where) {
6389
- this.extraWhere = { ...this.extraWhere, ...where };
6717
+ this.extraWhere.push({ ...where });
6390
6718
  return this;
6391
6719
  }
6392
6720
  orderBy(orderBy) {
@@ -6398,7 +6726,7 @@ class HasManyThroughRelationQuery {
6398
6726
  return this;
6399
6727
  }
6400
6728
  applyEagerLoad(query, alias) {
6401
- 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));
6402
6730
  }
6403
6731
  hydrateEager(row, alias) {
6404
6732
  const value = row[alias] ?? [];
@@ -6407,9 +6735,8 @@ class HasManyThroughRelationQuery {
6407
6735
  }
6408
6736
  toExistsClause(parentTable) {
6409
6737
  const farTable = this.related.repository().getTable().name;
6410
- const extra = buildAdvancedWhereClause(farTable, this.extraWhere, [], []);
6411
- const extraSql = extra.clause.replace(/^ WHERE /, "");
6412
- 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}`;
6413
6740
  return { sql, params: extra.params };
6414
6741
  }
6415
6742
  async get() {
@@ -6434,8 +6761,7 @@ class HasManyThroughRelationQuery {
6434
6761
  }
6435
6762
  async farRows() {
6436
6763
  return this.related.repository().withConnection(this.parent.getRepository().getConnection()).findHasManyThrough(this.parent.get(this.relation.localKey), this.relation, {
6437
- ...this.extraOptions,
6438
- where: this.extraWhere
6764
+ ...relatedOptions(this.related, this.extraWhere, this.extraOptions)
6439
6765
  });
6440
6766
  }
6441
6767
  then(onfulfilled, onrejected) {
@@ -6444,7 +6770,17 @@ class HasManyThroughRelationQuery {
6444
6770
  }
6445
6771
 
6446
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
+ }
6447
6782
  var modelRepositories = new WeakMap;
6783
+ var modelRepositoryFactories = new WeakMap;
6448
6784
  var namedModels = new Map;
6449
6785
  var modelGlobalScopes = new WeakMap;
6450
6786
  var modelObservers = new WeakMap;
@@ -6535,12 +6871,54 @@ async function eagerLoadOnModels(models, paths) {
6535
6871
  async function loadNested(model, path) {
6536
6872
  await eagerLoadOnModels([model], [path]);
6537
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
+ }
6538
6881
  function resolveModelRepository(model) {
6539
- const repository = modelRepositories.get(model);
6540
- if (!repository) {
6541
- 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);
6542
6921
  }
6543
- return repository;
6544
6922
  }
6545
6923
  function registerModelClass(name, model) {
6546
6924
  namedModels.set(name, model);
@@ -6591,10 +6969,26 @@ function ensureBooted(model) {
6591
6969
  if (modelBooted.has(model)) {
6592
6970
  return;
6593
6971
  }
6972
+ nameModel(model);
6594
6973
  modelBooted.add(model);
6974
+ const scopes = modelGlobalScopes.get(model);
6975
+ const observers = modelObservers.get(model);
6595
6976
  const boot = model.boot;
6596
6977
  if (typeof boot === "function") {
6597
- 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
+ }
6598
6992
  }
6599
6993
  }
6600
6994
  function getGlobalScopes(model) {
@@ -6604,12 +6998,14 @@ var PASSWORD_HASH_PATTERN = /^\$(?:2[aby]?|argon2(?:i|d|id)?)\$/;
6604
6998
  function isAlreadyHashed(value) {
6605
6999
  return PASSWORD_HASH_PATTERN.test(value);
6606
7000
  }
6607
- function hashCastValue(value) {
7001
+ async function hashCastValue(value) {
7002
+ if (value === null || value === undefined)
7003
+ return value;
6608
7004
  const plain = String(value);
6609
7005
  if (isAlreadyHashed(plain)) {
6610
7006
  return plain;
6611
7007
  }
6612
- return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 12 });
7008
+ return await hashPassword(plain);
6613
7009
  }
6614
7010
  function hydrateValue(value, cast) {
6615
7011
  if (value === null || value === undefined) {
@@ -6634,6 +7030,8 @@ function hydrateValue(value, cast) {
6634
7030
  }
6635
7031
  }
6636
7032
  function dehydrateValue(value, cast) {
7033
+ if (cast === "hashed")
7034
+ return hashCastValue(value);
6637
7035
  if (value === null || value === undefined) {
6638
7036
  return value;
6639
7037
  }
@@ -6649,8 +7047,6 @@ function dehydrateValue(value, cast) {
6649
7047
  case "integer":
6650
7048
  case "int":
6651
7049
  return value === "" ? null : Number(value);
6652
- case "hashed":
6653
- return hashCastValue(value);
6654
7050
  default:
6655
7051
  return value;
6656
7052
  }
@@ -6671,17 +7067,25 @@ function filterMassAssignable(fillable, guarded, input) {
6671
7067
  return Object.fromEntries(Object.entries(input).filter(([key]) => !blocked.has(key)));
6672
7068
  }
6673
7069
  function applyCasts(values, casts, direction) {
6674
- if (Object.keys(casts).length === 0) {
6675
- return values;
7070
+ const entries = Object.entries(casts);
7071
+ if (entries.length === 0) {
7072
+ return direction === "hydrate" ? values : Promise.resolve(values);
6676
7073
  }
6677
7074
  const result = { ...values };
6678
- const castFn = direction === "hydrate" ? hydrateValue : dehydrateValue;
6679
- for (const [key, cast] of Object.entries(casts)) {
6680
- if (key in result && cast) {
6681
- result[key] = castFn(result[key], cast);
7075
+ if (direction === "hydrate") {
7076
+ for (const [key, cast] of entries) {
7077
+ if (key in result && cast)
7078
+ result[key] = hydrateValue(result[key], cast);
6682
7079
  }
7080
+ return result;
6683
7081
  }
6684
- return result;
7082
+ return (async () => {
7083
+ for (const [key, cast] of entries) {
7084
+ if (key in result && cast)
7085
+ result[key] = await dehydrateValue(result[key], cast);
7086
+ }
7087
+ return result;
7088
+ })();
6685
7089
  }
6686
7090
  function castPluckedValue(modelClass, column, value) {
6687
7091
  const cast = modelStatics(modelClass).$casts?.[column];
@@ -6712,6 +7116,19 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
6712
7116
  return result;
6713
7117
  }
6714
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
+
6715
7132
  class ModelQuery {
6716
7133
  modelClass;
6717
7134
  query;
@@ -6744,6 +7161,16 @@ class ModelQuery {
6744
7161
  this.query.orWhere(input);
6745
7162
  return this;
6746
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
+ }
6747
7174
  orderBy(orderBy) {
6748
7175
  this.query.orderBy(orderBy);
6749
7176
  return this;
@@ -6892,7 +7319,7 @@ class ModelQuery {
6892
7319
  }
6893
7320
  async find(id) {
6894
7321
  const primaryKey = resolveModelRepository(this.modelClass).getTable().primaryKey;
6895
- return this.where({ [primaryKey]: id }).first();
7322
+ return this.whereDynamic({ [primaryKey]: id }).first();
6896
7323
  }
6897
7324
  async findOrFail(id, errorFactory) {
6898
7325
  const model = await this.find(id);
@@ -6949,6 +7376,7 @@ class Model {
6949
7376
  static $appends;
6950
7377
  static $morphClass;
6951
7378
  _exists;
7379
+ originalAttributes;
6952
7380
  loadedRelations = {};
6953
7381
  hiddenOverrides = [];
6954
7382
  visibleOverrides = [];
@@ -6958,6 +7386,24 @@ class Model {
6958
7386
  this.repository = repository;
6959
7387
  this._exists = exists;
6960
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
+ });
6961
7407
  }
6962
7408
  getRepository() {
6963
7409
  return this.repository;
@@ -7050,21 +7496,30 @@ class Model {
7050
7496
  ]);
7051
7497
  }
7052
7498
  static repository() {
7053
- 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;
7054
7509
  }
7055
7510
  static query() {
7056
7511
  ensureBooted(this);
7057
7512
  const repository = resolveModelRepository(this);
7058
- let query = repository.query();
7059
- for (const scope of getGlobalScopes(this)) {
7060
- query = scope(query);
7061
- }
7062
- return new ModelQuery(this, query);
7513
+ const scopeQuery = Model.scopeQuery;
7514
+ return new ModelQuery(this, scopeQuery.call(this, repository.query()));
7063
7515
  }
7064
7516
  static newFromRecord(record, exists = true) {
7065
7517
  const repository = resolveModelRepository(this);
7066
7518
  return modelStatics(this).fromRecord(record, repository, exists);
7067
7519
  }
7520
+ static newFromTrustedRecord(record, exists = true) {
7521
+ return modelStatics(this).newFromRecord(record, exists);
7522
+ }
7068
7523
  static async create(attributes, forced = {}) {
7069
7524
  const statics = modelStatics(this);
7070
7525
  ensureBooted(this);
@@ -7076,7 +7531,7 @@ class Model {
7076
7531
  ...forced
7077
7532
  };
7078
7533
  const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
7079
- const payload = statics.dehydrateAttributes(withTimestamps);
7534
+ const payload = await statics.dehydrateAttributes(withTimestamps);
7080
7535
  const pending = statics.newFromRecord({ ...payload }, false);
7081
7536
  if (await runObservers(pending, "saving") === false) {
7082
7537
  throw new Error(`${this.name}.create() was cancelled by an observer.`);
@@ -7102,14 +7557,14 @@ class Model {
7102
7557
  static async chunk(count, callback) {
7103
7558
  const statics = modelStatics(this);
7104
7559
  ensureBooted(this);
7105
- await resolveModelRepository(this).chunk(count, async (rows) => {
7560
+ await Model.query.call(this).query.chunk(count, async (rows) => {
7106
7561
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7107
7562
  });
7108
7563
  }
7109
7564
  static async cursorPaginate(options) {
7110
7565
  const statics = modelStatics(this);
7111
7566
  ensureBooted(this);
7112
- const page = await resolveModelRepository(this).cursorPaginate({
7567
+ const page = await Model.query.call(this).query.cursorPaginate({
7113
7568
  perPage: options.perPage,
7114
7569
  cursor: options.cursor
7115
7570
  });
@@ -7217,19 +7672,23 @@ class Model {
7217
7672
  return this;
7218
7673
  }
7219
7674
  if (updating) {
7220
- const changes = applyTimestampsOnUpdate(table.columns, 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);
7221
7681
  const record = await this.repository.updateByIdOrThrow(this.id, changes);
7222
- this.attributes = ModelClass.hydrateAttributes(record);
7682
+ this.acceptPersistedAttributes(record);
7223
7683
  await runObservers(this, "updated");
7224
7684
  await runObservers(this, "saved");
7225
7685
  return this;
7226
7686
  }
7227
7687
  const assignable = filterMassAssignable(ModelClass.$fillable, ModelClass.$guarded, this.attributes);
7228
7688
  const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
7229
- const payload = ModelClass.dehydrateAttributes(withTimestamps);
7689
+ const payload = await ModelClass.dehydrateAttributes(withTimestamps);
7230
7690
  const record = await this.repository.create(payload);
7231
- this.attributes = ModelClass.hydrateAttributes(record);
7232
- this._exists = true;
7691
+ this.acceptPersistedAttributes(record);
7233
7692
  await runObservers(this, "created");
7234
7693
  await runObservers(this, "saved");
7235
7694
  return this;
@@ -7254,12 +7713,11 @@ class Model {
7254
7713
  return await this.repository.forceDeleteById(this.id);
7255
7714
  }
7256
7715
  async restore() {
7257
- const ModelClass = modelStatics(this.constructor);
7258
7716
  const record = await this.repository.restoreById(this.id);
7259
7717
  if (!record) {
7260
7718
  return null;
7261
7719
  }
7262
- this.attributes = ModelClass.hydrateAttributes(record);
7720
+ this.acceptPersistedAttributes(record);
7263
7721
  return this;
7264
7722
  }
7265
7723
  async loadHasMany(as, relation, childRepository, options = {}) {
@@ -7414,14 +7872,7 @@ class Model {
7414
7872
  }
7415
7873
  function registerModelRepository(model, repository) {
7416
7874
  modelRepositories.set(model, repository);
7417
- const name = model.name;
7418
- if (name) {
7419
- namedModels.set(name, model);
7420
- }
7421
- const morphClass = model.$morphClass;
7422
- if (morphClass) {
7423
- namedModels.set(morphClass, model);
7424
- }
7875
+ nameModel(model);
7425
7876
  ensureBooted(model);
7426
7877
  return model;
7427
7878
  }
@@ -9146,29 +9597,95 @@ function storageFacade() {
9146
9597
  return storage();
9147
9598
  }
9148
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
+ }
9149
9657
  var DEFAULT_MAX_BODY_BYTES = 1048576;
9150
9658
  function resolveMaxBodyBytes() {
9151
9659
  const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
9152
9660
  if (!raw) {
9153
9661
  return DEFAULT_MAX_BODY_BYTES;
9154
9662
  }
9155
- const parsed = Number.parseInt(raw, 10);
9156
- if (!Number.isInteger(parsed) || parsed <= 0) {
9663
+ const parsed = Number(raw);
9664
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
9157
9665
  return DEFAULT_MAX_BODY_BYTES;
9158
9666
  }
9159
9667
  return parsed;
9160
9668
  }
9161
9669
  function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
9670
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
9671
+ throw new RangeError("Body limit must be a positive safe integer.");
9162
9672
  return async (request, next) => {
9163
- const contentLength = request.headers.get("content-length");
9164
- if (contentLength) {
9165
- const bytes = Number.parseInt(contentLength, 10);
9166
- if (Number.isInteger(bytes) && bytes > maxBytes) {
9167
- const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
9168
- return Response.json({ error: error.message }, { status: error.status });
9169
- }
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;
9170
9688
  }
9171
- return await next();
9172
9689
  };
9173
9690
  }
9174
9691
  // ../../src/core/http/requestMetaContext.ts
@@ -10617,10 +11134,7 @@ function getRouteParams(request) {
10617
11134
  }
10618
11135
  // ../../src/core/http/routeMiddleware.ts
10619
11136
  function withMiddleware(...middleware) {
10620
- const wrap = composeMiddleware(...middleware);
10621
- return (handler) => {
10622
- return wrap(handler);
10623
- };
11137
+ return composeMiddleware(...middleware);
10624
11138
  }
10625
11139
  // ../../src/core/http/routeModelBinding.ts
10626
11140
  function bindRouteModel(param, resolver, handler) {
@@ -10699,6 +11213,67 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
10699
11213
  return response;
10700
11214
  };
10701
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
+
10702
11277
  // ../../src/core/http/throttleMiddleware.ts
10703
11278
  import { createHash as createHash3 } from "crypto";
10704
11279
  init_tenantContext();
@@ -10743,28 +11318,32 @@ if ttl < 0 then
10743
11318
  end
10744
11319
  return count
10745
11320
  `;
10746
- function redisThrottleKey(request, prefix, identity) {
11321
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
10747
11322
  const bucket = JSON.stringify([
10748
- currentTenant()?.id ?? null,
11323
+ tenantId,
10749
11324
  request.method,
10750
11325
  currentRequestMeta().routeTemplate ?? "__unmatched__",
10751
11326
  identity
10752
11327
  ]);
10753
11328
  return namespacedRedisKey(`${prefix}:${createHash3("sha256").update(bucket).digest("hex")}`);
10754
11329
  }
10755
- async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
10756
- 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) {
10757
11333
  throw new Error("Invalid throttle expiry or command timeout.");
10758
11334
  }
11335
+ if (signal?.aborted)
11336
+ throw new Error("Throttle consumer is disposed.");
11337
+ let onAbort;
10759
11338
  let timer;
10760
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
+ });
10761
11344
  const result = await Promise.race([
10762
- client.send("EVAL", [
10763
- CONSUME_THROTTLE,
10764
- "1",
10765
- key,
10766
- String(Math.max(1, Math.ceil(decaySeconds * 1000)))
10767
- ]),
11345
+ cancelled,
11346
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
10768
11347
  new Promise((_resolve, reject) => {
10769
11348
  timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
10770
11349
  })
@@ -10775,11 +11354,16 @@ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000)
10775
11354
  return count;
10776
11355
  } finally {
10777
11356
  clearTimeout(timer);
11357
+ if (onAbort)
11358
+ signal?.removeEventListener("abort", onAbort);
10778
11359
  }
10779
11360
  }
10780
11361
  function createRedisThrottleConsumer(options) {
10781
11362
  let ownedClient;
10782
- 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.");
10783
11367
  if (!options.redisClient && !ownedClient) {
10784
11368
  ownedClient = new RedisClient2(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
10785
11369
  }
@@ -10787,7 +11371,10 @@ function createRedisThrottleConsumer(options) {
10787
11371
  if (!client)
10788
11372
  throw new Error("Throttle client is required.");
10789
11373
  try {
10790
- 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;
10791
11378
  } catch (error) {
10792
11379
  if (client === ownedClient) {
10793
11380
  ownedClient.close();
@@ -10796,6 +11383,17 @@ function createRedisThrottleConsumer(options) {
10796
11383
  throw error;
10797
11384
  }
10798
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
+ });
10799
11397
  }
10800
11398
  function throttleUnavailableResponse() {
10801
11399
  return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
@@ -10803,27 +11401,52 @@ function throttleUnavailableResponse() {
10803
11401
  headers: { "retry-after": "1" }
10804
11402
  });
10805
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
+ }
10806
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
+ }
10807
11421
  const consume = createRedisThrottleConsumer(options);
10808
11422
  const prefix = options.keyPrefix ?? "throttle";
10809
- return async (request, next) => {
10810
- 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);
10811
11432
  let attempts;
10812
11433
  try {
10813
11434
  attempts = await consume(key, options.decaySeconds);
10814
11435
  } catch {
10815
11436
  return throttleUnavailableResponse();
10816
11437
  }
10817
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
11438
+ if (consume.isDisposed())
11439
+ return throttleUnavailableResponse();
10818
11440
  if (attempts > maxAttempts) {
10819
11441
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10820
11442
  }
10821
11443
  return await next();
10822
11444
  };
11445
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10823
11446
  }
10824
11447
 
10825
11448
  // ../../src/core/http/loginThrottleMiddleware.ts
10826
- var memoryLoginBuckets = new Map;
11449
+ var resetGeneration = 0;
10827
11450
  function resolveLoginIdentity(request) {
10828
11451
  return readClientIp(request) ?? "unknown";
10829
11452
  }
@@ -10841,33 +11464,31 @@ async function resolveLoginEmail(request) {
10841
11464
  return "unknown";
10842
11465
  }
10843
11466
  }
10844
- function consumeMemoryAttempt(key, decaySeconds) {
10845
- const now = Date.now();
10846
- const existing = memoryLoginBuckets.get(key);
10847
- if (!existing || existing.resetAt <= now) {
10848
- memoryLoginBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
10849
- return 1;
10850
- }
10851
- existing.count += 1;
10852
- return existing.count;
10853
- }
10854
11467
  function createMemoryLoginThrottleMiddleware(options) {
10855
- const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
10856
- return async (request, next) => {
10857
- 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
+ }
10858
11477
  const email = await resolveLoginEmail(request);
10859
- const throttleKey = `${prefix}${identity}:${email}`;
10860
- const attempts = consumeMemoryAttempt(throttleKey, options.decaySeconds);
10861
- 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)
10862
11483
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10863
- }
10864
11484
  return await next();
10865
11485
  };
11486
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10866
11487
  }
10867
11488
  function createRedisLoginThrottleMiddleware(options) {
10868
11489
  const consume = createRedisThrottleConsumer(options);
10869
11490
  const prefix = options.keyPrefix ?? "login-throttle";
10870
- return async (request, next) => {
11491
+ const middleware = async (request, next) => {
10871
11492
  const identity = resolveLoginIdentity(request);
10872
11493
  const email = await resolveLoginEmail(request);
10873
11494
  const throttleKey = redisThrottleKey(request, prefix, JSON.stringify([identity, email]));
@@ -10877,11 +11498,14 @@ function createRedisLoginThrottleMiddleware(options) {
10877
11498
  } catch {
10878
11499
  return throttleUnavailableResponse();
10879
11500
  }
11501
+ if (consume.isDisposed())
11502
+ return throttleUnavailableResponse();
10880
11503
  if (attempts > options.maxAttempts) {
10881
11504
  return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
10882
11505
  }
10883
11506
  return await next();
10884
11507
  };
11508
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
10885
11509
  }
10886
11510
  function createLoginThrottleMiddleware(options) {
10887
11511
  const redisUrl = options.redisUrl?.trim() ?? "";
@@ -10891,35 +11515,37 @@ function createLoginThrottleMiddleware(options) {
10891
11515
  return createMemoryLoginThrottleMiddleware(options);
10892
11516
  }
10893
11517
  function resetMemoryLoginThrottleForTests() {
10894
- memoryLoginBuckets.clear();
11518
+ resetGeneration++;
10895
11519
  }
10896
11520
  // ../../src/core/http/memoryThrottleMiddleware.ts
10897
- var throttleBucketRegistries = new Set;
11521
+ var resetGeneration2 = 0;
10898
11522
  function createMemoryThrottleMiddleware(options) {
10899
- const prefix = options.keyPrefix ?? namespacedRedisKey("memory-throttle:");
10900
- const buckets = new Map;
10901
- throttleBucketRegistries.add(buckets);
10902
- return async (request, next) => {
10903
- const path = new URL(request.url).pathname;
10904
- const identity = readClientIp(request) ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
10905
- const key = `${prefix}${identity}:${path}`;
10906
- const now = Date.now();
10907
- const existing = buckets.get(key);
10908
- if (!existing || existing.resetAt <= now) {
10909
- buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
10910
- 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();
10911
11537
  }
10912
- existing.count += 1;
10913
- 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)
10914
11542
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
10915
- }
10916
11543
  return await next();
10917
11544
  };
11545
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
10918
11546
  }
10919
11547
  function resetMemoryThrottleForTests() {
10920
- for (const buckets of throttleBucketRegistries) {
10921
- buckets.clear();
10922
- }
11548
+ resetGeneration2++;
10923
11549
  }
10924
11550
  // ../../src/core/metrics/prometheus.ts
10925
11551
  var HTTP_DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 1e4];
@@ -11189,8 +11815,6 @@ function createRequireWebAuthMiddleware(auth) {
11189
11815
  }
11190
11816
  // ../../src/core/http/scimThrottleMiddleware.ts
11191
11817
  import { createHash as createHash4 } from "crypto";
11192
- var {RedisClient: RedisClient3 } = globalThis.Bun;
11193
- var memoryBuckets = new Map;
11194
11818
  function resolveScimIdentity(request) {
11195
11819
  const authorization = request.headers.get("authorization") ?? "";
11196
11820
  const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
@@ -11199,40 +11823,44 @@ function resolveScimIdentity(request) {
11199
11823
  }
11200
11824
  return readClientIp(request) ?? "unknown";
11201
11825
  }
11202
- function throttleFromMemory(key, maxAttempts, decaySeconds) {
11203
- const now = Date.now();
11204
- const existing = memoryBuckets.get(key);
11205
- if (!existing || existing.resetAt <= now) {
11206
- memoryBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
11207
- return null;
11208
- }
11209
- existing.count += 1;
11210
- if (existing.count > maxAttempts) {
11211
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(decaySeconds) } });
11212
- }
11213
- return null;
11214
- }
11215
11826
  function createScimThrottleMiddleware(options) {
11216
- const client = options.redisUrl ? new RedisClient3(options.redisUrl) : null;
11217
- 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();
11218
11837
  const identity = resolveScimIdentity(request);
11219
- const key = `${namespacedRedisKey("scim-throttle:")}${identity}`;
11220
- if (client) {
11221
- const attempts = Number(await client.incr(key));
11222
- if (attempts === 1) {
11223
- await client.expire(key, options.decaySeconds);
11224
- }
11225
- if (attempts > options.maxAttempts) {
11226
- return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
11227
- }
11228
- return await next();
11229
- }
11230
- const limited = throttleFromMemory(key, options.maxAttempts, options.decaySeconds);
11231
- if (limited) {
11232
- 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();
11233
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) } });
11234
11852
  return await next();
11235
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
+ });
11236
11864
  }
11237
11865
  // ../../src/core/http/securityHeadersMiddleware.ts
11238
11866
  function createSecurityHeadersMiddleware(options = {}) {
@@ -11400,47 +12028,150 @@ class WebFormRequest {
11400
12028
  }
11401
12029
  }
11402
12030
  // ../../src/core/lifecycle/gracefulShutdown.ts
11403
- var shutdownHandlers = new Map;
11404
- var shutdownInstalled = false;
11405
- var shuttingDown = false;
11406
- function registerShutdownHandler(name, handler) {
11407
- shutdownHandlers.set(name, handler);
11408
- return () => {
11409
- shutdownHandlers.delete(name);
11410
- };
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;
11411
12037
  }
11412
- async function runGracefulShutdown(signal) {
11413
- if (shuttingDown) {
11414
- 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
+ };
11415
12062
  }
11416
- shuttingDown = true;
11417
- console.log(`[shutdown] Received ${signal}, draining ${shutdownHandlers.size} handler(s)...`);
11418
- 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
+ })();
11419
12126
  try {
11420
- await handler();
11421
- console.log(`[shutdown] Completed ${name}`);
11422
- } catch (error) {
11423
- console.error(`[shutdown] Failed ${name}:`, error);
12127
+ await Promise.race([sequence, deadline]);
12128
+ } finally {
12129
+ clearTimeout(timer);
12130
+ this.handlers.clear();
11424
12131
  }
12132
+ return { successful: errors.length === 0 && !timedOut, timedOut, errors: [...errors] };
11425
12133
  }
11426
12134
  }
11427
- function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
11428
- if (shutdownInstalled) {
11429
- return;
11430
- }
11431
- shutdownInstalled = true;
11432
- for (const signal of signals) {
11433
- process.on(signal, () => {
11434
- runGracefulShutdown(signal).finally(() => {
11435
- process.exit(0);
11436
- });
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);
11437
12155
  });
11438
- }
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;
11439
12170
  }
11440
12171
  function resetGracefulShutdownForTests() {
11441
- shutdownHandlers.clear();
11442
- shutdownInstalled = false;
11443
- shuttingDown = false;
12172
+ for (const remove of [...installations.values()])
12173
+ remove();
12174
+ defaultLifecycle = new LifecycleCoordinator;
11444
12175
  }
11445
12176
  // ../../src/core/logging/requestLoggingMiddleware.ts
11446
12177
  function createRequestLoggingMiddleware() {
@@ -11719,12 +12450,37 @@ class SyncQueue {
11719
12450
  }
11720
12451
 
11721
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
+ }
11722
12472
  async dispatch(job, payload) {
11723
- setTimeout(() => {
11724
- job.handle(payload).catch((error) => {
11725
- console.error("[AsyncQueue] Job failed:", error);
11726
- });
11727
- }, 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;
11728
12484
  }
11729
12485
  }
11730
12486
  function createQueue(driver) {
@@ -11845,7 +12601,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
11845
12601
  }
11846
12602
 
11847
12603
  // ../../src/core/queue/redisQueue.ts
11848
- var {RedisClient: RedisClient4 } = globalThis.Bun;
12604
+ var {RedisClient: RedisClient3 } = globalThis.Bun;
11849
12605
  function queueListKey() {
11850
12606
  return namespacedRedisKey("queue:default");
11851
12607
  }
@@ -12041,7 +12797,10 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
12041
12797
  class RedisQueue {
12042
12798
  client;
12043
12799
  constructor(redisUrl) {
12044
- this.client = new RedisClient4(redisUrl);
12800
+ this.client = new RedisClient3(redisUrl);
12801
+ }
12802
+ close() {
12803
+ this.client.close();
12045
12804
  }
12046
12805
  async dispatch(job, payload) {
12047
12806
  const name = jobRegistry.resolveName(job);
@@ -12070,7 +12829,7 @@ class QueueWorker {
12070
12829
  this.failedJobs = failedJobs;
12071
12830
  this.timeoutSeconds = timeoutSeconds;
12072
12831
  this.queueKeys = queueKeys;
12073
- this.client = new RedisClient4(redisUrl);
12832
+ this.client = new RedisClient3(redisUrl);
12074
12833
  }
12075
12834
  requestStop() {
12076
12835
  this.stopping = true;
@@ -12160,14 +12919,16 @@ class QueueWorker {
12160
12919
  }
12161
12920
 
12162
12921
  // ../../src/core/queue/resilientQueue.ts
12163
- class ResilientQueue {
12922
+ class ResilientQueue extends AsyncQueue {
12164
12923
  failedJobs;
12165
12924
  asyncDispatch;
12166
12925
  constructor(failedJobs, asyncDispatch = false) {
12926
+ super();
12167
12927
  this.failedJobs = failedJobs;
12168
12928
  this.asyncDispatch = asyncDispatch;
12169
12929
  }
12170
12930
  async dispatch(job, payload) {
12931
+ this.assertOpen();
12171
12932
  const name = jobRegistry.resolveName(job);
12172
12933
  if (!name) {
12173
12934
  throw new Error("Job is not registered with the queue worker registry.");
@@ -12178,11 +12939,9 @@ class ResilientQueue {
12178
12939
  attempts: 0
12179
12940
  };
12180
12941
  if (this.asyncDispatch) {
12181
- setTimeout(() => {
12182
- runQueueJob(envelope, this.failedJobs).catch((error) => {
12183
- console.error("[ResilientQueue] Job failed:", error);
12184
- });
12185
- }, 0);
12942
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
12943
+ console.error("[ResilientQueue] Job failed:", error);
12944
+ });
12186
12945
  return;
12187
12946
  }
12188
12947
  await runQueueJob(envelope, this.failedJobs);
@@ -12211,9 +12970,9 @@ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
12211
12970
  return new QueueWorker(redisUrl, failedJobs);
12212
12971
  }
12213
12972
  // ../../src/core/queue/queueMetrics.ts
12214
- var {RedisClient: RedisClient5 } = globalThis.Bun;
12973
+ var {RedisClient: RedisClient4 } = globalThis.Bun;
12215
12974
  async function readRedisQueueDepth(redisUrl) {
12216
- const client = new RedisClient5(redisUrl);
12975
+ const client = new RedisClient4(redisUrl);
12217
12976
  const [high, defaultQueue, low] = await Promise.all([
12218
12977
  client.llen(QUEUE_HIGH_KEY),
12219
12978
  client.llen(QUEUE_LIST_KEY),
@@ -12261,6 +13020,149 @@ async function collectQueueMetrics() {
12261
13020
  failedCount
12262
13021
  };
12263
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
+
12264
13166
  // ../../src/core/scheduler/osCron.ts
12265
13167
  function parseScheduleExpression(expression, from = new Date) {
12266
13168
  return Bun.cron.parse(expression, from);
@@ -12291,11 +13193,25 @@ function assertScheduleExpression(expression) {
12291
13193
  }
12292
13194
 
12293
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
+
12294
13203
  class Schedule {
12295
13204
  tasks = [];
12296
13205
  command(expression, name, run) {
12297
13206
  assertScheduleExpression(expression);
12298
- 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 });
12299
13215
  return this;
12300
13216
  }
12301
13217
  dueTasks(now = new Date) {
@@ -12306,12 +13222,125 @@ class Schedule {
12306
13222
  }
12307
13223
  }
12308
13224
  var appSchedule = new Schedule;
12309
- 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.");
12310
13235
  const due = schedule.dueTasks(now);
12311
- for (const task of due) {
12312
- 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();
12313
13343
  }
12314
- return due.length;
12315
13344
  }
12316
13345
  // ../../src/core/security/publicReads.ts
12317
13346
  function isPublicReadsEnabled() {
@@ -12433,7 +13462,7 @@ init_tenantContext();
12433
13462
  init_tenantDatabaseScope();
12434
13463
 
12435
13464
  // ../../src/core/tenant/tenantMiddleware.ts
12436
- import { createHash as createHash5 } from "crypto";
13465
+ import { createHash as createHash7 } from "crypto";
12437
13466
  init_repositoryConnection();
12438
13467
  init_databaseTenantContext();
12439
13468
  init_tenancyConfig();
@@ -12441,9 +13470,7 @@ init_tenantContext();
12441
13470
  init_tenantDatabaseScope();
12442
13471
  var DEFAULT_TENANT = {
12443
13472
  id: 1,
12444
- slug: "default",
12445
- plan: "free",
12446
- region: "eu"
13473
+ slug: "default"
12447
13474
  };
12448
13475
  async function resolveUserTenantId(userId) {
12449
13476
  if (!isTenancyEnabled()) {
@@ -12460,9 +13487,9 @@ async function resolveUserTenantId(userId) {
12460
13487
  });
12461
13488
  }
12462
13489
  function auditChecksum(payload) {
12463
- return createHash5("sha256").update(JSON.stringify(payload)).digest("hex");
13490
+ return createHash7("sha256").update(JSON.stringify(payload)).digest("hex");
12464
13491
  }
12465
- async function resolveTenantForRequest(request) {
13492
+ async function resolveTenantForRequest(request, resolver) {
12466
13493
  const user = currentAuthUser();
12467
13494
  const headerValue = request.headers.get("x-tenant-id")?.trim();
12468
13495
  const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number.parseInt(headerValue, 10) : Number.NaN;
@@ -12474,20 +13501,20 @@ async function resolveTenantForRequest(request) {
12474
13501
  if (Number.isInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
12475
13502
  throw new ForbiddenError("Tenant header does not match your account.");
12476
13503
  }
12477
- const memberTenant = await resolveTenant(userTenantId);
13504
+ const memberTenant = await resolver(userTenantId);
12478
13505
  if (memberTenant) {
12479
13506
  return memberTenant;
12480
13507
  }
12481
13508
  return DEFAULT_TENANT;
12482
13509
  }
12483
13510
  if (Number.isInteger(parsedHeader) && parsedHeader > 0) {
12484
- const headerTenant = await resolveTenant(parsedHeader);
13511
+ const headerTenant = await resolver(parsedHeader);
12485
13512
  if (headerTenant) {
12486
13513
  return headerTenant;
12487
13514
  }
12488
13515
  return DEFAULT_TENANT;
12489
13516
  }
12490
- const adminTenant = await resolveTenant(userTenantId);
13517
+ const adminTenant = await resolver(userTenantId);
12491
13518
  if (adminTenant) {
12492
13519
  return adminTenant;
12493
13520
  }
@@ -12496,13 +13523,14 @@ async function resolveTenantForRequest(request) {
12496
13523
  }
12497
13524
  const headerTenantId = Number.isInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
12498
13525
  const tenantId = isPublicReadsEnabled() && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
12499
- const guestTenant = await resolveTenant(tenantId);
13526
+ const guestTenant = await resolver(tenantId);
12500
13527
  if (guestTenant) {
12501
13528
  return guestTenant;
12502
13529
  }
12503
13530
  return DEFAULT_TENANT;
12504
13531
  }
12505
- function createTenantMiddleware() {
13532
+ function createTenantMiddleware(options = {}) {
13533
+ const resolver = options.resolveTenant ?? resolveTenant;
12506
13534
  return async (request, next) => {
12507
13535
  const pathname = new URL(request.url).pathname;
12508
13536
  if (pathname.startsWith("/scim/")) {
@@ -12512,7 +13540,16 @@ function createTenantMiddleware() {
12512
13540
  return await runWithTenant(DEFAULT_TENANT, next);
12513
13541
  }
12514
13542
  try {
12515
- 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
+ });
12516
13553
  return await runWithTenantDatabase(tenant, async () => {
12517
13554
  return await next();
12518
13555
  });
@@ -12534,99 +13571,185 @@ function runWithTraceContext(context, callback) {
12534
13571
  function currentTraceId() {
12535
13572
  return traceContextStorage.getStore()?.traceId ?? null;
12536
13573
  }
13574
+ // ../../src/core/tracing/tracingMiddleware.ts
13575
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
13576
+
12537
13577
  // ../../src/core/tracing/otel.ts
12538
- import { randomBytes as randomBytes5 } from "crypto";
12539
- function randomHex(bytes) {
12540
- return randomBytes5(bytes).toString("hex");
12541
- }
12542
- function createSpan(input) {
12543
- const spanId = randomHex(8);
12544
- return {
12545
- traceId: input.traceId,
12546
- spanId,
12547
- name: input.name,
12548
- startTimeUnixNano: String(Math.floor(input.startedAt * 1e6)),
12549
- endTimeUnixNano: String(Math.floor(input.endedAt * 1e6)),
12550
- attributes: Object.entries(input.attributes ?? {}).map(([key, value]) => ({
12551
- key,
12552
- value: { stringValue: value }
12553
- })),
12554
- status: { code: 1 }
12555
- };
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;
12556
13596
  }
12557
- async function exportOtelSpan(span) {
12558
- const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
12559
- if (!endpoint) {
13597
+ function ensureContextManager() {
13598
+ const key = Symbol.for("@getstrata/otel-context-installed");
13599
+ const state = globalThis;
13600
+ if (state[key])
12560
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`;
12561
13628
  }
12562
- const serviceName = otelServiceName();
12563
- const url = endpoint.endsWith("/v1/traces") ? endpoint : `${endpoint.replace(/\/$/, "")}/v1/traces`;
12564
- await safeFetch(url, {
12565
- method: "POST",
12566
- headers: { "content-type": "application/json" },
12567
- body: JSON.stringify({
12568
- resourceSpans: [
12569
- {
12570
- resource: {
12571
- attributes: [{ key: "service.name", value: { stringValue: serviceName } }]
12572
- },
12573
- scopeSpans: [{ spans: [span] }]
12574
- }
12575
- ]
12576
- })
12577
- }, {
12578
- allowHttp: !isProductionEnv(),
12579
- allowPrivate: !isProductionEnv(),
12580
- timeoutMs: 5000,
12581
- 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
12582
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
+ };
12583
13697
  }
12584
13698
 
12585
13699
  // ../../src/core/tracing/tracingMiddleware.ts
12586
- function readClientTraceId(request) {
12587
- if (!envFlagEnabled(process.env.APP_DEBUG)) {
12588
- return null;
12589
- }
12590
- const raw = request.headers.get("x-trace-id")?.trim() ?? "";
12591
- if (!raw) {
12592
- return null;
12593
- }
12594
- const compact = raw.replace(/-/g, "");
12595
- return /^[0-9a-f]{32}$/iu.test(compact) ? compact.toLowerCase() : null;
12596
- }
12597
- 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) {
12598
13716
  return async (request, next) => {
12599
- const traceId = readClientTraceId(request) ?? crypto.randomUUID().replace(/-/g, "");
12600
- const spanId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
12601
- const startedAt = performance.now();
12602
- const path = new URL(request.url).pathname;
12603
- return await runWithTraceContext({ traceId, spanId }, async () => {
12604
- const response = await next();
12605
- const endedAt = performance.now();
12606
- const headers = new Headers(response.headers);
12607
- headers.set("x-trace-id", traceId);
12608
- headers.set("x-span-id", spanId);
12609
- headers.set("traceparent", `00-${traceId}-${spanId}-01`);
12610
- headers.set("server-timing", `app;dur=${(endedAt - startedAt).toFixed(2)}`);
12611
- exportOtelSpan(createSpan({
12612
- traceId,
12613
- name: `${request.method} ${path}`,
12614
- startedAt,
12615
- endedAt,
12616
- attributes: {
12617
- "http.method": request.method,
12618
- "http.route": path,
12619
- "http.status_code": String(response.status)
12620
- }
12621
- })).catch(() => {
12622
- return;
12623
- });
12624
- return new Response(response.body, {
12625
- status: response.status,
12626
- statusText: response.statusText,
12627
- headers
12628
- });
12629
- });
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
+ }));
12630
13753
  };
12631
13754
  }
12632
13755
  // ../../src/core/validation/rules.ts
@@ -13037,6 +14160,7 @@ export {
13037
14160
  JobRegistry,
13038
14161
  JsonResource,
13039
14162
  JwtGuard,
14163
+ LifecycleCoordinator,
13040
14164
  LocalStorageDriver,
13041
14165
  LogMailDriver,
13042
14166
  MAX_PER_PAGE,
@@ -13062,12 +14186,14 @@ export {
13062
14186
  QueueWorker,
13063
14187
  RLS_HELPER_SQL,
13064
14188
  RedisQueue,
14189
+ RedisSchedulerLeaseStore,
13065
14190
  RepositoryQuery,
13066
14191
  ResilientQueue,
13067
14192
  ResourceCollection,
13068
14193
  SamlProvider,
13069
14194
  SamlServiceProvider,
13070
14195
  Schedule,
14196
+ SchedulerLeaseLostError,
13071
14197
  Schema,
13072
14198
  ServiceContainer,
13073
14199
  SqlOutbox,
@@ -13081,6 +14207,7 @@ export {
13081
14207
  WebFormRequest,
13082
14208
  WhereBuilder,
13083
14209
  absoluteTemporarySignedUrl,
14210
+ acquireTracingRuntime,
13084
14211
  apiPrefix,
13085
14212
  appCookieName,
13086
14213
  appDevSecret,
@@ -13111,6 +14238,7 @@ export {
13111
14238
  bindBunSql,
13112
14239
  bindDatabaseConnection,
13113
14240
  bindRouteModel,
14241
+ bootModels,
13114
14242
  buildAdvancedWhereClause,
13115
14243
  buildCountQuery,
13116
14244
  buildDeleteByIdQuery,
@@ -13130,6 +14258,7 @@ export {
13130
14258
  buildUpdateQuery,
13131
14259
  buildWhereClause,
13132
14260
  cache,
14261
+ clearActiveApplicationContext,
13133
14262
  collectQueueMetrics,
13134
14263
  compileBlueprint,
13135
14264
  completePasswordLogin,
@@ -13179,12 +14308,14 @@ export {
13179
14308
  createScimAuthMiddleware,
13180
14309
  createScimThrottleMiddleware,
13181
14310
  createSecurityHeadersMiddleware,
14311
+ createServiceToken,
13182
14312
  createSqliteConnection,
13183
14313
  createStorageDriver,
13184
14314
  createTenantMiddleware,
13185
14315
  createThrottleMiddleware,
13186
14316
  createTokenAbilityChecker,
13187
14317
  createTracingMiddleware,
14318
+ createTracingRuntime,
13188
14319
  createTrackedJob,
13189
14320
  createValidateSignatureMiddleware,
13190
14321
  createdResponse,
@@ -13197,6 +14328,7 @@ export {
13197
14328
  currentTenant,
13198
14329
  currentTenantId,
13199
14330
  currentTraceId,
14331
+ defineModel,
13200
14332
  defineTable,
13201
14333
  dehydrateValue,
13202
14334
  dialectFor,
@@ -13228,6 +14360,7 @@ export {
13228
14360
  getNamedConnection,
13229
14361
  getQueryParams,
13230
14362
  getRouteParams,
14363
+ getTracingRuntime,
13231
14364
  grammarForDriver,
13232
14365
  guestCanViewResource,
13233
14366
  hasActiveDatabaseConnection,
@@ -13311,7 +14444,6 @@ export {
13311
14444
  qualifyColumn,
13312
14445
  queue,
13313
14446
  quoteIdentifier,
13314
- rateLimitMultiplierForPlan,
13315
14447
  readBunRequestCookie,
13316
14448
  readClientIp,
13317
14449
  readOptionalEnum,