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
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Smoke test — the four CRUD operations + the requireUser middleware.
3
+ *
4
+ * Boots the api against a real Node http server, registers a fresh
5
+ * in-memory TodoStore on a container, and dispatches fetch requests
6
+ * exactly the way clients will.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
10
+ import http from "node:http";
11
+ import type { Server } from "node:http";
12
+ import { InMemoryContainer } from "@nwire/container";
13
+ import { TodoStore } from "../app/store/todo-store";
14
+ import { api } from "../app/api";
15
+
16
+ let server: Server;
17
+ let url: string;
18
+
19
+ beforeAll(async () => {
20
+ const container = new InMemoryContainer();
21
+ container.register("todos", new TodoStore());
22
+ api.provide(container);
23
+
24
+ server = http.createServer(api.compile());
25
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
26
+ const addr = server.address() as { port: number };
27
+ url = `http://127.0.0.1:${addr.port}`;
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await new Promise<void>((resolve, reject) =>
32
+ server.close((err) => (err ? reject(err) : resolve())),
33
+ );
34
+ });
35
+
36
+ async function req(
37
+ path: string,
38
+ init: RequestInit & { user?: string } = {},
39
+ ): Promise<{ status: number; body: unknown }> {
40
+ const headers: Record<string, string> = {
41
+ "content-type": "application/json",
42
+ ...((init.headers as Record<string, string>) ?? {}),
43
+ };
44
+ if (init.user) headers["x-user-id"] = init.user;
45
+ const res = await fetch(`${url}${path}`, { ...init, headers });
46
+ const text = await res.text();
47
+ let body: unknown = text;
48
+ try {
49
+ body = JSON.parse(text);
50
+ } catch {
51
+ /* keep as text */
52
+ }
53
+ return { status: res.status, body };
54
+ }
55
+
56
+ describe("todo api", () => {
57
+ it("requires x-user-id (requireUser middleware)", async () => {
58
+ const r = await req("/api/todos", { method: "GET" });
59
+ expect(r.status).toBe(401);
60
+ expect((r.body as { error: { code: string } }).error.code).toBe("MISSING_USER_ID");
61
+ });
62
+
63
+ it("creates, lists, completes, deletes", async () => {
64
+ // create
65
+ const created = await req("/api/todos", {
66
+ method: "POST",
67
+ body: JSON.stringify({ text: "buy milk" }),
68
+ user: "alice",
69
+ });
70
+ expect(created.status).toBe(201);
71
+ const todo = created.body as { id: string; text: string; status: string };
72
+ expect(todo.text).toBe("buy milk");
73
+ expect(todo.status).toBe("open");
74
+
75
+ // list
76
+ const listed = await req("/api/todos", { method: "GET", user: "alice" });
77
+ expect(listed.status).toBe(200);
78
+ const list = listed.body as { items: Array<{ id: string }>; total: number };
79
+ expect(list.total).toBe(1);
80
+ expect(list.items[0]!.id).toBe(todo.id);
81
+
82
+ // complete
83
+ const completed = await req(`/api/todos/${todo.id}/complete`, {
84
+ method: "POST",
85
+ user: "alice",
86
+ });
87
+ expect(completed.status).toBe(200);
88
+ expect((completed.body as { status: string }).status).toBe("completed");
89
+
90
+ // double-complete → 409
91
+ const again = await req(`/api/todos/${todo.id}/complete`, {
92
+ method: "POST",
93
+ user: "alice",
94
+ });
95
+ expect(again.status).toBe(409);
96
+
97
+ // delete
98
+ const removed = await req(`/api/todos/${todo.id}`, {
99
+ method: "DELETE",
100
+ user: "alice",
101
+ });
102
+ expect(removed.status).toBe(204);
103
+ });
104
+
105
+ it("isolates todos per user (alice doesn't see bob's)", async () => {
106
+ await req("/api/todos", {
107
+ method: "POST",
108
+ body: JSON.stringify({ text: "bob's task" }),
109
+ user: "bob",
110
+ });
111
+ const aliceList = await req("/api/todos", { method: "GET", user: "alice" });
112
+ const items = (aliceList.body as { items: Array<{ text: string }> }).items;
113
+ expect(items.find((i) => i.text === "bob's task")).toBeUndefined();
114
+ });
115
+ });
@@ -1,125 +1,30 @@
1
1
  /**
2
- * The HTTP interface — four routes + their inline handlers.
2
+ * HTTP interface — the surface this app exposes.
3
3
  *
4
- * Each route binding carries its own schemas (params + body + query);
5
- * the framework validates the request against them before the handler
6
- * runs. The handler receives a typed `input` (merged from all three)
7
- * plus the resolved request context.
4
+ * One `.wire(Route, Handler)` line per route. To add a new route, drop a
5
+ * file under `./routes/` exporting `{name}Route + {name}Handler`, then
6
+ * `.wire()` it in here.
8
7
  *
9
- * What the handler reads from ctx:
8
+ * Each route's verb + path + schema + handler live in one file under
9
+ * `./routes/`. The interface stays a 1-line-per-route reducer — easy to
10
+ * audit, easy to grep, no second config file.
10
11
  *
11
- * - `input` parsed + validated route input
12
- * - `resolve<T>(name)` container binding (e.g. the todo store)
13
- * - `_raw` Koa context (for state.userId set by requireUser)
14
- *
15
- * The middleware chain `requireUser` runs before EVERY route — adding it
16
- * once at the interface level instead of repeating per-route. Per-route
17
- * middleware would go on individual `.wire(...)` calls if needed.
18
- *
19
- * Response shapes use `Todo.project(record)` so the public field list
20
- * defined on the model controls what ships. No hand-mapping; no risk
21
- * of an internal field leaking.
12
+ * Cross-cutting:
13
+ * - `requireUser` middleware runs before EVERY route (chained once here).
14
+ * - `.provide(container)` is wired by `main.ts` with the todo store.
22
15
  */
