Global Bible Tools is a Tanstack Start full-stack application for collaborative Bible translation. It uses:
- React 19, TypeScript 5 (strict mode)
- PostgreSQL via Kysely (type-safe query builder) and
pg - Tailwind CSS 4, Headless UI, Font Awesome icons
- use-intl for i18n (English + Arabic)
- Zod for schema validation
- Vitest for testing
- Pino for structured logging
docker compose exec server npm run lint # ESLint
docker compose exec server npm run format # Prettier --write on all files
docker compose exec server npm run check-types # tsc --noEmit (type-check only, no emit)docker compose exec server npm run test # Vitest in watch mode
docker compose exec server npm run test:run # Vitest single pass (used in CI)
# Run a single test file
docker compose exec server npx vitest run src/modules/translation/actions/updateGloss.test.ts
# Run all tests under a module directory
docker compose exec server npx vitest run src/modules/translation
# Run tests matching a name pattern
docker compose exec server npx vitest run -t "creates a phrase"
# Run with verbose reporter
docker compose exec server npx vitest run --reporter=verbose src/modules/translation/model/Phrase.unit.tsThe codebase follows Domain-Driven Design layering inside src/modules/. Each of the 10 modules (access, bible-core, dashboard, export, languages, reporting, study, translation, users) is structured as:
src/modules/<module>/
actions/ # Next.js Server Actions ("use server") — boundary layer
use-cases/ # Business logic / application layer
data-access/ # Repositories — DB reads, domain model mapping
read-models/ # Query-side read models (Kysely query builders)
model/ # Domain model classes with domain events
db/
schema.ts # Kysely table interfaces (Generated/Selectable/Insertable)
migrations/ # SQL migration files
ui/ # React components specific to this module
jobs/ # Background job handlers
test-utils/ # Factories and DB helpers for tests
__mocks__/ # Vitest module mocks
index.ts # Public barrel export
types.ts # Shared enums and types within the module
Data flow: UI Component → Server Fn → Use Case → Repository / Domain Model
- Business logic lives in use cases.
- DB mapping lives in repositories.
- Domain events are emitted by model classes.
- Server functions catch errors and call tanstack start primitives (
notFound(),redirect()).
Shared cross-cutting code lives in src/shared/ (errors, feature-flags, i18n, jobs, ulid).
Path aliases: @/* → src/*, @/tests/* → tests/*.
- Double quotes, semicolons on, 2-space indent, trailing commas (all), 80-char print width.
experimentalTernaries: true— use the "curious ternary" style:const result = condition ? consequentValue : alternateValue;
- Run
npm run formatbefore committing, or rely on the Husky pre-commit hook (lint-staged runs Prettier on staged files automatically).
strict: trueis enabled. No implicitany, strict null checks apply everywhere.@typescript-eslint/no-explicit-anyis turned off — explicitanyis acceptable where practical (e.g., server action signatures, query helpers).- Prefer
interfaceovertypefor object shapes. Usetypefor unions, intersections, and computed types. - Use
as constobjects + a type alias (type Foo = (typeof FooMap)[keyof typeof FooMap]) for string-valued enumerations used as discriminated unions. - Use Kysely's
Generated<T>,Selectable<T>,Insertable<T>helpers for DB schema types. - Use
readonlyon class fields and array return types where mutation is unintended. noImplicitOverride: trueis on — always add theoverridekeyword when overriding a base class member.
| Kind | Convention | Example |
|---|---|---|
| React component files | PascalCase.tsx |
TranslateWord.tsx |
| Other TypeScript files | camelCase.ts or kebab-case.ts |
updateGloss.ts, form-parser.ts |
| Test / unit files | <name>.test.ts / <name>.unit.ts |
updateGloss.test.ts, Phrase.unit.ts |
| Classes | PascalCase |
Phrase, Policy |
| Interfaces | PascalCase |
UpdateGlossUseCaseRequest |
| React components | PascalCase (default export) |
TranslateWord |
| Use-case functions | camelCase + UseCase suffix |
updateGlossUseCase |
| Server functions | camelCase |
updateGloss |
| DB table interfaces | PascalCase + Table suffix |
GlossTable |
| Test factory helpers | camelCase + Factory suffix |
phraseFactory |
| General variables/functions | camelCase |
phraseRepository |
| Module-level constants | camelCase (or UPPER_SNAKE_CASE for env-derived values) |
EXPIRES_IN |
- Use
@/alias for all imports fromsrc/. Use@/tests/for test utilities. - Use relative imports for files internal to a module.
- Group order (no enforced sort, but follow this in practice):
- External packages
@/path aliases (internal modules)- Relative imports
- Named imports are preferred. Default imports are used for React components and domain model classes.
- Use
export typefor type-only re-exports in barrel files.
- Custom error classes extend
Errorand carry typed metadata:export class NotFoundError extends Error { constructor(readonly resource: string) { super(); } }
- In server actions: catch
NotFoundError→ callnotFound(); re-throw unknown errors. - Authorization: use the
createPolicyMiddlewareto authorize a server function androuterGuardfor frontend routes
- Migrate schema and data in separate migrations using _.schema.sql and _.data.sql
- Use the expand/contract pattern to migrate breaking changes
- After migrations are written, reset the database, run the migrations, and reexport the schema and data.dump using the db scripts.
| File suffix | Type | Description |
|---|---|---|
*.unit.ts(x) |
Unit | Pure functions and domain models — no DB, no external services |
*.client.unit.ts(x) |
Unit | Frontend react tests — no DB, no server calls, no external services |
*.test.ts(x) |
Integration | Server actions, repositories, use cases — real PostgreSQL |
import { initializeDatabase } from "@/tests/vitest/dbUtils";
import { test, expect } from "vitest";
import { languageFactory } from "@/modules/languages/test-utils/languageFactory";
import { logIn } from "@/tests/vitest/login";
initializeDatabase(); // Drops and recreates DB before each test
test("does something", async () => {
const { language, members } = await languageFactory.build();
await logIn(members[0].user_id);
const formData = new FormData();
formData.set("field", "value");
await expect(someAction(formData)).resolves.toEqual(...);
});import { describe, test, expect, vi } from "vitest";
vi.mock("@/modules/languages"); // Auto-reset between tests (mockReset: true)
describe("feature", () => {
test("does something", () => {
// Arrange, Act, Assert
});
});tests/vitest/dbSetup.ts— global setup; runs once to create the PostgreSQL template DB.tests/vitest/dbUtils.ts—initializeDatabase()— provides per-test DB isolation using a fresh copy of the template.tests/vitest/matchers.ts— custom matchers:toBeUlid(),toBeNow(),toBeTanstackNotFound(),toBeDaysIntoFuture(n),toBeToken().tests/vitest/login.ts—logIn(userId)helper to set a session cookie.mockReset: trueis set globally — allvi.fn()mocks reset automatically between tests.
- Use factories to create data in integration tests, and dbUtils to fetch data for assertions.
- Try to assert on the entire object state instead of using arrayContaining or objectContaining
- Test cases are based on a set of inputs and can have multiple assertions for all expected results. Avoid creating separate test cases with the same inputs to isolate assertions.
- i18n: use
useTranslations(namespace)in client components - Logging: import from
@/logging(Pino-based); this import is auto-mocked in all test files. - ULIDs are used as primary keys — generate with the
ulid()helper from@/shared/ulid. - Background jobs live in
src/shared/jobs/and modulejobs/directories; the worker entrypoint issrc/shared/jobs/bin/worker.ts.