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`. | - 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 }); - 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) })})`, - candidatenew idea
Durable workflows are journal entities
A workflow run is an entity; every step, wait and undo is an event in journal_events, and each step runs as a job that survives a restart.
packages/journal/README.mdline 810
A workflow is a list of steps that runs to the end even when the process dies. Each step runs as a job in `journal_jobs`. When a step finishes, its result is saved (a checkpoint) and the next step is scheduled, in one transaction. A run can wait for a signal from outside, or sleep, for hours or days. When it cannot finish, it undoes the steps it did, newest first (compensation). The run is a journal entity (`workflow.run`), so every move is an event in `journal_events`. - candidatenew idea
A workflow starts from a domain event
startOn ties a run to an entity event; the run id and its input come from that event, in the transaction that recorded it.
packages/payments/src/refunds/workflow.tslines 124-129
startOn: { entity: refund, event: refundsJournal.events.RefundApproved, id: e => refundRunId(e.entityId), input: e => ({ refundId: e.entityId }), }, - candidatenew idea
waitFor a signal, with a timeout
A run can wait days for an outside signal, check it against a schema, and carry on or stop when the time runs out.
packages/payments/src/refunds/workflow.tslines 201-206
.waitFor('settled', { signal: 'stripe.refund.settled', schema: settledSchema, timeout: '3d', // No signal in 3 days: `record` asks Stripe onTimeout: 'continue', - 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
- 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.
Web and UI
Typed HTTP you can test without a socket, and components built on one token vocabulary.
3 rows · 1 new ideas
- 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'));
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.
Platform
The small packages every layer stands on: failures, schemas, logs, the command line and time.
10 rows · 4 new ideas
- candidatenew idea
Failures without a wrapper
A function returns the value itself or a failure; there is no ok() to add and no .value to unwrap.
packages/failure/README.mdlines 24, 26, 28-32
import { z } from 'zod';import { defineFailure, isFailure } from '@arki/failure';const EmailTaken = defineFailure('user.email_taken', { status: 409, data: z.object({ email: z.string() }), message: 'email {email} is taken',}); - candidatenew idea
The failure union is inferred
A function's return type lists every failure it can produce, including the ones it passes on; nobody writes the union down.
packages/failure/README.mdline 48
`signUp` returns `{ email } | Failure<'user.email_taken', { email: string }>`. Nobody wrote that type. The compiler infers it, and `result.email` is a type error until `isFailure` has run. - candidatenew idea
A lint rule catches an ignored failure
must-use reports a call whose failure is thrown away; no-blind-accept reports an accept of a failure not checked by kind.
packages/failure/README.mdline 9
- **Caught when ignored.** The `must-use` lint rule reports a call whose failure is thrown away. `no-blind-accept` reports `accept(r)` of a failure not checked by kind. - candidatenew idea
A JSON envelope when piped, text on a terminal
One defineCommand gives the flags, their coercion and the help from the input schema; a run writes JSON when stdout is a pipe.
packages/cli/README.mdline 3
Typed command-line programs from one `defineCommand({...})`. The input schema drives the flags, their coercion and their help. A run writes a JSON envelope when stdout is a pipe and text on a terminal. Exit codes are stable. Help and `--version` never run a command, and import at most one lazy level of the tree.