6 sheets · 42 rows · 26 new ideas · 39 with a code line
What's inside
Every row is one thing arki or Dot does today, with the package it lives in and the line of code that shows it.
- shippedon npm now
- candidatein the next release
- plannedon the roadmap
Dot: the app kernel
Dot composes an app from typed units, and the compiler checks the wiring before anything runs.
10 rows · 9 new ideas
- shippednew idea
Provider order checked by the compiler
Mount a unit before the one it needs, or the editor rejects that line and the error says where to move it.
packages/dot/src/define-app.tslines 42-56, 93-98
import type { AnyUnit, EmptyPhases, EmptyShape, MergePhases, RuntimeUnit, ServiceRecord, UnitCloseFailure, UnitOpenFailure, UnitPhaseFailures, UnitPhases, UnitRole, UnitSourceGuard, UnitWiring,} from './unit-contract.js';type NeedsError<TAvail extends ServiceRecord, TNeeds extends ServiceRecord> = { readonly 'DOT: this unit needs services that nothing before it provides. Mount their providers earlier (.infra → .features → .entrypoints)': { readonly missing: MissingKeys<TAvail, TNeeds>; readonly mismatched: MismatchedKeys<TAvail, TNeeds>; };}; - shippednew idea
Key collisions caught at compile time
Two units that provide the same key do not type-check; rename() gives one of them a new key and rebuilds its handles.
packages/dot/src/define-app.tslines 100-103
type CollisionError<TAvail, TProvides> = { readonly 'DOT: this unit provides a key that an earlier unit already provides. Give one of them a new key with rename()': keyof TAvail & keyof TProvides;}; - shippednew idea
Typed service handles
A unit names what it needs as handles like database.provides.db; the type comes from the provider, and .lazy defers the value until first use.
packages/dot/docs/di.mdlines 646-649
.needs({ db: database.provides.db, // typed from the provider cache: cache.provides.kv.lazy, // a Lazy<T>, even if the provider is eager }) - shippednew idea
entrypointOnly: infra the usecases never see
A feature can take db or kv for its own store and limiter while its usecases only pick the services they name.
apps/avocabo/backend/src/features/waitlist/feature.tslines 12, 17-19, 34-39
import { defineFeature, entrypointOnly } from '@arki/dot/feature';import { database } from '../../infra/db';import { cache } from '../../infra/kv';import { emailClient } from '../../infra/mail/client'; .needs({ db: entrypointOnly(database.provides.db), kv: entrypointOnly(cache.provides.kv), // The app's outbound email client; a test fakes the plugin. mailer: emailClient.provides.emailClient, }) - shippednew idea
Stable error codes
Every kernel failure has an ARKI_DOT_E### code that is never renumbered or reused, so a link to its page stays valid.
packages/dot/src/lifecycle.tslines 50-55
* Stable error codes for every failure the kernel throws, `ARKI_DOT_E001`.. * Codes are never renumbered and never reused (E009 and E010 are retired). * E001-E021 and E033 are lifecycle failures; E022-E031 are feature-composition * failures (see `FEATURE_ERROR_CODES`); E032 is non-JSON manifest data. */export const DotLifecycleErrorCode = { - shippednew idea
Wrong order caught at configure, before boot
A provider mounted after its consumer fails as ARKI_DOT_E020 while the app is still being configured; no connection has opened yet.
packages/dot/docs/diagnostics.mdlines 218-220
at `configure()`:- `ARKI_DOT_E020` — a provider is mounted after its consumer. The fix - shippednew idea
boot() returns a failure, never throws
A failed boot comes back as a typed value; the units already booted are disposed in reverse order and the app is marked failed.
packages/dot/docs/lifecycle.mdlines 100-102
- Previously booted units dispose in reverse declaration order.- App transitions to `failed`; no failed provider publishes a service.- The original refusal is returned when cleanup succeeds; unknown throws become - shippednew idea
dot explain and dot doctor
explain prints the app manifest from configure alone; doctor boots the app, runs its checks and disposes it; both answer in JSON.
packages/dot/docs/cli.mdlines 9-10
| `dot explain` | Imports the app file and runs `configure`. Never boots. || `dot doctor` | Boots the app (`boot` hooks run, migrations apply), then disposes it. Never calls `start`. | - shipped
OTel-first kernel
Traces, metrics and logs come out of the kernel; you wire only the exporter, and without an SDK the no-op API is used.
packages/dot/docs/observability.mdlines 3-4
DOT is OTel-first. Traces, metrics, and logs come out of the kernelwithout you wiring anything — you wire the _exporter_. Without an OTel SDK, tracing and metrics safely use the API’s no-op implementations. - shippednew idea
TanStack Start as the driving adapter
Every Start request gets a typed context: the session, an RPC client and the mounts, built from the app's own services.
apps/avocabo/backend/src/app.tslines 18, 23, 26, 42-47
import { tanstackStart } from '@arki/tanstack-start/dot';import { gateway } from './entrypoints/gateway';import { authn } from './infra/auth'; .entrypoints( hostClock, tanstackStart({ needs: { rpc: gateway.provides.rpc, auth: authn.provides.auth, scheduler }, session: ({ auth }, request) => auth.principal(request.headers), rpc: ({ rpc }, request) => rpc.client(request.headers),
Journal: the write path
Every change is one immutable event next to a normal row, and both are written in one transaction.
12 rows · 10 new ideas
- shippednew idea
One transaction for row, events, projections and jobs
A write appends the events, saves the row, runs the inline projections and queues the after-commit jobs, and all of it commits or rolls back together.
packages/journal/src/write.tslines 377-381
for (const event of recorded) { for (const projection of projections) { if (projection.types.has(event.type) && projection.entities.some(e => e.kind === entity.kind)) { await projection.run(sp, event); } - shippednew idea
Commands refuse with typed failures
A rule that says no returns a failure; the write rolls back and run returns it with its type, so every caller must handle it.
packages/journal/README.mdlines 46, 85-90
import { CommandConflict, CommandNotFound, initJournal, need } from '@arki/journal'; write(order, input.orderId, row => { if (!row) return CommandNotFound({ detail: 'Order not found' }); if (row.status !== 'placed') return CommandConflict({ detail: `Order is already ${row.status}` }); return [OrderPaid({ amount: input.amount })]; // [] means: write nothing }),); - shippednew idea
Reducers patch the row; null deletes it
An entity maps each event to the columns that change; returning null removes the row while its events stay in the log.
packages/journal/README.mdlines 75-77
.create(OrderPlaced, e => ({ customerId: e.data.customerId, total: e.data.total, status: 'placed' })) .on(OrderPaid, () => ({ status: 'paid' })) // a patch: only the columns that change .on(OrderCancelled, () => null); // null deletes the row; the events stay - shippednew idea
Idempotent commands and HTTP replies, one receipts table
journal_receipts answers a retried command with its first result, and the same table caches a whole HTTP response for the idempotent() middleware.
packages/journal/README.mdlines 191-194
import { idempotent } from '@arki/http/idempotency';import { responseCache } from '@arki/journal/http';idempotent({ cache: responseCache(db, tables), scope, routes: ['POST /orders'], ttl: 86_400_000 }); - shipped
Effects after commit, at least once
An effect runs from a job row once the transaction committed; its jobId stays the same across retries, so you pass it on as an idempotency key.
packages/journal/README.mdline 409
An effect runs after the commit, at least once, from a row in `journal_jobs`. A handler gets the event and a context: `(e, { jobId, signal, db, run })`. - shippednew idea
Wake channel on commit
Each commit that recorded events calls pg_notify with the seq range, so every listening worker in every process drains at once.
packages/journal/src/write.tslines 7, 410
import { and, eq, sql } from 'drizzle-orm'; sql`select pg_notify(${deps.wakeChannel}, ${JSON.stringify({ from: Math.min(...seqs), to: Math.max(...seqs) })})`, - shippednew idea
Erasure that keeps history consistent
forget rewrites an entity's events to their erased form and repairs the row from the rewritten log, in one transaction.
packages/journal/README.mdline 262
`journal.forget(db, entity, id)` rewrites every stored event of that entity that has an erased form to it, sets `erased_at` on those rows, and then repairs the row from the rewritten log, so the row and its history agree. All in one transaction. An erased form the schema rejects is `BAD_DEFINITION` and nothing changes. A history replay cannot rebuild, such as a legacy row that was never adopted, is `NO_HISTORY` and nothing changes: adopt it first. Running it twice is safe; the first erased time is kept. It returns `{ erased, row }`. - shippednew idea
The journal as a Dot plugin
Add the journal to a Dot app with two plugins: journal() gives features a services.journal to write through, and journalWorker() runs the effects, taps and async projections, handing each the services of the feature that lists it.No app uses it yet.
packages/journal/src/dot.tslines 612-617
* The entrypoint, for `.entrypoints()`: one worker between `start` and `stop`, for the * journal that `journal(...)` published. Each effect and tap of a feature gets the * services of that feature. A process that serves no worker still writes: its jobs * wait for a process that does. With `jobs`, the worker also runs queue rows * (`@arki/queue/outbox`) through the runner the source builds at boot. */
Data
Postgres through Drizzle, a key-value store with a fallback, and config that carries its types.
4 rows · 1 new ideas
- shipped
Three ways to run Postgres
node-postgres by default, Bun's native SQL through the bun export condition, or embedded PGlite for local and test runs.
packages/db/README.mdline 5
- Two runtime entry points (`node-postgres` pool by default, Bun-native SQL via the `bun` export condition, embedded PGlite via `./runtime-local`). - shipped
Key-value with a safe fallback
Keyv underneath, Redis rate limiting, TLS from rediss:// URLs, and an in-memory fallback when Redis is unreachable.
packages/kv/README.mdline 3
Key-value store abstraction with pluggable adapters for ARKI. Wraps [Keyv](https://keyv.org/) for general key-value access and [`rate-limiter-flexible`](https://github.com/animir/node-rate-limiter-flexible) for Redis-backed rate limiting. Provides automatic TLS handling (via `rediss://` URLs), Upstash support, and a graceful in-memory fallback when Redis is unreachable. - shippednew idea
Encrypted env files you can commit
Secrets live encrypted in .env.<environment> files; a public key in the file lets anyone add a value, and only the private key reads them.
packages/env/README.mdline 85
Commit `.env.<environment>` files with their secrets encrypted. The format is dotenvx 2.x, so the `dotenvx` CLI reads the same files. Each file holds a public key, so anyone can add a value. Only a holder of the private key can read one. The private key lives in `DOTENV_PRIVATE_KEY_<ENV>` (for `.env.production`: `DOTENV_PRIVATE_KEY_PRODUCTION`) or in the gitignored `.env.keys` file. - shipped
Typed env with any Standard Schema
defineEnv validates on the first read and returns a typed object; Zod, Valibot, ArkType or a hand-written schema all work.
packages/env/README.mdline 3
Type-safe environment variables for ARKI. `defineEnv()` wraps [`@t3-oss/env-core`](https://env.t3.gg/). It validates on the first read and returns a typed object. Any [Standard Schema](https://standardschema.dev) works: Zod, Valibot, ArkType or a hand-written `~standard` object.
Web and UI
Typed HTTP you can test without a socket, and components built on one token vocabulary.
3 rows · 1 new ideas
- shipped
Route contracts as data, OpenAPI from them
A route is a value; handlers are validated by any Standard Schema, and the OpenAPI document is generated the same way every time.
packages/http/README.mdlines 3-6
Typed HTTP for ARKI — route contracts as data, handlers validated by anyStandard Schema (zod, valibot, arktype, your own),request-scope derivers, SSE streaming, and deterministic OpenAPIgeneration. Ships a DOT adapter (`@arki/http/dot`) whose `http()` entrypoint unit - shippednew idea
An engine you can call without a socket
buildEngine returns a fetch function; a test passes a Request and reads the Response, and no server is started.
packages/http/README.mdlines 28, 39-41
import { buildEngine, route, routes } from '@arki/http';const engine = buildEngine([routes().bind(getOrder, ({ params }) => ({ id: params.id, total: 12 }))]);const response = await engine.fetch(new Request('http://localhost/orders/ord_1')); - shipped
One import per component
Base UI primitives styled with Tailwind v4 semantic tokens: forms, overlays, navigation, data display and charts.
packages/ui/README.mdline 3
React component library for ARKI apps: Base UI primitives styled with Tailwind v4 semantic tokens. Forms, overlays, navigation, data display and charts. One import per component.
Money and payments
Amounts are whole units, currencies are kept apart by the type, and payments write through the journal.
3 rows · 1 new ideas
- shippednew idea
Exact units, a number until bigint is needed
19.99 EUR is 1999 units; nothing rounds unless you ask, and a plain number is used up to 2^53.
packages/money/README.mdline 5
- **Exact.** Amounts are whole numbers of units: `19.99 EUR` is `1999` units at 2 decimals. Nothing rounds unless you pass a rounding mode. - shipped
Currencies kept apart by the type
Money<'EUR'> cannot be added to Money<'USD'>; the compiler stops it, and the runtime throws too.
packages/money/README.mdline 7
- **Safe types.** `Money<'EUR'>` cannot be added to `Money<'USD'>`. The compiler stops it, and the runtime throws `CurrencyMismatchError` too. - shipped
Stripe provider with journal commands
Plans, customers, subscriptions, payments, refunds, dunning and tax, with table factories, repositories and commands; routers stay in your app.
packages/payments/README.mdline 3
Payments for ARKI apps: a Stripe provider, plans, customers, subscriptions, payments, refunds, dunning and tax. It ships Drizzle table factories, repositories, services and [`@arki/journal`](https://www.npmjs.com/package/@arki/journal) commands. Routers stay in each consuming app, so the app owns its RPC context and its authorization policy.
Platform
The small packages every layer stands on: failures, schemas, logs, the command line and time.
10 rows · 4 new ideas
- shipped
Message templates keep values as fields
A template like 'user {userId} signed in' logs its fields, a processor can redact one, and records map onto the OpenTelemetry log model.
packages/log/README.mdline 3
Zero-dependency structured logging for every JavaScript runtime. Libraries log; apps configure. Message templates keep their values as fields, channels are set in one config, and records map onto the OpenTelemetry log data model. Importing it does nothing: no env read, no output, no listener. - shipped
Temporal dates with no hidden now
Calendar dates, instants, zoned date-times, durations and business days; values are immutable, and there is no hidden zone.
packages/date/README.mdline 3
Date and time math on [Temporal](https://tc39.es/proposal-temporal/) values with a small, explicit API. Calendar dates, instants, zoned date-times, durations, interval algebra, business days, formatting and relative text. Values are immutable, one arithmetic policy applies everywhere, and there is no hidden "now" and no hidden zone.