Coding Rules
Naming Conventions
| Target | Rule | Example |
|---|---|---|
| Variables and functions | camelCase | getLatestVersionId |
| Types, classes, components | PascalCase | SurveyStore, AppShell |
| File names | kebab-case | survey-store.ts, question-editor-panel.tsx |
| Constants | UPPER_SNAKE_CASE | VERB_NOT_INCLUDES |
| DB column names | snake_case | latest_version_no |
| API URIs and JSON fields | camelCase | /api/surveys/{surveyId}, latestVersionNo |
Only DB column names use snake_case; TypeScript properties use camelCase. The conversion happens in the Drizzle schema definition.
Formatter and Linter
# frontend
bun run lint # ESLint
bun run lint:fix
bun run format # Prettier (check)
bun run format:write
# type checking (all repositories)
bun run typecheck # backend
tsc --noEmit # frontend / extension
Configuration files: eslint.config.mjs / .prettierrc (frontend), tsconfig.json (each repository).
On the frontend, husky + lint-staged run ESLint and Prettier automatically on commit.
API Design Rules
- Every API lives under
/api(except the health check at/, plus/openapi.jsonand/swagger) - Request and response bodies are JSON
- Responses always take this shape:
// success
{ "ok": true, "data": { /* ... */ } }
// failure
{ "ok": false, "error": "Survey not found" }
- Authentication uses the
x-user-email/x-user-name/x-user-imageheaders set by the frontend. Only the import API also acceptsx-api-key
Choosing an Error Response
| Situation | Status |
|---|---|
| Malformed request | 400 (returned automatically by Zod validation) |
| Business rule violation (e.g. a question that cannot be a condition source) | 400 |
| Missing authentication | 401 |
| Insufficient permission (delete operations) | 403 |
| Target does not exist, or permission is missing | 404 |
Missing permission is generally collapsed into 404 to hide whether the survey exists. Only delete operations return 403, to make the intent explicit.
Layers (backend)
| Rule | Contents |
|---|---|
app.ts |
Only configures the auth middleware and calls registerXxxRoutes() |
routes |
Calls only repositories. Handlers only call the store and choose the HTTP status |
repositories |
May reference db and lib. DB access and permission checks are confined here |
| Same layer | Modules never call each other |
| Lower โ higher | Forbidden |
| from \ to | routes | repositories | db | lib | types |
|---|---|---|---|---|---|
| app.ts | โฏ | โฏ (for injection) | โ | โฏ | โ |
| routes | โ | โฏ | โ | โฏ | โฏ |
| repositories | โ | โ | โฏ | โฏ | โฏ |
| lib | โ | โณ (auth only) | โฏ | โ | โฏ |
| db | โ | โ | โ | โ | โ |
lib/auth.tsreferencessurvey-storein order to auto-create users. Apart from that single case,libnever callsrepositories.
Writing routes
- One file per resource, exporting
registerXxxRoutes(app, store)as a named export - Route definitions are declared at module scope with
createRoute(), and handlers are typed withRouteHandler<typeof route, AppBindings> - Request values are always taken from
c.req.valid("json" | "param" | "query")(never rawc.req.json()) storeis received as an argument with the production implementation as its default, so tests can inject a fake
export function registerQuestionRoutes(
app: OpenAPIHono<AppBindings>,
store: SurveyStore = surveyStore,
) {
const deleteQuestionHandler: RouteHandler<typeof deleteQuestionRoute, AppBindings> = async (c) => {
const { questionId } = c.req.valid("param");
const currentUser = c.get("currentUser");
const deleted = await store.deleteQuestion(questionId, currentUser);
if (!deleted) return c.json({ ok: false, error: "Question not found" }, 404);
return c.json({ ok: true, data: { deleted: true } }, 200);
};
app.openapi(deleteQuestionRoute, deleteQuestionHandler);
}
Writing repositories
- Methods take the target ID as the first argument and
actor: AuthenticatedUseras the last - When permission is missing, return
nullrather than throwing;routesturns that into a 404 - Writes spanning several tables are wrapped in
db.transaction() - Setting
createdAt/updatedAt/deletedAtis the repository's responsibility
Schema Definitions
- Request and response Zod schemas are collected in
src/lib/openapi-schemas.ts, each carrying.openapi("SchemaName") - Domain types are defined in
src/types/domain.tsand cross-checked on the Zod side viaz.ZodType<Xxx>
| Purpose | Naming |
|---|---|
| Domain type | {Resource}Schema |
| Create request | Create{Resource}InputSchema |
| Update request | Update{Resource}InputSchema |
| Single response | ApiResponse{Resource}Schema |
| List response | ApiResponse{Resource}ListSchema |
DB Schema Rules
- PostgreSQL enums are defined with
pgEnum() - New enum values must always be appended at the end. Inserting in the middle produces unstable
ADD VALUE BEFOREoutput from drizzle-kit
export const questionTypeEnum = pgEnum("question_type", [
"single",
"multi",
"free_text",
"matrix",
"intro",
// ๆซๅฐพ่ฟฝๅ ใๅฟ
้ (ไธญ้ๆฟๅ
ฅใฏ drizzle-kit ใฎ ADD VALUE BEFORE ็ๆใไธๅฎๅฎ)
"pulldown",
]);
- Tables with ordering carry a unique index on
(parent ID, sortOrder). Reordering goes through a temporary value to avoid collisions - Parent-child relationships set
onDelete: "cascade" - Only
surveysuses soft deletion. Read queries includedeletedAt IS NULL
Frontend Rules
- One component per directory:
index.tsx+index.module.scss(scaffold withbun run new:component) - Client components start with
"use client"; - Styling uses CSS Modules (SCSS); class names are combined with
mergeClassNames() - Backend calls go only through the hooks generated by orval;
fetchis never written by hand - Shared fetch behavior lives in
customFetchinlib/orval/client.ts - Logic independent of the UI is extracted into
lib/surveys/as pure functions and tested with Vitest
Chrome Extension Rules
- CS conversion logic is collected in
plan/build-import-plan.tsas pure functions, separate from the API calls (execute-import-plan.ts) - Magic numbers such as the CS
verbare defined as named constants, with a comment recording their provenance (which real-traffic log they came from) - Every change to the conversion logic adds a test to
build-import-plan.test.ts
Testing Policy
- The backend injects a fake store and fake auth resolver into
createApp()and writes API tests that do not touch the database - Fix the behavior with API tests first, then implement the repository
- See Test Case for details
const app = createApp({ store: fakeStore, resolveUser: async () => fakeUser });
const res = await app.request("/api/surveys");
PR and Review Standards
- Keep one PR to one purpose
- Write an explanation in the PR body so reviewers can follow it
- Self-review before requesting a review
- After changing a backend API, run
bun run export:openapiand the frontend'sbun run generate:api, and include the result in the same PR - After changing the DB schema, include the migration generated by
bun run db:generatein the same PR - A push to
maindeploys to the dev environment automatically, so verify behavior before merging
Anti-patterns
- Writing business logic in
routes(put it inrepositories) - Touching Drizzle directly from
routes(DB access belongs inrepositories) - Editing
lib/api/generated.tsby hand (it is rebuilt withclean: true, so the edit disappears) - Inserting an enum value in the middle (migration generation becomes unstable; always append)
- Referencing choices by ID in branch conditions (IDs change on every update; use the label string)
- Reading
process.envfromroutes/repositories(collect it in theliblayer) - Overusing
any(derive types from Zod schemas) - Fetching data in a way that bypasses permission checks (always go through the checks in
repositories)