create-nwire 0.7.1 → 0.8.17

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 (56) 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/package.json +2 -2
  15. package/templates/L1/tsconfig.json +1 -1
  16. package/templates/L2/README.md +33 -13
  17. package/templates/L2/__tests__/todo-api.test.ts +115 -0
  18. package/templates/L2/app/api.ts +20 -115
  19. package/templates/L2/app/{errors.ts → errors/todo-errors.ts} +6 -6
  20. package/templates/L2/app/main.ts +7 -7
  21. package/templates/L2/app/middleware/require-user.ts +29 -0
  22. package/templates/L2/app/{model.ts → resources/todo.ts} +21 -5
  23. package/templates/L2/app/routes/complete-todo.ts +36 -0
  24. package/templates/L2/app/routes/create-todo.ts +27 -0
  25. package/templates/L2/app/routes/delete-todo.ts +31 -0
  26. package/templates/L2/app/routes/list-todos.ts +28 -0
  27. package/templates/L2/app/{store.ts → store/todo-store.ts} +13 -5
  28. package/templates/L2/package.json +5 -5
  29. package/templates/L2/tsconfig.json +1 -1
  30. package/templates/L4/README.md +73 -17
  31. package/templates/L4/__tests__/auto-moderate.test.ts +96 -0
  32. package/templates/L4/__tests__/submit-flow.test.ts +80 -0
  33. package/templates/L4/app/api.ts +19 -109
  34. package/templates/L4/app/app.ts +4 -4
  35. package/templates/L4/app/main.ts +11 -12
  36. package/templates/L4/modules/posts/actions/approve-post.ts +28 -0
  37. package/templates/L4/modules/posts/actions/reject-post.ts +29 -0
  38. package/templates/L4/modules/posts/actions/submit-post.ts +41 -0
  39. package/templates/L4/modules/posts/events/post-was-approved.ts +22 -0
  40. package/templates/L4/modules/posts/events/post-was-rejected.ts +20 -0
  41. package/templates/L4/modules/posts/events/post-was-submitted.ts +27 -0
  42. package/templates/L4/modules/posts/posts.module.ts +36 -0
  43. package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
  44. package/templates/L4/modules/posts/queries/posts-by-author.ts +53 -0
  45. package/templates/L4/modules/posts/routes/approve-post.ts +26 -0
  46. package/templates/L4/modules/posts/routes/get-post.ts +20 -0
  47. package/templates/L4/modules/posts/routes/list-queue.ts +22 -0
  48. package/templates/L4/modules/posts/routes/reject-post.ts +29 -0
  49. package/templates/L4/modules/posts/routes/submit-post.ts +28 -0
  50. package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts} +13 -20
  51. package/templates/L4/package.json +6 -6
  52. package/templates/L4/tsconfig.json +1 -1
  53. package/templates/L2/app/middleware.ts +0 -32
  54. package/templates/L4/app/actions.ts +0 -81
  55. package/templates/L4/app/events.ts +0 -53
  56. package/templates/L4/app/moderation.module.ts +0 -28
@@ -1,8 +1,8 @@
1
1
  # {{PROJECT_NAME}}
2
2
 
3
3
  A Level 4 Nwire app — the full enterprise shape: bounded modules,
4
- actor-based domain, events, a stateful workflow (saga), a projection
5
- for read-side queries, and resolvers for the HTTP surface.
4
+ events, actions that emit them, a stateful workflow (saga), a projection
5
+ for read-side queries, and HTTP routes that bind them.
6
6
 
7
7
  Built around a moderation-queue example so you can see the moving
8
8
  parts before swapping in your own domain.
@@ -24,29 +24,85 @@ curl -X POST http://localhost:3000/api/posts \
24
24
 
25
25
  # read the dashboard projection
26
26
  curl http://localhost:3000/api/queue
