"use strict"; // const mergeByTemplate = require("merge-by-template"); const syncpipe = require("syncpipe"); const mapObject = require("map-obj"); const deepMergeAndMap = require("./deep-merge-and-map"); const InvalidObject = require("./invalid-object"); /* 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] = { __moduleID: getModuleID(module), source: module, // func: wrapModuleFunction(module, factory) func: async function (args, context) { // NOTE: We pass through the context here without modifying it; because a module-specific context will be injected from the main runtime let instance = await factory(args, context); if (instance !== InvalidObject) { // We need to patch every created object, so that the correct context gets injected into its type-specific methods when they are called return mapObject(instance, (key, value) => { return [ key, wrapModuleValue(module, value) ]; }); } else { return instance; } } }; } }, 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 wrapModuleValue(module, value) { if (typeof value === "function") { return wrapModuleFunction(module, value); } else { return value; } } 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 // TODO: Disallow modules with duplicate names? This may be necessary to prevent issues with getModuleContext, which provides name-based context access let types = createTypeTracker(); let typeExtensions = createExtensionTracker(); let nameToID = new Map(); // TODO: Make this a tracker abstraction? 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) => wrapModuleValue(module, value)); }, {}); for (let module of modules) { if (module.name != null) { nameToID.set(module.name, getModuleID(module)); } 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(), nameToID: nameToID, 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); }; } }; };