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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP interface — six routes covering submit + decide + dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Resolvers call into actions and queries via the forge runtime. The
|
|
5
|
+
* runtime is the implicit bus — `dispatch(action, input)` runs an
|
|
6
|
+
* action through the full pipeline (validation, retry, DLQ, event
|
|
7
|
+
* recording, workflow notification, projection folding). `query(name,
|
|
8
|
+
* input)` reads the projection's materialized state.
|
|
9
|
+
*
|
|
10
|
+
* The HTTP shape is intentionally thin: translate request → action
|
|
11
|
+
* input, dispatch, return a tagged response. All the orchestration
|
|
12
|
+
* (auto-moderate workflow, projection updates) happens downstream of
|
|
13
|
+
* the event the action emits — never inside the resolver.
|
|
14
|
+
*
|
|
15
|
+
* Once `createApp` is also extracted to `@nwire/app` (v1.1), these
|
|
16
|
+
* resolvers will use `ctx.execute(action, input)` + `ctx.query(...)`
|
|
17
|
+
* directly. For v1.0 we route through the runtime explicitly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { httpInterface, get, post } from "@nwire/http";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { app } from "./app";
|
|
23
|
+
import { submitPost, approvePost, rejectPost } from "./actions";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tiny adapter — the resolver layer needs the runtime to dispatch
|
|
27
|
+
* actions and read projections. We hand it the unwrapped runtime
|
|
28
|
+
* instance from the forge app. v1.1's createApp extraction will
|
|
29
|
+
* expose this through `ctx.execute()` / `ctx.query()` directly.
|
|
30
|
+
*/
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
const runtime = (app as any).runtime;
|
|
33
|
+
|
|
34
|
+
export const api = httpInterface({ prefix: "/api" })
|
|
35
|
+
// POST /api/posts — author submits a draft for moderation
|
|
36
|
+
.wire(
|
|
37
|
+
post("/posts", {
|
|
38
|
+
body: z.object({
|
|
39
|
+
authorId: z.string(),
|
|
40
|
+
body: z.string().min(1).max(2000),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
async ({ input }) => {
|
|
44
|
+
await runtime.dispatch(submitPost, input);
|
|
45
|
+
/**
|
|
46
|
+
* Return 202 — accepted, not done. The submitPost action
|
|
47
|
+
* recorded PostWasSubmitted; the auto-moderate workflow + the
|
|
48
|
+
* dashboard projection will run downstream of that. By the time
|
|
49
|
+
* the client polls GET /queue the post may already be auto-decided.
|
|
50
|
+
*/
|
|
51
|
+
return { $status: 202, body: { accepted: true } };
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// POST /api/posts/:postId/approve — human moderator approves
|
|
56
|
+
.wire(
|
|
57
|
+
post("/posts/:postId/approve", {
|
|
58
|
+
params: z.object({ postId: z.string() }),
|
|
59
|
+
body: z.object({ moderatorId: z.string() }),
|
|
60
|
+
}),
|
|
61
|
+
async ({ input }) => {
|
|
62
|
+
const { postId, moderatorId } = input as { postId: string; moderatorId: string };
|
|
63
|
+
await runtime.dispatch(approvePost, {
|
|
64
|
+
postId,
|
|
65
|
+
approvedBy: moderatorId,
|
|
66
|
+
});
|
|
67
|
+
return { $status: 202, body: { accepted: true } };
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// POST /api/posts/:postId/reject — human moderator rejects with reason
|
|
72
|
+
.wire(
|
|
73
|
+
post("/posts/:postId/reject", {
|
|
74
|
+
params: z.object({ postId: z.string() }),
|
|
75
|
+
body: z.object({
|
|
76
|
+
moderatorId: z.string(),
|
|
77
|
+
reason: z.string().min(1).max(500),
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
async ({ input }) => {
|
|
81
|
+
const { postId, moderatorId, reason } = input as {
|
|
82
|
+
postId: string;
|
|
83
|
+
moderatorId: string;
|
|
84
|
+
reason: string;
|
|
85
|
+
};
|
|
86
|
+
await runtime.dispatch(rejectPost, {
|
|
87
|
+
postId,
|
|
88
|
+
rejectedBy: moderatorId,
|
|
89
|
+
reason,
|
|
90
|
+
});
|
|
91
|
+
return { $status: 202, body: { accepted: true } };
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// GET /api/queue — dashboard view
|
|
96
|
+
.wire(
|
|
97
|
+
get("/queue", {
|
|
98
|
+
query: z.object({
|
|
99
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
async ({ input }) => ({
|
|
103
|
+
items: await runtime.query("moderation.list-pending", input),
|
|
104
|
+
totals: await runtime.query("moderation.queue-totals", {}),
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// GET /api/posts/:postId — single post lookup
|
|
109
|
+
.wire(
|
|
110
|
+
get("/posts/:postId", {
|
|
111
|
+
params: z.object({ postId: z.string() }),
|
|
112
|
+
}),
|
|
113
|
+
async ({ input }) => {
|
|
114
|
+
const post = await runtime.query("moderation.get-post", input);
|
|
115
|
+
if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
|
|
116
|
+
return post;
|
|
117
|
+
},
|
|
118
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createApp` composes the moderation module into a runnable app.
|
|
3
|
+
*
|
|
4
|
+
* Why createApp at Level 2 (when todo-app skipped it):
|
|
5
|
+
*
|
|
6
|
+
* - We have a workflow that subscribes to events on the bus. The
|
|
7
|
+
* workflow needs the runtime to register its subscription and
|
|
8
|
+
* fire it on PostWasSubmitted. That's what `createApp` orchestrates.
|
|
9
|
+
*
|
|
10
|
+
* - We have a projection that folds events into state. Same story —
|
|
11
|
+
* the runtime registers the projection's reducers and calls them
|
|
12
|
+
* on every matching event.
|
|
13
|
+
*
|
|
14
|
+
* - We have actions that emit events. The runtime persists those
|
|
15
|
+
* events through the implicit bus and routes them to all subscribers.
|
|
16
|
+
*
|
|
17
|
+
* One module, no plugins yet — but the moment we add a Postgres-backed
|
|
18
|
+
* projection store, a Redis cache, OTel tracing, an audit log plugin,
|
|
19
|
+
* they all attach here via `.use(plugin)`. The same `createApp` API
|
|
20
|
+
* scales from one module to dozens.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createApp } from "@nwire/forge";
|
|
24
|
+
import { moderationModule } from "./moderation.module";
|
|
25
|
+
|
|
26
|
+
export const app = createApp({
|
|
27
|
+
modules: [moderationModule],
|
|
28
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `autoModerate` — workflow that runs cheap auto-moderation on every
|
|
3
|
+
* submitted post and decides whether to bypass the human queue.
|
|
4
|
+
*
|
|
5
|
+
* Pattern: workflows are subscribers — they react to events with the
|
|
6
|
+
* same `on(Event, handler)` shape projections use. The difference is
|
|
7
|
+
* what they DO with the event:
|
|
8
|
+
*
|
|
9
|
+
* - Workflows produce SIDE EFFECTS — dispatch more actions, send
|
|
10
|
+
* external messages, schedule timers, fire integrations.
|
|
11
|
+
* - Projections produce READ STATE — fold events into a denormalized
|
|
12
|
+
* model that queries read from.
|
|
13
|
+
*
|
|
14
|
+
* Same subscription primitive; different intent. The symmetry is real —
|
|
15
|
+
* if you have one, you have the shape to add the other.
|
|
16
|
+
*
|
|
17
|
+
* ## Why a workflow here vs. doing it inline in the resolver
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* The workflow decouples the two: the action returns immediately after
|
|
25
|
+
* recording PostWasSubmitted; the workflow picks up the event async and
|
|
26
|
+
* does the heavy lifting. From the user's perspective: "Submitted!"
|
|
27
|
+
* From the system's perspective: the moderation flow runs to completion
|
|
28
|
+
* with retries, DLQ, and replay if the worker process restarts.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineWorkflow } from "@nwire/forge";
|
|
32
|
+
import { PostWasSubmitted } from "./events";
|
|
33
|
+
import { approvePost, rejectPost } from "./actions";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Trivial moderation check — in a real app this would call an LLM,
|
|
37
|
+
* 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.
|
|
41
|
+
*/
|
|
42
|
+
function autoCheck(body: string): "approve" | { reject: string } | "human" {
|
|
43
|
+
if (body.toLowerCase().includes("spam-marker")) return { reject: "Auto-detected spam" };
|
|
44
|
+
if (body.length < 5) return "human";
|
|
45
|
+
return "approve";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const autoModerate = defineWorkflow("auto-moderate", ({ on, send }) => {
|
|
49
|
+
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
|
+
const decision = autoCheck(event.body);
|
|
60
|
+
|
|
61
|
+
if (decision === "approve") {
|
|
62
|
+
await send(approvePost, { postId: event.postId, approvedBy: "auto" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof decision === "object") {
|
|
67
|
+
await send(rejectPost, {
|
|
68
|
+
postId: event.postId,
|
|
69
|
+
rejectedBy: "auto",
|
|
70
|
+
reason: decision.reject,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// "human" — do nothing; the post stays in the pending queue,
|
|
76
|
+
// visible to moderators via GET /queue.
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry — boot the createApp and run the http interface under endpoint().
|
|
3
|
+
*
|
|
4
|
+
* The order matters:
|
|
5
|
+
*
|
|
6
|
+
* 1. `app` is a configured-but-unbooted forge app — modules registered,
|
|
7
|
+
* workflow subscribers wired, projection reducers attached, but
|
|
8
|
+
* nothing's actually running yet.
|
|
9
|
+
* 2. `app.start()` boots the runtime — workflows subscribe to the bus,
|
|
10
|
+
* projection state initializes, framework events fire.
|
|
11
|
+
* 3. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
|
|
12
|
+
*
|
|
13
|
+
* Once running:
|
|
14
|
+
* - HTTP `POST /api/posts` → submitPost action → PostWasSubmitted event
|
|
15
|
+
* - autoModerate workflow picks up the event, runs auto-check, dispatches
|
|
16
|
+
* approve/reject if obvious
|
|
17
|
+
* - queueDashboard projection picks up ALL three events, updates state
|
|
18
|
+
* - HTTP `GET /api/queue` reads from the projection
|
|
19
|
+
*
|
|
20
|
+
* Run: pnpm dev
|
|
21
|
+
* Try: curl -X POST http://localhost:3000/api/posts \
|
|
22
|
+
* -H "content-type: application/json" \
|
|
23
|
+
* -d '{"authorId":"alice","body":"Hello, world!"}'
|
|
24
|
+
* curl http://localhost:3000/api/queue
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { endpoint } from "@nwire/endpoint";
|
|
28
|
+
import { app } from "./app";
|
|
29
|
+
import { api } from "./api";
|
|
30
|
+
|
|
31
|
+
await app.start();
|
|
32
|
+
|
|
33
|
+
// `.inspect(app)` mounts the `/_nwire/*` introspection surface that
|
|
34
|
+
// Studio's Live + Trace pages consume — live telemetry SSE stream,
|
|
35
|
+
// actor browser, projection viewer, DLQ list, manual dispatch.
|
|
36
|
+
// Off-by-default; opt in to demonstrate the dev-mode introspection.
|
|
37
|
+
api.inspect(app);
|
|
38
|
+
|
|
39
|
+
await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `queueDashboard` projection — the read model for the moderation queue.
|
|
3
|
+
*
|
|
4
|
+
* NOTICE THE SYMMETRY:
|
|
5
|
+
*
|
|
6
|
+
* - The autoModerate WORKFLOW (./auto-moderate.workflow.ts) subscribes
|
|
7
|
+
* to events via `on(PostWasSubmitted, (event) => ...)`. Its job is
|
|
8
|
+
* to produce SIDE EFFECTS — dispatch more actions.
|
|
9
|
+
*
|
|
10
|
+
* - This PROJECTION subscribes to events via the same `on` map shape.
|
|
11
|
+
* Its job is to fold events into a denormalized state that queries
|
|
12
|
+
* read from. No side effects; pure state derivation.
|
|
13
|
+
*
|
|
14
|
+
* Same subscription primitive. Same `on(Event)` ergonomics. Different
|
|
15
|
+
* intent. If you understand workflows, you understand projections —
|
|
16
|
+
* just write to state instead of dispatching.
|
|
17
|
+
*
|
|
18
|
+
* ## Why a projection vs. computing on every read
|
|
19
|
+
*
|
|
20
|
+
* The HTTP `GET /queue?status=pending` could query the event log + fold
|
|
21
|
+
* matching events on every request. That works for low traffic. For a
|
|
22
|
+
* busy moderation dashboard with thousands of moderators each polling
|
|
23
|
+
* every 5 seconds, that's wasted CPU.
|
|
24
|
+
*
|
|
25
|
+
* The projection folds incrementally: every time an event fires, it
|
|
26
|
+
* updates the state. Queries read the materialized state directly.
|
|
27
|
+
* Trade-off: more memory, less CPU; eventually-consistent (the
|
|
28
|
+
* projection lags the event by milliseconds at worst). For dashboards
|
|
29
|
+
* that's the right trade.
|
|
30
|
+
*
|
|
31
|
+
* ## When this lives in a database
|
|
32
|
+
*
|
|
33
|
+
* The in-memory state here is for example clarity. In production the
|
|
34
|
+
* projection's `on` reducers would write to Postgres / Mongo / Redis,
|
|
35
|
+
* and the query would read from there. The PRIMITIVE is the same —
|
|
36
|
+
* `on(Event) → mutate state`. Where state lives is a deployment choice.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { defineProjection, defineQuery } from "@nwire/forge";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
|
|
42
|
+
|
|
43
|
+
// ─── The projection state shape ────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface QueueItem {
|
|
46
|
+
postId: string;
|
|
47
|
+
authorId: string;
|
|
48
|
+
body: string;
|
|
49
|
+
status: "pending" | "approved" | "rejected";
|
|
50
|
+
submittedAt: string;
|
|
51
|
+
decidedAt?: string;
|
|
52
|
+
decidedBy?: string;
|
|
53
|
+
reason?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface QueueState {
|
|
57
|
+
byId: Record<string, QueueItem>;
|
|
58
|
+
pendingIds: string[]; // ordered, oldest first
|
|
59
|
+
totals: { pending: number; approved: number; rejected: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── The projection — note the `on` map matches the workflow's ───
|
|
63
|
+
|
|
64
|
+
export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
65
|
+
description: "The moderation dashboard read model — pending queue + decision totals.",
|
|
66
|
+
listens: [PostWasSubmitted, PostWasApproved, PostWasRejected],
|
|
67
|
+
initial: () => ({
|
|
68
|
+
byId: {},
|
|
69
|
+
pendingIds: [],
|
|
70
|
+
totals: { pending: 0, approved: 0, rejected: 0 },
|
|
71
|
+
}),
|
|
72
|
+
on: {
|
|
73
|
+
/**
|
|
74
|
+
* Same shape as a workflow's `on(PostWasSubmitted, ...)`. The
|
|
75
|
+
* difference: this returns state, the workflow returns void
|
|
76
|
+
* (and produces side effects).
|
|
77
|
+
*/
|
|
78
|
+
[PostWasSubmitted.name]: (state, event) => ({
|
|
79
|
+
byId: {
|
|
80
|
+
...state.byId,
|
|
81
|
+
[event.postId]: {
|
|
82
|
+
postId: event.postId,
|
|
83
|
+
authorId: event.authorId,
|
|
84
|
+
body: event.body,
|
|
85
|
+
status: "pending",
|
|
86
|
+
submittedAt: event.submittedAt,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
pendingIds: [...state.pendingIds, event.postId],
|
|
90
|
+
totals: { ...state.totals, pending: state.totals.pending + 1 },
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
[PostWasApproved.name]: (state, event) => {
|
|
94
|
+
const item = state.byId[event.postId];
|
|
95
|
+
if (!item) return state;
|
|
96
|
+
return {
|
|
97
|
+
byId: {
|
|
98
|
+
...state.byId,
|
|
99
|
+
[event.postId]: {
|
|
100
|
+
...item,
|
|
101
|
+
status: "approved",
|
|
102
|
+
decidedAt: event.approvedAt,
|
|
103
|
+
decidedBy: event.approvedBy,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
pendingIds: state.pendingIds.filter((id) => id !== event.postId),
|
|
107
|
+
totals: {
|
|
108
|
+
...state.totals,
|
|
109
|
+
pending: state.totals.pending - 1,
|
|
110
|
+
approved: state.totals.approved + 1,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
[PostWasRejected.name]: (state, event) => {
|
|
116
|
+
const item = state.byId[event.postId];
|
|
117
|
+
if (!item) return state;
|
|
118
|
+
return {
|
|
119
|
+
byId: {
|
|
120
|
+
...state.byId,
|
|
121
|
+
[event.postId]: {
|
|
122
|
+
...item,
|
|
123
|
+
status: "rejected",
|
|
124
|
+
decidedAt: event.rejectedAt,
|
|
125
|
+
decidedBy: event.rejectedBy,
|
|
126
|
+
reason: event.reason,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
pendingIds: state.pendingIds.filter((id) => id !== event.postId),
|
|
130
|
+
totals: {
|
|
131
|
+
...state.totals,
|
|
132
|
+
pending: state.totals.pending - 1,
|
|
133
|
+
rejected: state.totals.rejected + 1,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── Queries — read the projection's state ─────────────────────
|
|
141
|
+
|
|
142
|
+
export const listPending = defineQuery(queueDashboard, {
|
|
143
|
+
name: "moderation.list-pending",
|
|
144
|
+
schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
|
|
145
|
+
execute: (state, { limit }) =>
|
|
146
|
+
state.pendingIds
|
|
147
|
+
.slice(0, limit)
|
|
148
|
+
.map((id) => state.byId[id])
|
|
149
|
+
.filter((x): x is QueueItem => x !== undefined),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const queueTotals = defineQuery(queueDashboard, {
|
|
153
|
+
name: "moderation.queue-totals",
|
|
154
|
+
schema: z.object({}),
|
|
155
|
+
execute: (state) => state.totals,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export const getPost = defineQuery(queueDashboard, {
|
|
159
|
+
name: "moderation.get-post",
|
|
160
|
+
schema: z.object({ postId: z.string() }),
|
|
161
|
+
execute: (state, { postId }) => state.byId[postId] ?? null,
|
|
162
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
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/endpoint": "^0.7.0",
|
|
13
|
+
"@nwire/forge": "^0.7.0",
|
|
14
|
+
"@nwire/http": "^0.7.0",
|
|
15
|
+
"@nwire/messages": "^0.7.0",
|
|
16
|
+
"zod": "^4.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nwire/test-kit": "^0.7.0",
|
|
20
|
+
"@types/node": "^22.19.9",
|
|
21
|
+
"typescript": "^5.9.0",
|
|
22
|
+
"vite-node": "^3.2.4",
|
|
23
|
+
"vitest": "^4.0.18"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|