Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-mode repos #1

Closed
erikpukinskis opened this issue Jun 4, 2022 · 7 comments
Closed

Multi-mode repos #1

erikpukinskis opened this issue Jun 4, 2022 · 7 comments

Comments

@erikpukinskis
Copy link
Owner

erikpukinskis commented Jun 4, 2022

There's a tension in Confgen's design so far, between two different goals:

1. Multiple modes of execution in a single repo

From the very beginning there have been multiple ways to run code in a single repo:

In a design system library there will be:

  • demo dev server
  • exported library code
    • with a test env
  • another test env for the build

In an application package there will be:

  • demo dev server
    • with a test env
  • demo API server
  • static app build
  • library code wrapping the static app
    • with a test env

In a data layer library there will be:

  • library code
    • with a test env
  • another test env for the build

Some of this is absolutely a necessity. A dev server, plus an exported package, plus some test for the build are all basic necessities for a module repo.

The trouble is in many of these cases you will want a different source folder, Vite config, and/or TypeScript config, not to mention ESLint config, etc. Which brings us to our second goal:

2. One source context per repo

A foundational principle behind Confgen is to enable a smooth workflows for a collection of single purpose repos. Confgen does that by taking on the responsibility for configuring all of the 3rd party generate, build, fix, check, and publish packages that you might need.

So as I have encountered this desire to have multiple independent source contexts in a single repo (like an API AND a static site) I have tried to resolve that conflict by moving those two pieces into separate repos.

The conflict

We don't want to encourage monorepo structures. However, there is an absolute necessity to have multiple source contexts. Minimally:

  • multi-process packages require it
  • dist tests require it
  • packaged application builds strongly encourage it, otherwise we would need very thin wrapper repos that just package code from another repo
  • and documentation wants to live with code, which also strongly encourages it

How to resolve this conflict

A) Configure the primary source context in a way that the secondary source context can be added manually

I have used this approach so far for things like build tests, which may need their own tsconfig, eslint config, etc. I also used it for the library exported by the application. But that caused the repo to drift a bit from Confgen... well maybe not entirely but there is e.g. a build:lib script that Confgen doesn't know about.

I did update the build script generator to leave in any additional scripts you might have thrown in there. But that feels a bit sketchy. First, it adds extra manual work for the dev who wants to configure something like that. But second, it means the config isn't really "owned" by Confgen anymore. An interesting property of Terraform (which is a major inspiration for Confgen) is that it will override all manual configuration. That's not a foundational principle of Confgen but maybe it should be!

B) Allow configuring an arbitrary number of source contexts

This seems to fly in the face of smooth workflows for a collection of single purpose repos because it means you could have many different source folders all with different configurations. It would seem to enable Confgen to support a multi-repo, which is an anti-goal.

C) Have a small number of fixed contexts

This is kind of de-facto the path that we're on: we have library, devServer, apiServer, and appBuild presets and each of those potentially creates its own build steps at least.

Many of them also take a folder as their first argument, so that you can decide what to call that thing. For example, in Outer Frame's design system package, the dev server is in the docs/ folder, since that's a good name for what's in there.

We could make this setup a bit more robust by planning for separate tsconfigs, separate source folders maybe even separate test commands, etc for each of these contexts.

We could also possibly simplify this by just mandating what folders the source code goes in. So for example, if we wanted to use all four of the above presets, we might have a folder structure like:

  • lib/
    • index.ts
    • tsconfig.json
    • myFunction.test.ts
    • vite.config.js
  • app/
    • index.html
    • index.ts
    • tsconfig.json
    • vite.config.js
    • App.tsx
  • package/
    • package.test.ts
  • server/
    • index.ts
    • myEndpoint.test.ts
    • tsconfig.json

And maybe those folder names are just fixed. That drives Confgen toward one of Rails' foundational principles: convention over configuration.

D) Fixed contexts, with preset targeting

A slight variation on C) which might solve some of the conflicts and questions laid out in the comments below would be to allow you to target presets @ a certain source context. So let's say we have four named contexts:

  • lib — code is called via an interface (either in Node or the browser)
  • app — code that boots in an HTML context in the browser
  • server — code that boots in Node
  • package — code that consumes the built packaged (and which may or may not be built)

And rather than just having presets consume the context as a normal argument, presets would have a specific context.

So for an application that gets built and packaged as an exported fastify function:

confgen app+server+package build@app+package codegen@app:queries react vitest

For a browser library with a demo documentation server and dist tests:

confgen lib+package+app build@lib react vitest

And for a data layer:

confgen lib+package build@lib codegen@lib:schema:resolvers vitest

Sidebar: Documentation

It's worth noting that in that design system repo, what's in docs/ is basically a totally independent package from what's in src/.

And while we don't currently export anything from docs/ in the build... we might want to someday! We might want to export a package that could be tacked onto a fastify server or something like that. Maybe with tree shaking you can get good package sizes, although you still would have extra packages in your package.json dependencies.

