Skip to content

Code & Database Conventions

This document defines the coding standards, naming conventions, and database patterns for the Feoda BI project.


TypeScript Configuration

The project uses strict TypeScript with ES2022 target and NodeNext module resolution. Key compiler options:

Option Value Purpose
strict true Enables all strict type-checking options
noUnusedLocals true Errors on unused local variables
noUnusedParameters true Errors on unused function parameters
noImplicitReturns true All code paths must return a value
noFallthroughCasesInSwitch true Prevents accidental switch fallthrough

Use _ prefix for intentionally unused parameters:

app.get('/health', async (_request, reply) => {
  return reply.send({ status: 'ok' });
});

ESLint

ESLint is configured in .eslintrc.json with the @typescript-eslint plugin and Prettier integration.

Key rules:

Rule Setting Effect
@typescript-eslint/no-unused-vars error Unused vars are errors (use _ prefix to ignore)
@typescript-eslint/explicit-function-return-type off TypeScript infers return types — no need to annotate every function
@typescript-eslint/no-explicit-any warn Warns on any usage — prefer specific types

Run ESLint:

npm run lint

ESLint auto-fixes on save when using the Dev Container (configured in devcontainer.json).


Prettier

Prettier is configured in .prettierrc:

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf"
}

Key formatting rules:

  • Single quotes for strings
  • Semicolons required
  • 2-space indentation
  • Trailing commas everywhere (cleaner diffs)
  • 100-character line width
  • Unix line endings (lf)

Prettier runs on save when using the Dev Container. To format manually:

npm run format

File & Folder Naming

General Rules

  • Files: kebab-case.ts — e.g., error-handler.ts, health.routes.ts
  • Folders: kebab-case — e.g., modules/, health/
  • Test files: <name>.test.ts — co-located with source or in tests/

Module Structure

Each feature module lives under src/modules/<module-name>/ with a consistent file pattern:

src/modules/tenants/
├── tenant.routes.ts     ← Route definitions (Fastify route handlers)
├── tenant.service.ts    ← Business logic
├── tenant.schema.ts     ← Zod schemas for validation
└── tenant.model.ts      ← TypeScript type definitions
File Purpose
<module>.routes.ts Fastify route registration, request/response handling
<module>.service.ts Business logic, database queries via Kysely
<module>.schema.ts Zod schemas for request validation
<module>.model.ts TypeScript type/interface definitions

Other Source Directories

Directory Purpose
src/config/ Environment configuration loader
src/database/migrations/ Database migration files
src/database/seeds/ Development seed data
src/middleware/ Fastify middleware (auth, error handling)
src/plugins/ Fastify plugins (postgres, redis, swagger)
src/utils/ Shared utilities (logger, errors, pagination)

TypeScript Conventions

Imports

  • Use relative imports: import { config } from '../config/index.js';
  • Include the .js extension (required by NodeNext module resolution)
  • Group imports: Node built-ins → external packages → internal modules
import { readFile } from 'node:fs/promises';

import { FastifyInstance } from 'fastify';
import { z } from 'zod';

import { db } from '../database/connection.js';
import { logger } from '../utils/logger.js';

Type Definitions

  • Prefer interface for object shapes, type for unions/intersections
  • Export types that other modules consume
  • Co-locate types with the module that owns them
// tenant.model.ts
export interface Tenant {
  id: string;
  name: string;
  slug: string;
  timezone: string;
  currency: string;
  status: 'active' | 'inactive';
  createdAt: Date;
  updatedAt: Date;
}

Error Handling

  • Use custom error classes from src/utils/errors.ts
  • Let the global error handler in src/middleware/error-handler.ts catch and format errors
  • Don't swallow errors silently — log and rethrow or handle explicitly
import { NotFoundError } from '../utils/errors.js';

const tenant = await findTenantById(id);
if (!tenant) {
  throw new NotFoundError(`Tenant ${id} not found`);
}

Zod Schema Conventions

Use Zod for runtime validation of request bodies, query parameters, and external data.

Naming

  • Schema variables: <Entity><Action>Schema — e.g., TenantCreateSchema, InvoiceQuerySchema
  • Inferred types: <Entity><Action> — e.g., TenantCreate, InvoiceQuery
// tenant.schema.ts
import { z } from 'zod';

export const TenantCreateSchema = z.object({
  name: z.string().min(1).max(255),
  slug: z.string().regex(/^[a-z0-9-]+$/).min(1).max(100),
  timezone: z.string().default('UTC'),
  currency: z.string().length(3).default('AUD'),
});

export type TenantCreate = z.infer<typeof TenantCreateSchema>;

