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:
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:
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:
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 intests/
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
.jsextension (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
interfacefor object shapes,typefor 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.tscatch 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— 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
downfunction for local development rollback - One logical change per migration (don't mix table creation with data seeding)
- Use
sqltagged 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.tsorsrc/<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/afterAllfor expensive setup (database connections) - Use
beforeEach/afterEachfor per-test cleanup - Name test files to match the module they test
Running Tests¶
Environment Variables¶
- All environment variables are defined in
.env.example - Copy to
.envfor local overrides (.envis 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