And exporting two separate packages from one repo would seem to violate smooth workflows for a collection of single purpose repos. But putting the design system documentation in a totally separate repo from the design system seems really sketchy.

And maybe the docs are just exported as a static build artifact that can be generated and sent off to a service. That avoids the whole problem, which... for now is still just a one-off, theoretical problem.

@erikpukinskis
Copy link
Owner Author

Notes:

  • I think we will want to error out if we find a src/ folder and tell folks to rename it
  • We might want to have the presets match the folder names (lib, app, server)

@erikpukinskis
Copy link
Owner Author

erikpukinskis commented Jun 5, 2022

OK, first issue that's come up during implementation: Where should the generated folder go?

If we have an app build, it might go in app/__generated__.

But in a library, it might go in lib/__generated__.

In the current code this is resolved because src/ is the "primary" folder, and it could either be an app build or a library or a dev server. So any add-on presets like generate just work in src/.

We could signal some way to say which preset is primary, like:

confgen --primary=devServer apiServer appBuild codegen:queries

Or use some syntactic sugar like:

confgen @devServer apiServer appBuild codegen:queries

Or specify the context in the codegen args:

confgen devServer apiServer appBuild codegen:devServer:queries

We could maybe detect where the codegen is needed... but that's a bit to much AI for this library I think. And it wouldn't really work for the schema arg... unless I guess we could require people put the schema in the particular folder where they want it available.

If we go with the last option, specifying the context in the args, would that mean we could do codegen in both place? Like:

confgen devServer library codegen:devServer:queries codegen:library:schema

?

I'm leaning towards no—you can either generate code for the app or the library but not both. If you need both, consolidate those use cases into one or the other. That fits in to smooth workflows for single purpose repos.

@erikpukinskis erikpukinskis reopened this Jun 5, 2022
@erikpukinskis
Copy link
Owner Author

The only other place where I'm seeing us hardcode the src/ path is when we're generating sample tests.

The reason we even do that is because yarn test will fail if there are zero tests. So it'd be very easy to just pick one of the source contexts and generate the test appropriate for that one.

But longer term I do think it'd be nice to generate tests for each source context. Shouldn't be a problem to do that.

@erikpukinskis
Copy link
Owner Author

Some more thoughts on presets and paths and such that are only needed in one source context:

If I add the react preset then that will affect the tsconfig and eslintrc in every source context, even though it may not be needed in all of them.

For example, in an application repo, I might want react installed for the app/ folder but I may be using the lib/ folder just to wrap the static build as an importable function. In that case I wouldn't need React in that source context at all... no Eslint rules, no JSX support in the tsconfig, no import aliases, etc.

But in the opposite situation, where I have a design system in lib/ and the documentation in app/ then I would absolutely want the React plugins, import aliases, etc in both folders.

Maybe this isn't a real issue... I think we could potentially just say "if you add it, it's available everywhere". It's not hurting anything to have extra eslint plugins or import aliases.

That would drive me away from having separate tsconfigs in different folders though. And I'm not sure if that really will work... right now I have do need a separate tsconfig.lib.json for that first case. In that case I want to build only the types in lib, but also check the types in app/ and server/.

So that's pointing me at maybe having a build-specific tsconfig.build.json rather than source-context-specific tsconfigs.

Other than that, I don't think this breaks the overall plan.

@erikpukinskis
Copy link
Owner Author

erikpukinskis commented Jun 5, 2022

Resolution (WIP)

In order to support the principle of smooth workflows for single purpose repos, we will...

  1. Not support arbitrary numbers of source contexts, a.k.a "runtimes"... We'll have a small number of fixed, named runtimes representing different fundamental modes of code running:

    • lib — code is called via an interface (either in Node or the browser)
    • app — code that boots in an HTML context in the browser
    • server — code that boots in Node
    • package — code that consumes the built artifacts of one or more of the other three runtimes (and which itself may or may not be built)

This will be specified as the first argument A runtime is added by adding it as an argument to the confgen command with the @ prefix:

confgen @app @lib @package
  1. Because some presets seemingly must be targeted at a runtime, we will add the concept of a preset runtime, e.g. codegen@lib In some cases presets need to be targeted at a runtime, which can be done with the normal preset args:
confgen @app @lib git yarn dist:app:lib
  1. In order to discourage wildly different configurations for different runtimes, and to make it easier to reason about the result of a given preset execution, presets are still defined globally.

  2. We'll get rid of the src/ folder and instead use lib/, app/, server/, and package/, as needed, and these will be standardized and non-configurable.

  3. We will avoid targeting presets unless it is absolutely necessary. In cases where a preset might leak some configuration into an inappropriate runtime, but it won't breaking anything, that's OK. As much as possible we should try to keep a consistent environment for all of the runtimes, in order to discourage multi-purpose repos.

    Examples:

    • The react preset can apply changes to the global eslint config and the test.environment in the vite config, even if none of that is needed for the package/ context.
    • All source contexts should use a single tsconfig.json, with additional configs being created per-preset (e.g. tsconfig.dist.json for the dist preset) rather than per-context