Rules

  • Define schemas in <module>.schema.ts
  • Validate at the route handler level (request boundary)
  • Use .transform() for data normalisation at the boundary
  • Internal functions receive already-validated types

Database Conventions

Technology

  • PostgreSQL 16 — primary database
  • Kysely — type-safe SQL query builder (not an ORM)
  • Migrations — forward-only, versioned schema changes

Table Naming

  • Tables: snake_case, plural — e.g., tenants, billing_items, payment_records
  • Columns: snake_case — e.g., tenant_id, created_at, invoice_number
  • Indexes: idx_<table>_<column(s)> — e.g., idx_tenants_slug, idx_invoices_tenant_id_status
  • Foreign keys: fk_<table>_<referenced_table> — e.g., fk_invoices_tenants
  • Unique constraints: uq_<table>_<column(s)> — e.g., uq_tenants_slug

Standard Columns

Every table includes these columns:

Column Type Purpose
id uuid (default: gen_random_uuid()) Primary key
tenant_id uuid Tenant isolation (FK → tenants.id)
created_at timestamptz (default: now()) Record creation timestamp
updated_at timestamptz (default: now()) Last update timestamp

Exceptions: the tenants table itself does not have a tenant_id column.

Migration Files

Migration files live in src/database/migrations/ and follow this naming pattern:

<NNN>_<description>.ts
  • NNN — zero-padded sequential number (e.g., 001, 002, 015)
  • description — snake_case description of the change

Examples:

001_create_tenants.ts
002_create_debtors.ts
003_create_students.ts
004_create_billing_items.ts
005_add_status_to_invoices.ts

Migration Structure

Each migration exports up and down functions using Kysely's schema builder:

import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<unknown>): Promise<void> {
  await db.schema
    .createTable('tenants')
    .addColumn('id', 'uuid', (col) =>
      col.primaryKey().defaultTo(sql`gen_random_uuid()`),
    )
    .addColumn('name', 'varchar(255)', (col) => col.notNull())
    .addColumn('slug', 'varchar(100)', (col) => col.notNull().unique())
    .addColumn('timezone', 'varchar(50)', (col) =>
      col.notNull().defaultTo('UTC'),
    )
    .addColumn('currency', 'char(3)', (col) =>
      col.notNull().defaultTo('AUD'),
    )
    .addColumn('status', 'varchar(20)', (col) =>
      col.notNull().defaultTo('active'),
    )
    .addColumn('created_at', 'timestamptz', (col) =>
      col.notNull().defaultTo(sql`now()`),
    )
    .addColumn('updated_at', 'timestamptz', (col) =>
      col.notNull().defaultTo(sql`now()`),
    )
    .execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
  await db.schema.dropTable('tenants').execute();
}

Migration Rules

  • Migrations are forward-only in production — never edit a migration that has been applied
  • Always provide a down function for local development rollback
  • One logical change per migration (don't mix table creation with data seeding)
  • Use sql tagged template for PostgreSQL-specific features (e.g., gen_random_uuid(), now())

Kysely Query Patterns

Use Kysely's type-safe query builder for all database operations:

// Select
const tenants = await db
  .selectFrom('tenants')
  .where('status', '=', 'active')
  .selectAll()
  .execute();

// Insert
const tenant = await db
  .insertInto('tenants')
  .values({ name, slug, timezone, currency })
  .returningAll()
  .executeTakeFirstOrThrow();

// Update
await db
  .updateTable('tenants')
  .set({ name: newName, updated_at: sql`now()` })
  .where('id', '=', tenantId)
  .execute();

Testing Conventions

Framework

  • Vitest — test runner and assertion library
  • Test files: tests/<name>.test.ts or src/<module>/<name>.test.ts
  • Setup file: tests/setup.ts (test database, cleanup)

Test Structure

import { describe, it, expect, beforeAll, afterAll } from 'vitest';

describe('Health endpoint', () => {
  it('should return 200 with status ok', async () => {
    const response = await app.inject({ method: 'GET', url: '/health' });
    expect(response.statusCode).toBe(200);
    expect(response.json()).toEqual({ status: 'ok' });
  });
});

Rules

  • Test behaviour, not implementation
  • Each test should be independent — don't rely on execution order
  • Use beforeAll/afterAll for expensive setup (database connections)
  • Use beforeEach/afterEach for per-test cleanup
  • Name test files to match the module they test

Running Tests

npm test            # Run all tests once
npm run test:watch  # Watch mode (re-runs on file changes)

Environment Variables

  • All environment variables are defined in .env.example
  • Copy to .env for local overrides (.env is gitignored)
  • Access via the config loader in src/config/index.ts
  • Never commit secrets — use Azure DevOps variable groups for CI/CD
  • Required variables are validated at startup — the app fails fast with a clear error if any are missing