27
+
28
+ # decide a borderline one manually
29
+ curl -X POST http://localhost:3000/api/posts/<id>/approve \
30
+ -H "content-type: application/json" \
31
+ -d '{"moderatorId":"miri"}'
27
32
  ```
28
33
 
29
- ## Source map
34
+ ## Test
30
35
 
31
- | File | What it does |
32
- | --------------------------- | -------------------------------------------------- |
33
- | `main.ts` | Boots app + endpoint + Studio inspect |
34
- | `app.ts` | `createApp({ modules })` — the configured runtime |
35
- | `api.ts` | `httpInterface` + resolvers |
36
- | `events.ts` | `defineEvent(...)` — domain event contracts |
37
- | `actions.ts` | `defineAction(...)` — intent → event handlers |
38
- | `moderation.module.ts` | `defineModule(...)` — bounded context wiring |
39
- | `auto-moderate.workflow.ts` | `defineWorkflow(...)` — saga that reacts to events |
40
- | `queue.projection.ts` | `defineProjection(...)` denormalized read model |
36
+ ```bash
37
+ pnpm test
38
+ ```
39
+
40
+ ## Folder shape
41
+
42
+ ```
43
+ {{PROJECT_NAME}}/
44
+ ├── app/
45
+ │ ├── main.ts endpoint + app.start + run
46
+ │ ├── api.ts ← httpInterface — wires every route
47
+ │ └── app.ts ← createApp({ modules: [...] })
48
+ ├── modules/
49
+ │ └── posts/ ← bounded context: posts moderation
50
+ │ ├── posts.module.ts ← defineModule — collects + exposes the BC
51
+ │ ├── events/
52
+ │ │ ├── post-was-submitted.ts
53
+ │ │ ├── post-was-approved.ts
54
+ │ │ └── post-was-rejected.ts
55
+ │ ├── actions/
56
+ │ │ ├── submit-post.ts
57
+ │ │ ├── approve-post.ts
58
+ │ │ └── reject-post.ts
59
+ │ ├── workflows/
60
+ │ │ └── auto-moderate.ts ← defineWorkflow — saga shape
61
+ │ ├── projections/
62
+ │ │ └── queue-dashboard.ts ← defineProjection + queries
63
+ │ ├── queries/
64
+ │ │ └── posts-by-author.ts ← defineQuery handler form (no projection)
65
+ │ └── routes/
66
+ │ ├── submit-post.ts ← POST /api/posts
67
+ │ ├── approve-post.ts ← POST /api/posts/:postId/approve
68
+ │ ├── reject-post.ts ← POST /api/posts/:postId/reject
69
+ │ ├── list-queue.ts ← GET /api/queue
70
+ │ └── get-post.ts ← GET /api/posts/:postId
71
+ └── __tests__/
72
+ ├── submit-flow.test.ts
73
+ └── auto-moderate.test.ts
74
+ ```
75
+
76
+ ## Where to add a …
77
+
78
+ | You want to add … | Where it goes |
79
+ | ----------------- | ------------------------------------------------------------------------------------------------------- |
80
+ | A new event | `modules/<bc>/events/<noun-was-verbed>.ts` — `defineEvent`; register in module |
81
+ | A new action | `modules/<bc>/actions/<verb-noun>.ts` — `defineAction({ emits, handler })` |
82
+ | A new workflow | `modules/<bc>/workflows/<name>.ts` — `defineWorkflow(({ on, send }) => ...)` |
83
+ | A new projection | `modules/<bc>/projections/<name>.ts` — `defineProjection` + co-located `defineQuery` |
84
+ | A query off a DB | `modules/<bc>/queries/<name>.ts` — `defineQuery({ name, schema, handler })`; `ctx.resolve("db")` |
85
+ | A new route | `modules/<bc>/routes/<verb-noun>.ts` exports `{name}Route + {name}Handler`; `.wire()` from `app/api.ts` |
86
+ | A new bounded ctx | `modules/<new-bc>/` mirroring `posts/`; add to `createApp({ modules: [...] })` in `app/app.ts` |
87
+
88
+ See also: [Concepts → Write paths](https://nwire.dev/docs/concepts/write-paths) — when to use an
89
+ actor (`defineActor`) versus a direct DB write inside a handler.
41
90
 
42
91
  ## Studio
43
92
 
44
- `api.inspect(app)` mounts the `/_nwire/*` introspection surface.
45
- Point Studio at this app:
93
+ `api.inspect(app)` mounts the `/_nwire/*` introspection surface. Point
94
+ Studio at this app:
46
95
 
47
96
  ```bash
48
97
  npx -p @nwire/cli nwire studio
49
98
  ```
50
99
 
51
- You'll get live traces, an actor browser, projection viewer, DLQ list,
52
- and manual dispatch — all reading from this app.
100
+ You'll get live traces, action / event / workflow browsers, projection
101
+ viewer, DLQ list, and manual dispatch — all reading from this app.
102
+
103
+ ## Grow up
104
+
105
+ When you need multi-app deployments, cross-service buses, persistent
106
+ projection stores, durable timers, queue workers — keep this same shape
107
+ and add the matching `@nwire/*` adapter package. Nothing in this template
108
+ changes; the boot wires in the new adapter.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Auto-moderate workflow — exercises both decision branches:
3
+ *
4
+ * - Clean content → auto-approved (totals.approved increments)
5
+ * - Spam marker → auto-rejected (totals.rejected increments)
6
+ *
7
+ * The workflow runs in-process (same runtime the route dispatches into),
8
+ * so `await idle()` between submit + dashboard read is enough to observe
9
+ * the post-decision state.
10
+ */
11
+
12
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
+ import http from "node:http";
14
+ import type { Server } from "node:http";
15
+ import { app } from "../app/app";
16
+ import { api } from "../app/api";
17
+
18
+ let server: Server;
19
+ let url: string;
20
+
21
+ beforeAll(async () => {
22
+ await app.start();
23
+ server = http.createServer(api.compile());
24
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
25
+ const addr = server.address() as { port: number };
26
+ url = `http://127.0.0.1:${addr.port}`;
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await new Promise<void>((resolve, reject) =>
31
+ server.close((err) => (err ? reject(err) : resolve())),
32
+ );
33
+ await app.stop();
34
+ });
35
+
36
+ const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
37
+
38
+ async function req(
39
+ path: string,
40
+ init: RequestInit = {},
41
+ ): Promise<{ status: number; body: unknown }> {
42
+ const res = await fetch(`${url}${path}`, {
43
+ ...init,
44
+ headers: {
45
+ "content-type": "application/json",
46
+ ...((init.headers as Record<string, string>) ?? {}),
47
+ },
48
+ });
49
+ const text = await res.text();
50
+ let body: unknown = text;
51
+ try {
52
+ body = JSON.parse(text);
53
+ } catch {
54
+ /* keep as text */
55
+ }
56
+ return { status: res.status, body };
57
+ }
58
+
59
+ async function totals(): Promise<{ pending: number; approved: number; rejected: number }> {
60
+ const res = await req("/api/queue");
61
+ return (res.body as { totals: { pending: number; approved: number; rejected: number } }).totals;
62
+ }
63
+
64
+ describe("auto-moderate workflow", () => {
65
+ it("auto-approves clean content end-to-end", async () => {
66
+ const before = await totals();
67
+ await req("/api/posts", {
68
+ method: "POST",
69
+ body: JSON.stringify({
70
+ authorId: "alice",
71
+ body: "Hello, this is a perfectly clean post.",
72
+ }),
73
+ });
74
+ await idle();
75
+ await idle();
76
+ await idle();
77
+ const after = await totals();
78
+ expect(after.approved).toBe(before.approved + 1);
79
+ });
80
+
81
+ it("auto-rejects content with the spam marker", async () => {
82
+ const before = await totals();
83
+ await req("/api/posts", {
84
+ method: "POST",
85
+ body: JSON.stringify({
86
+ authorId: "bob",
87
+ body: "Buy now! spam-marker free stuff!",
88
+ }),
89
+ });
90
+ await idle();
91
+ await idle();
92
+ await idle();
93
+ const after = await totals();
94
+ expect(after.rejected).toBe(before.rejected + 1);
95
+ });
96
+ });
@@ -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,41 @@
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
+ // dispatch via: execute(submitPost({ authorId, body }))
22
+ // — same shape as defineEvent: the action is callable, mints a typed
23
+ // CommandMessage with validated input, then `execute` routes it.
24
+ // `runtime.dispatch(submitPost, input)` still works too.
25
+
26
+ export const submitPost = defineAction({
27
+ name: "posts.submit-post",
28
+ description: "Author submits a draft to the moderation queue.",
29
+ schema: z.object({
30
+ authorId: z.string(),
31
+ body: z.string().min(1).max(2000),
32
+ }),
33
+ emits: [PostWasSubmitted],
34
+ handler: async (input) =>
35
+ PostWasSubmitted({
36
+ postId: randomUUID(),
37
+ authorId: input.authorId,
38
+ body: input.body,
39
+ submittedAt: new Date().toISOString(),
40
+ }),
41
+ });
@@ -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
+ });