@arki/dot
v0.4.0
Published
TypeScript-first application composition framework for the ARKI package family — lifecycle-aware plugins, deterministic boot order, manifest/diagnostics envelopes, and an agent-native CLI. A DOT app is the sum of its plugins.
Maintainers
Readme
@arki/dot
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 plugin — provides services.kv
.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.
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 minimal DOT app (package.json, tsconfig, app entrypoint,
# env schema, AGENTS.md, README, gitignore, vitest boot test).
dot new my-app
# 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 a minimal DOT app under <app-name>/ (override with --target).
The scaffold ships a defineApp(...) entrypoint wired to @arki/env/dot,
a vitest that boots the app and asserts the manifest shape, and an
AGENTS.md documenting verification commands and the public/private
boundary for agents working in the generated tree. 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 at templates/app-minimal/ and
ship with the published tarball.
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 { eventSourcing } from '@arki/event-sourcing/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.
License
MIT — see LICENSE.
