create-nwire 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
- package/templates/enterprise/AGENTS.md +11 -11
- package/templates/enterprise/README.md +15 -10
- package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
- package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
- package/templates/enterprise/app/api.ts +5 -1
- package/templates/enterprise/app/app.ts +23 -15
- package/templates/enterprise/app/main.ts +20 -45
- package/templates/enterprise/config/app.ts +18 -0
- package/templates/enterprise/config/env.ts +32 -0
- package/templates/enterprise/config/http.ts +15 -0
- package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
- package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
- package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
- package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
- package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
- package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
- package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +34 -27
- package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
- package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
- package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
- package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
- package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
- package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
- package/templates/enterprise/package.json +10 -11
- package/templates/enterprise/tsconfig.json +1 -1
- package/templates/mcp/AGENTS.md +73 -0
- package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
- package/templates/mcp/_gitignore +5 -0
- package/templates/mcp/_npmrc +4 -0
- package/templates/mcp/_pnpm-workspace.yaml +12 -0
- package/templates/mcp/app/app.ts +24 -0
- package/templates/mcp/app/main.ts +197 -0
- package/templates/mcp/app/store/facts-store.ts +17 -0
- package/templates/mcp/app/tools/add-fact.ts +27 -0
- package/templates/mcp/app/tools/list-facts.ts +17 -0
- package/templates/mcp/app/tools/lookup-fact.ts +28 -0
- package/templates/mcp/app/tools.ts +19 -0
- package/templates/mcp/config/app.ts +16 -0
- package/templates/mcp/config/env.ts +27 -0
- package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/mcp/package.json +25 -0
- package/templates/mcp/tsconfig.json +15 -0
- package/templates/mcp/vitest.config.ts +8 -0
- package/templates/minimal/AGENTS.md +16 -9
- package/templates/minimal/README.md +20 -10
- package/templates/minimal/__tests__/hello.test.ts +3 -3
- package/templates/minimal/app/app.ts +21 -0
- package/templates/minimal/app/main.ts +15 -24
- package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/minimal/package.json +7 -8
- package/templates/service/AGENTS.md +13 -11
- package/templates/service/README.md +5 -3
- package/templates/service/__tests__/todo-api.test.ts +5 -5
- package/templates/service/app/app.ts +25 -0
- package/templates/service/app/main.ts +20 -30
- package/templates/service/app/store/todo-store.ts +6 -22
- package/templates/service/config/app.ts +18 -0
- package/templates/service/config/env.ts +32 -0
- package/templates/service/config/http.ts +15 -0
- package/templates/service/package.json +8 -9
- package/templates/service/tsconfig.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Usage:
|
|
9
9
|
* pnpm create nwire <target-dir> [--template minimal|service|enterprise]
|
|
10
10
|
*/
|
|
11
|
-
export type TemplateName = "minimal" | "service" | "enterprise";
|
|
11
|
+
export type TemplateName = "minimal" | "service" | "enterprise" | "mcp";
|
|
12
12
|
interface ScaffoldOptions {
|
|
13
13
|
target: string;
|
|
14
14
|
template: TemplateName;
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,10 @@ const TEMPLATES = {
|
|
|
37
37
|
title: "Enterprise",
|
|
38
38
|
blurb: "modules/<bc>/{events,actions,workflows,projections,routes}. Full forge shape.",
|
|
39
39
|
},
|
|
40
|
+
mcp: {
|
|
41
|
+
title: "MCP",
|
|
42
|
+
blurb: "Model Context Protocol server over stdio. AI clients call tool() wires.",
|
|
43
|
+
},
|
|
40
44
|
};
|
|
41
45
|
function templatesRoot() {
|
|
42
46
|
const candidate = resolve(__dirname, "..", "templates");
|
|
@@ -142,7 +146,7 @@ const main = defineCommand({
|
|
|
142
146
|
},
|
|
143
147
|
template: {
|
|
144
148
|
type: "string",
|
|
145
|
-
description: "Template: minimal | service | enterprise",
|
|
149
|
+
description: "Template: minimal | service | enterprise | mcp",
|
|
146
150
|
default: "service",
|
|
147
151
|
},
|
|
148
152
|
name: {
|
|
@@ -164,7 +168,7 @@ const main = defineCommand({
|
|
|
164
168
|
}
|
|
165
169
|
const template = resolveTemplateName(String(ctx.args.template));
|
|
166
170
|
if (!template) {
|
|
167
|
-
console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, or
|
|
171
|
+
console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, enterprise, or mcp.`));
|
|
168
172
|
process.exit(1);
|
|
169
173
|
}
|
|
170
174
|
const target = resolve(process.cwd(), targetArg);
|
package/package.json
CHANGED
|
@@ -10,15 +10,15 @@ gets real, forge adds events, actors, projections, and workflows.
|
|
|
10
10
|
|
|
11
11
|
The most common mistake is importing a primitive from the wrong package:
|
|
12
12
|
|
|
13
|
-
| Primitive
|
|
14
|
-
|
|
15
|
-
| `createApp`, `appCompose`, `definePlugin`
|
|
16
|
-
| `endpoint`
|
|
17
|
-
| `get` `post` `put` `patch` `del`
|
|
18
|
-
| `httpKoa`
|
|
19
|
-
| `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest`
|
|
20
|
-
| `defineEvent`
|
|
21
|
-
| `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `
|
|
13
|
+
| Primitive | Package |
|
|
14
|
+
| ------------------------------------------------------------------------------------------------------------------ | ------------------- |
|
|
15
|
+
| `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
|
|
16
|
+
| `endpoint` | `@nwire/endpoint` |
|
|
17
|
+
| `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
|
|
18
|
+
| `httpKoa` | `@nwire/koa` |
|
|
19
|
+
| `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
|
|
20
|
+
| `defineEvent` | `@nwire/messages` |
|
|
21
|
+
| `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `forgePlugins` | `@nwire/forge` |
|
|
22
22
|
|
|
23
23
|
`defineResource` and `defineError` are in **`@nwire/handler`**, not
|
|
24
24
|
`@nwire/forge` — even though forge re-exports some of them, import from
|
|
@@ -31,8 +31,8 @@ The most common mistake is importing a primitive from the wrong package:
|
|
|
31
31
|
- **Event** — a past-tense fact (`defineEvent`). Emit with `ctx.emit`;
|
|
32
32
|
react to it with `when(Event, fn)` in a listener file (`on` is for
|
|
33
33
|
lifecycle hooks only).
|
|
34
|
-
- **Action** — a named command (`defineAction`)
|
|
35
|
-
emit events from. Register via `
|
|
34
|
+
- **Action** — a named command (`defineAction`) that is itself the handler
|
|
35
|
+
and may emit events from. Register via `createApp({ handlers: [act] })`.
|
|
36
36
|
- **Actor** — a thing with identity that guards a rule. **Projection** — a
|
|
37
37
|
read model folded from events. **Workflow** — a process that reacts over
|
|
38
38
|
time.
|
|
@@ -16,19 +16,24 @@ pnpm dev
|
|
|
16
16
|
|
|
17
17
|
## Try
|
|
18
18
|
|
|
19
|
+
`pnpm dev` serves the wire + Studio on http://localhost:4000.
|
|
20
|
+
|
|
19
21
|
```bash
|
|
20
22
|
# submit a post (becomes pending, auto-moderation triggers)
|
|
21
|
-
curl -X POST http://localhost:
|
|
23
|
+
curl -X POST http://localhost:4000/api/posts \
|
|
22
24
|
-H "content-type: application/json" \
|
|
23
25
|
-d '{"authorId":"alice","body":"Hello, world!"}'
|
|
24
26
|
|
|
25
|
-
# read the dashboard projection
|
|
26
|
-
curl http://localhost:
|
|
27
|
+
# read the dashboard projection — copy a pending postId from here
|
|
28
|
+
curl http://localhost:4000/api/queue
|
|
27
29
|
|
|
28
|
-
# decide
|
|
29
|
-
curl -X POST http://localhost:
|
|
30
|
+
# decide one manually (paste the postId from /api/queue above)
|
|
31
|
+
curl -X POST http://localhost:4000/api/posts/<postId>/approve \
|
|
30
32
|
-H "content-type: application/json" \
|
|
31
33
|
-d '{"moderatorId":"miri"}'
|
|
34
|
+
|
|
35
|
+
# all posts by an author, across statuses
|
|
36
|
+
curl "http://localhost:4000/api/posts/by-author?authorId=alice"
|
|
32
37
|
```
|
|
33
38
|
|
|
34
39
|
## Test
|
|
@@ -42,8 +47,8 @@ pnpm test
|
|
|
42
47
|
```
|
|
43
48
|
{{PROJECT_NAME}}/
|
|
44
49
|
├── app/
|
|
45
|
-
│ ├── main.ts ← endpoint
|
|
46
|
-
│ ├── api.ts ←
|
|
50
|
+
│ ├── main.ts ← endpoint().use(httpKoa()).mount(app).run()
|
|
51
|
+
│ ├── api.ts ← wires[] — every route + handler pair
|
|
47
52
|
│ └── app.ts ← createApp({ modules: [...] })
|
|
48
53
|
├── modules/
|
|
49
54
|
│ └── posts/ ← bounded context: posts moderation
|
|
@@ -90,11 +95,11 @@ actor (`defineActor`) versus a direct DB write inside a handler.
|
|
|
90
95
|
|
|
91
96
|
## Studio
|
|
92
97
|
|
|
93
|
-
`
|
|
94
|
-
Studio
|
|
98
|
+
`pnpm dev` already serves Studio alongside the wire on http://localhost:4000.
|
|
99
|
+
For the standalone Studio process (multi-project shell, process manager):
|
|
95
100
|
|
|
96
101
|
```bash
|
|
97
|
-
|
|
102
|
+
pnpm studio
|
|
98
103
|
```
|
|
99
104
|
|
|
100
105
|
You'll get live traces, action / event / workflow browsers, projection
|
|
@@ -7,22 +7,38 @@
|
|
|
7
7
|
* The workflow runs in-process (same runtime the route dispatches into),
|
|
8
8
|
* so `await idle()` between submit + dashboard read is enough to observe
|
|
9
9
|
* the post-decision state.
|
|
10
|
+
*
|
|
11
|
+
* Boots the app from `app.ts` (the pure value) on an ephemeral port.
|
|
12
|
+
* `app` is complete on import — routes are wired in the module body of
|
|
13
|
+
* `app.ts`. Never imports `main.ts` — that file runs a real server and
|
|
14
|
+
* hangs the test runner.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
13
|
-
import {
|
|
18
|
+
import { endpoint } from "@nwire/endpoint";
|
|
19
|
+
import { httpKoa } from "@nwire/koa";
|
|
20
|
+
import { app } from "../app/app";
|
|
14
21
|
|
|
15
|
-
let running: Awaited<ReturnType<typeof
|
|
22
|
+
let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
|
|
23
|
+
let koa: ReturnType<typeof httpKoa>;
|
|
16
24
|
let url: string;
|
|
17
25
|
|
|
18
26
|
beforeAll(async () => {
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
koa = httpKoa({ port: 0, prefix: "/api" });
|
|
28
|
+
running = await endpoint("auto-moderate-test", {
|
|
29
|
+
exitOnShutdown: false,
|
|
30
|
+
banner: false,
|
|
31
|
+
probes: { enabled: false },
|
|
32
|
+
})
|
|
33
|
+
.use(koa)
|
|
34
|
+
.mount(app)
|
|
35
|
+
.run();
|
|
36
|
+
url = `http://127.0.0.1:${koa.port()}`;
|
|
21
37
|
});
|
|
22
38
|
|
|
23
39
|
afterAll(async () => {
|
|
24
|
-
await running.
|
|
25
|
-
await
|
|
40
|
+
await running.shutdown("test");
|
|
41
|
+
await app.stop();
|
|
26
42
|
});
|
|
27
43
|
|
|
28
44
|
const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Happy path — submit a post via HTTP, verify the action runs, the
|
|
3
3
|
* workflow + projection update the dashboard.
|
|
4
|
+
*
|
|
5
|
+
* Boots the app from `app.ts` (the pure value) on an ephemeral port.
|
|
6
|
+
* `app` is complete on import — routes are wired in the module body of
|
|
7
|
+
* `app.ts`. Never imports `main.ts` — that file runs a real server and
|
|
8
|
+
* hangs the test runner.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
7
|
-
import {
|
|
12
|
+
import { endpoint } from "@nwire/endpoint";
|
|
13
|
+
import { httpKoa } from "@nwire/koa";
|
|
14
|
+
import { app } from "../app/app";
|
|
8
15
|
|
|
9
|
-
let running: Awaited<ReturnType<typeof
|
|
16
|
+
let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
|
|
17
|
+
let koa: ReturnType<typeof httpKoa>;
|
|
10
18
|
let url: string;
|
|
11
19
|
|
|
12
20
|
beforeAll(async () => {
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
koa = httpKoa({ port: 0, prefix: "/api" });
|
|
22
|
+
running = await endpoint("submit-flow-test", {
|
|
23
|
+
exitOnShutdown: false,
|
|
24
|
+
banner: false,
|
|
25
|
+
probes: { enabled: false },
|
|
26
|
+
})
|
|
27
|
+
.use(koa)
|
|
28
|
+
.mount(app)
|
|
29
|
+
.run();
|
|
30
|
+
url = `http://127.0.0.1:${koa.port()}`;
|
|
15
31
|
});
|
|
16
32
|
|
|
17
33
|
afterAll(async () => {
|
|
18
|
-
await running.
|
|
19
|
-
await
|
|
34
|
+
await running.shutdown("test");
|
|
35
|
+
await app.stop();
|
|
20
36
|
});
|
|
21
37
|
|
|
22
38
|
const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every route file under `../modules/posts/routes/` exports a
|
|
5
5
|
* `{name}Route` (binding) + `{name}Handler` (function). The `wires`
|
|
6
|
-
* array
|
|
6
|
+
* array feeds into `app.wire(...)` in `app.ts`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
|
|
@@ -11,11 +11,15 @@ import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/ap
|
|
|
11
11
|
import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
|
|
12
12
|
import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
|
|
13
13
|
import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
|
|
14
|
+
import { postsByAuthorRoute, postsByAuthorHandler } from "../modules/posts/routes/posts-by-author";
|
|
14
15
|
|
|
15
16
|
export const wires = [
|
|
16
17
|
{ binding: submitPostRoute, handler: submitPostHandler },
|
|
17
18
|
{ binding: approvePostRoute, handler: approvePostHandler },
|
|
18
19
|
{ binding: rejectPostRoute, handler: rejectPostHandler },
|
|
19
20
|
{ binding: listQueueRoute, handler: listQueueHandler },
|
|
21
|
+
// Literal `/posts/by-author` must be wired before the `/posts/:postId`
|
|
22
|
+
// param route — otherwise `:postId` captures "by-author" and shadows it.
|
|
23
|
+
{ binding: postsByAuthorRoute, handler: postsByAuthorHandler },
|
|
20
24
|
{ binding: getPostRoute, handler: getPostHandler },
|
|
21
25
|
] as const;
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* App — the posts bounded context as a pure value.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* `createApp` installs the forge plugin set (workflows + projections) and
|
|
5
|
+
* wires all HTTP routes in this module body. The exported `app` is COMPLETE
|
|
6
|
+
* on import — no caller needs to wire or start it. No ports are bound here;
|
|
7
|
+
* that is `main.ts`'s job.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* Tests import `{ app }` from this file and build an isolated endpoint per
|
|
10
|
+
* suite. Never import `main.ts` from tests: it starts the server and hangs
|
|
11
|
+
* the runner.
|
|
12
|
+
*
|
|
13
|
+
* Multi-BC: build each as its own App and compose at the endpoint:
|
|
9
14
|
*
|
|
10
15
|
* const monolith = appCompose(postsApp, ordersApp);
|
|
11
|
-
* await endpoint("monolith", { port
|
|
16
|
+
* await endpoint("monolith", { port }).use(httpKoa()).mount(monolith).run();
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import { createApp } from "@nwire/app";
|
|
15
|
-
import {
|
|
20
|
+
import { forgePlugins } from "@nwire/forge";
|
|
16
21
|
|
|
17
22
|
import { submitPost } from "../modules/posts/actions/submit-post";
|
|
18
23
|
import { approvePost } from "../modules/posts/actions/approve-post";
|
|
@@ -25,16 +30,19 @@ import {
|
|
|
25
30
|
getPost,
|
|
26
31
|
} from "../modules/posts/projections/queue-dashboard";
|
|
27
32
|
import { postsByAuthor } from "../modules/posts/queries/posts-by-author";
|
|
33
|
+
import { wires } from "./api";
|
|
28
34
|
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
export const app = createApp({
|
|
36
|
+
appName: "{{PROJECT_NAME}}",
|
|
37
|
+
handlers: [submitPost, approvePost, rejectPost, listPending, queueTotals, getPost, postsByAuthor],
|
|
38
|
+
plugins: [
|
|
39
|
+
...forgePlugins({
|
|
33
40
|
workflows: [autoModerate],
|
|
34
41
|
projections: [queueDashboard],
|
|
35
|
-
queries: [listPending, queueTotals, getPost, postsByAuthor],
|
|
36
42
|
}),
|
|
37
|
-
|
|
38
|
-
}
|
|
43
|
+
],
|
|
44
|
+
});
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
for (const { binding, handler } of wires) {
|
|
47
|
+
app.wire(binding, handler);
|
|
48
|
+
}
|
|
@@ -1,55 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry —
|
|
2
|
+
* Entry — the only file that boots a real HTTP server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* projections, queries) via `.with(createForgePlugin(...))`.
|
|
6
|
-
* 2. app.wire(...) pairs every route binding with its handler.
|
|
7
|
-
* 3. endpoint().use(httpKoa({prefix:"/api"})).mount(app).run() starts
|
|
8
|
-
* the HTTP listener + graceful drain + K8s probes.
|
|
4
|
+
* pnpm dev # wire + Studio on http://localhost:4000
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - queueDashboard projection folds all three events into state
|
|
15
|
-
* - HTTP GET /api/queue reads from the projection
|
|
6
|
+
* curl -X POST http://localhost:4000/api/posts \
|
|
7
|
+
* -H "content-type: application/json" \
|
|
8
|
+
* -d '{"authorId":"alice","body":"Hello, world!"}'
|
|
9
|
+
* curl http://localhost:4000/api/queue
|
|
16
10
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
11
|
+
* `app` is a pure value — routes are wired in `./app` on import.
|
|
12
|
+
* This file adds the one side effect: binding a port and serving traffic.
|
|
13
|
+
* Tests import `{ app }` from `./app` and build their own ephemeral
|
|
14
|
+
* endpoint — never import this file from tests or it boots a server and
|
|
15
|
+
* hangs the runner.
|
|
22
16
|
*/
|
|
23
17
|
|
|
24
18
|
import { endpoint } from "@nwire/endpoint";
|
|
25
19
|
import { httpKoa } from "@nwire/koa";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
20
|
+
import { appConfig } from "../config/app";
|
|
21
|
+
import { httpConfig } from "../config/http";
|
|
22
|
+
import { app } from "./app";
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
readonly test?: boolean;
|
|
32
|
-
}
|
|
24
|
+
const cfg = appConfig();
|
|
25
|
+
const http = httpConfig();
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const app = buildApp();
|
|
39
|
-
for (const { binding, handler } of wires) {
|
|
40
|
-
app.wire(binding, handler);
|
|
41
|
-
}
|
|
42
|
-
await app.start();
|
|
43
|
-
const koa = httpKoa({ port, prefix: "/api" });
|
|
44
|
-
const running = await endpoint("{{PROJECT_NAME}}", {
|
|
45
|
-
exitOnShutdown: !test,
|
|
46
|
-
banner: !test,
|
|
47
|
-
probes: { enabled: !test },
|
|
48
|
-
})
|
|
49
|
-
.use(koa)
|
|
50
|
-
.mount(app)
|
|
51
|
-
.run();
|
|
52
|
-
return { app, running, koa };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
await bootstrap();
|
|
27
|
+
await endpoint(cfg.name, { port: cfg.port, banner: cfg.banner })
|
|
28
|
+
.use(httpKoa({ prefix: http.prefix }))
|
|
29
|
+
.mount(app)
|
|
30
|
+
.run();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-level settings — the process identity and lifecycle budgets the
|
|
3
|
+
* endpoint owns. A function of the typed `env`, so the same config file
|
|
4
|
+
* reads differently per environment without an `if` in sight.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { env, type Env } from "./env";
|
|
8
|
+
|
|
9
|
+
export const appConfig = (e: Env = env) => ({
|
|
10
|
+
/** Endpoint + app name — shows in the banner, logs, and telemetry. */
|
|
11
|
+
name: "{{PROJECT_NAME}}",
|
|
12
|
+
/** Port the HTTP transport binds. */
|
|
13
|
+
port: e.PORT,
|
|
14
|
+
/** Print the boot banner outside of tests. */
|
|
15
|
+
banner: e.NODE_ENV !== "test",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type AppConfig = ReturnType<typeof appConfig>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed environment — read `process.env` once, through a schema, and never
|
|
3
|
+
* touch it again from app code. Missing or malformed values fail loudly at
|
|
4
|
+
* boot with a readable message, not three layers deep at first request.
|
|
5
|
+
*
|
|
6
|
+
* `config/*.ts` files take this typed `env` and shape it into the settings
|
|
7
|
+
* each concern needs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
const EnvSchema = z.object({
|
|
13
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
14
|
+
PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
|
|
15
|
+
HTTP_PREFIX: z.string().default("/api"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type Env = z.output<typeof EnvSchema>;
|
|
19
|
+
|
|
20
|
+
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
|
21
|
+
const parsed = EnvSchema.safeParse(source);
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
const summary = parsed.error.issues
|
|
24
|
+
.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
25
|
+
.join("\n");
|
|
26
|
+
throw new Error(`Invalid environment:\n${summary}`);
|
|
27
|
+
}
|
|
28
|
+
return parsed.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The validated environment, loaded once. */
|
|
32
|
+
export const env: Env = loadEnv();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport settings — prefix and the dev inspect surface Studio reads.
|
|
3
|
+
* A function of the typed `env`, like every config file here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { env, type Env } from "./env";
|
|
7
|
+
|
|
8
|
+
export const httpConfig = (e: Env = env) => ({
|
|
9
|
+
/** Mounted under this prefix — e.g. `POST /api/posts`. */
|
|
10
|
+
prefix: e.HTTP_PREFIX,
|
|
11
|
+
/** Expose detailed errors in dev; redact in production. */
|
|
12
|
+
exposeErrors: e.NODE_ENV !== "production",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type HttpConfig = ReturnType<typeof httpConfig>;
|
|
@@ -13,13 +13,14 @@ import { PostWasApproved } from "../events/post-was-approved";
|
|
|
13
13
|
|
|
14
14
|
export const approvePost = defineAction({
|
|
15
15
|
name: "posts.approve-post",
|
|
16
|
-
description:
|
|
17
|
-
|
|
16
|
+
description:
|
|
17
|
+
"Miri reviews the post and approves it — or the auto-check clears it so Alice doesn't have to wait.",
|
|
18
|
+
input: z.object({
|
|
18
19
|
postId: z.string(),
|
|
19
20
|
approvedBy: z.string(),
|
|
20
21
|
}),
|
|
21
22
|
emits: [PostWasApproved],
|
|
22
|
-
handler: async (input) =>
|
|
23
|
+
handler: async ({ input }) =>
|
|
23
24
|
PostWasApproved({
|
|
24
25
|
postId: input.postId,
|
|
25
26
|
approvedBy: input.approvedBy,
|
|
@@ -12,14 +12,14 @@ import { PostWasRejected } from "../events/post-was-rejected";
|
|
|
12
12
|
|
|
13
13
|
export const rejectPost = defineAction({
|
|
14
14
|
name: "posts.reject-post",
|
|
15
|
-
description: "
|
|
16
|
-
|
|
15
|
+
description: "Miri (or the auto-check) rejects the post with a reason Alice can see and act on.",
|
|
16
|
+
input: z.object({
|
|
17
17
|
postId: z.string(),
|
|
18
18
|
rejectedBy: z.string(),
|
|
19
19
|
reason: z.string().min(1).max(500),
|
|
20
20
|
}),
|
|
21
21
|
emits: [PostWasRejected],
|
|
22
|
-
handler: async (input) =>
|
|
22
|
+
handler: async ({ input }) =>
|
|
23
23
|
PostWasRejected({
|
|
24
24
|
postId: input.postId,
|
|
25
25
|
rejectedBy: input.rejectedBy,
|
|
@@ -25,13 +25,14 @@ import { PostWasSubmitted } from "../events/post-was-submitted";
|
|
|
25
25
|
|
|
26
26
|
export const submitPost = defineAction({
|
|
27
27
|
name: "posts.submit-post",
|
|
28
|
-
description:
|
|
29
|
-
|
|
28
|
+
description:
|
|
29
|
+
"Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
|
|
30
|
+
input: z.object({
|
|
30
31
|
authorId: z.string(),
|
|
31
32
|
body: z.string().min(1).max(2000),
|
|
32
33
|
}),
|
|
33
34
|
emits: [PostWasSubmitted],
|
|
34
|
-
handler: async (input) =>
|
|
35
|
+
handler: async ({ input }) =>
|
|
35
36
|
PostWasSubmitted({
|
|
36
37
|
postId: randomUUID(),
|
|
37
38
|
authorId: input.authorId,
|
|
@@ -11,7 +11,7 @@ import { defineEvent } from "@nwire/messages";
|
|
|
11
11
|
|
|
12
12
|
export const PostWasApproved = defineEvent({
|
|
13
13
|
name: "posts.post-was-approved",
|
|
14
|
-
description: "
|
|
14
|
+
description: "The post cleared moderation — Alice's words are now visible to readers.",
|
|
15
15
|
outcome: "success",
|
|
16
16
|
audience: ["product", "ops"],
|
|
17
17
|
schema: z.object({
|
|
@@ -8,7 +8,8 @@ import { defineEvent } from "@nwire/messages";
|
|
|
8
8
|
|
|
9
9
|
export const PostWasRejected = defineEvent({
|
|
10
10
|
name: "posts.post-was-rejected",
|
|
11
|
-
description:
|
|
11
|
+
description:
|
|
12
|
+
"The post was turned away — Miri or the auto-check flagged it, and Alice sees the reason.",
|
|
12
13
|
outcome: "failure",
|
|
13
14
|
audience: ["product", "ops"],
|
|
14
15
|
schema: z.object({
|
|
@@ -15,7 +15,8 @@ import { defineEvent } from "@nwire/messages";
|
|
|
15
15
|
|
|
16
16
|
export const PostWasSubmitted = defineEvent({
|
|
17
17
|
name: "posts.post-was-submitted",
|
|
18
|
-
description:
|
|
18
|
+
description:
|
|
19
|
+
"Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
|
|
19
20
|
outcome: "milestone",
|
|
20
21
|
audience: ["product", "ops"],
|
|
21
22
|
schema: z.object({
|