Skip to content

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.json and /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-image headers set by the frontend. Only the import API also accepts x-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)

app.ts  โ”€โ–บ  routes/  โ”€โ–บ  repositories/  โ”€โ–บ  db/
              โ”‚               โ”‚
              โ””โ”€โ”€โ–บ  lib/  โ—„โ”€โ”€โ”€โ”˜
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.ts references survey-store in order to auto-create users. Apart from that single case, lib never calls repositories.

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 with RouteHandler<typeof route, AppBindings>
  • Request values are always taken from c.req.valid("json" | "param" | "query") (never raw c.req.json())
  • store is 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: AuthenticatedUser as the last
  • When permission is missing, return null rather than throwing; routes turns that into a 404
  • Writes spanning several tables are wrapped in db.transaction()
  • Setting createdAt / updatedAt / deletedAt is 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.ts and cross-checked on the Zod side via z.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 BEFORE output 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 surveys uses soft deletion. Read queries include deletedAt IS NULL

Frontend Rules

  • One component per directory: index.tsx + index.module.scss (scaffold with bun 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; fetch is never written by hand
  • Shared fetch behavior lives in customFetch in lib/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.ts as pure functions, separate from the API calls (execute-import-plan.ts)
  • Magic numbers such as the CS verb are 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:openapi and the frontend's bun run generate:api, and include the result in the same PR
  • After changing the DB schema, include the migration generated by bun run db:generate in the same PR
  • A push to main deploys to the dev environment automatically, so verify behavior before merging

Anti-patterns

  • Writing business logic in routes (put it in repositories)
  • Touching Drizzle directly from routes (DB access belongs in repositories)
  • Editing lib/api/generated.ts by hand (it is rebuilt with clean: 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.env from routes / repositories (collect it in the lib layer)
  • Overusing any (derive types from Zod schemas)
  • Fetching data in a way that bypasses permission checks (always go through the checks in repositories)