create-agent-rig 0.1.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 +87 -0
- package/package.json +54 -0
- package/packages/cli/dist/commands/create.js +109 -0
- package/packages/cli/dist/index.js +102 -0
- package/packages/cli/dist/lib/colors.js +14 -0
- package/packages/cli/dist/lib/composition.js +20 -0
- package/packages/cli/dist/lib/copy-tree.js +91 -0
- package/packages/cli/dist/lib/prompts.js +24 -0
- package/packages/cli/dist/lib/substitute.js +22 -0
- package/packages/cli/dist/lib/summary.js +41 -0
- package/packages/cli/dist/lib/targets.js +14 -0
- package/packages/cli/dist/templates.js +21 -0
- package/scripts/prepare.mjs +29 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +49 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +59 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +51 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +39 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +36 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +36 -0
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +43 -0
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +79 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +53 -0
- package/templates/agent-os/universal/.claude/rules/architecture.md +74 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +81 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +62 -0
- package/templates/agent-os/universal/.claude/settings.json +28 -0
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +53 -0
- package/templates/agent-os/universal/CLAUDE.md +62 -0
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +23 -0
- package/templates/skeleton/aws-serverless/README.md +78 -0
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +17 -0
- package/templates/skeleton/aws-serverless/apps/web/package.json +19 -0
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +17 -0
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +96 -0
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +29 -0
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +23 -0
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +38 -0
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +14 -0
- package/templates/skeleton/aws-serverless/eslint.config.mjs +20 -0
- package/templates/skeleton/aws-serverless/gitignore +9 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +19 -0
- package/templates/skeleton/aws-serverless/infra/cdk.json +3 -0
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +116 -0
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +32 -0
- package/templates/skeleton/aws-serverless/infra/package.json +18 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +104 -0
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +41 -0
- package/templates/skeleton/aws-serverless/package.json +30 -0
- package/templates/skeleton/aws-serverless/packages/core/package.json +11 -0
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +14 -0
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +15 -0
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +69 -0
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +23 -0
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +101 -0
- package/templates/skeleton/aws-serverless/packages/db/package.json +14 -0
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +17 -0
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +2 -0
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +52 -0
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +91 -0
- package/templates/skeleton/aws-serverless/packages/shared/package.json +11 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +17 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +33 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +3 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +20 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +26 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +28 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +19 -0
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +2855 -0
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +14 -0
- package/templates/skeleton/aws-serverless/services/api/package.json +15 -0
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +26 -0
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +42 -0
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +24 -0
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +12 -0
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +21 -0
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +30 -0
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +14 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +92 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +45 -0
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +51 -0
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +22 -0
- package/templates/skeleton/aws-serverless/services/worker/package.json +12 -0
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +15 -0
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +7 -0
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +37 -0
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +61 -0
- package/templates/skeleton/aws-serverless/tsconfig.base.json +15 -0
- package/templates/skeleton/aws-serverless/tsconfig.json +16 -0
- package/templates/skeleton/aws-serverless/vitest.config.ts +14 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +22 -0
- package/templates/skeleton/node-service/README.md +74 -0
- package/templates/skeleton/node-service/apps/web/next.config.mjs +17 -0
- package/templates/skeleton/node-service/apps/web/package.json +19 -0
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +17 -0
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +96 -0
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +29 -0
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +23 -0
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +38 -0
- package/templates/skeleton/node-service/apps/web/tsconfig.json +14 -0
- package/templates/skeleton/node-service/eslint.config.mjs +20 -0
- package/templates/skeleton/node-service/gitignore +9 -0
- package/templates/skeleton/node-service/package.json +28 -0
- package/templates/skeleton/node-service/packages/core/package.json +11 -0
- package/templates/skeleton/node-service/packages/core/src/events.ts +14 -0
- package/templates/skeleton/node-service/packages/core/src/index.ts +15 -0
- package/templates/skeleton/node-service/packages/core/src/note.ts +69 -0
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +23 -0
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +101 -0
- package/templates/skeleton/node-service/packages/db/package.json +12 -0
- package/templates/skeleton/node-service/packages/db/src/index.ts +1 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +63 -0
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +80 -0
- package/templates/skeleton/node-service/packages/shared/package.json +11 -0
- package/templates/skeleton/node-service/packages/shared/src/env.ts +17 -0
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +33 -0
- package/templates/skeleton/node-service/packages/shared/src/index.ts +3 -0
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +20 -0
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +26 -0
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +28 -0
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +19 -0
- package/templates/skeleton/node-service/pnpm-lock.yaml +2399 -0
- package/templates/skeleton/node-service/pnpm-workspace.yaml +13 -0
- package/templates/skeleton/node-service/services/api/package.json +17 -0
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +23 -0
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +40 -0
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +23 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +47 -0
- package/templates/skeleton/node-service/services/api/src/server.ts +89 -0
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +30 -0
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +14 -0
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +64 -0
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +43 -0
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +48 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +123 -0
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +32 -0
- package/templates/skeleton/node-service/services/worker/package.json +16 -0
- package/templates/skeleton/node-service/services/worker/src/main.ts +28 -0
- package/templates/skeleton/node-service/services/worker/src/spool.ts +60 -0
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +38 -0
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +34 -0
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +76 -0
- package/templates/skeleton/node-service/tsconfig.base.json +15 -0
- package/templates/skeleton/node-service/tsconfig.json +13 -0
- package/templates/skeleton/node-service/vitest.config.ts +12 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A plain Node service generated by `create-agent-rig`, target
|
|
4
|
+
`node-service`. Same architecture as every target — pure core, mandatory
|
|
5
|
+
usecase layer, single-owner storage model, queue with DLQ discipline — with no
|
|
6
|
+
cloud: HTTP via `node:http`, storage in a JSON file, the queue as a spool
|
|
7
|
+
directory, the worker as a process. This project is yours: **delete what you
|
|
8
|
+
don't need.**
|
|
9
|
+
|
|
10
|
+
## Layout
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
packages/core/ pure domain: zod schemas + createNote() (no I/O — hook-enforced)
|
|
14
|
+
packages/shared/ logger, loadEnv(zod), typed errors
|
|
15
|
+
packages/db/ JsonFileNoteStore — the only code touching the data file
|
|
16
|
+
services/api/ POST /notes + GET /notes: payload → handler → usecase → model;
|
|
17
|
+
also serves the built web bundle (no second runtime)
|
|
18
|
+
services/worker/ spool consumer (3 attempts → var/dlq + ALARM log line)
|
|
19
|
+
apps/web/ static Next export; validates with the SAME core schema the
|
|
20
|
+
server trusts (imports core+shared only — hook-enforced)
|
|
21
|
+
.claude/ the agent operating system: rules, gates, blocking hooks
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The agent-facing map is `CLAUDE.md`; the rules live in `.claude/rules/`.
|
|
25
|
+
|
|
26
|
+
## Run it
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pnpm install
|
|
30
|
+
pnpm test
|
|
31
|
+
pnpm lint
|
|
32
|
+
pnpm typecheck
|
|
33
|
+
pnpm check # all of the above
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Start the service (two processes; build the web bundle first to get the UI):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pnpm build:web # static Next export → apps/web/out
|
|
40
|
+
pnpm start:api # PORT=3000; serves the API and apps/web/out at /
|
|
41
|
+
pnpm start:worker # polls var/queue, dead-letters into var/dlq
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Open http://localhost:3000/ — the form validates with the same core schema
|
|
45
|
+
the server applies again on POST.
|
|
46
|
+
|
|
47
|
+
Configuration is environment-first (`PORT`, `DATA_DIR`, `QUEUE_DIR`, `DLQ_DIR`,
|
|
48
|
+
`POLL_INTERVAL_MS`) with working defaults — see each service's `main.ts`.
|
|
49
|
+
|
|
50
|
+
## Verify runtime health (CI-green ≠ runtime-healthy)
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
curl -s -X POST localhost:3000/notes \
|
|
54
|
+
-H 'content-type: application/json' \
|
|
55
|
+
-d '{"title":"smoke test","tags":["deploy"]}'
|
|
56
|
+
# expect: HTTP 201 with { "note": { … } }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then confirm the pipeline: the worker logs `note.created processed`, and
|
|
60
|
+
**`var/dlq/` stays empty** (an `ALARM` log line means a message was
|
|
61
|
+
dead-lettered). On regression: roll back to the previous revision first,
|
|
62
|
+
diagnose second.
|
|
63
|
+
|
|
64
|
+
## Boundaries (the short version)
|
|
65
|
+
|
|
66
|
+
- Every request follows `payload → handler → usecase → model`. The usecase
|
|
67
|
+
layer is mandatory even when it looks like ceremony.
|
|
68
|
+
- `packages/core` is pure — the `guard-core-purity` hook refuses I/O, clock,
|
|
69
|
+
randomness, and environment access at the tool layer.
|
|
70
|
+
- `packages/db` is the only module that touches the stored data.
|
|
71
|
+
- A failing queue message is poison: it throws, the spool retries ×3, then the
|
|
72
|
+
DLQ gets it and the ALARM line fires. Never wrap the worker in a broad catch.
|
|
73
|
+
|
|
74
|
+
See `.claude/rules/architecture.md` for the full rules.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @type {import('next').NextConfig} */
|
|
2
|
+
const nextConfig = {
|
|
3
|
+
// Static export: one build, no second runtime — the API server (or a CDN)
|
|
4
|
+
// serves the bundle. See README for how this target serves it.
|
|
5
|
+
output: 'export',
|
|
6
|
+
// The core ships as TypeScript source; Next transpiles it for the browser.
|
|
7
|
+
transpilePackages: ['@app/core'],
|
|
8
|
+
// The core uses NodeNext-style relative imports ("./note.js" resolving to
|
|
9
|
+
// note.ts). Turbopack cannot map that, so the build runs webpack (see the
|
|
10
|
+
// build script) with the standard extension alias.
|
|
11
|
+
webpack: (config) => {
|
|
12
|
+
config.resolve.extensionAlias = { '.js': ['.ts', '.tsx', '.js'] };
|
|
13
|
+
return config;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default nextConfig;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --port 3001 --webpack",
|
|
7
|
+
"build": "next build --webpack"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@app/core": "workspace:*",
|
|
11
|
+
"next": "^16.2.11",
|
|
12
|
+
"react": "^19.2.8",
|
|
13
|
+
"react-dom": "^19.2.8"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^19.2.17",
|
|
17
|
+
"@types/react-dom": "^19.2.3"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// Deliberately unstyled: a starter's frontend is scaffolding, not a product.
|
|
4
|
+
// Polish belongs in the mechanism (the shared validation, the boundary hook),
|
|
5
|
+
// not in the pixels — replace all of this freely.
|
|
6
|
+
export const metadata = {
|
|
7
|
+
title: '__PROJECT_NAME__',
|
|
8
|
+
description: 'Notes — generated by create-agent-rig',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<body>{children}</body>
|
|
15
|
+
</html>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// One page, one point: the SAME domain schema validates here (instant
|
|
3
|
+
// feedback) and on the server (trust). See src/lib/validate.ts.
|
|
4
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
5
|
+
import type { Note } from '@app/core';
|
|
6
|
+
import { createNote, listNotes } from '../lib/api';
|
|
7
|
+
import { validateNewNote } from '../lib/validate';
|
|
8
|
+
|
|
9
|
+
export default function NotesPage() {
|
|
10
|
+
const [title, setTitle] = useState('');
|
|
11
|
+
const [tags, setTags] = useState('');
|
|
12
|
+
const [issues, setIssues] = useState<string[]>([]);
|
|
13
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
14
|
+
const [status, setStatus] = useState<string>('');
|
|
15
|
+
|
|
16
|
+
const refresh = useCallback(async () => {
|
|
17
|
+
try {
|
|
18
|
+
setNotes(await listNotes());
|
|
19
|
+
setStatus('');
|
|
20
|
+
} catch (error) {
|
|
21
|
+
setStatus(`could not load notes: ${String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
void refresh();
|
|
27
|
+
}, [refresh]);
|
|
28
|
+
|
|
29
|
+
async function onSubmit(event: React.FormEvent) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
const input = {
|
|
32
|
+
title,
|
|
33
|
+
tags: tags
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((tag) => tag.trim())
|
|
36
|
+
.filter(Boolean),
|
|
37
|
+
};
|
|
38
|
+
// Client-side: the same core schema the server will apply again.
|
|
39
|
+
const verdict = validateNewNote(input);
|
|
40
|
+
setIssues(verdict.issues);
|
|
41
|
+
if (!verdict.ok) return;
|
|
42
|
+
try {
|
|
43
|
+
await createNote(input);
|
|
44
|
+
setTitle('');
|
|
45
|
+
setTags('');
|
|
46
|
+
await refresh();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
setIssues([String(error)]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<main>
|
|
54
|
+
<h1>Notes</h1>
|
|
55
|
+
<p>
|
|
56
|
+
The form validates with the <em>same</em> core function the server
|
|
57
|
+
trusts — one schema, both sides of the wire.
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
<form onSubmit={onSubmit}>
|
|
61
|
+
<label>
|
|
62
|
+
Title{' '}
|
|
63
|
+
<input
|
|
64
|
+
name="title"
|
|
65
|
+
value={title}
|
|
66
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
67
|
+
/>
|
|
68
|
+
</label>{' '}
|
|
69
|
+
<label>
|
|
70
|
+
Tags (comma-separated){' '}
|
|
71
|
+
<input name="tags" value={tags} onChange={(event) => setTags(event.target.value)} />
|
|
72
|
+
</label>{' '}
|
|
73
|
+
<button type="submit">Create</button>
|
|
74
|
+
</form>
|
|
75
|
+
|
|
76
|
+
{issues.length > 0 && (
|
|
77
|
+
<ul role="alert">
|
|
78
|
+
{issues.map((issue) => (
|
|
79
|
+
<li key={issue}>{issue}</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
)}
|
|
83
|
+
{status && <p role="status">{status}</p>}
|
|
84
|
+
|
|
85
|
+
<h2>Existing</h2>
|
|
86
|
+
<ul>
|
|
87
|
+
{notes.map((note) => (
|
|
88
|
+
<li key={note.id}>
|
|
89
|
+
<strong>{note.title}</strong> <code>{note.slug}</code>{' '}
|
|
90
|
+
<small>{note.tags.join(', ')}</small>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
</main>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The web talks to the backend over HTTP only — never by importing it
|
|
2
|
+
// (the guard-web-boundary hook refuses such imports at the tool layer).
|
|
3
|
+
import type { Note } from '@app/core';
|
|
4
|
+
|
|
5
|
+
// Same-origin by default (the API server serves this bundle); set
|
|
6
|
+
// NEXT_PUBLIC_API_URL at build time when the API lives elsewhere.
|
|
7
|
+
const base = process.env.NEXT_PUBLIC_API_URL ?? '';
|
|
8
|
+
|
|
9
|
+
export async function createNote(input: { title: string; tags: string[] }): Promise<Note> {
|
|
10
|
+
const response = await fetch(`${base}/notes`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
body: JSON.stringify(input),
|
|
14
|
+
});
|
|
15
|
+
const body = (await response.json()) as { note?: Note; error?: string; issues?: string[] };
|
|
16
|
+
if (!response.ok || !body.note) {
|
|
17
|
+
throw new Error(body.issues?.join('; ') ?? body.error ?? `HTTP ${response.status}`);
|
|
18
|
+
}
|
|
19
|
+
return body.note;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listNotes(): Promise<Note[]> {
|
|
23
|
+
const response = await fetch(`${base}/notes`);
|
|
24
|
+
const body = (await response.json()) as { notes?: Note[]; error?: string };
|
|
25
|
+
if (!response.ok || !body.notes) {
|
|
26
|
+
throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
return body.notes;
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The load-bearing import of this whole app: the browser validates with the
|
|
2
|
+
// SAME schema the server trusts. Client-side for instant feedback,
|
|
3
|
+
// server-side for trust — one function, two sides of the wire. This is what
|
|
4
|
+
// core purity buys: `@app/core` has no I/O, so it runs anywhere.
|
|
5
|
+
import { NewNoteSchema } from '@app/core';
|
|
6
|
+
|
|
7
|
+
export interface ValidationResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
issues: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateNewNote(input: unknown): ValidationResult {
|
|
13
|
+
const parsed = NewNoteSchema.safeParse(input);
|
|
14
|
+
if (parsed.success) {
|
|
15
|
+
return { ok: true, issues: [] };
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
issues: parsed.error.issues.map(
|
|
20
|
+
(issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`,
|
|
21
|
+
),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// THE load-bearing test of the frontend (web brief §5): the browser-side
|
|
2
|
+
// validator and the server-side schema must be the same decision procedure.
|
|
3
|
+
// No jsdom, no rendering — the boundary is the point, not the pixels.
|
|
4
|
+
import { InvalidNoteError, NewNoteSchema, createNote } from '@app/core';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { validateNewNote } from '../src/lib/validate';
|
|
7
|
+
|
|
8
|
+
const identity = { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' };
|
|
9
|
+
|
|
10
|
+
const CASES: Array<{ name: string; input: unknown }> = [
|
|
11
|
+
{ name: 'valid minimal', input: { title: 'Hello' } },
|
|
12
|
+
{ name: 'valid with tags', input: { title: 'Hello', tags: ['a', 'b'] } },
|
|
13
|
+
{ name: 'empty title', input: { title: '' } },
|
|
14
|
+
{ name: 'whitespace title', input: { title: ' ' } },
|
|
15
|
+
{ name: 'title too long', input: { title: 'x'.repeat(201) } },
|
|
16
|
+
{ name: 'too many tags', input: { title: 'T', tags: Array.from({ length: 11 }, (_, i) => `${i}`) } },
|
|
17
|
+
{ name: 'empty tag', input: { title: 'T', tags: [''] } },
|
|
18
|
+
{ name: 'wrong types', input: { title: 42 } },
|
|
19
|
+
{ name: 'not an object', input: 'nope' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe('one schema, both sides of the wire', () => {
|
|
23
|
+
for (const { name, input } of CASES) {
|
|
24
|
+
it(`web and core agree on: ${name}`, () => {
|
|
25
|
+
const webVerdict = validateNewNote(input);
|
|
26
|
+
const coreVerdict = NewNoteSchema.safeParse(input);
|
|
27
|
+
expect(webVerdict.ok).toBe(coreVerdict.success);
|
|
28
|
+
|
|
29
|
+
// …and the domain function (used by the API usecase) agrees with both.
|
|
30
|
+
if (coreVerdict.success) {
|
|
31
|
+
expect(() => createNote(input, identity)).not.toThrow();
|
|
32
|
+
} else {
|
|
33
|
+
expect(() => createNote(input, identity)).toThrow(InvalidNoteError);
|
|
34
|
+
expect(webVerdict.issues.length).toBeGreaterThan(0);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"lib": ["dom", "dom.iterable", "es2022"],
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"plugins": [{ "name": "next" }]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"],
|
|
13
|
+
"exclude": ["node_modules", "out", ".next"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import eslint from '@eslint/js';
|
|
2
|
+
import globals from 'globals';
|
|
3
|
+
import tseslint from 'typescript-eslint';
|
|
4
|
+
|
|
5
|
+
export default tseslint.config(
|
|
6
|
+
{
|
|
7
|
+
ignores: [
|
|
8
|
+
'**/node_modules/**',
|
|
9
|
+
'**/dist/**',
|
|
10
|
+
'**/coverage/**',
|
|
11
|
+
'**/var/**',
|
|
12
|
+
'**/.next/**',
|
|
13
|
+
'**/out/**',
|
|
14
|
+
'**/next-env.d.ts',
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
eslint.configs.recommended,
|
|
18
|
+
...tseslint.configs.recommended,
|
|
19
|
+
{ languageOptions: { globals: globals.node } },
|
|
20
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/root",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"lint": "eslint .",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p apps/web/tsconfig.json",
|
|
13
|
+
"build:web": "pnpm --filter @app/web build",
|
|
14
|
+
"start:api": "pnpm --filter @app/api start",
|
|
15
|
+
"start:worker": "pnpm --filter @app/worker start",
|
|
16
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:web"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@eslint/js": "^10.0.1",
|
|
20
|
+
"@types/node": "^26.1.1",
|
|
21
|
+
"eslint": "^10.7.0",
|
|
22
|
+
"globals": "^17.7.0",
|
|
23
|
+
"tsx": "^4.23.1",
|
|
24
|
+
"typescript": "^6.0.3",
|
|
25
|
+
"typescript-eslint": "^8.65.0",
|
|
26
|
+
"vitest": "^4.1.10"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Domain event vocabulary. The schema is the contract between the API (producer)
|
|
2
|
+
// and the worker (consumer) — both sides validate against it.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { NoteSchema, type Note } from './note.js';
|
|
5
|
+
|
|
6
|
+
export const NoteCreatedEventSchema = z.object({
|
|
7
|
+
type: z.literal('note.created'),
|
|
8
|
+
note: NoteSchema,
|
|
9
|
+
});
|
|
10
|
+
export type NoteCreatedEvent = z.infer<typeof NoteCreatedEventSchema>;
|
|
11
|
+
|
|
12
|
+
export function makeNoteCreatedEvent(note: Note): NoteCreatedEvent {
|
|
13
|
+
return { type: 'note.created', note };
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
NewNoteSchema,
|
|
3
|
+
NoteSchema,
|
|
4
|
+
InvalidNoteError,
|
|
5
|
+
createNote,
|
|
6
|
+
slugify,
|
|
7
|
+
type NewNote,
|
|
8
|
+
type Note,
|
|
9
|
+
type NoteIdentity,
|
|
10
|
+
} from './note.js';
|
|
11
|
+
export {
|
|
12
|
+
NoteCreatedEventSchema,
|
|
13
|
+
makeNoteCreatedEvent,
|
|
14
|
+
type NoteCreatedEvent,
|
|
15
|
+
} from './events.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The domain core is pure: no I/O, no clock, no randomness, no environment.
|
|
2
|
+
// "Now" and "a new id" are decided by the caller (the usecase layer) and enter
|
|
3
|
+
// as arguments. The guard-core-purity hook enforces this at the tool layer.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
export const NewNoteSchema = z.object({
|
|
7
|
+
title: z.string().trim().min(1, 'title must not be empty').max(200, 'title too long'),
|
|
8
|
+
tags: z.array(z.string().trim().min(1).max(40)).max(10).default([]),
|
|
9
|
+
});
|
|
10
|
+
export type NewNote = z.infer<typeof NewNoteSchema>;
|
|
11
|
+
|
|
12
|
+
export const NoteSchema = z.object({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
title: z.string().min(1),
|
|
15
|
+
slug: z.string().min(1),
|
|
16
|
+
tags: z.array(z.string()),
|
|
17
|
+
createdAt: z.string().min(1),
|
|
18
|
+
});
|
|
19
|
+
export type Note = z.infer<typeof NoteSchema>;
|
|
20
|
+
|
|
21
|
+
/** Thrown when input fails domain validation. The core owns its own error type. */
|
|
22
|
+
export class InvalidNoteError extends Error {
|
|
23
|
+
readonly issues: readonly string[];
|
|
24
|
+
|
|
25
|
+
constructor(issues: readonly string[]) {
|
|
26
|
+
super(`invalid note: ${issues.join('; ')}`);
|
|
27
|
+
this.name = 'InvalidNoteError';
|
|
28
|
+
this.issues = issues;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NoteIdentity {
|
|
33
|
+
/** Generated by the caller — the core never invents ids. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** ISO-8601 timestamp decided by the caller — the core never reads the clock. */
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one domain function: validate raw input and construct a Note.
|
|
41
|
+
* Deterministic — identical arguments always yield an identical note.
|
|
42
|
+
*/
|
|
43
|
+
export function createNote(input: unknown, identity: NoteIdentity): Note {
|
|
44
|
+
const parsed = NewNoteSchema.safeParse(input);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
throw new InvalidNoteError(
|
|
47
|
+
parsed.error.issues.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const { title, tags } = parsed.data;
|
|
51
|
+
return {
|
|
52
|
+
id: identity.id,
|
|
53
|
+
title,
|
|
54
|
+
slug: slugify(title),
|
|
55
|
+
tags: [...new Set(tags)],
|
|
56
|
+
createdAt: identity.createdAt,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lowercased, hyphen-separated, ascii-ish slug. Pure string work. */
|
|
61
|
+
export function slugify(title: string): string {
|
|
62
|
+
const slug = title
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.normalize('NFKD')
|
|
65
|
+
.replace(/[\u0300-\u036f]/g, '') // strip combining marks left by NFKD
|
|
66
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
67
|
+
.replace(/^-+|-+$/g, '');
|
|
68
|
+
return slug.length > 0 ? slug : 'note';
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { NoteCreatedEventSchema, makeNoteCreatedEvent } from '../src/events.js';
|
|
3
|
+
import { createNote } from '../src/note.js';
|
|
4
|
+
|
|
5
|
+
const note = createNote({ title: 'Hello' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
6
|
+
|
|
7
|
+
describe('note.created event', () => {
|
|
8
|
+
it('wraps a note and round-trips through its own schema', () => {
|
|
9
|
+
const event = makeNoteCreatedEvent(note);
|
|
10
|
+
expect(event.type).toBe('note.created');
|
|
11
|
+
expect(NoteCreatedEventSchema.parse(JSON.parse(JSON.stringify(event)))).toEqual(event);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects a payload with the wrong type tag', () => {
|
|
15
|
+
expect(NoteCreatedEventSchema.safeParse({ type: 'other', note }).success).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects a payload with a malformed note', () => {
|
|
19
|
+
expect(
|
|
20
|
+
NoteCreatedEventSchema.safeParse({ type: 'note.created', note: { id: '' } }).success,
|
|
21
|
+
).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { InvalidNoteError, createNote, slugify } from '../src/note.js';
|
|
3
|
+
|
|
4
|
+
const identity = { id: 'note-1', createdAt: '2024-01-01T00:00:00.000Z' };
|
|
5
|
+
|
|
6
|
+
describe('createNote', () => {
|
|
7
|
+
it('builds a note from valid input', () => {
|
|
8
|
+
const note = createNote({ title: 'Hello World', tags: ['a', 'b'] }, identity);
|
|
9
|
+
expect(note).toEqual({
|
|
10
|
+
id: 'note-1',
|
|
11
|
+
title: 'Hello World',
|
|
12
|
+
slug: 'hello-world',
|
|
13
|
+
tags: ['a', 'b'],
|
|
14
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('is deterministic: identical arguments yield an identical note', () => {
|
|
19
|
+
const a = createNote({ title: 'Same' }, identity);
|
|
20
|
+
const b = createNote({ title: 'Same' }, identity);
|
|
21
|
+
expect(a).toEqual(b);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('defaults tags to an empty array', () => {
|
|
25
|
+
expect(createNote({ title: 'No tags' }, identity).tags).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('trims the title', () => {
|
|
29
|
+
expect(createNote({ title: ' padded ' }, identity).title).toBe('padded');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('deduplicates tags', () => {
|
|
33
|
+
expect(createNote({ title: 'T', tags: ['x', 'x', 'y'] }, identity).tags).toEqual(['x', 'y']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('refuses an empty or whitespace-only title', () => {
|
|
37
|
+
expect(() => createNote({ title: '' }, identity)).toThrow(InvalidNoteError);
|
|
38
|
+
expect(() => createNote({ title: ' ' }, identity)).toThrow(InvalidNoteError);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('refuses a title over 200 characters', () => {
|
|
42
|
+
expect(() => createNote({ title: 'x'.repeat(201) }, identity)).toThrow(InvalidNoteError);
|
|
43
|
+
expect(() => createNote({ title: 'x'.repeat(200) }, identity)).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refuses more than 10 tags', () => {
|
|
47
|
+
const tags = Array.from({ length: 11 }, (_, i) => `t${i}`);
|
|
48
|
+
expect(() => createNote({ title: 'T', tags }, identity)).toThrow(InvalidNoteError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('refuses empty or oversized tags', () => {
|
|
52
|
+
expect(() => createNote({ title: 'T', tags: [''] }, identity)).toThrow(InvalidNoteError);
|
|
53
|
+
expect(() => createNote({ title: 'T', tags: ['x'.repeat(41)] }, identity)).toThrow(
|
|
54
|
+
InvalidNoteError,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refuses non-object input', () => {
|
|
59
|
+
for (const bad of [null, undefined, 42, 'title', []]) {
|
|
60
|
+
expect(() => createNote(bad, identity)).toThrow(InvalidNoteError);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('refuses wrongly-typed fields', () => {
|
|
65
|
+
expect(() => createNote({ title: 42 }, identity)).toThrow(InvalidNoteError);
|
|
66
|
+
expect(() => createNote({ title: 'T', tags: 'not-an-array' }, identity)).toThrow(
|
|
67
|
+
InvalidNoteError,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports every issue with its path', () => {
|
|
72
|
+
try {
|
|
73
|
+
createNote({ title: '', tags: [''] }, identity);
|
|
74
|
+
expect.unreachable('should have thrown');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const invalid = error as InvalidNoteError;
|
|
77
|
+
expect(invalid.issues.length).toBeGreaterThanOrEqual(2);
|
|
78
|
+
expect(invalid.issues.join('\n')).toMatch(/title/);
|
|
79
|
+
expect(invalid.issues.join('\n')).toMatch(/tags/);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('slugify', () => {
|
|
85
|
+
it('lowercases and hyphenates', () => {
|
|
86
|
+
expect(slugify('Hello World')).toBe('hello-world');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('collapses runs of non-alphanumerics and trims hyphens', () => {
|
|
90
|
+
expect(slugify(' a -- b!! c ')).toBe('a-b-c');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('strips diacritics', () => {
|
|
94
|
+
expect(slugify('Crème Brûlée')).toBe('creme-brulee');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('falls back to "note" when nothing survives', () => {
|
|
98
|
+
expect(slugify('!!!')).toBe('note');
|
|
99
|
+
expect(slugify('日本語')).toBe('note');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { JsonFileNoteStore } from './note-store.js';
|