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,29 @@
1
+ /**
2
+ * POST /api/posts/:postId/reject — human moderator rejects with a reason.
3
+ *
4
+ * The reason is mandatory and becomes part of the audit trail via the
5
+ * PostWasRejected event.
6
+ */
7
+
8
+ import { post, type HttpHandler } from "@nwire/http";
9
+ import { z } from "zod";
10
+ import { app } from "../../../app/app";
11
+ import { rejectPost } from "../actions/reject-post";
12
+
13
+ const Params = z.object({ postId: z.string() });
14
+ const Body = z.object({
15
+ moderatorId: z.string(),
16
+ reason: z.string().min(1).max(500),
17
+ });
18
+ type Input = z.infer<typeof Params> & z.infer<typeof Body>;
19
+
20
+ export const rejectPostRoute = post("/posts/:postId/reject", { params: Params, body: Body });
21
+
22
+ export const rejectPostHandler: HttpHandler<Input> = async ({ input }) => {
23
+ await app.runtime.dispatch(rejectPost, {
24
+ postId: input.postId,
25
+ rejectedBy: input.moderatorId,
26
+ reason: input.reason,
27
+ });
28
+ return { $status: 202, body: { accepted: true } };
29
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * POST /api/posts — author submits a draft for moderation.
3
+ *
4
+ * Returns 202 (Accepted) — the action recorded PostWasSubmitted; the
5
+ * auto-moderate workflow + dashboard projection run downstream. By the
6
+ * time the client polls GET /api/queue the post may already be auto-decided.
7
+ *
8
+ * The route handler is a thin shim: translate request → action input,
9
+ * dispatch, return the tagged response. All orchestration happens
10
+ * downstream of the event the action emits — never inside the handler.
11
+ */
12
+
13
+ import { post, type HttpHandler } from "@nwire/http";
14
+ import { z } from "zod";
15
+ import { app } from "../../../app/app";
16
+ import { submitPost } from "../actions/submit-post";
17
+
18
+ const Body = z.object({
19
+ authorId: z.string(),
20
+ body: z.string().min(1).max(2000),
21
+ });
22
+
23
+ export const submitPostRoute = post("/posts", { body: Body });
24
+
25
+ export const submitPostHandler: HttpHandler<z.infer<typeof Body>> = async ({ input }) => {
26
+ await app.runtime.dispatch(submitPost, input);
27
+ return { $status: 202, body: { accepted: true } };
28
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `autoModerate` — workflow that runs cheap auto-moderation on every
2
+ * autoModerate — workflow that runs cheap auto-moderation on every
3
3
  * submitted post and decides whether to bypass the human queue.
4
4
  *
5
5
  * Pattern: workflows are subscribers — they react to events with the
@@ -14,12 +14,12 @@
14
14
  * Same subscription primitive; different intent. The symmetry is real —
15
15
  * if you have one, you have the shape to add the other.
16
16
  *
17
- * ## Why a workflow here vs. doing it inline in the resolver
17
+ * ## Why a workflow here vs. doing it inline in the route handler
18
18
  *
19
- * The HTTP resolver for "submit post" should return quickly — the user
20
- * is waiting on a response. Running auto-moderation (which might call
21
- * an LLM, scan a profanity list, lookup author reputation) takes time
22
- * we can't afford in the request path.
19
+ * The HTTP `POST /posts` route should return quickly — the user is
20
+ * waiting on a response. Running auto-moderation (which might call an
21
+ * LLM, scan a profanity list, lookup author reputation) takes time we
22
+ * can't afford in the request path.
23
23
  *
24
24
  * The workflow decouples the two: the action returns immediately after
25
25
  * recording PostWasSubmitted; the workflow picks up the event async and
@@ -29,15 +29,17 @@
29
29
  */
30
30
 
31
31
  import { defineWorkflow } from "@nwire/forge";
32
- import { PostWasSubmitted } from "./events";
33
- import { approvePost, rejectPost } from "./actions";
32
+ import { PostWasSubmitted } from "../events/post-was-submitted";
33
+ import { approvePost } from "../actions/approve-post";
34
+ import { rejectPost } from "../actions/reject-post";
34
35
 
35
36
  /**
36
37
  * Trivial moderation check — in a real app this would call an LLM,
37
38
  * check against a banned-words list, look up author reputation, etc.
38
- * Returns "approve" for clean content, "reject" with a reason for
39
- * obvious violations, "human" when the content is ambiguous and needs
40
- * a moderator's eye.
39
+ *
40
+ * - "approve" for clean content
41
+ * - `{ reject: reason }` for obvious violations
42
+ * - "human" when content is ambiguous and a moderator should decide
41
43
  */
42
44
  function autoCheck(body: string): "approve" | { reject: string } | "human" {
43
45
  if (body.toLowerCase().includes("spam-marker")) return { reject: "Auto-detected spam" };
@@ -47,15 +49,6 @@ function autoCheck(body: string): "approve" | { reject: string } | "human" {
47
49
 
48
50
  export const autoModerate = defineWorkflow("auto-moderate", ({ on, send }) => {
49
51
  on(PostWasSubmitted, async (event) => {
50
- /**
51
- * Three possible outcomes from the auto-check. Each maps to either
52
- * dispatching the matching action (approve/reject) or doing nothing
53
- * and letting a human moderator handle it via the HTTP endpoints.
54
- *
55
- * `send(action, input)` is the workflow's way to dispatch — it
56
- * runs the action through the same pipeline as an HTTP-triggered
57
- * dispatch, including retry/DLQ/idempotency.
58
- */
59
52
  const decision = autoCheck(event.body);
60
53
 
61
54
  if (decision === "approve") {
@@ -11,5 +11,5 @@
11
11
  "noEmit": true,
12
12
  "types": ["node", "vite/client"]
13
13
  },
14
- "include": ["app/**/*"]
14
+ "include": ["app/**/*", "modules/**/*", "__tests__/**/*"]
15
15
  }
@@ -1,32 +0,0 @@
1
- /**
2
- * Tiny `requireUser` middleware — proves the chain pattern.
3
- *
4
- * A real app would use `@nwire/auth` + an IdP adapter (Logto,
5
- * better-auth, etc.) to extract `user` from a JWT in the Authorization
6
- * header. For this example we trust the `x-user-id` header so the test
7
- * suite stays focused on the framework shape, not on JWT handling.
8
- *
9
- * The middleware does two things:
10
- *
11
- * 1. Throws `NoUserId` if the header is absent — surfaces as 401 JSON.
12
- * 2. Attaches the user id to the request-scoped container so resolvers
13
- * can `ctx.resolve<string>("userId")` without re-reading the header.
14
- *
15
- * Resolvers chain it with `.use(requireUser)`; auth lives at the
16
- * interface seam, not inside business logic.
17
- */
18
-
19
- import type { HttpMiddleware } from "@nwire/http";
20
- import { NoUserId } from "./errors";
21
-
22
- export const requireUser: HttpMiddleware = async (ctx, next) => {
23
- const userId = ctx.request.headers["x-user-id"];
24
- if (typeof userId !== "string" || userId.length === 0) {
25
- throw NoUserId;
26
- }
27
- // Stash on Koa state so resolvers can read it via _raw.state.userId,
28
- // OR they can read the header directly via _raw.request.headers. We
29
- // attach to state so the handler's signature is clean.
30
- ctx.state.userId = userId;
31
- await next();
32
- };
@@ -1,81 +0,0 @@
1
- /**
2
- * Domain actions — the use cases the moderation system exposes.
3
- *
4
- * Each action is a named function with input validation, executable from:
5
- *
6
- * - A resolver (HTTP handler dispatches via `execute(action, input)`)
7
- * - A workflow (the auto-moderation workflow dispatches via `send(action, ...)`)
8
- * - Tests (via the harness — exercises the action without booting HTTP)
9
- *
10
- * That reuse across callers is exactly why actions are extracted from
11
- * resolvers. In the todo-app example we didn't extract — each CRUD
12
- * resolver did its own DB write. Here `approvePost` is called both from
13
- * the HTTP `POST /posts/:id/approve` endpoint AND from the workflow's
14
- * auto-approve branch. Extracting it keeps the logic in one place.
15
- *
16
- * The actions emit events, the framework records them, and the implicit
17
- * bus delivers them to subscribed workflows + projections.
18
- */
19
-
20
- import { randomUUID } from "node:crypto";
21
- import { z } from "zod";
22
- import { defineAction } from "@nwire/forge";
23
- import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
24
-
25
- export const submitPost = defineAction({
26
- name: "moderation.submit-post",
27
- description: "Author submits a draft to the moderation queue.",
28
- schema: z.object({
29
- authorId: z.string(),
30
- body: z.string().min(1).max(2000),
31
- }),
32
- emits: [PostWasSubmitted],
33
- handler: async (input) => {
34
- /**
35
- * The action's job is to validate the input and return the event(s)
36
- * that describe what happened. The framework persists the events,
37
- * notifies workflow subscribers, and folds them into projections.
38
- * Notice: the action doesn't know who's listening. That's the bus.
39
- */
40
- return PostWasSubmitted({
41
- postId: randomUUID(),
42
- authorId: input.authorId,
43
- body: input.body,
44
- submittedAt: new Date().toISOString(),
45
- });
46
- },
47
- });
48
-
49
- export const approvePost = defineAction({
50
- name: "moderation.approve-post",
51
- description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
52
- schema: z.object({
53
- postId: z.string(),
54
- approvedBy: z.string(),
55
- }),
56
- emits: [PostWasApproved],
57
- handler: async (input) =>
58
- PostWasApproved({
59
- postId: input.postId,
60
- approvedBy: input.approvedBy,
61
- approvedAt: new Date().toISOString(),
62
- }),
63
- });
64
-
65
- export const rejectPost = defineAction({
66
- name: "moderation.reject-post",
67
- description: "Reject a post with a stated reason.",
68
- schema: z.object({
69
- postId: z.string(),
70
- rejectedBy: z.string(),
71
- reason: z.string().min(1).max(500),
72
- }),
73
- emits: [PostWasRejected],
74
- handler: async (input) =>
75
- PostWasRejected({
76
- postId: input.postId,
77
- rejectedBy: input.rejectedBy,
78
- reason: input.reason,
79
- rejectedAt: new Date().toISOString(),
80
- }),
81
- });
@@ -1,53 +0,0 @@
1
- /**
2
- * Domain events for the moderation queue.
3
- *
4
- * Each event is past-tense — it represents a fact that happened, not a
5
- * command to do something. `WasSubmitted`, `WasApproved`, `WasRejected`.
6
- * The tense is the whole grammar: events name the past, actions name the
7
- * present-imperative.
8
- *
9
- * `outcome` and `audience` are Studio-aware metadata — the scanner picks
10
- * them up so the dashboard can render the event with the right semantics
11
- * ("milestone", "success", "failure") and route to the right subscribers.
12
- */
13
-
14
- import { z } from "zod";
15
- import { defineEvent } from "@nwire/messages";
16
-
17
- export const PostWasSubmitted = defineEvent({
18
- name: "moderation.post-was-submitted",
19
- description: "An author drafts a post and submits it for moderation.",
20
- outcome: "milestone",
21
- audience: ["product", "ops"],
22
- schema: z.object({
23
- postId: z.string(),
24
- authorId: z.string(),
25
- body: z.string(),
26
- submittedAt: z.string().datetime(),
27
- }),
28
- });
29
-
30
- export const PostWasApproved = defineEvent({
31
- name: "moderation.post-was-approved",
32
- description: "A moderator (or the auto-moderation workflow) approves a post.",
33
- outcome: "success",
34
- audience: ["product", "ops"],
35
- schema: z.object({
36
- postId: z.string(),
37
- approvedBy: z.string().describe("Moderator id, or 'auto' for workflow-driven approval"),
38
- approvedAt: z.string().datetime(),
39
- }),
40
- });
41
-
42
- export const PostWasRejected = defineEvent({
43
- name: "moderation.post-was-rejected",
44
- description: "A moderator (or the auto-moderation workflow) rejects a post.",
45
- outcome: "failure",
46
- audience: ["product", "ops"],
47
- schema: z.object({
48
- postId: z.string(),
49
- rejectedBy: z.string(),
50
- reason: z.string(),
51
- rejectedAt: z.string().datetime(),
52
- }),
53
- });
@@ -1,28 +0,0 @@
1
- /**
2
- * The moderation module — collects all the domain pieces for this
3
- * bounded context. `defineModule` is purely organizational: it groups
4
- * actions / events / workflows / projections / queries that belong
5
- * to one part of the business, marks which ones are public (callable
6
- * from other modules / apps), and gives the runtime a single value
7
- * to register.
8
- *
9
- * `.public()` controls cross-module visibility — see
10
- * `architecture-primitives.html` for the full rules. Here every action
11
- * and query is public because we have one module; in a multi-module
12
- * app you'd be more selective.
13
- */
14
-
15
- import { defineModule } from "@nwire/forge";
16
- import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
17
- import { submitPost, approvePost, rejectPost } from "./actions";
18
- import { autoModerate } from "./auto-moderate.workflow";
19
- import { queueDashboard, listPending, queueTotals, getPost } from "./queue.projection";
20
-
21
- export const moderationModule = defineModule("moderation", {
22
- description: "Post moderation queue — submit, auto-check, decide, dashboard.",
23
- events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
24
- actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
25
- workflows: [autoModerate],
26
- projections: [queueDashboard],
27
- queries: [listPending.public(), queueTotals.public(), getPost.public()],
28
- });