@nwire/forge
@nwire/forge
Domain primitives: actions, queries, actors, events, workflows, projections. The batteries you grow into, riding the one runtime.
pnpm add @nwire/forge zodQuick tour
1. Define an action
An action is a handler — defineAction = defineHandler.kind("action"). It
carries a name, a zod input, and the handler body. The body takes one ctx
arg with the validated input on it.
import { defineAction } from "@nwire/forge";
import { z } from "zod";
export const placeOrder = defineAction({
name: "orders.place",
input: z.object({
customerId: z.string(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })),
}),
handler: async ({ input, resolve }) => {
const orders = resolve<OrderRepo>("orders");
return orders.create(input);
},
});ctx carries input, resolve<T>(name), envelope (tenant / user /
correlation / causation), and the dispatch verbs request (ask + await),
send (fire-and-forget → MessageRef), emit (publish an event), query
(read a projection), and actor (load an actor view).
For a cross-module contract the local module doesn't handle, omit handler —
it's then a schema-only action you dispatch toward; the owning module registers
the handler.
2. Define a query
A query is a handler too — defineQuery = defineHandler.kind("query") — so it
wires to a GET and dispatches like anything else. Projection form reads folded
state; handler form reaches a source directly.
import { defineQuery } from "@nwire/forge";
export const listOrders = defineQuery(OrdersDashboard, {
name: "orders.list",
input: z.object({ status: z.enum(["open", "shipped"]).optional() }),
execute: (state, { status }) =>
Object.values(state).filter((o) => !status || o.status === status),
});3. Register + wire into an App
Registration is the app's job — pass handlers (actions and queries) to
createApp({ handlers }). forgePlugins() adds the batteries (it scans the
runtime for action/query handlers and wires the command pipeline + read engine);
it does not take a handler list.
import { createApp } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
import { post, get } from "@nwire/wires/http";
const app = createApp({
appName: "orders",
handlers: [placeOrder, listOrders],
plugins: [...forgePlugins({ actors: [subscription], projections: [OrdersDashboard] })],
});
// HTTP routes wire the same handlers — the action/query IS the handler.
app.wire(post("/orders", { body: placeOrder.input }), placeOrder);
app.wire(get("/orders", { query: listOrders.input }), listOrders);4. Dispatch from anywhere
await app.runtime.execute(placeOrder, {
customerId: "c-1",
items: [{ sku: "WIDGET", qty: 2 }],
});The runtime validates input, opens an envelope, runs middleware, fires
ActionDispatching + per-action before/after hooks, runs the handler with
retry + DLQ, publishes returned events, and surfaces telemetry. HTTP and queue
adopters land on the same runtime.execute, so dispatch semantics never differ
by transport. Inside a handler, prefer ctx.request / ctx.send.
Actors
import { defineActor, defineSchema } from "@nwire/forge";
const SubscriptionData = defineSchema({
name: "subscription",
key: "id",
fields: {
id: z.string(),
plan: z.enum(["free", "pro"]),
status: z.enum(["active", "past_due", "suspended"]),
},
states: {
active: { initial: true },
past_due: {},
suspended: { final: true },
},
});
export const subscription = defineActor(
"subscription",
({ states, when }) => {
const { active, pastDue } = states;
active(() =>
when(PaymentFailed, (_e, { assign }) => {
assign({ status: "past_due" });
return pastDue;
}),
);
},
{ schema: SubscriptionData },
);Actors carry state, version, and OCC; events drive transitions. The store seam
is an ActorStore adapter. Pass actors: [...] to forgePlugins.
Workflows
import { defineWorkflow } from "@nwire/forge";
export const paymentRenewal = defineWorkflow("payment.renewal", ({ when, schedule, timeout }) => {
const Overdue = timeout("overdue", "48h");
when(ChargeFailed, async (e, ctx) => {
await schedule(Overdue);
});
when(Overdue, async (_e, ctx) => {
await ctx.send(suspendSubscription, { id: _e.id });
});
});Workflows react to events with the one verb, when; schedule/timeout arm
timers and ctx.send(...) dispatches actions. Declare data/states (3rd arg)
to make it a stateful saga. Pass workflows: [...] to forgePlugins.
Projections
import { defineProjection } from "@nwire/forge";
export const OrdersDashboard = defineProjection(
"orders.dashboard",
({ when }) => {
when(orderPlaced, (state, event) => ({ ...state, [event.orderId]: event }));
},
{ initial: () => ({}) },
);Projections fold events into read models; queries read them. Pass
projections: [...] to forgePlugins.
Surface
- Primitives —
defineAction,defineQuery,defineActor,defineEvent,defineWorkflow,defineProjection; pluseventFactory,defineResource,defineUpcaster, response builders. (defineAction/defineQueryaredefineHandler.kind(...)from@nwire/handler.) - Plugins —
forgePlugins(options)(the set) or the à-la-carte concern plugins:actionsPlugin,queriesPlugin,actorsPlugin,projectionsPlugin,workflowsPlugin,idempotencyPlugin,externalCallsPlugin.
Related
@nwire/app—createApp, plugin lifecycle, runtime@nwire/handler— the handler primitive (defineHandler)@nwire/messages— typed envelope + zod helpers@nwire/koa— HTTP adopter that routes through the runtime