Skip to main content

Database

SyncAD uses PostgreSQL 15 with Drizzle ORM as the ORM. Schema definitions live in packages/db/src/schema.ts and are the single source of truth for all school databases.

Schema Architecture

Central DB Schema (Key Tables)

TablePurpose
SchoolSchool registration, databaseName, subdomain, settings
UserUser accounts (phone, email, password hash)
EmployeeUserSchoolLinks users to schools with role assignment
RoleRole definitions (SCHOOL_ADMIN, TEACHER, etc.)
ModuleModule names (attendance, exam, fee, ...)
Role_Module_PermissionPer-role, per-module read/write/delete flags
SessionActive sessions
RefreshTokenJWT refresh token registry

School DB Schema (Core Tables)

TablePurpose
StudentStudent records linked to user account
StudentClassStudent-class mappings
AttendanceDaily attendance records
ExamExam definitions
ResultStudent exam results
FeeStructureFee heads and amounts
FeeLedgerDouble-entry fee transactions
BusBus fleet
BusRouteRoute definitions
BusStopStop master
TripTrip instances
TripStopStop order within a trip
TripStudentMarkBoarding status per student per trip
AnnouncementSchool announcements
LeaveApplicationLeave requests
MessageMessaging threads
LibraryBookIssueBook issue/return records

Drizzle Schema Example

// packages/db/src/schema.ts
import {
pgTable,
text,
uuid,
timestamp,
boolean,
integer,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const School = pgTable('School', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
databaseName: text('databaseName').notNull().unique(),
subdomain: text('subdomain').notNull().unique(),
district: text('district'),
board: text('board'),
evaluationMode: boolean('evaluationMode').default(false),
createdAt: timestamp('createdAt').defaultNow(),
});

export const Student = pgTable('Student', {
id: uuid('id').primaryKey().defaultRandom(),
schoolId: uuid('schoolId').notNull(),
userId: uuid('userId'), // linked user account (optional)
admissionNo: text('admissionNo').notNull(),
firstName: text('firstName', { length: 100 }).notNull(),
lastName: text('lastName', { length: 100 }),
classId: uuid('classId'),
divisionId: uuid('divisionId'),
// ...
}, (table) => ({
schoolIdIdx: index('Student_schoolId_idx').on(table.schoolId),
classIdx: index('Student_class_idx').on(table.classId),
}));

export const Attendance = pgTable('Attendance', {
id: uuid('id').primaryKey().defaultRandom(),
schoolId: uuid('schoolId').notNull(),
studentId: uuid('studentId').notNull(),
date: timestamp('date').notNull(),
status: text('status', { length: 20 }).notNull(), // PRESENT, ABSENT, LATE, LEAVE
markedBy: uuid('markedBy'), // teacher userId
// ...
}, (table) => ({
schoolDateIdx: index('Attendance_school_date_idx').on(table.schoolId, table.date),
studentIdx: index('Attendance_student_idx').on(table.studentId),
}));

// Relations
export const schoolRelations = relations(School, ({ many }) => ({
users: many(EmployeeUserSchool),
}));

export const studentRelations = relations(Student, ({ one, many }) => ({
school: one(School, { fields: [Student.schoolId], references: [School.id] }),
attendance: many(Attendance),
results: many(Result),
}));

Client Factory

// packages/db/src/client.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

let centralPool: Pool;

export function getCentralPool(): Pool {
if (!centralPool) {
centralPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
}
return centralPool;
}

export function getCentralDb() {
return drizzle(getCentralPool(), { schema: centralSchema });
}

export async function getSchoolDb(schoolId: string) {
const school = await getCentralDb().query.School.findFirst({
where: eq(School.id, schoolId),
columns: { databaseName: true },
});

if (!school) throw new UnauthorizedException();

const schoolPool = new Pool({
connectionString: `${process.env.DATABASE_URL.replace('/syncad_central', `/${school.databaseName}`)}`,
max: 10,
});

return drizzle(schoolPool, { schema: schoolSchema });
}

Migrations

Drizzle Kit Config

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

Running Migrations

# Push schema (development — creates/alters tables)
pnpm --filter @syncad/db drizzle-kit push

# Generate migration files (production)
pnpm --filter @syncad/db drizzle-kit generate

# Apply migrations (production)
pnpm --filter @syncad/db drizzle-kit migrate

Idempotent Migrations

All school DB migrations run via school-db-migrator which executes CREATE TABLE IF NOT EXISTS statements. This ensures:

  1. First run: creates tables
  2. Subsequent runs: no-op (safe to re-run)
  3. New school provisioning: applies all migrations fresh
-- Migration file example: 001_initial_schema.sql
CREATE TABLE IF NOT EXISTS "Student" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"schoolId" uuid NOT NULL,
"admissionNo" varchar(50) NOT NULL,
"firstName" varchar(100) NOT NULL,
"lastName" varchar(100),
"classId" uuid,
"divisionId" uuid,
"createdAt" timestamptz DEFAULT now(),
"updatedAt" timestamptz DEFAULT now()
);

CREATE INDEX IF NOT EXISTS "Student_schoolId_idx" ON "Student" ("schoolId");
CREATE UNIQUE INDEX IF NOT EXISTS "Student_admissionNo_schoolId_idx" ON "Student" ("admissionNo", "schoolId");

Indexing Strategy

TableKey Indexes
Student(schoolId), (classId), (admissionNo, schoolId)
Attendance(schoolId, date), (studentId)
Result(schoolId, examId), (studentId)
FeeLedger(schoolId, studentId), (schoolId, date)
Trip(schoolId, busId, date)
TripStudentMark(tripId, studentId)

Double-Entry Bookkeeping (Fee Ledger)

Fee transactions use double-entry accounting:

export const FeeLedger = pgTable('FeeLedger', {
id: uuid('id').primaryKey().defaultRandom(),
schoolId: uuid('schoolId').notNull(),
studentId: uuid('studentId').notNull(),
academicYear: text('academicYear').notNull(),
voucherNo: text('voucherNo').notNull(),
date: timestamp('date').notNull(),
// Double-entry: amount is positive for debit, negative for credit
amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
// Account: FEE_RECEIVABLE, CASH, BANK, FEE_DISCOUNT, etc.
account: text('account').notNull(),
// DR or CR
entryType: text('entryType', { length: 2 }).notNull(), // DR | CR
narration: text('narration'),
});

For each fee payment:

  • DR entry: FEE_RECEIVABLE (reduces what student owes)
  • CR entry: CASH or BANK (records money received)