From 5e57af4ca1bd3b002f7f594eedf840c4be623815 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Mon, 28 Sep 2026 11:20:13 +0400 Subject: [PATCH] feat(core): add Magek.init for programmatic environment setup Magek.config was created when the module loaded, so MAGEK_ENV had to be set before import. Config is now created on first access. Magek.init(environment) sets the name before that. The MAGEK_ENV path still works. sinon replace on Magek.config has to become replaceGetter. Fixes #664 --- .../feat-magek-init_2026-09-28-07-30.json | 11 ++ .../advanced/environment-configuration.md | 20 ++++ docs/content/advanced/testing.md | 4 +- packages/core/src/magek.ts | 31 +++++- packages/core/test/magek.test.ts | 100 ++++++++++++++++-- packages/core/test/read-models-reader.test.ts | 16 +-- .../test/services/read-model-store.test.ts | 22 ++-- 7 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 common/changes/@magek/core/feat-magek-init_2026-09-28-07-30.json diff --git a/common/changes/@magek/core/feat-magek-init_2026-09-28-07-30.json b/common/changes/@magek/core/feat-magek-init_2026-09-28-07-30.json new file mode 100644 index 000000000..a8d9c803f --- /dev/null +++ b/common/changes/@magek/core/feat-magek-init_2026-09-28-07-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add Magek.init(environment). Config is created on first access and still uses MAGEK_ENV when init is not called. Stub Magek.config with sinon replaceGetter instead of replace.", + "type": "patch", + "packageName": "@magek/core" + } + ], + "packageName": "@magek/core", + "email": "omidomirzaei@gmail.com" +} diff --git a/docs/content/advanced/environment-configuration.md b/docs/content/advanced/environment-configuration.md index a541a1da2..09d0804d5 100644 --- a/docs/content/advanced/environment-configuration.md +++ b/docs/content/advanced/environment-configuration.md @@ -27,6 +27,26 @@ Magek.configure('prod', (config: MagekConfig): void => { }) ``` +## Programmatic initialization + +`Magek.config` is created the first time something reads it. When `MAGEK_ENV` is already set, that value is the environment name. That is the path used by the test runner and by apps that set the variable before startup. + +You can set the name in code instead: + +```typescript +import { Magek } from '@magek/core' + +Magek.init('production') + +Magek.configureCurrentEnv((config: MagekConfig): void => { + config.appName = 'fruit-store' +}) +``` + +`init` has to run before any module that uses Magek decorators or calls `Magek.configure` is evaluated. ES `import` declarations are hoisted, so a call written under those imports in the same file runs too late. Put `init` in a module that loads first, or import the rest of the app dynamically after `init`. + +Calling `init` again with the same name does nothing. Calling it with a different name throws, including when config was already built from `MAGEK_ENV`. + ## Pluggable Adapters Magek uses a pluggable architecture for data storage, allowing you to choose the most appropriate storage solution for your needs. The framework provides several adapter types: diff --git a/docs/content/advanced/testing.md b/docs/content/advanced/testing.md index 411e5df47..1cf79e4f5 100644 --- a/docs/content/advanced/testing.md +++ b/docs/content/advanced/testing.md @@ -29,7 +29,7 @@ The only thing that you should add to this line are the `AWS_SDK_LOAD_CONFIG=tru ### Testing with `sinon-chai` -The `MagekConfig` can be accessed through the `Magek.config` on any part of a Magek application. To properly mock it for your objective, we really recommend to use sinon `replace` method, after configuring your `Magek.config` as desired. +The `MagekConfig` can be accessed through the `Magek.config` on any part of a Magek application. `Magek.config` is a getter, so sinon `replace` throws on it. Use `replaceGetter` after configuring your `Magek.config` as desired. In the example below, we add 2 "empty" read-models, since we are iterating `Magek.config.readModels` from a command handler: @@ -41,7 +41,7 @@ config.appName = 'testing-time' config.runtime = {} as Runtime config.readModels['WoW'] = {} as ReadModelMetadata config.readModels['Amazing'] = {} as ReadModelMetadata -replace(Magek, 'config', config) +replaceGetter(Magek, 'config', () => config) const spyMyCall = spy(MyCommand, 'myCall') const command = new MyCommand('1', true) diff --git a/packages/core/src/magek.ts b/packages/core/src/magek.ts index 6e85031bd..d724923f4 100644 --- a/packages/core/src/magek.ts +++ b/packages/core/src/magek.ts @@ -31,7 +31,34 @@ import { eventSearch } from './event-search' */ export class Magek { public static readonly configuredEnvironments: Set = new Set() - public static readonly config = new MagekConfig(checkAndGetCurrentEnv()) + private static _config: MagekConfig | undefined + + public static get config(): MagekConfig { + if (!this._config) { + this._config = new MagekConfig(checkAndGetCurrentEnv()) + } + return this._config + } + + /** + * Sets the environment name before config is first read. + * Has to run before decorated classes or Magek.configure are evaluated. + */ + public static init(environment: string): void { + if (!environment || environment.trim().length === 0) { + throw new Error('Magek.init requires a non-empty environment name.') + } + if (this._config) { + if (this._config.environmentName !== environment) { + throw new Error( + `Magek already initialized with environment '${this._config.environmentName}'. ` + + `Cannot reinitialize with '${environment}'.` + ) + } + return + } + this._config = new MagekConfig(environment) + } public static configureCurrentEnv(configurator: (config: MagekConfig) => void): void { configurator(this.config) @@ -158,7 +185,7 @@ function checkAndGetCurrentEnv(): string { if (!env || env.trim().length == 0) { throw new Error( 'Magek environment is missing. You need to provide an environment to configure your Magek project.\n\n' + - 'To fix this error, set the MAGEK_ENV environment variable.\n' + + 'To fix this error, set the MAGEK_ENV environment variable or call Magek.init(environment).\n' + 'Common environment names: local, development, staging, production, test' ) } diff --git a/packages/core/test/magek.test.ts b/packages/core/test/magek.test.ts index e4211801c..c38b6ad07 100644 --- a/packages/core/test/magek.test.ts +++ b/packages/core/test/magek.test.ts @@ -446,15 +446,97 @@ describe('the `Magek` class', () => { }) }) - describe('MAGEK_ENV error handling', () => { - it('throws a helpful error message when MAGEK_ENV is not set', () => { - const errorMessage = - 'Magek environment is missing. You need to provide an environment to configure your Magek project.\n\n' + - 'To fix this error, set the MAGEK_ENV environment variable.\n' + - 'Common environment names: local, development, staging, production, test' - - expect(errorMessage).to.include('To fix this error, set the MAGEK_ENV environment variable') - expect(errorMessage).to.include('Common environment names: local, development, staging, production, test') + describe('the `init` method', () => { + const magek = Magek as unknown as { _config?: MagekConfig } + let savedConfig: MagekConfig | undefined + let savedEnv: string | undefined + let savedEnvironments: string[] + + beforeEach(() => { + savedConfig = magek._config + savedEnv = process.env.MAGEK_ENV + savedEnvironments = [...Magek.configuredEnvironments] + magek._config = undefined + }) + + afterEach(() => { + magek._config = savedConfig + if (savedEnv === undefined) { + delete process.env.MAGEK_ENV + } else { + process.env.MAGEK_ENV = savedEnv + } + Magek.configuredEnvironments.clear() + for (const name of savedEnvironments) { + Magek.configuredEnvironments.add(name) + } + }) + + it('uses the name passed to init for configureCurrentEnv', () => { + Magek.init('production') + let seen: string | undefined + Magek.configureCurrentEnv((config) => { + seen = config.environmentName + }) + expect(seen).to.equal('production') + }) + + it('builds config from MAGEK_ENV when init was not called', () => { + process.env.MAGEK_ENV = 'staging' + const config = Magek.config + expect(config.environmentName).to.equal('staging') + expect(Magek.config).to.equal(config) + }) + + it('throws when config is read with no init and no MAGEK_ENV', () => { + delete process.env.MAGEK_ENV + expect(() => Magek.config).to.throw(/set the MAGEK_ENV environment variable/) + expect(() => Magek.config).to.throw(/Magek.init/) + }) + + it('throws when MAGEK_ENV is only whitespace', () => { + process.env.MAGEK_ENV = ' ' + expect(() => Magek.config).to.throw(/set the MAGEK_ENV environment variable/) + }) + + it('does nothing when init is called again with the same name', () => { + Magek.init('a') + const config = Magek.config + Magek.init('a') + expect(Magek.config).to.equal(config) + }) + + it('throws when init is called again with a different name', () => { + Magek.init('a') + expect(() => Magek.init('b')).to.throw(/already initialized with environment 'a'/) + }) + + it('throws when init disagrees with a config already built from MAGEK_ENV', () => { + process.env.MAGEK_ENV = 'test' + const config = Magek.config + expect(() => Magek.init('production')).to.throw(/already initialized with environment 'test'/) + expect(Magek.config).to.equal(config) + Magek.init('test') + expect(Magek.config).to.equal(config) + }) + + it('throws when init is called with an empty name', () => { + expect(() => Magek.init('')).to.throw(/non-empty/) + expect(() => Magek.init(' ')).to.throw(/non-empty/) + }) + + it('runs configure only for the initialized environment', () => { + Magek.init('production') + let otherCalled = false + let productionCalled = false + Magek.configure('other', () => { + otherCalled = true + }) + Magek.configure('production', () => { + productionCalled = true + }) + expect(otherCalled).to.equal(false) + expect(productionCalled).to.equal(true) }) }) diff --git a/packages/core/test/read-models-reader.test.ts b/packages/core/test/read-models-reader.test.ts index 516edac3e..06b27d566 100644 --- a/packages/core/test/read-models-reader.test.ts +++ b/packages/core/test/read-models-reader.test.ts @@ -13,7 +13,7 @@ import { SubscriptionEnvelope, UUID, } from '@magek/common' -import { fake, match, replace, restore, SinonStub, stub } from 'sinon' +import { fake, match, replace, replaceGetter, restore, SinonStub, stub } from 'sinon' import { MagekReadModelsReader } from '../src/read-models-reader' import { faker } from '@faker-js/faker' import { Magek } from '../src/magek' @@ -421,7 +421,7 @@ describe('MagekReadModelReader', () => { const providerSearcherFunctionFake = fake.resolves(expectedReadModels) replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` migratorStub.callsFake(async (readModel, readModelName) => readModel) @@ -445,7 +445,7 @@ describe('MagekReadModelReader', () => { const providerSearcherFunctionFake = fake.resolves(expectedReadModels) replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` migratorStub.callsFake(async (readModel, readModelName) => readModel) @@ -475,7 +475,7 @@ describe('MagekReadModelReader', () => { const providerSearcherFunctionFake = fake.resolves(searchResult) replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` migratorStub.callsFake(async (readModel, readModelName) => readModel) @@ -513,7 +513,7 @@ describe('MagekReadModelReader', () => { const providerSearcherFunctionFake = fake.resolves(expectedReadModels) replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` migratorStub.callsFake(async (readModel, readModelName) => readModel) @@ -548,7 +548,7 @@ describe('MagekReadModelReader', () => { const providerSearcherFunctionFake = fake.resolves(expectedResult) replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` migratorStub.callsFake(async (readModel, readModelName) => readModel) @@ -582,7 +582,7 @@ describe('MagekReadModelReader', () => { before: [fakeBeforeFn], } - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) }) @@ -617,7 +617,7 @@ describe('MagekReadModelReader', () => { before: [beforeFnSpy, beforeFnV2Spy], } - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStoreAdapter as any, 'search', providerSearcherFunctionFake) }) diff --git a/packages/core/test/services/read-model-store.test.ts b/packages/core/test/services/read-model-store.test.ts index 2a9b95cc5..3ef3cf741 100644 --- a/packages/core/test/services/read-model-store.test.ts +++ b/packages/core/test/services/read-model-store.test.ts @@ -18,7 +18,7 @@ import { import { field } from '../../src' import { expect } from '../expect' import { MagekAuthorizer } from '../../src/authorizer' -import { fake, match, replace, restore, SinonFakeTimers, spy, stub, useFakeTimers } from 'sinon' +import { fake, match, replace, replaceGetter, restore, SinonFakeTimers, spy, stub, useFakeTimers } from 'sinon' import { Magek } from '../../src/magek' // Utility function to create complete mock adapters @@ -298,7 +298,7 @@ describe('ReadModelStore', () => { search: fake.resolves([]) as any, }) replace(config, 'readModelStoreAdapter', mockAdapter) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace( ReadModelStore.prototype, 'getProjectionFunction', @@ -321,7 +321,7 @@ describe('ReadModelStore', () => { search: fake.resolves([]) as any, }) replace(config, 'readModelStoreAdapter', mockAdapter) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace( ReadModelStore.prototype, 'getProjectionFunction', @@ -351,7 +351,7 @@ describe('ReadModelStore', () => { // dependency injection or replaceGetter for proper mocking. it.skip('creates new instances of the read models', async () => { replace(config.readModelStore, 'store', fake()) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore, 'search', fake.resolves([])) const readModelStore = new ReadModelStore(config) replace(readModelStore, 'fetchReadModel', fake.resolves(null)) @@ -460,7 +460,7 @@ describe('ReadModelStore', () => { const readModelStore = new ReadModelStore(config) const someReadModelStoredVersion = 10 const anotherReadModelStoredVersion = 32 - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` const searchStub = stub(config.readModelStore as any, 'search') as any searchStub.callsFake(async (_config: any, className: string) => { if (className == SomeReadModel.name) { @@ -627,7 +627,7 @@ describe('ReadModelStore', () => { context('when the projection calls an instance method in the entity', () => { it('is executed without failing', async () => { const readModelStore = new ReadModelStore(config) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore as any, 'search', fake.resolves([])) const getPrefixedKeyFake = fake() replace(AnImportantEntity.prototype, 'getPrefixedKey', getPrefixedKeyFake) @@ -639,7 +639,7 @@ describe('ReadModelStore', () => { context('when the projection calls an instance method in the read model', () => { it('is executed without failing', async () => { const readModelStore = new ReadModelStore(config) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore as any, 'search', fake.resolves([{ id: 'joinColumnID', count: 31415 }])) const getIdFake = fake() replace(SomeReadModel.prototype, 'getId', getIdFake) @@ -671,7 +671,7 @@ describe('ReadModelStore', () => { } return Promise.resolve() }) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore as any, 'search', fake.resolves([])) replace(config.readModelStore as any, 'store', fakeStore) const readModelStore = new ReadModelStore(config) @@ -722,7 +722,7 @@ describe('ReadModelStore', () => { replace(config.readModelStore as any, 'store', fake()) const readModelStore = new ReadModelStore(config) const someReadModelStoredVersion = 10 - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace( config.readModelStore as any, 'search', @@ -849,7 +849,7 @@ describe('ReadModelStore', () => { } ) replace(config.readModelStore as any, 'store', fakeStore) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore as any, 'search', fake.resolves([])) const readModelStore = new ReadModelStore(config) @@ -925,7 +925,7 @@ describe('ReadModelStore', () => { const readModelStore = new ReadModelStore(config) const fakeApplyProjectionToReadModel = fake() replace(readModelStore as any, 'applyProjectionToReadModel', fakeApplyProjectionToReadModel) - replace(Magek, 'config', config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` + replaceGetter(Magek, 'config', () => config) // Needed because the function `Magek.readModel` references `this.config` from `searchFunction` replace(config.readModelStore as any, 'search', fake.resolves([])) await readModelStore.project(anEntitySnapshot)