create-nwire 0.7.1 → 0.8.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 (52) hide show
  1. package/dist/__tests__/scaffold.test.d.ts +8 -3
  2. package/dist/__tests__/scaffold.test.d.ts.map +1 -1
  3. package/dist/__tests__/scaffold.test.js +49 -22
  4. package/dist/__tests__/scaffold.test.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +22 -8
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/templates/L1/README.md +34 -3
  10. package/templates/L1/__tests__/hello.test.ts +54 -0
  11. package/templates/L1/app/api.ts +17 -13
  12. package/templates/L1/app/main.ts +11 -1
  13. package/templates/L1/app/routes/hello.ts +29 -0
  14. package/templates/L1/tsconfig.json +1 -1
  15. package/templates/L2/README.md +33 -13
  16. package/templates/L2/__tests__/todo-api.test.ts +115 -0
  17. package/templates/L2/app/api.ts +20 -115
  18. package/templates/L2/app/{errors.ts → errors/todo-errors.ts} +6 -6
  19. package/templates/L2/app/main.ts +7 -7
  20. package/templates/L2/app/middleware/require-user.ts +29 -0
  21. package/templates/L2/app/{model.ts → resources/todo.ts} +21 -5
  22. package/templates/L2/app/routes/complete-todo.ts +36 -0
  23. package/templates/L2/app/routes/create-todo.ts +27 -0
  24. package/templates/L2/app/routes/delete-todo.ts +31 -0
  25. package/templates/L2/app/routes/list-todos.ts +28 -0
  26. package/templates/L2/app/{store.ts → store/todo-store.ts} +13 -5
  27. package/templates/L2/tsconfig.json +1 -1
  28. package/templates/L4/README.md +67 -17
  29. package/templates/L4/__tests__/auto-moderate.test.ts +96 -0
  30. package/templates/L4/__tests__/submit-flow.test.ts +80 -0
  31. package/templates/L4/app/api.ts +19 -109
  32. package/templates/L4/app/app.ts +4 -4
  33. package/templates/L4/app/main.ts +11 -12
  34. package/templates/L4/modules/posts/actions/approve-post.ts +28 -0
  35. package/templates/L4/modules/posts/actions/reject-post.ts +29 -0
  36. package/templates/L4/modules/posts/actions/submit-post.ts +36 -0
  37. package/templates/L4/modules/posts/events/post-was-approved.ts +22 -0
  38. package/templates/L4/modules/posts/events/post-was-rejected.ts +20 -0
  39. package/templates/L4/modules/posts/events/post-was-submitted.ts +27 -0
  40. package/templates/L4/modules/posts/posts.module.ts +35 -0
  41. package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
  42. package/templates/L4/modules/posts/routes/approve-post.ts +26 -0
  43. package/templates/L4/modules/posts/routes/get-post.ts +20 -0
  44. package/templates/L4/modules/posts/routes/list-queue.ts +22 -0
  45. package/templates/L4/modules/posts/routes/reject-post.ts +29 -0
  46. package/templates/L4/modules/posts/routes/submit-post.ts +28 -0
  47. package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts} +13 -20
  48. package/templates/L4/tsconfig.json +1 -1
  49. package/templates/L2/app/middleware.ts +0 -32
  50. package/templates/L4/app/actions.ts +0 -81
  51. package/templates/L4/app/events.ts +0 -53
  52. package/templates/L4/app/moderation.module.ts +0 -28
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Happy path — submit a post via HTTP, verify the action runs, the
3
+ * workflow + projection update the dashboard.
4
+ */
5
+
6
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
7
+ import http from "node:http";
8
+ import type { Server } from "node:http";
9
+ import { app } from "../app/app";
10
+ import { api } from "../app/api";
11
+
12
+ let server: Server;
13
+ let url: string;
14
+
15
+ beforeAll(async () => {
16
+ await app.start();
17
+ server = http.createServer(api.compile());
18
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
19
+ const addr = server.address() as { port: number };
20
+ url = `http://127.0.0.1:${addr.port}`;
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await new Promise<void>((resolve, reject) =>
25
+ server.close((err) => (err ? reject(err) : resolve())),
26
+ );
27
+ await app.stop();
28
+ });
29
+
30
+ const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
31
+
32
+ async function req(
33
+ path: string,
34
+ init: RequestInit = {},
35
+ ): Promise<{ status: number; body: unknown }> {
36
+ const res = await fetch(`${url}${path}`, {
37
+ ...init,
38
+ headers: {
39
+ "content-type": "application/json",
40
+ ...((init.headers as Record<string, string>) ?? {}),
41
+ },
42
+ });
43
+ const text = await res.text();
44
+ let body: unknown = text;
45
+ try {
46
+ body = JSON.parse(text);
47
+ } catch {
48
+ /* keep as text */
49
+ }
50
+ return { status: res.status, body };
51
+ }
52
+
53
+ describe("submit flow", () => {
54
+ it("accepts a submission and the dashboard reflects it", async () => {
55
+ const submit = await req("/api/posts", {
56
+ method: "POST",
57
+ body: JSON.stringify({ authorId: "alice", body: "hi" }), // length<5 → "human"
58
+ });
59
+ expect(submit.status).toBe(202);
60
+
61
+ await idle();
62
+ await idle();
63
+
64
+ const queue = await req("/api/queue");
65
+ const items = (queue.body as { items: Array<{ authorId: string; status: string }> }).items;
66
+ const alice = items.find((i) => i.authorId === "alice");
67
+ expect(alice).toBeDefined();
68
+ expect(alice?.status).toBe("pending");
69
+ });
70
+
71
+ it("400s on empty body (structured validation error)", async () => {
72
+ const res = await req("/api/posts", {
73
+ method: "POST",
74
+ body: JSON.stringify({ authorId: "alice", body: "" }),
75
+ });
76
+ expect(res.status).toBe(400);
77
+ const body = res.body as { error: { code: string } };
78
+ expect(body.error.code).toBe("validation_failed");
79
+ });
80
+ });
@@ -1,118 +1,28 @@
1
1
  /**
2
- * The HTTP interface — six routes covering submit + decide + dashboard.
2
+ * HTTP interface — one `.wire(Route, Handler)` per operation.
3
3
  *
4
- * Resolvers call into actions and queries via the forge runtime. The
5
- * runtime is the implicit bus — `dispatch(action, input)` runs an
6
- * action through the full pipeline (validation, retry, DLQ, event
7
- * recording, workflow notification, projection folding). `query(name,
8
- * input)` reads the projection's materialized state.
4
+ * The route + handler pairs all live in `modules/posts/routes/`. The
5
+ * interface stays a 1-line-per-route reducer.
9
6
  *
10
- * The HTTP shape is intentionally thin: translate request → action
11
- * input, dispatch, return a tagged response. All the orchestration
12
- * (auto-moderate workflow, projection updates) happens downstream of
13
- * the event the action emits — never inside the resolver.
14
- *
15
- * Once `createApp` is also extracted to `@nwire/app` (v1.1), these
16
- * resolvers will use `ctx.execute(action, input)` + `ctx.query(...)`
17
- * directly. For v1.0 we route through the runtime explicitly.
7
+ * `.provide(app.runtime.getContainer())` hands the forge runtime's
8
+ * container to the http layer so handlers can `resolve()` from any
9
+ * binding the runtime registered (e.g. dispatch, query, the runtime
10
+ * itself for ad-hoc reads).
18
11
  */
