create-nwire 0.7.0 → 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.
- package/dist/__tests__/scaffold.test.d.ts +8 -3
- package/dist/__tests__/scaffold.test.d.ts.map +1 -1
- package/dist/__tests__/scaffold.test.js +49 -22
- package/dist/__tests__/scaffold.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/templates/L1/README.md +34 -3
- package/templates/L1/__tests__/hello.test.ts +54 -0
- package/templates/L1/app/api.ts +17 -13
- package/templates/L1/app/main.ts +11 -1
- package/templates/L1/app/routes/hello.ts +29 -0
- package/templates/L1/tsconfig.json +1 -1
- package/templates/L2/README.md +33 -13
- package/templates/L2/__tests__/todo-api.test.ts +115 -0
- package/templates/L2/app/api.ts +20 -115
- package/templates/L2/app/{errors.ts → errors/todo-errors.ts} +6 -6
- package/templates/L2/app/main.ts +7 -7
- package/templates/L2/app/middleware/require-user.ts +29 -0
- package/templates/L2/app/{model.ts → resources/todo.ts} +21 -5
- package/templates/L2/app/routes/complete-todo.ts +36 -0
- package/templates/L2/app/routes/create-todo.ts +27 -0
- package/templates/L2/app/routes/delete-todo.ts +31 -0
- package/templates/L2/app/routes/list-todos.ts +28 -0
- package/templates/L2/app/{store.ts → store/todo-store.ts} +13 -5
- package/templates/L2/tsconfig.json +1 -1
- package/templates/L4/README.md +67 -17
- package/templates/L4/__tests__/auto-moderate.test.ts +96 -0
- package/templates/L4/__tests__/submit-flow.test.ts +80 -0
- package/templates/L4/app/api.ts +19 -109
- package/templates/L4/app/app.ts +4 -4
- package/templates/L4/app/main.ts +11 -12
- package/templates/L4/modules/posts/actions/approve-post.ts +28 -0
- package/templates/L4/modules/posts/actions/reject-post.ts +29 -0
- package/templates/L4/modules/posts/actions/submit-post.ts +36 -0
- package/templates/L4/modules/posts/events/post-was-approved.ts +22 -0
- package/templates/L4/modules/posts/events/post-was-rejected.ts +20 -0
- package/templates/L4/modules/posts/events/post-was-submitted.ts +27 -0
- package/templates/L4/modules/posts/posts.module.ts +35 -0
- package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
- package/templates/L4/modules/posts/routes/approve-post.ts +26 -0
- package/templates/L4/modules/posts/routes/get-post.ts +20 -0
- package/templates/L4/modules/posts/routes/list-queue.ts +22 -0
- package/templates/L4/modules/posts/routes/reject-post.ts +29 -0
- package/templates/L4/modules/posts/routes/submit-post.ts +28 -0
- package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts} +13 -20
- package/templates/L4/tsconfig.json +1 -1
- package/templates/L2/app/middleware.ts +0 -32
- package/templates/L4/app/actions.ts +0 -81
- package/templates/L4/app/events.ts +0 -53
- package/templates/L4/app/moderation.module.ts +0 -28
package/templates/L4/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
3
|
A Level 4 Nwire app — the full enterprise shape: bounded modules,
|
|
4
|
-
|
|
5
|
-
for read-side queries, and
|
|
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,79 @@ 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"}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Test
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm test
|
|
27
38
|
```
|
|
28
39
|
|
|
29
|
-
##
|
|
40
|
+
## Folder shape
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
│ └── routes/
|
|
64
|
+
│ ├── submit-post.ts ← POST /api/posts
|
|
65
|
+
│ ├── approve-post.ts ← POST /api/posts/:postId/approve
|
|
66
|
+
│ ├── reject-post.ts ← POST /api/posts/:postId/reject
|
|
67
|
+
│ ├── list-queue.ts ← GET /api/queue
|
|
68
|
+
│ └── get-post.ts ← GET /api/posts/:postId
|
|
69
|
+
└── __tests__/
|
|
70
|
+
├── submit-flow.test.ts
|
|
71
|
+
└── auto-moderate.test.ts
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Where to add a …
|
|
75
|
+
|
|
76
|
+
| You want to add … | Where it goes |
|
|
77
|
+
| ----------------- | ----------------------------------------------------------------------------------- |
|
|
78
|
+
| A new event | `modules/<bc>/events/<noun-was-verbed>.ts` — `defineEvent`; register in module |
|
|
79
|
+
| A new action | `modules/<bc>/actions/<verb-noun>.ts` — `defineAction({ emits, handler })` |
|
|
80
|
+
| A new workflow | `modules/<bc>/workflows/<name>.ts` — `defineWorkflow(({ on, send }) => ...)` |
|
|
81
|
+
| A new projection | `modules/<bc>/projections/<name>.ts` — `defineProjection` + co-located `defineQuery`|
|
|
82
|
+
| A new route | `modules/<bc>/routes/<verb-noun>.ts` exports `{name}Route + {name}Handler`; `.wire()` from `app/api.ts` |
|
|
83
|
+
| A new bounded ctx | `modules/<new-bc>/` mirroring `posts/`; add to `createApp({ modules: [...] })` in `app/app.ts` |
|
|
41
84
|
|
|
42
85
|
## Studio
|
|
43
86
|
|
|
44
|
-
`api.inspect(app)` mounts the `/_nwire/*` introspection surface.
|
|
45
|
-
|
|
87
|
+
`api.inspect(app)` mounts the `/_nwire/*` introspection surface. Point
|
|
88
|
+
Studio at this app:
|
|
46
89
|
|
|
47
90
|
```bash
|
|
48
91
|
npx -p @nwire/cli nwire studio
|
|
49
92
|
```
|
|
50
93
|
|
|
51
|
-
You'll get live traces,
|
|
52
|
-
and manual dispatch — all reading from this app.
|
|
94
|
+
You'll get live traces, action / event / workflow browsers, projection
|
|
95
|
+
viewer, DLQ list, and manual dispatch — all reading from this app.
|
|
96
|
+
|
|
97
|
+
## Grow up
|
|
98
|
+
|
|
99
|
+
When you need multi-app deployments, cross-service buses, persistent
|
|
100
|
+
projection stores, durable timers, queue workers — keep this same shape
|
|
101
|
+
and add the matching `@nwire/*` adapter package. Nothing in this template
|
|
102
|
+
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
|
+
});
|
package/templates/L4/app/api.ts
CHANGED
|
@@ -1,118 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HTTP interface — one `.wire(Route, Handler)` per operation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
.wire(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
package/templates/L4/app/app.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createApp` composes the
|
|
2
|
+
* `createApp` composes the posts module into a runnable app.
|
|
3
3
|
*
|
|
4
|
-
* Why createApp at
|
|
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 {
|
|
24
|
+
import { postsModule } from "../modules/posts/posts.module";
|
|
25
25
|
|
|
26
26
|
export const app = createApp({
|
|
27
|
-
modules: [
|
|
27
|
+
modules: [postsModule],
|
|
28
28
|
});
|
package/templates/L4/app/main.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry — boot the
|
|
2
|
+
* Entry — boot the forge app and run the http interface under endpoint().
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The lifecycle:
|
|
5
5
|
*
|
|
6
|
-
* 1. `app` is a configured-but-unbooted forge app — modules
|
|
7
|
-
* workflow subscribers wired, projection reducers
|
|
8
|
-
* nothing's
|
|
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. `
|
|
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
|
-
//
|
|
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
|
+
});
|