Skip to content

Commit

Permalink
feat: Synchronous providers (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
ch1ller0 committed Mar 1, 2024
1 parent 5fd1d8c commit ca44129
Show file tree
Hide file tree
Showing 19 changed files with 139 additions and 154 deletions.
6 changes: 6 additions & 0 deletions .changeset/hungry-beers-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fridgefm/inverter": minor
"@fridgefm/examples": minor
---

feat: Synchronous providers
2 changes: 1 addition & 1 deletion apps/examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Examples",
"private": true,
"scripts": {
"dev": "tsx watch",
"dev": "tsx",
"test:typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
Expand Down
8 changes: 4 additions & 4 deletions apps/examples/src/chat-app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const config: PublicContainer.Configuration = {
],
};

export const rootContainer = createContainer(config);

rootContainer.get(ROOT).catch((e) => {
try {
createContainer(config).get(ROOT);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
});
}
7 changes: 3 additions & 4 deletions apps/examples/src/shared/__tests__/example-shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ const moduleRef = Test.createTestingContainer({
describe('integration:shared', () => {
describe('LoggerModule', () => {
it('child', async () => {
await moduleRef
.compile()
.get(LOGGER_CREATE)
.then((createChild) => createChild('fake-name'));
const createChild = moduleRef.compile().get(LOGGER_CREATE);

createChild('fake-name');

expect(mocks.loggerGlobal.child).toHaveBeenCalledTimes(1);
expect(mocks.loggerGlobal.child).toHaveBeenLastCalledWith({ name: 'fake-name' });
Expand Down
13 changes: 7 additions & 6 deletions packages/inverter/src/base/__tests__/injection-methods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,21 @@ describe('injection-methods', () => {
});

describe('edge cases', () => {
it('type is correct when resolveing promises', async () => {
it('type is correct when resolving promises', async () => {
const [container] = createFakeContainers();
const t1 = createToken<Promise<number>>('tok:1:promise');
const t2 = createToken<number>('tok:2:promise');
const t2 = createToken<Promise<number>>('tok:2:promise');

injectable({
provide: t2,
useFactory: (a) => delay(10).then(() => a + 1),
useFactory: (a) =>
a.then((val) => {
return delay(10).then(() => val + 1);
}),
inject: [t1],
})(container)();
injectable({ provide: t1, useValue: delay(50).then(() => 100) })(container)();

// should be numbers array here, not promises
// @TODO add type chceking here
const res = await Promise.all([container.resolveSingle(t1), container.resolveSingle(t2)]);
expect(res).toEqual([100, 101]);
});
Expand All @@ -134,7 +135,7 @@ describe('injection-methods', () => {

expect(() => {
injectable({ provide: t1exp, useNothing: 1 })(container)();
}).toThrowError('How did you get here');
}).toThrow('Incorrect args passed to injectable factory');
});
});
});
5 changes: 2 additions & 3 deletions packages/inverter/src/base/__tests__/modified-tokens.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createBaseContainer } from '../container';
import { createToken, modifyToken } from '../token';
import { injectable } from '../injectable';
import { delay } from './utils.mock';

const t1exp = createToken<string>('tok:1:expect');
const t2dep = modifyToken.multi(createToken<string>('tok:2:dependent'));
Expand All @@ -23,8 +22,8 @@ describe('depending', () => {
injectable({ provide: t2dep, useValue: '1' }),
injectable({ provide: t2dep, useFactory: () => '2' }),
injectable({ provide: t2dep, useValue: '3' }),
injectable({ provide: t2dep, useFactory: () => delay(10).then(() => '4') }),
injectable({ provide: t2dep, useFactory: () => delay(1).then(() => '5') }),
injectable({ provide: t2dep, useFactory: () => '4' }),
injectable({ provide: t2dep, useFactory: () => '5' }),
];
providers.forEach((f) => f(container)());

Expand Down
33 changes: 17 additions & 16 deletions packages/inverter/src/base/__tests__/resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { createBaseContainer } from '../container';
import { createToken } from '../token';
import { injectable } from '../injectable';
import { delay } from './utils.mock';

describe('resolver', () => {
it('really long chain', async () => {
const mockFactory = jest.fn((prev) => Promise.resolve().then(() => prev + 2));
const mockFactory = jest.fn((prev) => prev + 2);
const container = createBaseContainer();
const tokens = new Array(500).fill(undefined).map((_, i) => createToken<number>(`tok${i}`));
const providers = tokens.map((tok, index) =>
index === tokens.length - 1
? injectable({ provide: tok, useFactory: () => Promise.resolve().then(() => 2) })
? injectable({ provide: tok, useFactory: () => 2 })
: injectable({ provide: tok, useFactory: mockFactory, inject: [tokens[index + 1]] }),
);

Expand All @@ -35,9 +34,12 @@ describe('resolver', () => {

injectable({ provide: t3, useFactory: (prev) => prev + 10, inject: [t2] })(container)();
injectable({ provide: t2, useFactory: (prev) => prev + 10, inject: [t1] })(container)();
injectable({ provide: t1, useFactory: () => delay(20).then(() => Promise.reject(new Error('Bibka'))) })(
container,
)();
injectable({
provide: t1,
useFactory: () => {
throw new Error('Bibka');
},
})(container)();

try {
await container.resolveSingle(t3);
Expand Down Expand Up @@ -86,7 +88,7 @@ describe('resolver', () => {
describe('hard violations', () => {
it('fail with good trace when token not provided', async () => {
// expect.assertions(3);
const mockFactory = jest.fn((prev) => delay(10).then(() => prev + 2));
const mockFactory = jest.fn((prev) => prev + 2);
const container = createBaseContainer();
const tokens = new Array(10).fill(undefined).map((_, i) => createToken<number>(`tok${i}`));
const providers = tokens.map((tok, index) => {
Expand All @@ -104,33 +106,32 @@ describe('resolver', () => {
});

try {
await container.resolveSingle(tokens[0]);
container.resolveSingle(tokens[0]);
} catch (e) {
// anything that depends on the middle token is failing
expect(e.message).toEqual(`Token \"tok5\" was not provided,
stack: \"tok0\" -> \"tok1\" -> \"tok2\" -> \"tok3\" -> \"tok4\" -> \"tok5\"`);
}

try {
await container.resolveSingle(tokens[4]);
container.resolveSingle(tokens[4]);
} catch (e) {
// anything that depends on the middle token is failing
// @TODO however the stack is incorrect, should be only tok4 -> tok5
expect(e.message).toEqual(`Token \"tok5\" was not provided,
stack: \"tok0\" -> \"tok1\" -> \"tok2\" -> \"tok3\" -> \"tok4\" -> \"tok5\"`);
stack: \"tok4\" -> \"tok5\"`);
}

// however for the rest tokens it works ok
const res = await Promise.all([
const res = [
container.resolveSingle(tokens[tokens.length - 1]),
container.resolveSingle(tokens[tokens.length - 2]),
container.resolveSingle(tokens[tokens.length - 3]),
]);
];
expect(res).toEqual([100, 102, 104]);
});

it('recursive -> fails with dep stack', async () => {
const mockFactory = jest.fn((prev) => Promise.resolve().then(() => prev + 2));
const mockFactory = jest.fn((prev) => prev + 2);
const container = createBaseContainer();
const tokens = new Array(5).fill(undefined).map((_, i) => createToken<number>(`tok${i}`));
const providers = tokens.map((tok, index) =>
Expand All @@ -144,14 +145,14 @@ describe('resolver', () => {
});

try {
await container.resolveSingle(tokens[0]);
container.resolveSingle(tokens[0]);
} catch (e) {
expect(e.message).toEqual(`Cyclic dependency detected for token: "tok0",
stack: "tok0" -> "tok1" -> "tok2" -> "tok3" -> "tok4" -> "tok0"`);
}

try {
await container.resolveSingle(tokens[3]);
container.resolveSingle(tokens[3]);
} catch (e) {
expect(e.message).toEqual(`Cyclic dependency detected for token: "tok3",
stack: "tok3" -> "tok4" -> "tok0" -> "tok1" -> "tok2" -> "tok3"`);
Expand Down
4 changes: 2 additions & 2 deletions packages/inverter/src/base/__tests__/scopes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe('container scopes', () => {
const garbageRegistry = new FinalizationRegistry(finalizationMock);
const parentContainer = createBaseContainer();
const fakeServer = {
cb: jest.fn((v: string) => Promise.resolve(v)),
cb: jest.fn((v: string) => v),
on: function (cb: (v: string) => void) {
this.cb = cb;
},
Expand All @@ -148,7 +148,7 @@ describe('container scopes', () => {
return childContainer.resolveSingle(t2);
});

const res = await Promise.all([fakeServer.cb('0'), fakeServer.cb('1'), fakeServer.cb('2')]);
const res = [fakeServer.cb('0'), fakeServer.cb('1'), fakeServer.cb('2')];
expect(res[0].startsWith('0+0')).toEqual(true);
expect(res[1].startsWith('1+1')).toEqual(true);
expect(res[2].startsWith('2+2')).toEqual(true);
Expand Down
31 changes: 22 additions & 9 deletions packages/inverter/src/base/container.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { NOT_FOUND_SYMBOL } from './injectable';
import { TokenNotProvidedError, CyclicDepError } from './errors';
import { createStorage } from './storage/index';
import type { Token } from './token.types';
import type { Container } from './container.types';
import type { Helper } from './injectable.types';

const unwrapCfg = <T>(cfg: Helper.CfgElement<T>) => {
const unwrapCfg = <T>(
cfg: Helper.CfgElement<T>,
): {
token: Token.Instance<T>;
optional: boolean;
} => {
if ('token' in cfg) {
return cfg;
}
Expand All @@ -22,32 +28,39 @@ export const createBaseContainer = (parent?: Container.Constructor): Container.C
resolveSingle: <I extends Helper.CfgElement>(
cfg: I,
stack: Container.Stack = new Set(),
): Promise<Helper.ResolvedDepSingle<I>> => {
): Helper.ResolvedDepSingle<I> => {
const { token, optional } = unwrapCfg(cfg);
if (stack.has(token)) {
throw new CyclicDepError([...stack, token].map((s) => s.symbol));
}

const promiseFound = storage.get(token, stack);
if (promiseFound === NOT_FOUND_SYMBOL) {
const resolvedValue = storage.get(token, stack);
if (resolvedValue === NOT_FOUND_SYMBOL) {
if (typeof parent !== 'undefined') {
// always search in the parent container, it is an expected behaviour by definition
return parent.resolveSingle(cfg);
}
} else {
stack.delete(token);
return promiseFound;
return resolvedValue;
}

if (typeof token.defaultValue !== 'undefined') {
return Promise.resolve(token.defaultValue);
return token.defaultValue;
}
if (!!optional) {
// @ts-expect-error this is fine
return Promise.resolve(undefined);
// @ts-expect-error it is fine
return undefined;
}

return Promise.reject(new TokenNotProvidedError([...stack, token].map((s) => s.symbol)));
throw new TokenNotProvidedError([...stack, token].map((s) => s.symbol));
},
resolveMany: <I extends Helper.CfgTuple>(cfgs?: I, stack?: Container.Stack): Helper.ResolvedDepTuple<I> => {
if (typeof cfgs === 'undefined') {
return [] as Helper.ResolvedDepTuple<I>;
}
// @ts-expect-error the type is even wider
return cfgs.map((cfg) => instance.resolveSingle(cfg, stack));
},
parent,
};
Expand Down
31 changes: 7 additions & 24 deletions packages/inverter/src/base/container.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Storage } from './storage/storage.types';
import type { Helper } from './injectable.types';
import type { Token } from './token.types';

Expand All @@ -6,43 +7,25 @@ export namespace Container {
* Stackof tokens which is passed while resolving the dependant factories
*/
export type Stack = Set<Token.AnyInstance>;
/**
* This is used to separate different implementations on the same token
*/
export type InjectionKey = symbol;
type Binders = {
/**
* Caches the provided value inside the container
*/
bindValue: <T extends Token.Instance<unknown>>(a: {
token: T;
value: Promise<Token.Provide<T>>;
injKey: symbol;
}) => void;
/**
* Binds a factory which is run on each token injection
*/
bindFactory: <T extends Token.Instance<unknown>>(a: {
token: T;
func: (stack: Set<Token.AnyInstance>) => Promise<Token.Provide<T>>;
injKey: InjectionKey;
}) => void;
};
/**
* This type is used to pass it to the injectable instance
*/
export type Constructor = {
/**
* Returns resolved value associated with a token
*/
resolveSingle: <I extends Helper.CfgElement>(cfg: I, stack?: Stack) => Promise<Helper.ResolvedDepSingle<I>>;
resolveSingle: <I extends Helper.CfgElement>(cfg: I, stack?: Stack) => Helper.ResolvedDepSingle<I>;
/**
* Returns resolved values associated with tokens
*/
resolveMany: <I extends Helper.CfgTuple>(cfgs?: I, stack?: Stack) => Helper.ResolvedDepTuple<I>;
/**
* The referral to the parent container if any
*/
parent?: Constructor;
/**
* Binding methods
*/
binders: Binders;
binders: Pick<Storage, 'bindFactory' | 'bindValue'>;
};
}
10 changes: 6 additions & 4 deletions packages/inverter/src/base/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Token } from './token.types';

type DepStack = Readonly<symbol[]>;

export class TokenNotProvidedError extends Error {
depStack: Readonly<symbol[]>;
depStack: DepStack;

constructor(depStack: symbol[]) {
constructor(depStack: DepStack) {
const descriptionStack = depStack.map((s) => `"${s.description}"`);
const failedTokenDescription = descriptionStack[descriptionStack.length - 1];
super(`Token ${failedTokenDescription} was not provided,
Expand All @@ -14,9 +16,9 @@ export class TokenNotProvidedError extends Error {
}

export class CyclicDepError extends Error {
depStack: Readonly<symbol[]>;
depStack: DepStack;

constructor(depStack: symbol[]) {
constructor(depStack: DepStack) {
const descriptionStack = depStack.map((s) => `"${s.description}"`);
const failedTokenDescription = descriptionStack[descriptionStack.length - 1];

Expand Down
Loading

0 comments on commit ca44129

Please sign in to comment.