19
12
 
20
- import { httpInterface, get, post } from "@nwire/http";
21
- import { z } from "zod";
13
+ import { httpInterface } from "@nwire/http";
22
14
  import { app } from "./app";
23
- import { submitPost, approvePost, rejectPost } from "./actions";
24
15
 
25
- /**
26
- * Tiny adapter the resolver layer needs the runtime to dispatch
27
- * actions and read projections. We hand it the unwrapped runtime
28
- * instance from the forge app. v1.1's createApp extraction will
29
- * expose this through `ctx.execute()` / `ctx.query()` directly.
30
- */
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- const runtime = (app as any).runtime;
16
+ import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
17
+ import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/approve-post";
18
+ import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
19
+ import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
20
+ import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
33
21
 
34
22
  export const api = httpInterface({ prefix: "/api" })
35
- // POST /api/posts — author submits a draft for moderation
36
- .wire(
37
- post("/posts", {
38
- body: z.object({
39
- authorId: z.string(),
40
- body: z.string().min(1).max(2000),
41
- }),
42
- }),
43
- async ({ input }) => {
44
- await runtime.dispatch(submitPost, input);
45
- /**
46
- * Return 202 — accepted, not done. The submitPost action
47
- * recorded PostWasSubmitted; the auto-moderate workflow + the
48
- * dashboard projection will run downstream of that. By the time
49
- * the client polls GET /queue the post may already be auto-decided.
50
- */
51
- return { $status: 202, body: { accepted: true } };
52
- },
53
- )
54
-
55
- // POST /api/posts/:postId/approve — human moderator approves
56
- .wire(
57
- post("/posts/:postId/approve", {
58
- params: z.object({ postId: z.string() }),
59
- body: z.object({ moderatorId: z.string() }),
60
- }),
61
- async ({ input }) => {
62
- const { postId, moderatorId } = input as { postId: string; moderatorId: string };
63
- await runtime.dispatch(approvePost, {
64
- postId,
65
- approvedBy: moderatorId,
66
- });
67
- return { $status: 202, body: { accepted: true } };
68
- },
69
- )
70
-
71
- // POST /api/posts/:postId/reject — human moderator rejects with reason
72
- .wire(
73
- post("/posts/:postId/reject", {
74
- params: z.object({ postId: z.string() }),
75
- body: z.object({
76
- moderatorId: z.string(),
77
- reason: z.string().min(1).max(500),
78
- }),
79
- }),
80
- async ({ input }) => {
81
- const { postId, moderatorId, reason } = input as {
82
- postId: string;
83
- moderatorId: string;
84
- reason: string;
85
- };
86
- await runtime.dispatch(rejectPost, {
87
- postId,
88
- rejectedBy: moderatorId,
89
- reason,
90
- });
91
- return { $status: 202, body: { accepted: true } };
92
- },
93
- )
94
-
95
- // GET /api/queue — dashboard view
96
- .wire(
97
- get("/queue", {
98
- query: z.object({
99
- limit: z.coerce.number().int().min(1).max(100).default(20),
100
- }),
101
- }),
102
- async ({ input }) => ({
103
- items: await runtime.query("moderation.list-pending", input),
104
- totals: await runtime.query("moderation.queue-totals", {}),
105
- }),
106
- )
107
-
108
- // GET /api/posts/:postId — single post lookup
109
- .wire(
110
- get("/posts/:postId", {
111
- params: z.object({ postId: z.string() }),
112
- }),
113
- async ({ input }) => {
114
- const post = await runtime.query("moderation.get-post", input);
115
- if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
116
- return post;
117
- },
118
- );
23
+ .provide(app.runtime.getContainer())
24
+ .wire(submitPostRoute, submitPostHandler)
25
+ .wire(approvePostRoute, approvePostHandler)
26
+ .wire(rejectPostRoute, rejectPostHandler)
27
+ .wire(listQueueRoute, listQueueHandler)
28
+ .wire(getPostRoute, getPostHandler);
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `createApp` composes the moderation module into a runnable app.
2
+ * `createApp` composes the posts module into a runnable app.
3
3
  *
