"use strict"; // const mergeByTemplate = require("merge-by-template"); const syncpipe = require("syncpipe"); const deepMergeAndMap = require("./deep-merge-and-map"); /* Take a list of modules; each module specifies a name, schema root, types, type extensions, and a context factory function. Each module is internally assigned a unique ID. This unique ID is associated with each type factory and type extension method, and used as the key for a map of context factories; that way, upon invoking those methods, the module's own corresponding context can be injected. Only a single context should be created per module per request, so there should be a cache layer for the contexts (keyed by module ID), with each request creating a new cache. */ // NOTE: This can be global because we identify existing assignments by object identity, and that will never conflict let numberedModules = new WeakMap(); let currentModuleNumber = 0; function getModuleID(module) { if (!numberedModules.has(module)) { numberedModules.set(module, currentModuleNumber++); } return numberedModules.get(module); } function createTypeTracker() { let typeFactories = {}; return { add: function (module, name, factory) { if (typeFactories[name] != null) { let existingEntry = typeFactories[name]; throw new Error(`Type '${name}' already exists (from module '${module.name}', already defined by module '${existingEntry.source.name}')`); } else { typeFactories[name] = { source: module, // No context provided to type factory functions for now, since they are not allowed to be async for now anyway // FIXME: Maybe add a warning if the user returns a Promise from a factory, asking them to file a bug if they really need it? // func: wrapModuleFunction(module, factory) func: factory }; } }, get: function () { return typeFactories; } }; } function createExtensionTracker() { let extendedTypes = {}; return { add: function (module, type, name, method) { if (extendedTypes[type] == null) { extendedTypes[type] = {}; } let extensions = extendedTypes[type]; if (extensions[name] != null) { let existingEntry = extensions[name]; throw new Error(`Type '${type}' already has a method extension named '${name}' (from module '${module.name}', already defined by module '${existingEntry.source.name}')`); } else { extensions[name] = { source: module, func: wrapModuleFunction(module, method) }; } }, get: function () { return extendedTypes; } }; } function wrapModuleFunction(module, func) { return { __moduleID: getModuleID(module), func: func }; } function defaultContext() { // Fallback function that generates an empty context, for when a module doesn't specify a makeContext handler return {}; } module.exports = function (modules) { // TODO: Eventually replace hand-crafted merging logic with merge-by-template, once it can support this usecase properly(tm) // TODO: Fix merge-by-template so that reasonable error messages can be generated here, that are actually aware of eg. the conflicting key let types = createTypeTracker(); let typeExtensions = createExtensionTracker(); let contextFactories = syncpipe(modules, [ _ => _.map((module) => [ getModuleID(module), module.makeContext ?? defaultContext ]), _ => new Map(_) ]); let schema = modules.reduce((schema, module) => { return deepMergeAndMap(schema, module.root, (value) => { if (typeof value === "function") { return wrapModuleFunction(module, value); } else { return value; } }); }, {}); for (let module of modules) { for (let [ type, factory ] of Object.entries(module.types ?? {})) { types.add(module, type, factory); } for (let [ type, extensions ] of Object.entries(module.extensions ?? {})) { for (let [ name, method ] of Object.entries(extensions)) { typeExtensions.add(module, type, name, method); } } } return { root: schema, types: types.get(), extensions: typeExtensions.get(), makeContextFactory: function (baseContext) { let cache = new Map(); return function makeContextForModule(moduleID) { if (!cache.has(moduleID)) { cache.set(moduleID, { ... baseContext, ... contextFactories.get(moduleID)() }); } return cache.get(moduleID); }; } }; };