Filling the Unit System: IntegrationUnit, AdapterUnit, and the Full Boot Sequence
Source: Dev.to
Last post
The Unit System had its first real types — RuntimeUnit, PackageManager, and defineRuntime(). The runtime layer was done. But a runtime by itself does nothing. It knows how to run things, but nothing is asking it to run anything yet. The system needed the other two unit kinds — integrations and adapters — before any of it would actually connect.
IntegrationUnit
IntegrationUnit is the interface for framework‑specific plugins — React, NestJS, Angular, Docker as an orchestrator, and anything else that wires into the Velnora lifecycle.
If runtimes are the foundation and adapters are the build tools, integrations are the things that actually give a workspace its identity.
Design highlights
-
All lifecycle hooks are optional.
- An integration only implements what it needs.
- If it just needs to expose an API during init, it implements
configure(). - If it also needs to do something at build time, it adds
build(). - If it needs neither, it can still exist as a unit that merely declares capabilities for others to depend on.
-
This is Interface Segregation in practice. The kernel duck‑types before calling — e.g.
'configure' in unit— so there is no dead code, no empty method stubs, and no forced implementations.
Two Phases, Two Contexts
Every unit kind now gets phased contexts.
| Hook | Context type | Capabilities |
|---|---|---|
configure() | IntegrationConfigureContext | Full access to ctx.expose() and ctx.query() |
build() | IntegrationBuildContext | Query‑only (registration is closed, no new APIs can be exposed) |
Right now all three unit kinds share the same idea, but the context types are not fully separated yet. Runtimes, adapters, and integrations each have different phase‑specific needs, and the concrete context interfaces still need to be split out per kind. That is a task for the next round.
The separation is not just for safety. It makes the intent clear at the type level. If you are inside build(), TypeScript will not even let you call expose(). You know exactly what phase you are in by looking at the context type.
defineIntegration
Just like defineRuntime(), the factory is thin:
defineIntegration(integrationDefinition)
- It injects
kind: 'integration'and returns aVelnoraUnit. - The same pattern, same type inference, same generics capturing literal strings from
requiresandcapabilities.
Calling conventions
All three define helpers — defineRuntime(), defineAdapter(), and defineIntegration() — support a second calling convention: a factory function.
defineIntegration((env: ConfigEnv) => ({
// …config that can depend on env.command, env.mode, etc.
}))
- Use this when you need to change behavior based on whether you are running
devorbuild. - The factory wraps your function and injects
kindat call‑time.
Both conventions produce the same thing: a static definition for simple cases, a factory for environment‑aware ones. The pattern is identical across all three unit kinds.
Init Order
In theory, integrations should initialize last — after runtimes, after adapters. By the time an integration runs its configure() hook, every runtime is booted and every adapter is ready, so the integration can safely query anything it depends on.
- Within the integration tier, a topological sort by
requiresandoptionalRequiresdetermines the exact ordering.- Example: if your integration depends on
'docker', the Docker integration would configure first.
- Example: if your integration depends on
The resolver that performs this sorting is not wired up yet.
Open question: circular dependencies
What happens when A requires B and B requires A?
- I haven’t decided yet.
- Possible strategies: reject the cycle at startup, or break it with a warning.
- For now the design assumes no cycles — if you create one, you get what you deserve. This will need a concrete answer before the resolver ships.
AdapterUnit — The Middle Layer
With integrations done, the adapter was next. AdapterUnit represents build‑tool orchestrators — Vite, Webpack, Turbopack, esbuild, and similar.
Required hooks
dev(project: Project, ctx: AdapterDevContext): DevServerResult
build(project: Project, ctx: AdapterBuildContext): BuildResult
- Both hooks are required.
dev()returns aDevServerResult— connection info that the Host uses to wire up the dev server.
Design shift: removing mode
Earlier iterations had mode: 'thread' | 'process' on the adapter itself. That was wrong. Whether something runs in‑thread or as a child process is the runtime’s decision, not the adapter’s.
- A Vite adapter should not care if it runs inside Node’s process or gets spawned separately.
- It only says what to run.
- The runtime decides how to run it.
Execution flow
- The adapter queries the runtime via
ctx.query(). - It calls something like
execute(). - The runtime returns a declarative
ExecutionPlan(a discriminated union).- Thread mode – carries a
runfunction that Velnora calls in‑process. - Process mode – carries the binary, args, and cwd for Velnora to spawn as a child process.
- Thread mode – carries a
Either way, the adapter code stays the same. The same adapter works with Node, Bun, or any other runtime.
Phased contexts
AdapterDevContextfordev()AdapterBuildContextforbuild()
defineAdapter() follows the same pattern as the other helpers: static object or factory function, kind injected automatically, full type inference on dependencies.
The Full Boot Sequence (In Theory)
The init order described above is not implemented yet, but the intended flow is:
- Runtimes boot first.
- Adapters initialize next (
dev/buildhooks become available). - Integrations configure last, after all runtimes and adapters are ready.
- Within each tier, a topological sort based on
requires/optionalRequiresresolves the exact ordering.
Once the resolver is wired up, the system will enforce this sequence, detect cycles, and provide clear error messages.
How It Should Work
Runtimes – language runtimes boot first
Adapters – build tools next
Integrations – frameworks last
Within each tier, a topological sort by requires and optionalRequires determines the exact ordering. You drop units into one array and the Kernel figures out the rest—that is the design. What actually happens in practice is a different story, but this is the contract the system is built around.
All three unit kinds are now implemented:
| Layer | Units / APIs |
|---|---|
| Runtime | RuntimeUnit, PackageManager, Toolchain, defineRuntime() |
| Integration | IntegrationUnit, phased contexts, defineIntegration() |
| Adapter | AdapterUnit, the execution‑model split, defineAdapter() |
The Unit System is complete—every unit kind has its interface, its factory, and its place in the boot sequence.
A Note on AI and How I Work
I use AI for generating tests and JSDoc, and in rare cases for code. Even when it generates code, I review it multiple times before applying it to the project and committing. The code architecture is mine.
The way the work gets seen—which packages should exist, how the spec should read, how the design should be documented—is where I use Notion AI. Each time I work on a feature, the process takes almost a couple of hours:
- Understand the design.
- Consult with AI.
- Analyze the AI output again.
- Document it properly.
The thinking is mine; the documentation process is a collaboration.
Current limitation:
Almost every code assistant has usage caps—except Notion AI, which I have already exhausted. Consequently, tests and JSDoc generation are temporarily unavailable until I get a local assistant running. I’m setting one up, but it isn’t fully ready yet. Right now I’m creating a connection between my PC and my Mac so I can run it remotely, independent of distance.
What Is Next
The Unit System is complete—every unit kind has its interface, its factory, and its place in the boot sequence. The next step is wiring them into the Kernel’s actual resolver and creating the first real unit packages. This means:
- Real
defineRuntime()calls for Node and Bun. - A real
defineAdapter()for Vite. - A real
defineIntegration()for React.
Interfaces are done; now it’s time to fill them.