4
- * Why createApp at Level 2 (when todo-app skipped it):
4
+ * Why createApp at L4 (when L2 skipped it):
5
5
  *
6
6
  * - We have a workflow that subscribes to events on the bus. The
7
7
  * workflow needs the runtime to register its subscription and
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { createApp } from "@nwire/forge";
24
- import { moderationModule } from "./moderation.module";
24
+ import { postsModule } from "../modules/posts/posts.module";
25
25
 
26
26
  export const app = createApp({
27
- modules: [moderationModule],
27
+ modules: [postsModule],
28
28
  });
@@ -1,20 +1,22 @@
1
1
  /**
2
- * Entry — boot the createApp and run the http interface under endpoint().
2
+ * Entry — boot the forge app and run the http interface under endpoint().
3
3
  *
4
- * The order matters:
4
+ * The lifecycle:
5
5
  *
6
- * 1. `app` is a configured-but-unbooted forge app — modules registered,
7
- * workflow subscribers wired, projection reducers attached, but
8
- * nothing's actually running yet.
6
+ * 1. `app` (./app.ts) is a configured-but-unbooted forge app — modules
7
+ * registered, workflow subscribers wired, projection reducers
8
+ * attached, but nothing's running yet.
9
9
  * 2. `app.start()` boots the runtime — workflows subscribe to the bus,
10
10
  * projection state initializes, framework events fire.
11
- * 3. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
11
+ * 3. `api.inspect(app)` mounts the `/_nwire/*` introspection surface
12
+ * that Studio's Live + Trace pages consume.
13
+ * 4. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
12
14
  *
13
15
  * Once running:
14
16
  * - HTTP `POST /api/posts` → submitPost action → PostWasSubmitted event
15
- * - autoModerate workflow picks up the event, runs auto-check, dispatches
17
+ * - `autoModerate` workflow picks up the event, runs auto-check, dispatches
16
18
  * approve/reject if obvious
17
- * - queueDashboard projection picks up ALL three events, updates state
19
+ * - `queueDashboard` projection picks up ALL three events, updates state
18
20
  * - HTTP `GET /api/queue` reads from the projection
19
21
  *
20
22
  * Run: pnpm dev
@@ -30,10 +32,7 @@ import { api } from "./api";
30
32
 
31
33
  await app.start();
32
34
 
33
- // `.inspect(app)` mounts the `/_nwire/*` introspection surface that
34
- // Studio's Live + Trace pages consume — live telemetry SSE stream,
35
- // actor browser, projection viewer, DLQ list, manual dispatch.
36
- // Off-by-default; opt in to demonstrate the dev-mode introspection.
35
+ // Off-by-default in production; opt in here so Studio's Live page works.
37
36
  api.inspect(app);
38
37
 
39
38
  await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * approvePost — approve a post.
3
+ *
4
+ * Called from BOTH a human moderator route (POST /posts/:id/approve) AND
5
+ * from the auto-moderation workflow when the auto-check decides "clean".
6
+ * That reuse across callers is exactly why actions are extracted from
7
+ * route handlers in the first place.
8
+ */
9
+
10
+ import { z } from "zod";
11
+ import { defineAction } from "@nwire/forge";
12
+ import { PostWasApproved } from "../events/post-was-approved";
13
+
14
+ export const approvePost = defineAction({
15
+ name: "posts.approve-post",
16
+ description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
17
+ schema: z.object({
18
+ postId: z.string(),
19
+ approvedBy: z.string(),
20
+ }),
21
+ emits: [PostWasApproved],
22
+ handler: async (input) =>
23
+ PostWasApproved({
24
+ postId: input.postId,
25
+ approvedBy: input.approvedBy,
26
+ approvedAt: new Date().toISOString(),
27
+ }),
28
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * rejectPost — reject a post with a stated reason.
3
+ *
4
+ * Called from BOTH a human moderator route (POST /posts/:id/reject) AND
5
+ * from the auto-moderation workflow when the auto-check matches a known
6
+ * spam pattern.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { defineAction } from "@nwire/forge";
11
+ import { PostWasRejected } from "../events/post-was-rejected";
12
+
13
+ export const rejectPost = defineAction({
14
+ name: "posts.reject-post",
15
+ description: "Reject a post with a stated reason.",
16
+ schema: z.object({
17
+ postId: z.string(),
18
+ rejectedBy: z.string(),
19
+ reason: z.string().min(1).max(500),
20
+ }),
21
+ emits: [PostWasRejected],
22
+ handler: async (input) =>
23
+ PostWasRejected({
24
+ postId: input.postId,
25
+ rejectedBy: input.rejectedBy,
26
+ reason: input.reason,
27
+ rejectedAt: new Date().toISOString(),
28
+ }),
29
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * submitPost — author submits a draft to the moderation queue.
3
+ *
4
+ * Actions are the use cases the BC exposes. Each is callable from:
5
+ *
6
+ * - A route handler (HTTP dispatch via `runtime.dispatch(action, input)`)
7
+ * - A workflow (the auto-moderation workflow dispatches via `send(...)`)
8
+ * - Tests (via the harness — exercises the action without booting HTTP)
9
+ *
10
+ * The action's job is to validate input and return the event(s) that
11
+ * describe what happened. The framework persists the events, notifies
12
+ * subscribed workflows, and folds them into projections. Actions don't
13
+ * know who's listening — that's the bus's job.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { z } from "zod";
18
+ import { defineAction } from "@nwire/forge";
19
+ import { PostWasSubmitted } from "../events/post-was-submitted";
20
+
21
+ export const submitPost = defineAction({
22
+ name: "posts.submit-post",
23
+ description: "Author submits a draft to the moderation queue.",
24
+ schema: z.object({
25
+ authorId: z.string(),
26
+ body: z.string().min(1).max(2000),
27
+ }),
28
+ emits: [PostWasSubmitted],
29
+ handler: async (input) =>
30
+ PostWasSubmitted({
31
+ postId: randomUUID(),
32
+ authorId: input.authorId,
33
+ body: input.body,
34
+ submittedAt: new Date().toISOString(),
35
+ }),
36
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * PostWasApproved — a moderator (or the auto-moderation workflow) approves a post.
3
+ *
4
+ * `approvedBy` carries either a human moderator id or the literal "auto"
5
+ * for workflow-driven approvals — the dashboard renders the badge
6
+ * differently in the two cases.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { defineEvent } from "@nwire/messages";
11
+
12
+ export const PostWasApproved = defineEvent({
13
+ name: "posts.post-was-approved",
14
+ description: "A moderator (or the auto-moderation workflow) approves a post.",
15
+ outcome: "success",
16
+ audience: ["product", "ops"],
17
+ schema: z.object({
18
+ postId: z.string(),
19
+ approvedBy: z.string().describe("Moderator id, or 'auto' for workflow-driven approval"),
20
+ approvedAt: z.string().datetime(),
21
+ }),
22
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * PostWasRejected — a moderator (or the auto-moderation workflow) rejects
3
+ * a post with a stated reason. The reason becomes part of the audit trail.
4
+ */
5
+
6
+ import { z } from "zod";
7
+ import { defineEvent } from "@nwire/messages";
8
+
9
+ export const PostWasRejected = defineEvent({
10
+ name: "posts.post-was-rejected",
11
+ description: "A moderator (or the auto-moderation workflow) rejects a post.",
12
+ outcome: "failure",
13
+ audience: ["product", "ops"],
14
+ schema: z.object({
15
+ postId: z.string(),
16
+ rejectedBy: z.string(),
17
+ reason: z.string(),
18
+ rejectedAt: z.string().datetime(),
19
+ }),
20
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * PostWasSubmitted — an author drafts a post and submits it for moderation.
3
+ *
4
+ * Past-tense by convention: events name FACTS that happened, not commands
5
+ * to do something. The grammar is the whole semantic: events = past,
6
+ * actions = present-imperative.
7
+ *
8
+ * `outcome` and `audience` are Studio-aware metadata — the scanner picks
9
+ * them up so the dashboard can render the event with the right semantics
10
+ * ("milestone", "success", "failure") and route to the right subscribers.
11
+ */
12
+
13
+ import { z } from "zod";
14
+ import { defineEvent } from "@nwire/messages";
15
+
16
+ export const PostWasSubmitted = defineEvent({
17
+ name: "posts.post-was-submitted",
18
+ description: "An author drafts a post and submits it for moderation.",
19
+ outcome: "milestone",
20
+ audience: ["product", "ops"],
21
+ schema: z.object({
22
+ postId: z.string(),
23
+ authorId: z.string(),
24
+ body: z.string(),
25
+ submittedAt: z.string().datetime(),
26
+ }),
27
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The posts module — collects every domain piece for this bounded
3
+ * context. `defineModule` is purely organizational: it groups events,
4
+ * actions, workflows, projections, and queries that belong to one part
5
+ * of the business, marks which are public (callable from other modules /
6
+ * apps), and gives the runtime a single value to register.
7
+ *
8
+ * `.public()` controls cross-module visibility. Here every action +
9
+ * query is public because we have one module; in a multi-module app
10
+ * you'd be more selective — only what's part of the BC's contract
11
+ * stays public.
12
+ */
13
+
14
+ import { defineModule } from "@nwire/forge";
15
+
16
+ import { PostWasSubmitted } from "./events/post-was-submitted";
17
+ import { PostWasApproved } from "./events/post-was-approved";
18
+ import { PostWasRejected } from "./events/post-was-rejected";
19
+
20
+ import { submitPost } from "./actions/submit-post";
21
+ import { approvePost } from "./actions/approve-post";
22
+ import { rejectPost } from "./actions/reject-post";
23
+
24
+ import { autoModerate } from "./workflows/auto-moderate";
25
+
26
+ import { queueDashboard, listPending, queueTotals, getPost } from "./projections/queue-dashboard";
27
+
28
+ export const postsModule = defineModule("posts", {
29
+ description: "Post moderation queue — submit, auto-check, decide, dashboard.",
30
+ events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
31
+ actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
32
+ workflows: [autoModerate],
33
+ projections: [queueDashboard],
34
+ queries: [listPending.public(), queueTotals.public(), getPost.public()],
35
+ });
@@ -1,11 +1,11 @@
1
1
  /**
2
- * `queueDashboard` projection — the read model for the moderation queue.
2
+ * queueDashboard — the read model for the moderation queue.
3
3
  *
4
- * NOTICE THE SYMMETRY:
4
+ * NOTE THE SYMMETRY:
5
5
  *
6
- * - The autoModerate WORKFLOW (./auto-moderate.workflow.ts) subscribes
7
- * to events via `on(PostWasSubmitted, (event) => ...)`. Its job is
8
- * to produce SIDE EFFECTS — dispatch more actions.
6
+ * - The autoModerate WORKFLOW subscribes to events via
7
+ * `on(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
8
+ * EFFECTS — dispatch more actions.
9
9
  *
10
10
  * - This PROJECTION subscribes to events via the same `on` map shape.
11
11
  * Its job is to fold events into a denormalized state that queries
@@ -15,32 +15,25 @@
15
15
  * intent. If you understand workflows, you understand projections —
16
16
  * just write to state instead of dispatching.
17
17
  *
18
- * ## Why a projection vs. computing on every read
19
- *
20
- * The HTTP `GET /queue?status=pending` could query the event log + fold
21
- * matching events on every request. That works for low traffic. For a
22
- * busy moderation dashboard with thousands of moderators each polling
23
- * every 5 seconds, that's wasted CPU.
24
- *
25
- * The projection folds incrementally: every time an event fires, it
26
- * updates the state. Queries read the materialized state directly.
27
- * Trade-off: more memory, less CPU; eventually-consistent (the
28
- * projection lags the event by milliseconds at worst). For dashboards
29
- * that's the right trade.
30
- *
31
18
  * ## When this lives in a database
32
19
  *
33
20
  * The in-memory state here is for example clarity. In production the
34
21
  * projection's `on` reducers would write to Postgres / Mongo / Redis,
35
22
  * and the query would read from there. The PRIMITIVE is the same —
36
23
  * `on(Event) → mutate state`. Where state lives is a deployment choice.
24
+ *
25
+ * Queries (`listPending`, `queueTotals`, `getPost`) live in this file
26
+ * too because they're tightly coupled to the projection's state shape.
27
+ * Split them out if/when queries grow beyond the projection's surface.
37
28
  */
38
29
 
39
30
  import { defineProjection, defineQuery } from "@nwire/forge";
40
31
  import { z } from "zod";
41
- import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
32
+ import { PostWasSubmitted } from "../events/post-was-submitted";
33
+ import { PostWasApproved } from "../events/post-was-approved";
34
+ import { PostWasRejected } from "../events/post-was-rejected";
42
35
 
43
- // ─── The projection state shape ────────────────────────────────
36
+ // ─── State shape ─────────────────────────────────────────────────
44
37
 
45
38
  interface QueueItem {
46
39
  postId: string;
@@ -59,7 +52,7 @@ interface QueueState {
59
52
  totals: { pending: number; approved: number; rejected: number };
60
53
  }
61
54
 
62
- // ─── The projection note the `on` map matches the workflow's ───
55
+ // ─── Projection — `on` map matches the workflow's shape ─────────
63
56
 
64
57
  export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
65
58
  description: "The moderation dashboard read model — pending queue + decision totals.",
@@ -70,11 +63,6 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
70
63
  totals: { pending: 0, approved: 0, rejected: 0 },
71
64
  }),
72
65
  on: {
73
- /**
74
- * Same shape as a workflow's `on(PostWasSubmitted, ...)`. The
75
- * difference: this returns state, the workflow returns void
76
- * (and produces side effects).
77
- */
78
66
  [PostWasSubmitted.name]: (state, event) => ({
79
67
  byId: {
80
68
  ...state.byId,
@@ -137,10 +125,10 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
137
125
  },
138
126
  });
139
127
 
140
- // ─── Queries — read the projection's state ─────────────────────
128
+ // ─── Queries — read the projection's state ──────────────────────
141
129
 
142
130
  export const listPending = defineQuery(queueDashboard, {
143
- name: "moderation.list-pending",
131
+ name: "posts.list-pending",
144
132
  schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
145
133
  execute: (state, { limit }) =>
146
134
  state.pendingIds
@@ -150,13 +138,13 @@ export const listPending = defineQuery(queueDashboard, {
150
138
  });
151
139
 
152
140
  export const queueTotals = defineQuery(queueDashboard, {
153
- name: "moderation.queue-totals",
141
+ name: "posts.queue-totals",
154
142
  schema: z.object({}),
155
143
  execute: (state) => state.totals,
156
144
  });
157
145
 
158
146
  export const getPost = defineQuery(queueDashboard, {
159
- name: "moderation.get-post",
147
+ name: "posts.get-post",
160
148
  schema: z.object({ postId: z.string() }),
161
149
  execute: (state, { postId }) => state.byId[postId] ?? null,
162
150
  });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * POST /api/posts/:postId/approve — human moderator approves a post.
3
+ *
4
+ * Dispatches the SAME action that the auto-moderate workflow dispatches
5
+ * — that reuse is exactly why approvePost lives in `actions/` and not
6
+ * inline in the route handler.
7
+ */
8
+
9
+ import { post, type HttpHandler } from "@nwire/http";
10
+ import { z } from "zod";
11
+ import { app } from "../../../app/app";
12
+ import { approvePost } from "../actions/approve-post";
13
+
14
+ const Params = z.object({ postId: z.string() });
15
+ const Body = z.object({ moderatorId: z.string() });
16
+ type Input = z.infer<typeof Params> & z.infer<typeof Body>;
17
+
18
+ export const approvePostRoute = post("/posts/:postId/approve", { params: Params, body: Body });
19
+
20
+ export const approvePostHandler: HttpHandler<Input> = async ({ input }) => {
21
+ await app.runtime.dispatch(approvePost, {
22
+ postId: input.postId,
23
+ approvedBy: input.moderatorId,
24
+ });
25
+ return { $status: 202, body: { accepted: true } };
26
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * GET /api/posts/:postId — single-post lookup.
3
+ *
4
+ * Returns the projection's record. 404 when the post isn't in the
5
+ * dashboard (never submitted, or projection state was reset).
6
+ */
7
+
8
+ import { get, type HttpHandler } from "@nwire/http";
9
+ import { z } from "zod";
10
+ import { app } from "../../../app/app";
11
+
12
+ const Params = z.object({ postId: z.string() });
13
+
14
+ export const getPostRoute = get("/posts/:postId", { params: Params });
15
+
16
+ export const getPostHandler: HttpHandler<z.infer<typeof Params>> = async ({ input }) => {
17
+ const post = await app.runtime.query("posts.get-post", input);
18
+ if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
19
+ return post;
20
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * GET /api/queue — dashboard view (pending list + totals).
3
+ *
4
+ * Reads from the queueDashboard projection via two queries. The
5
+ * projection has been folding events in the background — this is a
6
+ * straight state read, no recomputation.
7
+ */
8
+
9
+ import { get, type HttpHandler } from "@nwire/http";
10
+ import { z } from "zod";
11
+ import { app } from "../../../app/app";
12
+
13
+ const Query = z.object({
14
+ limit: z.coerce.number().int().min(1).max(100).default(20),
15
+ });
16
+
17
+ export const listQueueRoute = get("/queue", { query: Query });
18
+
19
+ export const listQueueHandler: HttpHandler<z.infer<typeof Query>> = async ({ input }) => ({
20
+ items: await app.runtime.query("posts.list-pending", input),
21
+ totals: await app.runtime.query("posts.queue-totals", {}),
22
+ });