npm.io
0.15.1 • Published 3d ago

@nwire/forge

Licence
MIT
Version
0.15.1
Deps
14
Size
381 kB
Vulns
0
Weekly
2.4K

@nwire/forge

Domain primitives: actions, queries, actors, events, workflows, projections. The batteries you grow into, riding the one runtime.

pnpm add @nwire/forge zod

Quick 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

  • PrimitivesdefineAction, defineQuery, defineActor, defineEvent, defineWorkflow, defineProjection; plus eventFactory, defineResource, defineUpcaster, response builders. (defineAction/defineQuery are defineHandler.kind(...) from @nwire/handler.)
  • PluginsforgePlugins(options) (the set) or the à-la-carte concern plugins: actionsPlugin, queriesPlugin, actorsPlugin, projectionsPlugin, workflowsPlugin, idempotencyPlugin, externalCallsPlugin.

Keywords