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