@erikpukinskis
Copy link
Owner Author

erikpukinskis commented Jul 8, 2022

Butting up against #5 right now.

Eslint started complaining about import statements in the vite.config.js file and I found myself adding some config in the .eslintrc for that to work:

  "parserOptions": {
    "sourceType": "module"
  },
  "env": {
    "es6": true,
    "node": true
  }

I'm not sure if I somehow switched from require to import in recent commits, or what. But that's what's happening since changing the folder structure described above.

I have a few concerns:

  • [minor concern] Can we really work with import statements everywhere? I suspect so, but I do wonder if Jest, Storybook, etc could have issues with it.
  • [more worrisome] Do we really want the node imports everywhere? They don't make sense for any of the browser environments for example.

So that last point has me thinking, maybe we want an .eslintrc in every runtime folder plus the root folder. Eslint does have some cascading features. That seems "correct" in a lot of ways.

But it violates the spirit of the single purpose aspect of the "smooth workflows for single purpose repos" principle. Part of that principle for me is that we don't have to think about a whole variety of different environments within a given repo.

That said, I think the whole purpose of this Issue and the whole purpose of the concept of these "runtimes" is to acknowledge and plan for the fact that many types of repos have multiple runtime environments within them. Just the simple fact that a browser library has a build environment that runs in node seems absolutely unavoidable.

Could a radical one-runtime-per-repo principle work?

Still, it's probably worth considering, what would it look like if we embraced "one runtime per repo" as a principle?

  • Browser library code would live in a separate repo from the code that packages it.
  • Build tests would live in a separate repo from either of those
  • Application code would live in a separate repo from the dev server that can boot it

All of that seems way too awkward. Non-starter.

Am I avoiding the elephant in the room: monorepos?

BUT, there is a third way I suppose, which I have been avoiding: Separate confgen commands for each runtime. Or, put another way: Embrace the monorepo and just use confgen for configuring the individual subfolders within your monorepo:

  • Each runtime becomes a yarn package or similar
  • Each package has its own confgen command and a single src/ folder
  • I guess they import each other?

That's a very intriguing path. It certainly moves towards a more "correct" model of these different environments. And it would pretty radically simplify confgen... we could remove the concept of runtimes entirely and reduce what will surely be a lot of branching.

This still leaves some concerns on the table though:

  • Even simply having a vite.config.js file, or a test runner, introduces a new runtime environment. And I'm not sure if those things can really be moved to a separate package. We could possible work around this by have lib/ and lib-build/ packages I suppose where lib-build just has the Vite config. This is pretty non-standard though.
  • Now there are multiple confgen commands to worry about just to configure a single package. It's a bit more complicated to get started.
  • [important] Are there any configuration dependencies between runtimes? Like, how do you set up your build tests so that they point to the correct build artifacts if the build config is in a totally separate package? If there could be any interdependencies between runtimes, that seems like a major risk. And a reason to keep runtimes as a concept in Confgen. Even just yarn all the things! seems like it becomes a good bit more difficult.
  • [important] If we go this route, suddenly Confgen is supporting having like four different browser libs in a single repo, which was really not the goal or the point.
  • [important] One of the principles was convention over configuration and the idea of setting up some nice defaults for polyrepo Javascript people. We don't lose out on that entirely, but we do lose out on some overall build architecture conventions if we go this way.

Making a decision

So... back to the Eslint question...

We are left with three possible strategies:

  1. Try to stick with the .eslintrc files a bit longer, understanding that things might be a little leaky between runtimes, but accepting that because of the adhering to a single configuration per repo principle.
  2. Stick with the concept of runtimes, but configure a separate .eslintrc for each runtime
  3. Ditch the entire concept of runtimes, and embrace monorepos while trying to adhere to the smooth workflows for single purpose repos principle.

I think for now I'm going to keep moving with No.1 since that gets me unblocked. No.3 just feels like it loses something essential about what makes Confgen interesting and easy to work with. I do suspect we should move towards No.2 in the long term though, because it gets us more correctness within the existing architecture.

@erikpukinskis erikpukinskis mentioned this issue Aug 18, 2022
14 tasks
@erikpukinskis
Copy link
Owner Author

After 4 months of off-and-on development and refinement, PR #4 is merged! So let's close this with some notes for the future....

Post-mortem

  • I am conditionally removing the React plugin from @lib runtimes if there is already an @app runtime that is presumably using React. The thinking here is, if we can make a determination that something isn't needed in a certain runtime, then why not exclude it. But I'm still iffy about this, as it goes against decision No.5 above, which says unless it is strictly necessary we keep the configuration exactly the same in all the runtimes.

  • I'm considering adding a @docs runtime which is basically the same as @app but it's just meant for documentation. The idea here is that documentation should be a standard part of every repo, and yet some repos have an application as well, which is it's own separate thing. As described above, we don't want to support having like four different applications in one repo, but documentation seems like a special case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant