create-nwire 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +6 -2
  3. package/package.json +1 -1
  4. package/templates/enterprise/AGENTS.md +11 -11
  5. package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
  6. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  7. package/templates/enterprise/app/api.ts +3 -1
  8. package/templates/enterprise/app/app.ts +23 -15
  9. package/templates/enterprise/app/main.ts +20 -45
  10. package/templates/enterprise/config/app.ts +18 -0
  11. package/templates/enterprise/config/env.ts +32 -0
  12. package/templates/enterprise/config/http.ts +15 -0
  13. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  14. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  15. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  16. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  17. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  18. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  19. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +27 -25
  20. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  21. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  22. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  23. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  24. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  25. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  26. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  27. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  28. package/templates/enterprise/package.json +10 -11
  29. package/templates/enterprise/tsconfig.json +1 -1
  30. package/templates/mcp/AGENTS.md +73 -0
  31. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  32. package/templates/mcp/_gitignore +5 -0
  33. package/templates/mcp/_npmrc +4 -0
  34. package/templates/mcp/_pnpm-workspace.yaml +6 -0
  35. package/templates/mcp/app/app.ts +24 -0
  36. package/templates/mcp/app/main.ts +197 -0
  37. package/templates/mcp/app/store/facts-store.ts +17 -0
  38. package/templates/mcp/app/tools/add-fact.ts +27 -0
  39. package/templates/mcp/app/tools/list-facts.ts +17 -0
  40. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  41. package/templates/mcp/app/tools.ts +19 -0
  42. package/templates/mcp/config/app.ts +16 -0
  43. package/templates/mcp/config/env.ts +27 -0
  44. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/templates/mcp/package.json +25 -0
  46. package/templates/mcp/tsconfig.json +15 -0
  47. package/templates/mcp/vitest.config.ts +8 -0
  48. package/templates/minimal/AGENTS.md +5 -5
  49. package/templates/minimal/__tests__/hello.test.ts +3 -3
  50. package/templates/minimal/app/app.ts +21 -0
  51. package/templates/minimal/app/main.ts +11 -23
  52. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  53. package/templates/minimal/package.json +7 -8
  54. package/templates/service/AGENTS.md +12 -10
  55. package/templates/service/__tests__/todo-api.test.ts +5 -5
  56. package/templates/service/app/app.ts +25 -0
  57. package/templates/service/app/main.ts +20 -30
  58. package/templates/service/app/store/todo-store.ts +6 -22
  59. package/templates/service/config/app.ts +18 -0
  60. package/templates/service/config/env.ts +32 -0
  61. package/templates/service/config/http.ts +15 -0
  62. package/templates/service/package.json +8 -9
  63. package/templates/service/tsconfig.json +1 -1
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Usage:
9
9
  * pnpm create nwire <target-dir> [--template minimal|service|enterprise]
10
10
  */
11
- export type TemplateName = "minimal" | "service" | "enterprise";
11
+ export type TemplateName = "minimal" | "service" | "enterprise" | "mcp";
12
12
  interface ScaffoldOptions {
13
13
  target: string;
14
14
  template: TemplateName;
package/dist/index.js CHANGED
@@ -37,6 +37,10 @@ const TEMPLATES = {
37
37
  title: "Enterprise",
38
38
  blurb: "modules/<bc>/{events,actions,workflows,projections,routes}. Full forge shape.",
39
39
  },
40
+ mcp: {
41
+ title: "MCP",
42
+ blurb: "Model Context Protocol server over stdio. AI clients call tool() wires.",
43
+ },
40
44
  };
41
45
  function templatesRoot() {
42
46
  const candidate = resolve(__dirname, "..", "templates");
@@ -142,7 +146,7 @@ const main = defineCommand({
142
146
  },
143
147
  template: {
144
148
  type: "string",
145
- description: "Template: minimal | service | enterprise",
149
+ description: "Template: minimal | service | enterprise | mcp",
146
150
  default: "service",
147
151
  },
148
152
  name: {
@@ -164,7 +168,7 @@ const main = defineCommand({
164
168
  }
165
169
  const template = resolveTemplateName(String(ctx.args.template));
166
170
  if (!template) {
167
- console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, or enterprise.`));
171
+ console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, enterprise, or mcp.`));
168
172
  process.exit(1);
169
173
  }
170
174
  const target = resolve(process.cwd(), targetArg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nwire",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Scaffolder for new Nwire projects. Run `pnpm create nwire <name>` or `npm create nwire <name>` to bootstrap.",
5
5
  "keywords": [
6
6
  "nwire",
@@ -10,15 +10,15 @@ gets real, forge adds events, actors, projections, and workflows.
10
10
 
11
11
  The most common mistake is importing a primitive from the wrong package:
12
12
 
13
- | Primitive | Package |
14
- |---|---|
15
- | `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
16
- | `endpoint` | `@nwire/endpoint` |
17
- | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
18
- | `httpKoa` | `@nwire/koa` |
19
- | `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
20
- | `defineEvent` | `@nwire/messages` |
21
- | `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `createForgePlugin` | `@nwire/forge` |
13
+ | Primitive | Package |
14
+ | ------------------------------------------------------------------------------------------------------------------ | ------------------- |
15
+ | `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
16
+ | `endpoint` | `@nwire/endpoint` |
17
+ | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
18
+ | `httpKoa` | `@nwire/koa` |
19
+ | `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
20
+ | `defineEvent` | `@nwire/messages` |
21
+ | `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `forgePlugins` | `@nwire/forge` |
22
22
 
23
23
  `defineResource` and `defineError` are in **`@nwire/handler`**, not
24
24
  `@nwire/forge` — even though forge re-exports some of them, import from
@@ -31,8 +31,8 @@ The most common mistake is importing a primitive from the wrong package:
31
31
  - **Event** — a past-tense fact (`defineEvent`). Emit with `ctx.emit`;
32
32
  react to it with `when(Event, fn)` in a listener file (`on` is for
33
33
  lifecycle hooks only).
34
- - **Action** — a named command (`defineAction`) a handler fulfils and may
35
- emit events from. Register via `createForgePlugin({ handlers: [act.handler] })`.
34
+ - **Action** — a named command (`defineAction`) that is itself the handler
35
+ and may emit events from. Register via `createApp({ handlers: [act] })`.
36
36
  - **Actor** — a thing with identity that guards a rule. **Projection** — a
37
37
  read model folded from events. **Workflow** — a process that reacts over
38
38
  time.
@@ -7,22 +7,38 @@
7
7
  * The workflow runs in-process (same runtime the route dispatches into),
8
8
  * so `await idle()` between submit + dashboard read is enough to observe
9
9
  * the post-decision state.
10
+ *
11
+ * Boots the app from `app.ts` (the pure value) on an ephemeral port.
12
+ * `app` is complete on import — routes are wired in the module body of
13
+ * `app.ts`. Never imports `main.ts` — that file runs a real server and
14
+ * hangs the test runner.
10
15
  */
11
16
 
12
17
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
- import { bootstrap } from "../app/main";
18
+ import { endpoint } from "@nwire/endpoint";
19
+ import { httpKoa } from "@nwire/koa";
20
+ import { app } from "../app/app";
14
21
 
15
- let running: Awaited<ReturnType<typeof bootstrap>>;
22
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
23
+ let koa: ReturnType<typeof httpKoa>;
16
24
  let url: string;
17
25
 
18
26
  beforeAll(async () => {
19
- running = await bootstrap({ port: 0, test: true });
20
- url = `http://127.0.0.1:${running.koa.port()}`;
27
+ koa = httpKoa({ port: 0, prefix: "/api" });
28
+ running = await endpoint("auto-moderate-test", {
29
+ exitOnShutdown: false,
30
+ banner: false,
31
+ probes: { enabled: false },
32
+ })
33
+ .use(koa)
34
+ .mount(app)
35
+ .run();
36
+ url = `http://127.0.0.1:${koa.port()}`;
21
37
  });
22
38
 
23
39
  afterAll(async () => {
24
- await running.running.shutdown("test");
25
- await running.app.stop();
40
+ await running.shutdown("test");
41
+ await app.stop();
26
42
  });
27
43
 
28
44
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -1,22 +1,38 @@
1
1
  /**
2
2
  * Happy path — submit a post via HTTP, verify the action runs, the
3
3
  * workflow + projection update the dashboard.
4
+ *
5
+ * Boots the app from `app.ts` (the pure value) on an ephemeral port.
6
+ * `app` is complete on import — routes are wired in the module body of
7
+ * `app.ts`. Never imports `main.ts` — that file runs a real server and
8
+ * hangs the test runner.
4
9
  */
5
10
 
6
11
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
7
- import { bootstrap } from "../app/main";
12
+ import { endpoint } from "@nwire/endpoint";
13
+ import { httpKoa } from "@nwire/koa";
14
+ import { app } from "../app/app";
8
15
 
9
- let running: Awaited<ReturnType<typeof bootstrap>>;
16
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
17
+ let koa: ReturnType<typeof httpKoa>;
10
18
  let url: string;
11
19
 
12
20
  beforeAll(async () => {
13
- running = await bootstrap({ port: 0, test: true });
14
- url = `http://127.0.0.1:${running.koa.port()}`;
21
+ koa = httpKoa({ port: 0, prefix: "/api" });
22
+ running = await endpoint("submit-flow-test", {
23
+ exitOnShutdown: false,
24
+ banner: false,
25
+ probes: { enabled: false },
26
+ })
27
+ .use(koa)
28
+ .mount(app)
29
+ .run();
30
+ url = `http://127.0.0.1:${koa.port()}`;
15
31
  });
16
32
 
17
33
  afterAll(async () => {
18
- await running.running.shutdown("test");
19
- await running.app.stop();
34
+ await running.shutdown("test");
35
+ await app.stop();
20
36
  });
21
37
 
22
38
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every route file under `../modules/posts/routes/` exports a
5
5
  * `{name}Route` (binding) + `{name}Handler` (function). The `wires`
6
- * array is what main.ts feeds into `app.wire(...)`.
6
+ * array feeds into `app.wire(...)` in `app.ts`.
7
7
  */
8
8
 
9
9
  import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
@@ -11,6 +11,7 @@ import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/ap
11
11
  import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
12
12
  import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
13
13
  import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
14
+ import { postsByAuthorRoute, postsByAuthorHandler } from "../modules/posts/routes/posts-by-author";
14
15
 
15
16
  export const wires = [
16
17
  { binding: submitPostRoute, handler: submitPostHandler },
@@ -18,4 +19,5 @@ export const wires = [
18
19
  { binding: rejectPostRoute, handler: rejectPostHandler },
19
20
  { binding: listQueueRoute, handler: listQueueHandler },
20
21
  { binding: getPostRoute, handler: getPostHandler },
22
+ { binding: postsByAuthorRoute, handler: postsByAuthorHandler },
21
23
  ] as const;
@@ -1,18 +1,23 @@
1
1
  /**
2
- * `buildApp` composes the posts bounded context into a runnable App.
2
+ * App the posts bounded context as a pure value.
3
3
  *
4
- * Each piece (events, actions, workflows, projections, queries) is
5
- * imported directly; `.with(createForgePlugin(...))` installs forge with
6
- * the handlers, workflows, projections, and queries the BC needs.
4
+ * `createApp` installs the forge plugin set (workflows + projections) and
5
+ * wires all HTTP routes in this module body. The exported `app` is COMPLETE
6
+ * on import no caller needs to wire or start it. No ports are bound here;
7
+ * that is `main.ts`'s job.
7
8
  *
8
- * For multi-BC apps, build each as its own App and compose:
9
+ * Tests import `{ app }` from this file and build an isolated endpoint per
10
+ * suite. Never import `main.ts` from tests: it starts the server and hangs
11
+ * the runner.
12
+ *
13
+ * Multi-BC: build each as its own App and compose at the endpoint:
9
14
  *
10
15
  * const monolith = appCompose(postsApp, ordersApp);
11
- * await endpoint("monolith", { port: 3000 }).use(httpKoa()).mount(monolith).run();
16
+ * await endpoint("monolith", { port }).use(httpKoa()).mount(monolith).run();
12
17
  */
13
18
 
14
19
  import { createApp } from "@nwire/app";
15
- import { createForgePlugin } from "@nwire/forge";
20
+ import { forgePlugins } from "@nwire/forge";
16
21
 
17
22
  import { submitPost } from "../modules/posts/actions/submit-post";
18
23
  import { approvePost } from "../modules/posts/actions/approve-post";
@@ -25,16 +30,19 @@ import {
25
30
  getPost,
26
31
  } from "../modules/posts/projections/queue-dashboard";
27
32
  import { postsByAuthor } from "../modules/posts/queries/posts-by-author";
33
+ import { wires } from "./api";
28
34
 
29
- export function buildApp() {
30
- return createApp({ appName: "{{PROJECT_NAME}}" }).with(
31
- createForgePlugin({
32
- handlers: [submitPost.handler!, approvePost.handler!, rejectPost.handler!],
35
+ export const app = createApp({
36
+ appName: "{{PROJECT_NAME}}",
37
+ handlers: [submitPost, approvePost, rejectPost, listPending, queueTotals, getPost, postsByAuthor],
38
+ plugins: [
39
+ ...forgePlugins({
33
40
  workflows: [autoModerate],
34
41
  projections: [queueDashboard],
35
- queries: [listPending, queueTotals, getPost, postsByAuthor],
36
42
  }),
37
- );
38
- }
43
+ ],
44
+ });
39
45
 
40
- export const app = buildApp();
46
+ for (const { binding, handler } of wires) {
47
+ app.wire(binding, handler);
48
+ }
@@ -1,55 +1,30 @@
1
1
  /**
2
- * Entry — boot the app under HTTP.
2
+ * Entry — the only file that boots a real HTTP server.
3
3
  *
4
- * 1. buildApp composes the App with forge (handlers, workflows,
5
- * projections, queries) via `.with(createForgePlugin(...))`.
6
- * 2. app.wire(...) pairs every route binding with its handler.
7
- * 3. endpoint().use(httpKoa({prefix:"/api"})).mount(app).run() starts
8
- * the HTTP listener + graceful drain + K8s probes.
4
+ * pnpm dev
9
5
  *
10
- * Once running:
11
- * - HTTP POST /api/posts → submitPost action → PostWasSubmitted event
12
- * - autoModerate workflow picks up the event, runs auto-check, dispatches
13
- * approve/reject if obvious
14
- * - queueDashboard projection folds all three events into state
15
- * - HTTP GET /api/queue reads from the projection
6
+ * curl -X POST http://localhost:3000/api/posts \
7
+ * -H "content-type: application/json" \
8
+ * -d '{"authorId":"alice","body":"Hello, world!"}'
9
+ * curl http://localhost:3000/api/queue
16
10
  *
17
- * Run: pnpm dev
18
- * Try: curl -X POST http://localhost:3000/api/posts \
19
- * -H "content-type: application/json" \
20
- * -d '{"authorId":"alice","body":"Hello, world!"}'
21
- * curl http://localhost:3000/api/queue
11
+ * `app` is a pure value — routes are wired in `./app` on import.
12
+ * This file adds the one side effect: binding a port and serving traffic.
13
+ * Tests import `{ app }` from `./app` and build their own ephemeral
14
+ * endpoint — never import this file from tests or it boots a server and
15
+ * hangs the runner.
22
16
  */
23
17
 
24
18
  import { endpoint } from "@nwire/endpoint";
25
19
  import { httpKoa } from "@nwire/koa";
26
- import { buildApp } from "./app";
27
- import { wires } from "./api";
20
+ import { appConfig } from "../config/app";
21
+ import { httpConfig } from "../config/http";
22
+ import { app } from "./app";
28
23
 
29
- export interface BootstrapOptions {
30
- readonly port?: number;
31
- readonly test?: boolean;
32
- }
24
+ const cfg = appConfig();
25
+ const http = httpConfig();
33
26
 
34
- export async function bootstrap(opts: number | BootstrapOptions = 3000) {
35
- const { port = 3000, test = false } =
36
- typeof opts === "number" ? { port: opts, test: false } : opts;
37
-
38
- const app = buildApp();
39
- for (const { binding, handler } of wires) {
40
- app.wire(binding, handler);
41
- }
42
- await app.start();
43
- const koa = httpKoa({ port, prefix: "/api" });
44
- const running = await endpoint("{{PROJECT_NAME}}", {
45
- exitOnShutdown: !test,
46
- banner: !test,
47
- probes: { enabled: !test },
48
- })
49
- .use(koa)
50
- .mount(app)
51
- .run();
52
- return { app, running, koa };
53
- }
54
-
55
- await bootstrap();
27
+ await endpoint(cfg.name, { port: cfg.port, banner: cfg.banner })
28
+ .use(httpKoa({ prefix: http.prefix }))
29
+ .mount(app)
30
+ .run();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * App-level settings — the process identity and lifecycle budgets the
3
+ * endpoint owns. A function of the typed `env`, so the same config file
4
+ * reads differently per environment without an `if` in sight.
5
+ */
6
+
7
+ import { env, type Env } from "./env";
8
+
9
+ export const appConfig = (e: Env = env) => ({
10
+ /** Endpoint + app name — shows in the banner, logs, and telemetry. */
11
+ name: "{{PROJECT_NAME}}",
12
+ /** Port the HTTP transport binds. */
13
+ port: e.PORT,
14
+ /** Print the boot banner outside of tests. */
15
+ banner: e.NODE_ENV !== "test",
16
+ });
17
+
18
+ export type AppConfig = ReturnType<typeof appConfig>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Typed environment — read `process.env` once, through a schema, and never
3
+ * touch it again from app code. Missing or malformed values fail loudly at
4
+ * boot with a readable message, not three layers deep at first request.
5
+ *
6
+ * `config/*.ts` files take this typed `env` and shape it into the settings
7
+ * each concern needs.
8
+ */
9
+
10
+ import { z } from "zod";
11
+
12
+ const EnvSchema = z.object({
13
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
14
+ PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
15
+ HTTP_PREFIX: z.string().default("/api"),
16
+ });
17
+
18
+ export type Env = z.output<typeof EnvSchema>;
19
+
20
+ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
21
+ const parsed = EnvSchema.safeParse(source);
22
+ if (!parsed.success) {
23
+ const summary = parsed.error.issues
24
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
25
+ .join("\n");
26
+ throw new Error(`Invalid environment:\n${summary}`);
27
+ }
28
+ return parsed.data;
29
+ }
30
+
31
+ /** The validated environment, loaded once. */
32
+ export const env: Env = loadEnv();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTTP transport settings — prefix and the dev inspect surface Studio reads.
3
+ * A function of the typed `env`, like every config file here.
4
+ */
5
+
6
+ import { env, type Env } from "./env";
7
+
8
+ export const httpConfig = (e: Env = env) => ({
9
+ /** Mounted under this prefix — e.g. `POST /api/posts`. */
10
+ prefix: e.HTTP_PREFIX,
11
+ /** Expose detailed errors in dev; redact in production. */
12
+ exposeErrors: e.NODE_ENV !== "production",
13
+ });
14
+
15
+ export type HttpConfig = ReturnType<typeof httpConfig>;
@@ -13,13 +13,14 @@ import { PostWasApproved } from "../events/post-was-approved";
13
13
 
14
14
  export const approvePost = defineAction({
15
15
  name: "posts.approve-post",
16
- description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
17
- schema: z.object({
16
+ description:
17
+ "Miri reviews the post and approves it — or the auto-check clears it so Alice doesn't have to wait.",
18
+ input: z.object({
18
19
  postId: z.string(),
19
20
  approvedBy: z.string(),
20
21
  }),
21
22
  emits: [PostWasApproved],
22
- handler: async (input) =>
23
+ handler: async ({ input }) =>
23
24
  PostWasApproved({
24
25
  postId: input.postId,
25
26
  approvedBy: input.approvedBy,
@@ -12,14 +12,14 @@ import { PostWasRejected } from "../events/post-was-rejected";
12
12
 
13
13
  export const rejectPost = defineAction({
14
14
  name: "posts.reject-post",
15
- description: "Reject a post with a stated reason.",
16
- schema: z.object({
15
+ description: "Miri (or the auto-check) rejects the post with a reason Alice can see and act on.",
16
+ input: z.object({
17
17
  postId: z.string(),
18
18
  rejectedBy: z.string(),
19
19
  reason: z.string().min(1).max(500),
20
20
  }),
21
21
  emits: [PostWasRejected],
22
- handler: async (input) =>
22
+ handler: async ({ input }) =>
23
23
  PostWasRejected({
24
24
  postId: input.postId,
25
25
  rejectedBy: input.rejectedBy,
@@ -25,13 +25,14 @@ import { PostWasSubmitted } from "../events/post-was-submitted";
25
25
 
26
26
  export const submitPost = defineAction({
27
27
  name: "posts.submit-post",
28
- description: "Author submits a draft to the moderation queue.",
29
- schema: z.object({
28
+ description:
29
+ "Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
30
+ input: z.object({
30
31
  authorId: z.string(),
31
32
  body: z.string().min(1).max(2000),
32
33
  }),
33
34
  emits: [PostWasSubmitted],
34
- handler: async (input) =>
35
+ handler: async ({ input }) =>
35
36
  PostWasSubmitted({
36
37
  postId: randomUUID(),
37
38
  authorId: input.authorId,
@@ -11,7 +11,7 @@ import { defineEvent } from "@nwire/messages";
11
11
 
12
12
  export const PostWasApproved = defineEvent({
13
13
  name: "posts.post-was-approved",
14
- description: "A moderator (or the auto-moderation workflow) approves a post.",
14
+ description: "The post cleared moderation Alice's words are now visible to readers.",
15
15
  outcome: "success",
16
16
  audience: ["product", "ops"],
17
17
  schema: z.object({
@@ -8,7 +8,8 @@ import { defineEvent } from "@nwire/messages";
8
8
 
9
9
  export const PostWasRejected = defineEvent({
10
10
  name: "posts.post-was-rejected",
11
- description: "A moderator (or the auto-moderation workflow) rejects a post.",
11
+ description:
12
+ "The post was turned away — Miri or the auto-check flagged it, and Alice sees the reason.",
12
13
  outcome: "failure",
13
14
  audience: ["product", "ops"],
14
15
  schema: z.object({
@@ -15,7 +15,8 @@ import { defineEvent } from "@nwire/messages";
15
15
 
16
16
  export const PostWasSubmitted = defineEvent({
17
17
  name: "posts.post-was-submitted",
18
- description: "An author drafts a post and submits it for moderation.",
18
+ description:
19
+ "Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
19
20
  outcome: "milestone",
20
21
  audience: ["product", "ops"],
21
22
  schema: z.object({
@@ -4,23 +4,23 @@
4
4
  * NOTE THE SYMMETRY:
5
5
  *
6
6
  * - The autoModerate WORKFLOW subscribes to events via
7
- * `on(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
7
+ * `when(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
8
8
  * EFFECTS — dispatch more actions.
9
9
  *
10
- * - This PROJECTION subscribes to events via the same `on` map shape.
10
+ * - This PROJECTION subscribes to events via the same `when` verb shape.
11
11
  * Its job is to fold events into a denormalized state that queries
12
12
  * read from. No side effects; pure state derivation.
13
13
  *
14
- * Same subscription primitive. Same `on(Event)` ergonomics. Different
14
+ * Same subscription primitive. Same `when(Event)` ergonomics. Different
15
15
  * intent. If you understand workflows, you understand projections —
16
16
  * just write to state instead of dispatching.
17
17
  *
18
18
  * ## When this lives in a database
19
19
  *
20
20
  * The in-memory state here is for example clarity. In production the
21
- * projection's `on` reducers would write to Postgres / Mongo / Redis,
21
+ * projection's `when` reducers would write to Postgres / Mongo / Redis,
22
22
  * and the query would read from there. The PRIMITIVE is the same —
23
- * `on(Event) → mutate state`. Where state lives is a deployment choice.
23
+ * `when(Event) → mutate state`. Where state lives is a deployment choice.
24
24
  *
25
25
  * Queries (`listPending`, `queueTotals`, `getPost`) live in this file
26
26
  * too because they're tightly coupled to the projection's state shape.
@@ -52,18 +52,12 @@ interface QueueState {
52
52
  totals: { pending: number; approved: number; rejected: number };
53
53
  }
54
54
 
55
- // ─── Projection — `on` map matches the workflow's shape ─────────
55
+ // ─── Projection — `when` verb matches the workflow's shape ──────
56
56
 
57
- export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
58
- description: "The moderation dashboard read model — pending queue + decision totals.",
59
- listens: [PostWasSubmitted, PostWasApproved, PostWasRejected],
60
- initial: () => ({
61
- byId: {},
62
- pendingIds: [],
63
- totals: { pending: 0, approved: 0, rejected: 0 },
64
- }),
65
- on: {
66
- [PostWasSubmitted.name]: (state, event) => ({
57
+ export const queueDashboard = defineProjection<QueueState>(
58
+ "queue-dashboard",
59
+ ({ when }) => {
60
+ when(PostWasSubmitted, (state, event) => ({
67
61
  byId: {
68
62
  ...state.byId,
69
63
  [event.postId]: {
@@ -76,9 +70,9 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
76
70
  },
77
71
  pendingIds: [...state.pendingIds, event.postId],
78
72
  totals: { ...state.totals, pending: state.totals.pending + 1 },
79
- }),
73
+ }));
80
74
 
81
- [PostWasApproved.name]: (state, event) => {
75
+ when(PostWasApproved, (state, event) => {
82
76
  const item = state.byId[event.postId];
83
77
  if (!item) return state;
84
78
  return {
@@ -98,9 +92,9 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
98
92
  approved: state.totals.approved + 1,
99
93
  },
100
94
  };
101
- },
95
+ });
102
96
 
103
- [PostWasRejected.name]: (state, event) => {
97
+ when(PostWasRejected, (state, event) => {
104
98
  const item = state.byId[event.postId];
105
99
  if (!item) return state;
106
100
  return {
@@ -121,15 +115,23 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
121
115
  rejected: state.totals.rejected + 1,
122
116
  },
123
117
  };
124
- },
118
+ });
125
119
  },
126
- });
120
+ {
121
+ description: "The moderation dashboard read model — pending queue + decision totals.",
122
+ initial: () => ({
123
+ byId: {},
124
+ pendingIds: [],
125
+ totals: { pending: 0, approved: 0, rejected: 0 },
126
+ }),
127
+ },
128
+ );
127
129
 
128
130
  // ─── Queries — read the projection's state ──────────────────────
129
131
 
130
132
  export const listPending = defineQuery(queueDashboard, {
131
133
  name: "posts.list-pending",
132
- schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
134
+ input: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
133
135
  execute: (state, { limit }) =>
134
136
  state.pendingIds
135
137
  .slice(0, limit)
@@ -139,12 +141,12 @@ export const listPending = defineQuery(queueDashboard, {
139
141
 
140
142
  export const queueTotals = defineQuery(queueDashboard, {
141
143
  name: "posts.queue-totals",
142
- schema: z.object({}),
144
+ input: z.object({}),
143
145
  execute: (state) => state.totals,
144
146
  });
145
147
 
146
148
  export const getPost = defineQuery(queueDashboard, {
147
149
  name: "posts.get-post",
148
- schema: z.object({ postId: z.string() }),
150
+ input: z.object({ postId: z.string() }),
149
151
  execute: (state, { postId }) => state.byId[postId] ?? null,
150
152
  });
@@ -13,7 +13,7 @@
13
13
  * bindings (a `db` connection, an http client, anything the runtime
14
14
  * registered).
15
15
  *
16
- * defineQuery({ name, schema, handler })
16
+ * defineQuery({ name, input, handler })
17
17
  *
18
18
  * The handler below returns a small mock list so the template boots
19
19
  * out of the box. The shape of a real implementation is in the comment
@@ -33,14 +33,16 @@ interface AuthorPost {
33
33
 
34
34
  export const postsByAuthor = defineQuery({
35
35
  name: "posts.by-author",
36
- description: "All posts an author has submitted, across statuses.",
37
- schema: z.object({
36
+ description:
37
+ "Alice checks her submission history — all posts she's ever sent, whatever their status.",
38
+ input: z.object({
38
39
  authorId: z.string(),
39
40
  limit: z.coerce.number().int().min(1).max(100).default(20),
40
41
  }),
41
- handler: async ({ authorId, limit }, _ctx): Promise<AuthorPost[]> => {
42
+ handler: async (ctx): Promise<AuthorPost[]> => {
43
+ const { authorId, limit } = ctx.input;
42
44
  // Production form:
43
- // const db = _ctx.resolve<DrizzleDb>("db");
45
+ // const db = ctx.resolve<DrizzleDb>("db");
44
46
  // return db.select().from(posts).where(eq(posts.authorId, authorId)).limit(limit);
45
47
  //
46
48
  // Template form — small in-memory mock so this boots without a DB: