From e17daed4b5ec952eae1e484d0fa7b86127045e57 Mon Sep 17 00:00:00 2001 From: Nicolas Froidure Date: Thu, 14 Nov 2024 14:33:35 +0100 Subject: [PATCH] feat(core): allow to override some services Let's integrate the userland service mapping right into the library. fix #127 --- ARCHITECTURE.md | 16 ++--- src/build.test.ts | 2 - src/build.ts | 8 ++- src/dispose.ts | 4 +- src/index.test.ts | 160 ++++++++++++++++++++++++++++++++++++++++-- src/index.ts | 75 +++++++++++++++++--- src/overrides.test.ts | 106 ++++++++++++++++++++++++++++ src/overrides.ts | 36 ++++++++++ src/util.ts | 17 +++-- 9 files changed, 390 insertions(+), 34 deletions(-) create mode 100644 src/overrides.test.ts create mode 100644 src/overrides.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d64d0fb..5c7c7e0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,7 +37,7 @@ It is designed to have a low footprint on services code. In fact, the Knifecycle API is aimed to allow to statically build its services load/unload code once in production. -[See in context](./src/index.ts#L202-L221) +[See in context](./src/index.ts#L204-L223) @@ -52,7 +52,7 @@ A service provider is full of state since its concern is [encapsulate](https://en.wikipedia.org/wiki/Encapsulation_(computer_programming)) your application global states. -[See in context](./src/index.ts#L223-L232) +[See in context](./src/index.ts#L225-L234) @@ -78,7 +78,7 @@ A service provider is full of state since its concern is `Knifecycle` provides a set of decorators that allows you to simply create new initializers. -[See in context](./src/util.ts#L11-L32) +[See in context](./src/util.ts#L14-L35) @@ -92,7 +92,7 @@ The `?` flag indicates an optional dependency. It allows to write generic services with fixed dependencies and remap their name at injection time. -[See in context](./src/util.ts#L1366-L1375) +[See in context](./src/util.ts#L1371-L1380) @@ -121,7 +121,7 @@ Initializers can be of three types: instanciated once for all for each executions silos using them (we will cover this topic later on). -[See in context](./src/index.ts#L312-L336) +[See in context](./src/index.ts#L315-L339) @@ -137,7 +137,7 @@ Depending on your application design, you could run it in only one execution silo or into several ones according to the isolation level your wish to reach. -[See in context](./src/index.ts#L645-L655) +[See in context](./src/index.ts#L671-L681) @@ -157,7 +157,7 @@ For the build to work, we need: - the dependencies list you want to initialize -[See in context](./src/build.ts#L32-L47) +[See in context](./src/build.ts#L34-L49) @@ -173,5 +173,5 @@ Sadly TypeScript does not allow to add generic types For more details, see: https://stackoverflow.com/questions/64948037/generics-type-loss-while-infering/64950184#64950184 -[See in context](./src/util.ts#L1436-L1447) +[See in context](./src/util.ts#L1441-L1452) diff --git a/src/build.test.ts b/src/build.test.ts index 3099be9..bbe2fe1 100644 --- a/src/build.test.ts +++ b/src/build.test.ts @@ -177,8 +177,6 @@ export async function initialize(services = {}) { $.register(constant('PWD', '~/my-project')); $.register(initAutoloader); $.register(initInitializerBuilder); - $.register(constant('$fatalError', {})); - $.register(constant('$instance', {})); const { buildInitializer } = await $.run(['buildInitializer']); diff --git a/src/build.ts b/src/build.ts index 2da7cee..7ded03a 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,19 +1,21 @@ import { SPECIAL_PROPS, + INSTANCE, + AUTOLOAD, parseDependencyDeclaration, initializer, } from './util.js'; import { buildInitializationSequence } from './sequence.js'; import { FATAL_ERROR } from './fatalError.js'; import { DISPOSE } from './dispose.js'; -import type { Autoloader } from './index.js'; +import { type Autoloader } from './index.js'; import type { DependencyDeclaration, Initializer, Dependencies, } from './util.js'; -export const MANAGED_SERVICES = [FATAL_ERROR, DISPOSE, '$instance']; +export const MANAGED_SERVICES = [FATAL_ERROR, DISPOSE, INSTANCE]; type DependencyTreeNode = { __name: string; @@ -50,7 +52,7 @@ export default initializer( { name: 'buildInitializer', type: 'service', - inject: ['$autoload'], + inject: [AUTOLOAD], }, initInitializerBuilder, ); diff --git a/src/dispose.ts b/src/dispose.ts index 8c97d4e..390dba5 100644 --- a/src/dispose.ts +++ b/src/dispose.ts @@ -1,5 +1,7 @@ import { + INSTANCE, NO_PROVIDER, + SILO_CONTEXT, SPECIAL_PROPS, parseDependencyDeclaration, service, @@ -145,4 +147,4 @@ async function initDispose({ }; } -export default service(initDispose, DISPOSE, ['$instance', '$siloContext']); +export default service(initDispose, DISPOSE, [INSTANCE, SILO_CONTEXT]); diff --git a/src/index.test.ts b/src/index.test.ts index b7f9f74..5369525 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -122,6 +122,24 @@ describe('Knifecycle', () => { ); } }); + + test('should fail when overriding a reserved service', async () => { + try { + $.register(constant('$dispose', 2)); + throw new YError('E_UNEXPECTED_SUCCESS'); + } catch (err) { + assert.equal((err as YError).code, 'E_IMMUTABLE_SERVICE_NAME'); + } + }); + + test('should fail when overriding a constant service with anything else', async () => { + try { + $.register(service(timeService, '$overrides')); + throw new YError('E_UNEXPECTED_SUCCESS'); + } catch (err) { + assert.equal((err as YError).code, 'E_CONSTANT_SERVICE_NAME'); + } + }); }); describe('with services', () => { @@ -152,6 +170,50 @@ describe('Knifecycle', () => { ); } }); + + test('should work with services names overrides', async () => { + $.register( + service( + async ({ test2 }) => + () => + test2(), + 'test', + ['test2'], + ), + ); + $.register(service(async () => () => 2, 'test2')); + $.register(service(async () => () => 3, 'test3')); + $.register(constant('$overrides', { test2: 'test3' })); + + const { test } = await $.run(['test']); + + assert.deepEqual(test(), 3); + }); + + test('should work with complex services names overrides', async () => { + $.register( + service( + async ({ log }) => + () => + log('from debugLog'), + 'debugLog', + ['log'], + ), + ); + $.register(service(async () => (str) => 'log ' + str, 'log')); + $.register( + constant('$overrides', { + log: 'debugLog', + debugLog: { + log: 'log', + }, + }), + ); + + const { log } = await $.run(['log']); + + assert.deepEqual(log(), 'log from debugLog'); + }); }); describe('with providers', () => { @@ -259,9 +321,53 @@ describe('Knifecycle', () => { ); } }); + + test('should work with provider names overrides', async () => { + $.register( + initializer( + { + type: 'provider', + name: 'test', + inject: ['test2'], + }, + async ({ test2 }) => ({ + service: test2, + }), + ), + ); + $.register( + initializer( + { + type: 'provider', + name: 'test2', + inject: [], + }, + async () => ({ + service: 2, + }), + ), + ); + $.register( + initializer( + { + type: 'provider', + name: 'test3', + inject: [], + }, + async () => ({ + service: 3, + }), + ), + ); + $.register(constant('$overrides', { test2: 'test3' })); + + const { test } = await $.run(['test']); + + assert.deepEqual(test, 3); + }); }); - test('should fail when intitializer is no a function', () => { + test('should fail when initializer is no a function', () => { assert.throws( () => { $.register('not_a_function' as any); @@ -347,7 +453,7 @@ describe('Knifecycle', () => { ); }); - test('should fail with special autoload intitializer that is not a singleton', () => { + test('should fail with special autoload initializer that is not a singleton', () => { assert.throws( () => { $.register( @@ -713,6 +819,7 @@ describe('Knifecycle', () => { assert.deepEqual(timeServiceStub.args, [[{}]]); }); }); + describe('should fail', () => { test('with bad service', async () => { $.register(service((() => undefined) as any, 'lol')); @@ -778,6 +885,51 @@ describe('Knifecycle', () => { } }); + test('with indirect circular dependencies', async () => { + $.register( + service( + async () => { + return () => 'human'; + }, + 'human', + ['tree'], + ), + ); + $.register( + service( + async () => { + return () => 'tree'; + }, + 'tree', + ['earth'], + ), + ); + $.register( + service( + async () => { + return () => 'earth'; + }, + 'earth', + ['person'], + ), + ); + $.register(constant('$overrides', { person: 'human' })); + + try { + await $.run(['human']); + throw new Error('E_UNEXPECTED_SUCCESS'); + } catch (err) { + assert.deepEqual((err as YError).code, 'E_CIRCULAR_DEPENDENCY'); + assert.deepEqual((err as YError).params, [ + '__run__', + 'human', + 'tree', + 'earth', + 'human', + ]); + } + }); + test('and provide a fatal error handler', async () => { $.register(constant('ENV', ENV)); $.register(constant('time', time)); @@ -1495,7 +1647,7 @@ describe('Knifecycle', () => { await $.destroy(); }); - test('should work when trigered from several silos simultaneously', async () => { + test('should work when triggered from several silos simultaneously', async () => { $.register(constant('ENV', ENV)); $.register(constant('time', time)); $.register(provider(hashProvider, 'hash', ['ENV'])); @@ -1548,7 +1700,7 @@ describe('Knifecycle', () => { try { await $.run(['ENV', 'hash', 'hash1']); - throw new YError('E_UNEXPECTED_SUCCES'); + throw new YError('E_UNEXPECTED_SUCCESS'); } catch (err) { assert.equal((err as YError).code, 'E_INSTANCE_DESTROYED'); } diff --git a/src/index.ts b/src/index.ts index 7f202ce..e5c74fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,9 @@ /* eslint max-len: ["warn", { "ignoreComments": true }] @typescript-eslint/no-this-alias: "warn" */ import { NO_PROVIDER, + INSTANCE, + SILO_CONTEXT, + AUTOLOAD, SPECIAL_PROPS, SPECIAL_PROPS_PREFIX, DECLARATION_SEPARATOR, @@ -38,6 +41,7 @@ import { } from './util.js'; import initFatalError, { FATAL_ERROR } from './fatalError.js'; import initDispose, { DISPOSE } from './dispose.js'; +import { type Overrides, OVERRIDES, pickOverridenName } from './overrides.js'; import initInitializerBuilder from './build.js'; import { YError, printStackTrace } from 'yerror'; import initDebug from 'debug'; @@ -103,6 +107,7 @@ export type { Parameters, BuildInitializer, FatalErrorService, + Overrides, }; export const RUN_DEPENDENT_NAME = '__run__'; @@ -187,11 +192,8 @@ export type InternalDependencies = { const debug = initDebug('knifecycle'); -export { DISPOSE, FATAL_ERROR }; -export const AUTOLOAD = '$autoload'; +export { DISPOSE, FATAL_ERROR, INSTANCE, SILO_CONTEXT, AUTOLOAD, OVERRIDES }; export const INJECTOR = '$injector'; -export const INSTANCE = '$instance'; -export const SILO_CONTEXT = '$siloContext'; export const UNBUILDABLE_SERVICES = [ AUTOLOAD, INJECTOR, @@ -256,8 +258,6 @@ class Knifecycle { */ constructor(options?: KnifecycleOptions) { this._options = options || {}; - this._silosCounter = 0; - this._silosContexts = {}; this._initializersStates = { [FATAL_ERROR]: { initializer: initFatalError, @@ -280,6 +280,7 @@ class Knifecycle { }, }; this.register(constant(INSTANCE, this)); + this.register(constant(OVERRIDES, {})); const initInjectorProvider = provider( async ({ @@ -307,6 +308,8 @@ class Knifecycle { ); this.register(initInjectorProvider); + this._silosCounter = 0; + this._silosContexts = {}; } /* Architecture Note #1.3: Registering initializers @@ -346,6 +349,27 @@ class Knifecycle { if (this._shutdownPromise) { throw new YError('E_INSTANCE_DESTROYED'); } + if ( + this._silosContexts && + [INSTANCE, INJECTOR, SILO_CONTEXT, DISPOSE].includes( + initializer[SPECIAL_PROPS.NAME], + ) + ) { + throw new YError( + 'E_IMMUTABLE_SERVICE_NAME', + initializer[SPECIAL_PROPS.NAME], + ); + } + if ( + initializer[SPECIAL_PROPS.NAME] === OVERRIDES && + initializer[SPECIAL_PROPS.TYPE] !== 'constant' + ) { + throw new YError( + 'E_CONSTANT_SERVICE_NAME', + initializer[SPECIAL_PROPS.NAME], + initializer[SPECIAL_PROPS.TYPE], + ); + } const initializerState: InitializerStateDescriptor = { initializer, @@ -405,6 +429,8 @@ class Knifecycle { } _checkInitializerDependencies(initializer: Initializer) { + // Here, we do not have to take in count the overrides since it + // won't impact the checking const initializerDependsOfItself = initializer[SPECIAL_PROPS.INJECT] .map((dependencyDeclaration) => { const { serviceName } = parseDependencyDeclaration( @@ -759,6 +785,12 @@ class Knifecycle { dependenciesDeclarations: DependencyDeclaration[], additionalDeclarations: DependencyDeclaration[], ): Promise { + const overrides = ( + this._initializersStates[OVERRIDES].initializer as ConstantInitializer< + Record + > + ).$value as Overrides; + debug( `${[...parentsNames].join( '->', @@ -773,19 +805,32 @@ class Knifecycle { for (const serviceDeclaration of allDependenciesDeclarations) { const { mappedName, optional } = parseDependencyDeclaration(serviceDeclaration); - const initializerState = this._initializersStates[mappedName] || { + const finalName = pickOverridenName(overrides, [ + ...parentsNames, + mappedName, + ]); + + if (finalName !== mappedName) { + debug( + `${[...parentsNames, mappedName].join( + '->', + )}: Mapping a dependency (${mappedName} => ${finalName}).`, + ); + } + + const initializerState = this._initializersStates[finalName] || { dependents: [], autoloaded: true, }; - this._initializersStates[mappedName] = initializerState; + this._initializersStates[finalName] = initializerState; initializerState.dependents.push({ silo: siloContext.index, name: parentsNames[parentsNames.length - 1], optional, }); - dependencies.push(mappedName); + dependencies.push(finalName); } do { @@ -823,7 +868,12 @@ class Knifecycle { (finalHash, dependencyDeclaration) => { const { serviceName, mappedName, optional } = parseDependencyDeclaration(dependencyDeclaration); - const provider = this._getServiceProvider(siloContext, mappedName); + const finalName = pickOverridenName(overrides, [ + ...parentsNames, + mappedName, + ]); + + const provider = this._getServiceProvider(siloContext, finalName); // We expect a provider here since everything // should be resolved @@ -867,6 +917,11 @@ class Knifecycle { `${[...parentsNames, serviceName].join('->')}: Loading the provider...`, ); + if (parentsNames.includes(serviceName)) { + // At that point there should be an initialiser property + throw new YError('E_CIRCULAR_DEPENDENCY', ...parentsNames, serviceName); + } + const initializerState = this._initializersStates[serviceName]; if (!('initializer' in initializerState) || !initializerState.initializer) { diff --git a/src/overrides.test.ts b/src/overrides.test.ts new file mode 100644 index 0000000..5600e09 --- /dev/null +++ b/src/overrides.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from '@jest/globals'; +import { pickOverridenName } from './overrides.js'; + +describe('pickOverridenName()', () => { + describe('should not replace the non-matching services', () => { + test('with simple maps', () => { + expect( + pickOverridenName( + { + originalService: 'overriddenService', + }, + ['anotherService'], + ), + ).toMatch('anotherService'); + }); + }); + + describe('should replace the matching services', () => { + test('with simple maps', () => { + expect( + pickOverridenName( + { + originalService: 'overriddenService', + }, + ['originalService'], + ), + ).toMatch('overriddenService'); + }); + + test('with simple maps and some parents', () => { + expect( + pickOverridenName( + { + originalService: 'overriddenService', + }, + [ + 'parentService1', + 'parentService2', + 'parentService3', + 'parentService4', + 'originalService', + ], + ), + ).toMatch('overriddenService'); + }); + + test('with 1 level tree maps', () => { + expect( + pickOverridenName( + { + parentService: { + originalService: 'overriddenService', + }, + }, + ['parentService', 'originalService'], + ), + ).toMatch('overriddenService'); + }); + + test('with lots of levels tree maps', () => { + expect( + pickOverridenName( + { + parentService1: { + parentService2: { + parentService3: { + parentService4: { + originalService: 'overriddenService', + }, + }, + }, + }, + }, + [ + 'parentService1', + 'parentService2', + 'parentService3', + 'parentService4', + 'originalService', + ], + ), + ).toMatch('overriddenService'); + }); + + test('with lots of levels tree maps and partial parents path', () => { + expect( + pickOverridenName( + { + parentService3: { + parentService4: { + originalService: 'overriddenService', + }, + }, + }, + [ + 'parentService1', + 'parentService2', + 'parentService3', + 'parentService4', + 'originalService', + ], + ), + ).toMatch('overriddenService'); + }); + }); +}); diff --git a/src/overrides.ts b/src/overrides.ts new file mode 100644 index 0000000..8304040 --- /dev/null +++ b/src/overrides.ts @@ -0,0 +1,36 @@ +/** + * A tree map to match service names to + * their overridden values at run/build time + */ +export type Overrides = { [key: string]: Overrides | string }; + +export const OVERRIDES = '$overrides'; + +export function pickOverridenName( + overrides: Overrides, + servicesNames: [...string[], string], +) { + const servicesDepth = servicesNames.length; + + for (let i = 0; i < servicesDepth; i++) { + let currentDepth = i; + let currentOverrides = overrides; + + while (currentDepth < servicesDepth) { + const candidateOverride = currentOverrides[servicesNames[currentDepth]]; + + if (typeof candidateOverride === 'string') { + if (currentDepth === servicesDepth - 1) { + return candidateOverride; + } + } else if (candidateOverride) { + currentOverrides = candidateOverride; + } else { + break; + } + currentDepth++; + } + } + + return servicesNames[servicesDepth - 1]; +} diff --git a/src/util.ts b/src/util.ts index 1be7d78..b09cbb4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -7,6 +7,9 @@ import initDebug from 'debug'; const debug = initDebug('knifecycle'); export const NO_PROVIDER = Symbol('NO_PROVIDER'); +export const INSTANCE = '$instance'; +export const SILO_CONTEXT = '$siloContext'; +export const AUTOLOAD = '$autoload'; /* Architecture Note #1.2: Creating initializers @@ -673,12 +676,14 @@ export function unInject, S>( ); return inject( - originalDependencies.filter(({ serviceName }) => - filteredDependencies.every( - ({ serviceName: filteredServiceName }) => - serviceName !== filteredServiceName, - ), - ).map(stringifyDependencyDeclaration), + originalDependencies + .filter(({ serviceName }) => + filteredDependencies.every( + ({ serviceName: filteredServiceName }) => + serviceName !== filteredServiceName, + ), + ) + .map(stringifyDependencyDeclaration), initializer as ServiceInitializerBuilder, ); }