create-nwire 0.7.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/LICENSE +21 -0
- package/README.md +50 -0
- package/bin.mjs +3 -0
- package/dist/__tests__/scaffold.test.d.ts +9 -0
- package/dist/__tests__/scaffold.test.d.ts.map +1 -0
- package/dist/__tests__/scaffold.test.js +83 -0
- package/dist/__tests__/scaffold.test.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +168 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/templates/L1/README.md +34 -0
- package/templates/L1/_gitignore +5 -0
- package/templates/L1/app/api.ts +18 -0
- package/templates/L1/app/main.ts +17 -0
- package/templates/L1/package.json +21 -0
- package/templates/L1/tsconfig.json +15 -0
- package/templates/L2/README.md +50 -0
- package/templates/L2/_gitignore +5 -0
- package/templates/L2/app/api.ts +125 -0
- package/templates/L2/app/errors.ts +48 -0
- package/templates/L2/app/main.ts +35 -0
- package/templates/L2/app/middleware.ts +32 -0
- package/templates/L2/app/model.ts +60 -0
- package/templates/L2/app/store.ts +122 -0
- package/templates/L2/package.json +24 -0
- package/templates/L2/tsconfig.json +15 -0
- package/templates/L4/README.md +52 -0
- package/templates/L4/_gitignore +5 -0
- package/templates/L4/app/actions.ts +81 -0
- package/templates/L4/app/api.ts +118 -0
- package/templates/L4/app/app.ts +28 -0
- package/templates/L4/app/auto-moderate.workflow.ts +78 -0
- package/templates/L4/app/events.ts +53 -0
- package/templates/L4/app/main.ts +39 -0
- package/templates/L4/app/moderation.module.ts +28 -0
- package/templates/L4/app/queue.projection.ts +162 -0
- package/templates/L4/package.json +25 -0
- package/templates/L4/tsconfig.json +15 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP interface — four routes + their inline handlers.
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* What the handler reads from ctx:
|
|
10
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
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";
|
|
30
|
+
|
|
31
|
+
// ─── The interface ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const api = httpInterface({ prefix: "/api" })
|
|
34
|
+
.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;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors — throwable values, rendered per transport.
|
|
3
|
+
*
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Bonus: clients can switch on `error.code` to render UI per-case
|
|
12
|
+
* (toast "Already done!" vs full 404 page). The shape is part of the
|
|
13
|
+
* contract, not invented per call site.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { defineError } from "@nwire/forge";
|
|
17
|
+
|
|
18
|
+
/** A user looked up a todo by id and we don't have it. */
|
|
19
|
+
export const TodoNotFound = defineError({
|
|
20
|
+
code: "TODO_NOT_FOUND",
|
|
21
|
+
status: 404,
|
|
22
|
+
summary: "Todo not found in this user's list",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The user tried to complete a todo that's already in the `completed`
|
|
27
|
+
* state. Distinct from "already removed" — this is a soft conflict the
|
|
28
|
+
* UI can recognize and ignore silently if it wants.
|
|
29
|
+
*/
|
|
30
|
+
export const TodoAlreadyCompleted = defineError({
|
|
31
|
+
code: "TODO_ALREADY_COMPLETED",
|
|
32
|
+
status: 409,
|
|
33
|
+
summary: "Todo has already been marked complete",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Caller didn't send an `x-user-id` header. Our auth middleware is
|
|
38
|
+
* intentionally trivial for this example — a real app would use
|
|
39
|
+
* `@nwire/auth` + `@nwire/auth-logto` or similar.
|
|
40
|
+
*/
|
|
41
|
+
export const NoUserId = defineError({
|
|
42
|
+
code: "MISSING_USER_ID",
|
|
43
|
+
status: 401,
|
|
44
|
+
summary: "Caller must provide x-user-id header",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Re-export common framework errors so resolvers import from one place.
|
|
48
|
+
export { Unauthorized, Forbidden, NotFound, Gone } from "@nwire/forge";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point — wire the store into the http interface, then run.
|
|
3
|
+
*
|
|
4
|
+
* The shape for Level 1:
|
|
5
|
+
*
|
|
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.
|
|
9
|
+
* 3. Wrap in `endpoint()` for graceful shutdown + K8s probes.
|
|
10
|
+
*
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* Run: pnpm dev
|
|
17
|
+
* Try: curl -X POST http://localhost:3000/api/todos \
|
|
18
|
+
* -H "content-type: application/json" \
|
|
19
|
+
* -H "x-user-id: alice" \
|
|
20
|
+
* -d '{"text":"buy milk"}'
|
|
21
|
+
*
|
|
22
|
+
* curl -H "x-user-id: alice" http://localhost:3000/api/todos
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { endpoint } from "@nwire/endpoint";
|
|
26
|
+
import { InMemoryContainer } from "@nwire/container";
|
|
27
|
+
import { TodoStore } from "./store";
|
|
28
|
+
import { api } from "./api";
|
|
29
|
+
|
|
30
|
+
const container = new InMemoryContainer();
|
|
31
|
+
container.register("todos", new TodoStore());
|
|
32
|
+
|
|
33
|
+
api.provide(container);
|
|
34
|
+
|
|
35
|
+
await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo — the public API shape.
|
|
3
|
+
*
|
|
4
|
+
* `defineResource` ties a Zod schema to an explicit `public` field list so
|
|
5
|
+
* the HTTP response automatically strips anything internal. Without this,
|
|
6
|
+
* an internal field added later (an audit timestamp, a soft-delete flag)
|
|
7
|
+
* silently leaks into clients on the next response. With it, that's
|
|
8
|
+
* a compile-time error: a field appears in the schema but not in `public`,
|
|
9
|
+
* the strip is explicit, and reviewers can see what the API exposes by
|
|
10
|
+
* scanning one array.
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { defineResource } from "@nwire/forge";
|
|
20
|
+
|
|
21
|
+
export const Todo = defineResource("Todo", {
|
|
22
|
+
summary: "A single todo item in a user's list",
|
|
23
|
+
|
|
24
|
+
schema: z.object({
|
|
25
|
+
id: z.string().describe("Unique todo identifier"),
|
|
26
|
+
userId: z.string().describe("Owner of the todo"),
|
|
27
|
+
text: z.string().describe("What the user wants to remember"),
|
|
28
|
+
status: z.enum(["open", "completed"]).describe("Lifecycle state"),
|
|
29
|
+
addedAt: z.string().datetime().describe("ISO 8601 creation timestamp"),
|
|
30
|
+
completedAt: z
|
|
31
|
+
.string()
|
|
32
|
+
.datetime()
|
|
33
|
+
.nullable()
|
|
34
|
+
.describe("Set when status=completed; null otherwise"),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
public: ["id", "userId", "text", "status", "addedAt", "completedAt"],
|
|
38
|
+
|
|
39
|
+
examples: {
|
|
40
|
+
open: {
|
|
41
|
+
id: "01HK7Z3YV4MPNG2BAYBA8XQZ4F",
|
|
42
|
+
userId: "alice",
|
|
43
|
+
text: "buy milk",
|
|
44
|
+
status: "open",
|
|
45
|
+
addedAt: "2026-05-21T10:00:00.000Z",
|
|
46
|
+
completedAt: null,
|
|
47
|
+
},
|
|
48
|
+
done: {
|
|
49
|
+
id: "01HK7Z4AVV4MPNG2BAYBA8XQZ4F",
|
|
50
|
+
userId: "alice",
|
|
51
|
+
text: "ship v1.0",
|
|
52
|
+
status: "completed",
|
|
53
|
+
addedAt: "2026-05-20T09:00:00.000Z",
|
|
54
|
+
completedAt: "2026-05-21T11:30:00.000Z",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Inferred TS type — what handlers work with internally. */
|
|
60
|
+
export type TodoRecord = z.infer<typeof Todo.schema>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory todo store.
|
|
3
|
+
*
|
|
4
|
+
* Two parts:
|
|
5
|
+
*
|
|
6
|
+
* 1. `TodoStore` — the actual data + operations (kept dead simple so
|
|
7
|
+
* readers see the framework, not the persistence layer).
|
|
8
|
+
* 2. `todoStorePlugin` — a `@nwire/app` plugin that registers the
|
|
9
|
+
* store on the container under the name "todos" and tears it down
|
|
10
|
+
* on graceful shutdown.
|
|
11
|
+
*
|
|
12
|
+
* In a real app you'd swap the store for `@nwire/data-drizzle` against
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { definePlugin } from "@nwire/app";
|
|
20
|
+
import type { TodoRecord } from "./model";
|
|
21
|
+
|
|
22
|
+
// ─── The store itself ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export class TodoStore {
|
|
25
|
+
private readonly byId = new Map<string, TodoRecord>();
|
|
26
|
+
private readonly byUser = new Map<string, Set<string>>();
|
|
27
|
+
|
|
28
|
+
add(input: { userId: string; text: string }): TodoRecord {
|
|
29
|
+
const todo: TodoRecord = {
|
|
30
|
+
id: randomUUID(),
|
|
31
|
+
userId: input.userId,
|
|
32
|
+
text: input.text,
|
|
33
|
+
status: "open",
|
|
34
|
+
addedAt: new Date().toISOString(),
|
|
35
|
+
completedAt: null,
|
|
36
|
+
};
|
|
37
|
+
this.byId.set(todo.id, todo);
|
|
38
|
+
const owned = this.byUser.get(input.userId) ?? new Set<string>();
|
|
39
|
+
owned.add(todo.id);
|
|
40
|
+
this.byUser.set(input.userId, owned);
|
|
41
|
+
return todo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get(id: string): TodoRecord | undefined {
|
|
45
|
+
return this.byId.get(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
complete(id: string): TodoRecord | undefined {
|
|
49
|
+
const existing = this.byId.get(id);
|
|
50
|
+
if (!existing) return undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Returning the SAME object would break callers that compare the
|
|
53
|
+
* old + new value (e.g., for change detection). Always allocate.
|
|
54
|
+
*/
|
|
55
|
+
const updated: TodoRecord = {
|
|
56
|
+
...existing,
|
|
57
|
+
status: "completed",
|
|
58
|
+
completedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
this.byId.set(id, updated);
|
|
61
|
+
return updated;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
remove(id: string): boolean {
|
|
65
|
+
const existing = this.byId.get(id);
|
|
66
|
+
if (!existing) return false;
|
|
67
|
+
this.byId.delete(id);
|
|
68
|
+
this.byUser.get(existing.userId)?.delete(id);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
listByUser(userId: string, status?: "open" | "completed"): TodoRecord[] {
|
|
73
|
+
const ids = this.byUser.get(userId) ?? new Set<string>();
|
|
74
|
+
const out: TodoRecord[] = [];
|
|
75
|
+
for (const id of ids) {
|
|
76
|
+
const t = this.byId.get(id);
|
|
77
|
+
if (!t) continue;
|
|
78
|
+
if (status && t.status !== status) continue;
|
|
79
|
+
out.push(t);
|
|
80
|
+
}
|
|
81
|
+
// Open todos first (recently-added on top); completed at the bottom.
|
|
82
|
+
out.sort((a, b) => {
|
|
83
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
84
|
+
return a.addedAt < b.addedAt ? 1 : -1;
|
|
85
|
+
});
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Diagnostic — number of todos held. */
|
|
90
|
+
size(): number {
|
|
91
|
+
return this.byId.size;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── The plugin ──────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Plugin shape: `provide("todos", { boot, shutdown })`.
|
|
99
|
+
*
|
|
100
|
+
* The framework calls `boot()` during app start, registers the result
|
|
101
|
+
* on the container under the name we chose, and calls `shutdown(store)`
|
|
102
|
+
* during graceful drain. From any resolver's ctx: `ctx.resolve("todos")`.
|
|
103
|
+
*
|
|
104
|
+
* This same shape works whether the store is in-memory (here), a Pool
|
|
105
|
+
* (Postgres via Drizzle), or a MongoClient. The plugin wrapper handles
|
|
106
|
+
* the lifecycle; the store implementation handles its own concerns.
|
|
107
|
+
*/
|
|
108
|
+
export const todoStorePlugin = definePlugin("todos", ({ provide }) => {
|
|
109
|
+
provide("todos", {
|
|
110
|
+
boot: () => new TodoStore(),
|
|
111
|
+
shutdown: (store) => {
|
|
112
|
+
// For an in-memory store there's nothing to close. A real adapter
|
|
113
|
+
// would call `pool.end()` / `client.close()` here.
|
|
114
|
+
void store;
|
|
115
|
+
},
|
|
116
|
+
healthCheck: (store) => {
|
|
117
|
+
// Trivial — proves the store is alive. A real adapter would
|
|
118
|
+
// SELECT 1 against the DB. Lightship calls this on every /readyz.
|
|
119
|
+
if (store.size() < 0) throw new Error("store corrupt");
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite-node app/main.ts",
|
|
8
|
+
"test": "vitest run"
|
|
9
|
+
},
|
|
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",
|
|
16
|
+
"zod": "^4.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.19.9",
|
|
20
|
+
"typescript": "^5.9.0",
|
|
21
|
+
"vite-node": "^3.2.4",
|
|
22
|
+
"vitest": "^4.0.18"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node", "vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["app/**/*"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+
Built around a moderation-queue example so you can see the moving
|
|
8
|
+
parts before swapping in your own domain.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm install
|
|
14
|
+
pnpm dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Try
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# submit a post (becomes pending, auto-moderation triggers)
|
|
21
|
+
curl -X POST http://localhost:3000/api/posts \
|
|
22
|
+
-H "content-type: application/json" \
|
|
23
|
+
-d '{"authorId":"alice","body":"Hello, world!"}'
|
|
24
|
+
|
|
25
|
+
# read the dashboard projection
|
|
26
|
+
curl http://localhost:3000/api/queue
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Source map
|
|
30
|
+
|
|
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 |
|
|
41
|
+
|
|
42
|
+
## Studio
|
|
43
|
+
|
|
44
|
+
`api.inspect(app)` mounts the `/_nwire/*` introspection surface.
|
|
45
|
+
Point Studio at this app:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx -p @nwire/cli nwire studio
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You'll get live traces, an actor browser, projection viewer, DLQ list,
|
|
52
|
+
and manual dispatch — all reading from this app.
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
});
|