23
16
 
24
- import { httpInterface, get, post, del } from "@nwire/http";
25
- import { z } from "zod";
26
- import { Todo, type TodoRecord } from "./model";
27
- import { TodoStore } from "./store";
28
- import { TodoNotFound, TodoAlreadyCompleted } from "./errors";
29
- import { requireUser } from "./middleware";
17
+ import { httpInterface } from "@nwire/http";
18
+ import { requireUser } from "./middleware/require-user";
30
19
 
31
- // ─── The interface ────────────────────────────────────────────────
20
+ import { listTodosRoute, listTodosHandler } from "./routes/list-todos";
21
+ import { createTodoRoute, createTodoHandler } from "./routes/create-todo";
22
+ import { completeTodoRoute, completeTodoHandler } from "./routes/complete-todo";
23
+ import { deleteTodoRoute, deleteTodoHandler } from "./routes/delete-todo";
32
24
 
33
25
  export const api = httpInterface({ prefix: "/api" })
34
26
  .use(requireUser)
35
-
36
- // POST /api/todos — add
37
- .wire(
38
- post("/todos", {
39
- body: z.object({ text: z.string().min(1).max(500) }),
40
- }),
41
- async ({ input, resolve, _raw }) => {
42
- const { text } = input as { text: string };
43
- const store = resolve<TodoStore>("todos");
44
- const userId = _raw.state.userId as string;
45
- const todo = store.add({ userId, text });
46
- return { $status: 201, body: project(todo) };
47
- },
48
- )
49
-
50
- // GET /api/todos?status=open|completed — list
51
- .wire(
52
- get("/todos", {
53
- query: z.object({
54
- status: z.enum(["open", "completed"]).optional(),
55
- }),
56
- }),
57
- async ({ input, resolve, _raw }) => {
58
- const { status } = input as { status?: "open" | "completed" };
59
- const store = resolve<TodoStore>("todos");
60
- const userId = _raw.state.userId as string;
61
- const list = store.listByUser(userId, status);
62
- return { items: list.map(project), total: list.length };
63
- },
64
- )
65
-
66
- // POST /api/todos/:id/complete — mark complete
67
- .wire(
68
- post("/todos/:id/complete", {
69
- params: z.object({ id: z.string() }),
70
- }),
71
- async ({ input, resolve, _raw }) => {
72
- const { id } = input as { id: string };
73
- const store = resolve<TodoStore>("todos");
74
- const userId = _raw.state.userId as string;
75
-
76
- const existing = store.get(id);
77
- if (!existing || existing.userId !== userId) throw TodoNotFound;
78
- if (existing.status === "completed") throw TodoAlreadyCompleted;
79
-
80
- const updated = store.complete(id);
81
- return project(updated!);
82
- },
83
- )
84
-
85
- // DELETE /api/todos/:id — remove
86
- .wire(
87
- del("/todos/:id", {
88
- params: z.object({ id: z.string() }),
89
- }),
90
- async ({ input, resolve, _raw }) => {
91
- const { id } = input as { id: string };
92
- const store = resolve<TodoStore>("todos");
93
- const userId = _raw.state.userId as string;
94
-
95
- const existing = store.get(id);
96
- if (!existing || existing.userId !== userId) throw TodoNotFound;
97
-
98
- store.remove(id);
99
- return null; // → 204 No Content
100
- },
101
- );
102
-
103
- // ─── Helpers ──────────────────────────────────────────────────────
104
-
105
- /**
106
- * `project()` shapes the internal record onto the public Todo model.
107
- * In a richer setup with `response.list(Todo)` this happens automatically.
108
- * Here we call it explicitly so the example reads top-to-bottom.
109
- */
110
- function project(t: TodoRecord) {
111
- return {
112
- id: t.id,
113
- userId: t.userId,
114
- text: t.text,
115
- status: t.status,
116
- addedAt: t.addedAt,
117
- completedAt: t.completedAt,
118
- };
119
- }
120
-
121
- // Reference Todo so the import isn't tree-shaken — the model is the
122
- // canonical declaration of the public field set, even when we project
123
- // by hand. Future: switch to response.list(Todo) once the new builder
124
- // has the project() helper wired.
125
- void Todo;
27
+ .wire(listTodosRoute, listTodosHandler)
28
+ .wire(createTodoRoute, createTodoHandler)
29
+ .wire(completeTodoRoute, completeTodoHandler)
30
+ .wire(deleteTodoRoute, deleteTodoHandler);
@@ -2,11 +2,11 @@
2
2
  * Typed errors — throwable values, rendered per transport.
3
3
  *
4
4
  * `defineError` returns an Error subclass with `code` + `status` baked
5
- * in. In a resolver handler you just `throw TodoNotFound` and the HTTP
6
- * transport renders it as JSON `{ error: { code: "TODO_NOT_FOUND" } }`
7
- * with status 404. GraphQL would render it as a GQL error with the same
8
- * extensions. CLI would print + exit with the right code. One error
9
- * declaration, every transport does the right thing.
5
+ * in. In a route handler you `throw TodoNotFound` and the HTTP transport
6
+ * renders it as JSON `{ error: { code: "TODO_NOT_FOUND" } }` with status
7
+ * 404. GraphQL would render it as a GQL error with the same extensions.
8
+ * CLI would print + exit with the right code. One error declaration,
9
+ * every transport does the right thing.
10
10
  *
11
11
  * Bonus: clients can switch on `error.code` to render UI per-case
12
12
  * (toast "Already done!" vs full 404 page). The shape is part of the
@@ -44,5 +44,5 @@ export const NoUserId = defineError({
44
44
  summary: "Caller must provide x-user-id header",
45
45
  });
46
46
 
47
- // Re-export common framework errors so resolvers import from one place.
47
+ // Re-export common framework errors so handlers import from one place.
48
48
  export { Unauthorized, Forbidden, NotFound, Gone } from "@nwire/forge";
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * Entry point — wire the store into the http interface, then run.
3
3
  *
4
- * The shape for Level 1:
4
+ * The shape for Level 2:
5
5
  *
6
6
  * 1. Build an in-memory container, register the store on it.
7
- * 2. Hand it to the http interface via `.provide()` — the resolvers'
8
- * `resolve("todos")` reads from there.
7
+ * 2. Hand it to the http interface via `.provide()` — route handlers
8
+ * read it with `resolve<TodoStore>("todos")`.
9
9
  * 3. Wrap in `endpoint()` for graceful shutdown + K8s probes.
10
10
  *
11
11
  * No `createApp` here. With ONE binding and zero plugin lifecycle work,
12
- * createApp adds ceremony without value. The station-management example
13
- * shows the full createApp + plugin shape with many bindings and
14
- * framework events; Level 1 doesn't need it.
12
+ * createApp adds ceremony without value. The L4 template (and the
13
+ * station-management example) show the full createApp + plugin shape
14
+ * with many bindings and framework events; L2 doesn't need it.
15
15
  *
16
16
  * Run: pnpm dev
17
17
  * Try: curl -X POST http://localhost:3000/api/todos \
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { endpoint } from "@nwire/endpoint";
26
26
  import { InMemoryContainer } from "@nwire/container";
27
- import { TodoStore } from "./store";
27
+ import { TodoStore } from "./store/todo-store";
28
28
  import { api } from "./api";
29
29
 
30
30
  const container = new InMemoryContainer();
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `requireUser` — tiny auth shim that proves the middleware-chain pattern.
3
+ *
4
+ * A real app would use `@nwire/auth` + an IdP adapter (Logto, better-auth,
5
+ * etc.) to extract `user` from a JWT in the Authorization header. For this
6
+ * template we trust the `x-user-id` header so the test suite stays focused
7
+ * 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 Koa state so handlers can read it without
13
+ * re-parsing the request.
14
+ *
15
+ * Routes chain it once via `.use(requireUser)` in `api.ts`; auth lives at
16
+ * the interface seam, not inside business logic.
17
+ */
18
+
19
+ import type { HttpMiddleware } from "@nwire/http";
20
+ import { NoUserId } from "../errors/todo-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
+ ctx.state.userId = userId;
28
+ await next();
29
+ };
@@ -9,10 +9,10 @@
9
9
  * the strip is explicit, and reviewers can see what the API exposes by
10
10
  * scanning one array.
11
11
  *
12
- * The same `Todo` model is used by every resolver that returns todos —
13
- * single, list, paginated. `response.list(Todo)` and `response.ok(Todo)`
14
- * both call `Todo.project()` on each record so the public field rules
15
- * apply once, declared in one place.
12
+ * The same `Todo` resource is used by every route handler that returns
13
+ * todos — single, list, paginated. Future: `response.list(Todo)` and
14
+ * `response.ok(Todo)` will call `Todo.project()` on each record so the
15
+ * public field rules apply once, declared in one place.
16
16
  */
17
17
 
18
18
  import { z } from "zod";
@@ -56,5 +56,21 @@ export const Todo = defineResource("Todo", {
56
56
  },
57
57
  });
58
58
 
59
- /** Inferred TS type — what handlers work with internally. */
59
+ /** Inferred TS type — what handlers + the store work with internally. */
60
60
  export type TodoRecord = z.infer<typeof Todo.schema>;
