Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions common/changes/@magek/core/feat-magek-init_2026-09-28-07-30.json
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 20 additions & 0 deletions docs/content/advanced/environment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/content/advanced/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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)
Expand Down
31 changes: 29 additions & 2 deletions packages/core/src/magek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,34 @@ import { eventSearch } from './event-search'
*/
export class Magek {
public static readonly configuredEnvironments: Set<string> = new Set<string>()
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)
Expand Down Expand Up @@ -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'
)
}
Expand Down
100 changes: 91 additions & 9 deletions packages/core/test/magek.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/read-models-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})
Expand Down
22 changes: 11 additions & 11 deletions packages/core/test/services/read-model-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading