create-nwire 0.12.1 → 0.13.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/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/__tests__/auto-moderate.test.ts +22 -6
- package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
- package/templates/enterprise/app/api.ts +3 -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 +27 -25
- 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 +6 -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 +5 -5
- package/templates/minimal/__tests__/hello.test.ts +3 -3
- package/templates/minimal/app/app.ts +21 -0
- package/templates/minimal/app/main.ts +11 -23
- package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/templates/minimal/package.json +7 -8
- package/templates/service/AGENTS.md +12 -10
- 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.
|
|
@@ -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,6 +11,7 @@ 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 },
|
|
@@ -18,4 +19,5 @@ export const wires = [
|
|
|
18
19
|
{ binding: rejectPostRoute, handler: rejectPostHandler },
|
|
19
20
|
{ binding: listQueueRoute, handler: listQueueHandler },
|
|
20
21
|
{ binding: getPostRoute, handler: getPostHandler },
|
|
22
|
+
{ binding: postsByAuthorRoute, handler: postsByAuthorHandler },
|
|
21
23
|
] 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
|
|
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:3000/api/posts \
|
|
7
|
+
* -H "content-type: application/json" \
|
|
8
|
+
* -d '{"authorId":"alice","body":"Hello, world!"}'
|
|
9
|
+
* curl http://localhost:3000/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({
|
|
@@ -4,23 +4,23 @@
|
|
|
4
4
|
* NOTE THE SYMMETRY:
|
|
5
5
|
*
|
|
6
6
|
* - The autoModerate WORKFLOW subscribes to events via
|
|
7
|
-
* `
|
|
7
|
+
* `when(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
|
|
8
8
|
* EFFECTS — dispatch more actions.
|
|
9
9
|
*
|
|
10
|
-
* - This PROJECTION subscribes to events via the same `
|
|
10
|
+
* - This PROJECTION subscribes to events via the same `when` verb shape.
|
|
11
11
|
* Its job is to fold events into a denormalized state that queries
|
|
12
12
|
* read from. No side effects; pure state derivation.
|
|
13
13
|
*
|
|
14
|
-
* Same subscription primitive. Same `
|
|
14
|
+
* Same subscription primitive. Same `when(Event)` ergonomics. Different
|
|
15
15
|
* intent. If you understand workflows, you understand projections —
|
|
16
16
|
* just write to state instead of dispatching.
|
|
17
17
|
*
|
|
18
18
|
* ## When this lives in a database
|
|
19
19
|
*
|
|
20
20
|
* The in-memory state here is for example clarity. In production the
|
|
21
|
-
* projection's `
|
|
21
|
+
* projection's `when` reducers would write to Postgres / Mongo / Redis,
|
|
22
22
|
* and the query would read from there. The PRIMITIVE is the same —
|
|
23
|
-
* `
|
|
23
|
+
* `when(Event) → mutate state`. Where state lives is a deployment choice.
|
|
24
24
|
*
|
|
25
25
|
* Queries (`listPending`, `queueTotals`, `getPost`) live in this file
|
|
26
26
|
* too because they're tightly coupled to the projection's state shape.
|
|
@@ -52,18 +52,12 @@ interface QueueState {
|
|
|
52
52
|
totals: { pending: number; approved: number; rejected: number };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// ─── Projection — `
|
|
55
|
+
// ─── Projection — `when` verb matches the workflow's shape ──────
|
|
56
56
|
|
|
57
|
-
export const queueDashboard = defineProjection<QueueState>(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
byId: {},
|
|
62
|
-
pendingIds: [],
|
|
63
|
-
totals: { pending: 0, approved: 0, rejected: 0 },
|
|
64
|
-
}),
|
|
65
|
-
on: {
|
|
66
|
-
[PostWasSubmitted.name]: (state, event) => ({
|
|
57
|
+
export const queueDashboard = defineProjection<QueueState>(
|
|
58
|
+
"queue-dashboard",
|
|
59
|
+
({ when }) => {
|
|
60
|
+
when(PostWasSubmitted, (state, event) => ({
|
|
67
61
|
byId: {
|
|
68
62
|
...state.byId,
|
|
69
63
|
[event.postId]: {
|
|
@@ -76,9 +70,9 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
|
76
70
|
},
|
|
77
71
|
pendingIds: [...state.pendingIds, event.postId],
|
|
78
72
|
totals: { ...state.totals, pending: state.totals.pending + 1 },
|
|
79
|
-
})
|
|
73
|
+
}));
|
|
80
74
|
|
|
81
|
-
|
|
75
|
+
when(PostWasApproved, (state, event) => {
|
|
82
76
|
const item = state.byId[event.postId];
|
|
83
77
|
if (!item) return state;
|
|
84
78
|
return {
|
|
@@ -98,9 +92,9 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
|
98
92
|
approved: state.totals.approved + 1,
|
|
99
93
|
},
|
|
100
94
|
};
|
|
101
|
-
}
|
|
95
|
+
});
|
|
102
96
|
|
|
103
|
-
|
|
97
|
+
when(PostWasRejected, (state, event) => {
|
|
104
98
|
const item = state.byId[event.postId];
|
|
105
99
|
if (!item) return state;
|
|
106
100
|
return {
|
|
@@ -121,15 +115,23 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
|
121
115
|
rejected: state.totals.rejected + 1,
|
|
122
116
|
},
|
|
123
117
|
};
|
|
124
|
-
}
|
|
118
|
+
});
|
|
125
119
|
},
|
|
126
|
-
|
|
120
|
+
{
|
|
121
|
+
description: "The moderation dashboard read model — pending queue + decision totals.",
|
|
122
|
+
initial: () => ({
|
|
123
|
+
byId: {},
|
|
124
|
+
pendingIds: [],
|
|
125
|
+
totals: { pending: 0, approved: 0, rejected: 0 },
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
);
|
|
127
129
|
|
|
128
130
|
// ─── Queries — read the projection's state ──────────────────────
|
|
129
131
|
|
|
130
132
|
export const listPending = defineQuery(queueDashboard, {
|
|
131
133
|
name: "posts.list-pending",
|
|
132
|
-
|
|
134
|
+
input: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
|
|
133
135
|
execute: (state, { limit }) =>
|
|
134
136
|
state.pendingIds
|
|
135
137
|
.slice(0, limit)
|
|
@@ -139,12 +141,12 @@ export const listPending = defineQuery(queueDashboard, {
|
|
|
139
141
|
|
|
140
142
|
export const queueTotals = defineQuery(queueDashboard, {
|
|
141
143
|
name: "posts.queue-totals",
|
|
142
|
-
|
|
144
|
+
input: z.object({}),
|
|
143
145
|
execute: (state) => state.totals,
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
export const getPost = defineQuery(queueDashboard, {
|
|
147
149
|
name: "posts.get-post",
|
|
148
|
-
|
|
150
|
+
input: z.object({ postId: z.string() }),
|
|
149
151
|
execute: (state, { postId }) => state.byId[postId] ?? null,
|
|
150
152
|
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* bindings (a `db` connection, an http client, anything the runtime
|
|
14
14
|
* registered).
|
|
15
15
|
*
|
|
16
|
-
* defineQuery({ name,
|
|
16
|
+
* defineQuery({ name, input, handler })
|
|
17
17
|
*
|
|
18
18
|
* The handler below returns a small mock list so the template boots
|
|
19
19
|
* out of the box. The shape of a real implementation is in the comment
|
|
@@ -33,14 +33,16 @@ interface AuthorPost {
|
|
|
33
33
|
|
|
34
34
|
export const postsByAuthor = defineQuery({
|
|
35
35
|
name: "posts.by-author",
|
|
36
|
-
description:
|
|
37
|
-
|
|
36
|
+
description:
|
|
37
|
+
"Alice checks her submission history — all posts she's ever sent, whatever their status.",
|
|
38
|
+
input: z.object({
|
|
38
39
|
authorId: z.string(),
|
|
39
40
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
40
41
|
}),
|
|
41
|
-
handler: async (
|
|
42
|
+
handler: async (ctx): Promise<AuthorPost[]> => {
|
|
43
|
+
const { authorId, limit } = ctx.input;
|
|
42
44
|
// Production form:
|
|
43
|
-
// const db =
|
|
45
|
+
// const db = ctx.resolve<DrizzleDb>("db");
|
|
44
46
|
// return db.select().from(posts).where(eq(posts.authorId, authorId)).limit(limit);
|
|
45
47
|
//
|
|
46
48
|
// Template form — small in-memory mock so this boots without a DB:
|