61
+
62
+ /**
63
+ * `project()` shapes the internal record onto the public Todo shape.
64
+ * Today this just hand-picks fields; future versions will call
65
+ * `Todo.project(record)` once the helper lands on `defineResource`.
66
+ */
67
+ export function project(t: TodoRecord) {
68
+ return {
69
+ id: t.id,
70
+ userId: t.userId,
71
+ text: t.text,
72
+ status: t.status,
73
+ addedAt: t.addedAt,
74
+ completedAt: t.completedAt,
75
+ };
76
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * POST /api/todos/:id/complete — mark a todo done.
3
+ *
4
+ * Two negative cases mapped to typed errors:
5
+ *
6
+ * - Todo doesn't exist OR belongs to another user → `TodoNotFound` (404)
7
+ * - Todo already completed → `TodoAlreadyCompleted` (409)
8
+ *
9
+ * Throwing typed errors keeps the handler readable; transport translates.
10
+ */
11
+
12
+ import { post, type HttpHandler } from "@nwire/http";
13
+ import { z } from "zod";
14
+ import { TodoStore } from "../store/todo-store";
15
+ import { project } from "../resources/todo";
16
+ import { TodoNotFound, TodoAlreadyCompleted } from "../errors/todo-errors";
17
+
18
+ const Params = z.object({ id: z.string() });
19
+
20
+ export const completeTodoRoute = post("/todos/:id/complete", { params: Params });
21
+
22
+ export const completeTodoHandler: HttpHandler<z.infer<typeof Params>> = async ({
23
+ input,
24
+ resolve,
25
+ _raw,
26
+ }) => {
27
+ const store = resolve<TodoStore>("todos");
28
+ const userId = _raw.state.userId as string;
29
+
30
+ const existing = store.get(input.id);
31
+ if (!existing || existing.userId !== userId) throw TodoNotFound;
32
+ if (existing.status === "completed") throw TodoAlreadyCompleted;
33
+
34
+ const updated = store.complete(input.id);
35
+ return project(updated!);
36
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * POST /api/todos — add a new todo to the caller's list.
3
+ *
4
+ * Returns 201 + the new todo. The handler reads `userId` from Koa state
5
+ * (set by `requireUser` middleware) and the store from the request-scoped
6
+ * container.
7
+ */
8
+
9
+ import { post, type HttpHandler } from "@nwire/http";
10
+ import { z } from "zod";
11
+ import { TodoStore } from "../store/todo-store";
12
+ import { project } from "../resources/todo";
13
+
14
+ const Body = z.object({ text: z.string().min(1).max(500) });
15
+
16
+ export const createTodoRoute = post("/todos", { body: Body });
17
+
18
+ export const createTodoHandler: HttpHandler<z.infer<typeof Body>> = async ({
19
+ input,
20
+ resolve,
21
+ _raw,
22
+ }) => {
23
+ const store = resolve<TodoStore>("todos");
24
+ const userId = _raw.state.userId as string;
25
+ const todo = store.add({ userId, text: input.text });
26
+ return { $status: 201, body: project(todo) };
27
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * DELETE /api/todos/:id — remove a todo from the caller's list.
3
+ *
4
+ * Returns 204 (no content). Throws `TodoNotFound` if the id is unknown
5
+ * OR belongs to another user — never leak "exists but not yours" vs
6
+ * "doesn't exist", that's an information-disclosure vector.
7
+ */
8
+
9
+ import { del, type HttpHandler } from "@nwire/http";
10
+ import { z } from "zod";
11
+ import { TodoStore } from "../store/todo-store";
12
+ import { TodoNotFound } from "../errors/todo-errors";
13
+
14
+ const Params = z.object({ id: z.string() });
15
+
16
+ export const deleteTodoRoute = del("/todos/:id", { params: Params });
17
+
18
+ export const deleteTodoHandler: HttpHandler<z.infer<typeof Params>> = async ({
19
+ input,
20
+ resolve,
21
+ _raw,
22
+ }) => {
23
+ const store = resolve<TodoStore>("todos");
24
+ const userId = _raw.state.userId as string;
25
+
26
+ const existing = store.get(input.id);
27
+ if (!existing || existing.userId !== userId) throw TodoNotFound;
28
+
29
+ store.remove(input.id);
30
+ return null; // → 204 No Content
31
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /api/todos — list the caller's todos, optionally filtered by status.
3
+ *
4
+ * Returns `{ items, total }`. The store sorts so open-first by added-at;
5
+ * the handler just projects the records and counts.
6
+ */
7
+
8
+ import { get, type HttpHandler } from "@nwire/http";
9
+ import { z } from "zod";
10
+ import { TodoStore } from "../store/todo-store";
11
+ import { project } from "../resources/todo";
12
+
13
+ const Query = z.object({
14
+ status: z.enum(["open", "completed"]).optional(),
15
+ });
16
+
17
+ export const listTodosRoute = get("/todos", { query: Query });
18
+
19
+ export const listTodosHandler: HttpHandler<z.infer<typeof Query>> = async ({
20
+ input,
21
+ resolve,
22
+ _raw,
23
+ }) => {
24
+ const store = resolve<TodoStore>("todos");
25
+ const userId = _raw.state.userId as string;
26
+ const list = store.listByUser(userId, input.status);
27
+ return { items: list.map(project), total: list.length };
28
+ };
@@ -11,13 +11,17 @@
11
11
  *
12
12
  * In a real app you'd swap the store for `@nwire/data-drizzle` against
13
13
  * Postgres or `@nwire/store-mongo` against Mongo. The PLUGIN shape stays
14
- * the same — `provide("todos", { boot, shutdown })`. Resolvers always do
15
- * `ctx.resolve("todos")` and get whatever the plugin booted.
14
+ * the same — `provide("todos", { boot, shutdown })`. Handlers always do
15
+ * `resolve("todos")` and get whatever the plugin booted.
16
+ *
17
+ * The L2 template uses the simpler container-register-then-provide path
18
+ * from `main.ts` (no createApp). The plugin form is exported so it's
19
+ * one rename away once you graduate to L4.
16
20
  */
17
21
 
18
22
  import { randomUUID } from "node:crypto";
19
23
  import { definePlugin } from "@nwire/app";
20
- import type { TodoRecord } from "./model";
24
+ import type { TodoRecord } from "../resources/todo";
21
25
 
22
26
  // ─── The store itself ────────────────────────────────────────────
23
27
 
@@ -92,18 +96,22 @@ export class TodoStore {
92
96
  }
93
97
  }
94
98
 
95
- // ─── The plugin ──────────────────────────────────────────────────
99
+ // ─── The plugin (optional — used when you graduate to L4) ────────
96
100
 
97
101
  /**
98
102
  * Plugin shape: `provide("todos", { boot, shutdown })`.
99
103
  *
100
104
  * The framework calls `boot()` during app start, registers the result
101
105
  * on the container under the name we chose, and calls `shutdown(store)`
102
- * during graceful drain. From any resolver's ctx: `ctx.resolve("todos")`.
106
+ * during graceful drain. From any handler: `resolve("todos")`.
103
107
  *
104
108
  * This same shape works whether the store is in-memory (here), a Pool
105
109
  * (Postgres via Drizzle), or a MongoClient. The plugin wrapper handles
106
110
  * the lifecycle; the store implementation handles its own concerns.
111
+ *
112
+ * L2 main.ts uses the simpler `container.register(...)` path because we
113
+ * don't have a forge app yet. The plugin is exported so L4 graduation
114
+ * is `import { todoStorePlugin } from "./store/todo-store"`.
107
115
  */
108
116
  export const todoStorePlugin = definePlugin("todos", ({ provide }) => {
109
117
  provide("todos", {
@@ -8,11 +8,11 @@
8
8
  "test": "vitest run"
9
9
  },
10
10
  "dependencies": {
11
- "@nwire/app": "^0.7.0",
12
- "@nwire/container": "^0.7.0",
13
- "@nwire/endpoint": "^0.7.0",
14
- "@nwire/forge": "^0.7.0",
15
- "@nwire/http": "^0.7.0",
11
+ "@nwire/app": "^0.8.17",
12
+ "@nwire/container": "^0.8.17",
13
+ "@nwire/endpoint": "^0.8.17",
14
+ "@nwire/forge": "^0.8.17",
15
+ "@nwire/http": "^0.8.17",
16
16
  "zod": "^4.0.0"
17
17
  },
18
18
  "devDependencies": {
@@ -11,5 +11,5 @@
11
11
  "noEmit": true,
12
12
  "types": ["node", "vite/client"]
13
13
  },
14
- "include": ["app/**/*"]
14
+ "include": ["app/**/*", "__tests__/**/*"]
15
15
  }