@arki/dot
archivefor @arki/[email protected] · Tier 1: the stack
TypeScript-first application composition framework for the ARKI package family.
Install
bun add @arki/[email protected]Other package managers
npm install @arki/[email protected]
pnpm add @arki/[email protected]
yarn add @arki/[email protected]Release health for @arki/[email protected]
Doc examples
No example report was recorded for this version.
Open gaps
2 release checks fail · 6 gaps were found by demos
- check
file-agents-md - check
pkg-engines - Plugin hook spans are created with tracer.startSpan (not startActiveSpan), so work done i… found by drop-studio
- Plugins do not declare their source package found by drop-studio
- Instrumentation scope version is hard-coded (0.1.0) while the package is 0.4.1, and plugi… found by drop-studio
- Running app.boot() under context.with(ROOTCONTEXT.setValue(key, v), …) stalls boot under… found by drop-studio
- The dot bin runs under Node (#!/usr/bin/env node) and cannot import a .ts app file there… found by drop-studio
- createAppRuntime(app).get() returns a DotApp type that is not assignable to the result ty… found by faultline
Used by demos
No demo uses this package yet.
Runtimes
No engines field, so no runtime requirement is declared.
Benchmarks
No benchmarks are published for this version.
README
TypeScript-first application composition framework for the ARKI package family.
@arki/dot is the kernel that wires plugins, lifecycle hooks, dependency
injection, and diagnostics into a deterministic application boot. It gives
library authors a stable contract for declaring how their package
participates in an app, and gives app developers a single place to wire
those packages together — with the type checker verifying the wiring before
anything runs.
What is a plugin?
A plugin is the unit a DOT app is built from — one self-describing, lifecycle-aware piece of an application. Each plugin:
- declares a name, version, and the services it needs as a shape of type witnesses;
- provides services by returning them from its
boothook — provides are inferred from the return type, never declared separately; - runs a 5-hook lifecycle —
configure→boot→start→stop→dispose; - publishes typed services to a shared, type-safe registry that later plugins can read from;
- composes deterministically — plugins boot in declaration order and dispose in reverse.
The name comes from the small dots on dice, dominoes, and music notation: each plugin is one small mark, and the combination of plugins is what gives the app its value. Two plugins on a die make a value of two; six plugins make six. The plugins are the app — not optional add-ons to a hidden core.
import { defineApp } from '@arki/dot';
import { env } from '@arki/env/dot'; // env plugin
import { db } from '@arki/db/dot'; // db plugin
import { kv } from '@arki/kv/dot'; // kv plugin
const app = await defineApp('orders')
.use(env({ schema: { /* ... */ } })) // 1st plugin — provides services.env
.use(db({ relations })) // 2nd plugin — provides services.db
.use(kv(() => ({ url: process.env.KV_URL }))) // 3rd — services.kv; env read at boot, not import
.boot(); // plugins boot in declaration order
await app.services.db.query(/* ... */);
await app.dispose(); // plugins dispose in REVERSE orderEach /dot subpath exports a plugin for that package. The subpath names
the framework the adapter targets (DOT), not the unit (which is a plugin).
Why not "plugin"? Plugins suggest optional add-ons to a core. DOT's reality is the opposite: there is no hidden core — the plugins are the app. "Plugin" names that truth, and ties the framework to the DOT name etymologically (a dot, a plugin, a small mark that gains meaning by combining with others).
Installation
npm install @arki/dot
# or
bun add @arki/dotQuick start
import { defineApp, plugin, service } from '@arki/dot';
type Db = { query(sql: string): Promise<unknown[]> };
const dbPlugin = plugin({
name: 'database',
async boot() {
const db = await openDb(process.env.DATABASE_URL);
return { db }; // ← this IS the provides declaration
},
async dispose({ db }) {
await db.close();
},
});
const billingPlugin = plugin({
name: 'billing',
version: '1.0.0',
needs: { db: service<Db>() }, // typed injection — destructure in hooks
configure(ctx) {
// Validate config, register schemas, no I/O.
},
async boot({ db }) {
// Open connections. The return value IS what this plugin provides.
return { stripe: makeStripeClient(db) };
},
async start({ stripe }) {
// Begin processing — workers, subscriptions, schedulers.
},
async stop({ stripe }) {
// Stop processing — drain workers, unsubscribe.
},
async dispose({ stripe }) {
// Close connections, free resources.
},
});
const app = await defineApp('acme')
.use(dbPlugin) // providers before consumers — enforced at compile time
.use(billingPlugin)
.start();
// app.manifest, app.diagnostics — agent-friendly envelopes.
await app.stop();
await app.dispose();There is no registry to configure, no decorators, no reflection. The needs
shape is the injection contract; the boot return type is the provision
contract; the builder's type-level guard connects the two.
Dependency injection
Needs are witnesses, provides are inferred
service<T>() creates a phantom type witness — a runtime no-op that
carries T at the type level. The property name you give it is your local
alias and (for anonymous witnesses) the wire key:
const search = plugin({
name: 'search',
needs: {
db: service<Db>(), // wire key 'db', local alias 'db'
log: service<Logger>(), // wire key 'log'
},
async boot({ db, log, $app }) { // $app/$plugin/$config: kernel context
log.info(`indexing for ${$app}`);
return { search: await buildIndex(db) };
},
});start, stop, and dispose additionally receive the plugin's own
provides — and because teardown runs in reverse declaration order, a plugin's
needs are still alive in its dispose:
async dispose({ search, db }) { // own provide + still-live need
await search.flush(db);
},Wrong wiring does not compile
Declaration order IS boot order. The .use() guard makes unsatisfied needs,
type mismatches, and key collisions compile errors at the call site:
defineApp('shop')
.use(billing) // ❌ "Expected 2 arguments, but got 1."
.use(database); // billing's `db` need has no earlier provider
defineApp('shop')
.use(database)
.use(database2); // ❌ collision: two providers of wire key 'db'Reordering the first example — or rename-ing one provider in the second —
makes both compile. The kernel re-validates at runtime with coded errors
(E012 unsatisfied need, E013 collision, E014 reserved key) for
erased/dynamic composition, with full rollback of already-booted plugins.
There is no dependency graph to debug and cycles are unrepresentable:
services flow strictly forward through the .use() chain. If two plugins need
each other's services, that's a design smell the type system surfaces
immediately — merge them, or extract the shared piece into a third plugin both
consume.
Tokens — cross-package service contracts
An anonymous service<T>() couples consumer and provider by property name.
When a contract spans packages, a token owns the wire key and the local
alias becomes free choice:
// The package that owns the contract exports the token:
export const Db = token<NodePgDatabase<Schema>>()('arki.db');
// A provider publishes under the token's key:
const database = plugin({
name: 'database',
async boot() {
return provide(Db, await connect()); // → { 'arki.db': NodePgDatabase }
},
});
// Any consumer, any package, any local alias:
const reports = plugin({
name: 'reports',
needs: { warehouse: Db }, // wire key 'arki.db', alias yours
async boot({ warehouse }) { /* ... */ },
});Multi-instance with rename
Mounting an adapter twice would collide on its wire keys — loudly, at
compile time. rename is the multi-instance primitive, and
Token.instance() derives the matching contract:
const ReportsDb = Db.instance('reports'); // token for key 'arki.db#reports'
const app = await defineApp('shop')
.use(database({ url: PRIMARY_URL }))
.use(rename(
database({ url: REPLICA_URL }),
{ 'arki.db': ReportsDb.key }, // republish under the derived key
'reports-db', // distinct plugin name
))
.use(plugin({
name: 'analytics',
needs: { primary: Db, replica: ReportsDb },
async boot({ primary, replica }) { /* two live instances, both typed */ },
}))
.boot();Renames compose left-to-right and are applied at publish time — collision checks see the final keys.
Lazy services
lazy(init, { dispose }) publishes a handle instead of an open
resource. Initialization runs on first get() (memoized, single-flight;
failed attempts retry). Never-touched handles never initialize — and the
kernel auto-disposes initialized ones during teardown, after the
publishing plugin's own dispose hook, even if that plugin has none:
const search = plugin({
name: 'search',
async boot() {
return {
search: lazy(() => connectToElastic(), {
dispose: client => client.close(),
}),
};
},
});Consumers that shouldn't care whether a provider is eager or lazy declare a
lifting witness — service.lazy<T>() always delivers a Lazy<T>
handle, wrapping eager provides and passing lazy ones through by identity:
const suggestions = plugin({
name: 'suggestions',
needs: { search: service.lazy<SearchClient>() },
async start({ search }) {
const client = await search.get(); // first access initializes
/* ... */
},
});Swapping the search provider between eager and lazy is invisible to every consumer — the wiring guard accepts both shapes against the same witness.
Kernel context — the reserved $ namespace
Every service-carrying hook context includes $app (app name), $plugin (plugin
name), and $config (the defineApp(name, { config }) bag). The $ prefix
is enforced as reserved: plugin() rejects $-prefixed needs aliases and
publish keys at compile time, and the kernel re-validates at runtime
(DOT_LIFECYCLE_E014) — kernel keys can never be shadowed.
A complex setup
A distilled commerce platform showing everything above working together. (The package's stress-test suite boots a 28-plugin version of this and asserts exact boot/teardown ordering.)
import { defineApp, lazy, plugin, provide, rename, service, token } from '@arki/dot';
// ── contracts.ts — tokens owned by their respective packages ─────────
export const Env = token<AppEnv>()('shop.env');
export const Db = token<DbHandle>()('shop.db');
export const ReportsDb = Db.instance('reports');
export const Cache = token<CacheHandle>()('shop.cache');
export const Bus = token<MessageBus>()('shop.bus');
// ── infrastructure plugins ───────────────────────────────────────────────
const env = plugin({
name: 'env',
boot: ({ $config }) => provide(Env, parseEnv($config)),
});
const telemetry = plugin({
name: 'telemetry',
needs: { env: Env },
boot: ({ env }) => ({ metrics: makeMetrics(env.OTEL_ENDPOINT) }),
dispose: ({ metrics }) => metrics.flush(),
});
const database = (url: string) =>
plugin({
name: 'database',
needs: { metrics: service<Metrics>() },
async boot({ metrics }) {
return provide(Db, await connectPg(url, { metrics }));
},
async dispose(ctx) {
await ctx[Db.key].end(); // own provide, published under the token key
},
});
const cache = plugin({
name: 'cache',
needs: { env: Env },
boot: ({ env }) => provide(Cache, connectRedis(env.REDIS_URL)),
dispose: async ctx => ctx[Cache.key].quit(),
});
const searchCluster = plugin({
name: 'search-cluster',
needs: { env: Env },
boot: ({ env }) => ({
// Expensive external cluster — only opens if something get()s it.
search: lazy(() => connectElastic(env.ELASTIC_URL), {
dispose: client => client.close(),
}),
}),
});
const bus = plugin({
name: 'bus',
boot: () => provide(Bus, makeInMemoryBus()),
});
// ── domain plugins ───────────────────────────────────────────────────────
const catalog = plugin({
name: 'catalog',
needs: {
db: Db,
cache: Cache,
search: service.lazy<SearchClient>(), // eager or lazy provider — same code
},
boot: ({ db, cache, search }) => ({
catalog: makeCatalog({ db, cache, search }),
}),
});
const checkout = plugin({
name: 'checkout',
needs: { db: Db, bus: Bus, catalog: service<Catalog>() },
boot: ({ db, bus, catalog }) => ({
checkout: makeCheckout({ db, bus, catalog }),
}),
});
const reporting = plugin({
name: 'reporting',
needs: { replica: ReportsDb, catalog: service<Catalog>() },
boot: ({ replica, catalog }) => ({ reporting: makeReporting(replica, catalog) }),
});
// ── process plugins — real work happens in start/stop ───────────────────
const outboxWorker = plugin({
name: 'outbox-worker',
needs: { db: Db, bus: Bus },
boot: ({ db, bus }) => ({ outbox: makeOutbox(db, bus) }),
start: ({ outbox }) => outbox.startPolling(),
stop: ({ outbox }) => outbox.drain(), // stop processing, keep resources
});
const http = plugin({
name: 'http',
needs: {
env: Env,
catalog: service<Catalog>(),
checkout: service<Checkout>(),
reporting: service<Reporting>(),
},
async boot({ env, catalog, checkout, reporting }) {
return { server: buildServer({ port: env.PORT, catalog, checkout, reporting }) };
},
start: ({ server }) => server.listen(),
stop: ({ server }) => server.close(),
});
// ── composition — the order IS the architecture ──────────────────────
const app = await defineApp('shop', { config: process.env })
.use(env)
.use(telemetry)
.use(database(PRIMARY_URL))
.use(rename(database(REPLICA_URL), { 'shop.db': ReportsDb.key }, 'reports-db'))
.use(cache)
.use(searchCluster)
.use(bus)
.use(catalog)
.use(checkout)
.use(reporting)
.use(outboxWorker)
.use(http)
.start();
// SIGTERM → dispose() cascades stop() first, tears down in exact reverse:
// http, outbox (drained), reporting, checkout, catalog, bus,
// search cluster (only if it ever initialized), cache, reports-db,
// primary db, telemetry, env.
process.on('SIGTERM', () => void app.dispose());What the type checker is holding for you in that chain:
- move
.use(catalog)above.use(cache)→ compile error at that line; - delete
.use(bus)→ compile errors atcheckoutandoutboxWorker; - mount the second
database(...)withoutrename→ collision error; - change
makeCatalogto return something that isn't aCatalog→ every consumer's.use()flags the mismatch.
Concurrent lifecycle calls are safe: transitions are serialized on one
queue, same-phase calls coalesce onto one in-flight promise, and a
dispose() racing a slow start() always runs after it — the app ends
disposed, never resurrected.
Logging
Dot logs through @arki/log. The kernel's own records go to the
arki:dot:lifecycle category. Importing Dot switches nothing on: the app
does, with log() from @arki/dot/log as its first .use(...).
import { defineApp } from '@arki/dot';
import { log } from '@arki/dot/log';
import { asyncContext, fromEnv, stdout } from '@arki/log/node';
const app = await defineApp('checkout')
.use(log(() => ({ ...fromEnv(), context: asyncContext(), channels: { stdout: stdout() }, default: { level: 'info', channels: ['stdout'] } })))
.boot();- The config is a thunk. It runs when the app boots, never at import.
resource['service.name']defaults to the app name.- The
configure()handle is published as theloggingservice (needs: { logging: service<Configured<'stdout'>>() }). log()reads no env on its own (spreadfromEnv()), installs no OpenTelemetry SDK and does not patchconsole.
log() fills Dot's logging slot. Any plugin can fill it with
plugin({ name, logging: ({ appName }) => handle }), where handle has
close(): Promise<void>. The kernel calls the factory before its first
lifecycle record (configure: starting) and closes the handle after its
last one (dispose: complete). On a failed configure, boot or start,
the failure is written first and logging closes last. An app has at most one
logging plugin: two is DOT_LIFECYCLE_E018.
Without a logging plugin, @arki/log keeps only the first 64 records at
warn and above, and writes nothing else.
Testing plugins
testPlugin is the typed unit-test builder: satisfy a plugin's needs directly
with fakes — no real providers, no dependency chain — and the compiler
holds the same line it holds in production. A missing fake means boot()
does not compile; a fake of the wrong shape fails at the .provide()
call site:
import { testPlugin } from '@arki/dot/test-harness';
const app = await testPlugin(catalog)
.provide(Db, fakeDb) // token need
.provide('cache', fakeKv) // anonymous need — wire key is the alias
.boot();
expect(app.services.catalog.list()).toEqual([]);
await app.dispose();The fakes are published by a synthetic first plugin, so lifecycle semantics
are the real kernel's — reverse-order teardown, lazy auto-dispose, and
$config all behave exactly as in production. service.lazy<T>() needs
accept a plain T fake (lifted automatically) or lazyOf(value).
For integration tests across several real plugins, testApp([...plugins])
builds an app from an erased plugin array (runtime validation only), and
bootTestApp is the one-line boot-and-return variant.
Operations
Two kernel helpers close the gap between "boots on my machine" and "runs under a process manager":
const app = await defineApp('shop', { hookTimeoutMs: 30_000 })
.use(/* ... */)
.start();
hookSignals(app); // SIGTERM/SIGINT → stop() + dispose() → re-raisehookSignals(app, { timeoutMs? })— graceful shutdown wiring. The first signal drains the app (bounded bytimeoutMs, default 10 s) and re-raises itself so the exit status keeps standard signal semantics; a second signal falls through to the runtime default (immediate kill). Returns an unhook function.hookTimeoutMs— a per-hook watchdog. Anyboot/start/stop/disposehook exceeding the budget fails withDOT_LIFECYCLE_E015naming the plugin and hook, and the kernel applies its normal rollback or aggregation rules. Your app cannot hang silently at boot.
Plugin authoring
plugin(config) accepts a needs shape plus five lifecycle hooks. Hook
contexts carry the needed services (typed, under your local aliases) and
$-prefixed kernel keys ($app, $plugin, $config):
| Hook | Purpose |
|---|---|
configure | Validate static config; declare schemas, routes, services. No I/O. |
boot | Open connections; the returned record is what the plugin provides. |
start | Begin processing (workers, subscribers, schedulers). |
stop | Stop processing in reverse declaration order. |
dispose | Free resources after stop; lazy handles auto-clean afterwards. |
Wiring is compile-time checked: .use(plugin) fails to typecheck when the
plugin's needs aren't satisfied by earlier .use() calls, or when its
provides collide with an existing wire key. rename(plugin, { db: 'reportsDb' })
mounts a second instance of an adapter without collision; token<T>()('key')
shares a service contract across packages; lazy(() => open(), { dispose })
defers an expensive open until first get() — never-touched services never
initialize, and the kernel auto-disposes initialized ones. Declaration
order is boot order — same input, same order, every time.
Lifecycle
defineApp(name) returns a builder. Calling .use(plugin) accumulates
plugins. The lifecycle then flows:
defined ──configure()──▶ configured ──boot()──▶ booted ──start()──▶ started
│
disposed ◀──dispose()── stopped ◀──stop()───────┘boot() runs configure() implicitly if you skipped it. start() runs
boot() implicitly. stop() and dispose() always run in reverse
declaration order, even when an earlier hook failed — failure isolation is
part of the contract. A boot failure rolls back every already-booted plugin
before throwing.
Runtime failures carry stable codes (DotLifecycleError.code):
| Code | Meaning |
|---|---|
DOT_LIFECYCLE_E011 | Plugin registered twice. |
DOT_LIFECYCLE_E012 | A need has no provider among earlier plugins. |
DOT_LIFECYCLE_E013 | A published wire key collides with an earlier one. |
DOT_LIFECYCLE_E014 | A service key uses the reserved $ (kernel) prefix. |
DOT_LIFECYCLE_E015 | A hook exceeded the hookTimeoutMs watchdog. |
(Full table including hook-failure codes: docs/lifecycle.md.)
CLI
@arki/dot ships a small CLI for scaffolding and inspecting apps:
# Scaffold a full-stack app: TanStack Start + oRPC + Postgres (@arki/db)
# + @arki/journal + @arki/ui, with Dockerfile, migrations and tests.
dot new my-app
# Scaffold the smallest DOT app instead (defineApp + env, vitest boot test).
dot new my-app --template minimal
# Preview the file operations without writing anything.
dot new my-app --dry-run --json | jq '.operations'
# Print the app manifest as a structured envelope.
dot explain --app ./my-app.ts
# Run boot-time diagnostics; non-zero exit if any check fails.
dot doctor --app ./my-app.ts
# Render the plugin graph as Mermaid flowchart source. explain shows
# declaration (= boot) order; doctor boots and shows the OBSERVED wiring.
dot doctor --app ./my-app.ts --graph
# Every command supports --json for agent-friendly output.
dot explain --app ./my-app.ts --json | jq '.data.plugins'The CLI emits the same envelope shape as the in-process diagnostics snapshot
(app.diagnostics), so the same downstream tools can consume either. The
manifest's dependency edges are observed, not declared — the kernel
records which plugin's published service satisfied which need during boot.
dot new <app-name>
Scaffolds an app under <app-name>/ (override with --target). Pick the
starting point with --template:
stack(default) — a full-stack app in one package: TanStack Start (React, Vite, Nitro), an oRPC API at/api/rpc, Postgres through@arki/db, every write through an@arki/journalcommand (an audit trail), all composed withdefineApp. UI uses@arki/uiwith the workbench design system. It ships one feature end to end (notes), an initial migration,/api/health, adocker-compose.ymlfor local Postgres, a productionDockerfile, tests on in-memory PGlite (no Docker), and a README + AGENTS.md. Run it withbun install,cp .env.example .env,docker compose up -d,bun run dev.minimal— adefineApp(...)entrypoint wired to@arki/env/dot, a vitest that boots the app and asserts the manifest shape, and anAGENTS.mddocumenting verification commands.
An unknown template name fails with DOT_CLI_E005. Pass --dry-run --json
to inspect the exact file operations (path, action, contentHash,
contentBytes, reason) before committing them to disk; --force
overwrites pre-existing files in the target directory.
Templates live in templates/ and ship with the published
tarball. Generated apps depend only on published @arki/* packages.
Architecture
@arki/dot is intentionally small: it defines the contracts (plugin shape,
lifecycle hooks, manifest schema, diagnostics envelope) and runs them. Adapters
that bridge databases, queues, auth providers, and HTTP routers live in their
own packages and consume @arki/dot as a peer dependency:
import { env } from '@arki/env/dot';
import { db } from '@arki/db/dot';
import { kv } from '@arki/kv/dot';
import { journal } from '@arki/journal/dot';This keeps the kernel free of optional dependencies and lets each adapter ship at its own cadence.
Documentation
The full docs live in docs/:
- Principles — read first. The five rules every API, error, and PR is measured against. Slightly playful, very precise.
- Quickstart — boot your first app in five minutes.
- Plugin authoring — write your own plugin.
- Lifecycle — the 5-hook contract, idempotency, error codes.
- Diagnostics —
app.manifest,app.diagnostics,dot explain,dot doctor. - Adapter authoring — expose your package as a DOT plugin.
- Agent guide — how coding agents inspect, modify, and verify DOT apps.
- Release policy — SemVer and deprecation.
Agent-discoverable index: llms.txt.
Benchmarks
A 20-service graph where each service depends on the two before it, built with @arki/dot plugins and with awilix, the most used dependency-injection container for Node. A hand-wired version is the floor: no container at all.
Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.
Build the graph and bring it up (boot + dispose, 20 services)
dot: declare 20 plugins, boot, dispose. awilix: create a container, register 20 factories, resolve all 20, dispose. Hand-wired: build the 20 objects in order.
| Library | Time / op | vs fastest |
|---|---|---|
| @arki/dot (boot + dispose) | 199.83 µs | 14.99× |
| awilix (register + resolve + dispose) | 65.92 µs | 4.94× |
| hand-written wiring (no container) | 13.33 µs | fastest |
Look up a service after boot
The hot path: code that already has a booted app asks for one service. dot services are plain properties; awilix resolve and cradle go through a cache lookup.
| Library | Time / op | vs fastest |
|---|---|---|
| @arki/dot (app.services.s19) | 30.3 ns | fastest |
| awilix (container.resolve('s19')) | 67.2 ns | 2.21× |
| awilix (container.cradle.s19) | 105.4 ns | 3.47× |
| hand-written (plain object property) | 32.0 ns | 1.05× |
Read the rows with the contracts in mind:
@arki/dotand awilix do different jobs. awilix is a container: it registers factories and builds them on first use. dot runs an application lifecycle: it orders plugins by their declared needs, runsconfigure,bootanddisposehooks in order (and in reverse on shutdown), checks the wiring at compile time, and records a manifest and diagnostics for every plugin.- The boot rows therefore measure more work in dot than in awilix. They are shown so you can see what that lifecycle costs, not to say the two are interchangeable.
- Boot is async in dot (plugin hooks may await). awilix resolves synchronously.
Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.
License
MIT — see LICENSE.
Public imports
This version has no API index.