Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 7 additions & 27 deletions scripts/drift-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,20 @@
# Schema drift check.
#
# Verifies that entity definitions do not drift from the migration-produced
# schema. The check fails when *new* drift is introduced.
#
# Pre-existing drift between entities and migrations (index/column/constraint
# naming mismatches accumulated before a baseline migration existed) is
# captured in scripts/known-drift.txt. That snapshot is tracked separately in
# a follow-up issue; this script only fails when the drift differs from the
# snapshot, i.e. when a model change lacks a corresponding migration.
# schema. The check fails when `migration:generate --check` reports ANY
# difference between the entity metadata and the schema produced by running
# all migrations — model changes without a corresponding migration are
# rejected.
set -uo pipefail

cd "$(dirname "$0")/.."

OUT=$(mktemp)
trap 'rm -f "$OUT"' EXIT

if npx typeorm-ts-node-commonjs migration:generate -d src/config/datasource.ts \
--check drift-check >"$OUT" 2>&1; then
--check drift-check >/dev/null 2>&1; then
echo "✓ No schema drift."
exit 0
fi

# Drift detected: extract the generated SQL statements, normalize whitespace,
# and compare against the known-drift snapshot.
grep -oE "await queryRunner\.query\(\`[^\`]*" "$OUT" \
| sed 's/^await queryRunner\.query(`//' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
| sort >"${OUT}.current"

if diff -q scripts/known-drift.txt "${OUT}.current" >/dev/null 2>&1; then
echo "⚠ Schema drift matches the known snapshot (see issue #1194). No new drift."
exit 0
fi

echo "::error::New schema drift detected — model changes exist without a corresponding migration."
echo "Run 'pnpm run migration:generate' to create the missing migration, or update scripts/known-drift.txt."
echo ""
diff scripts/known-drift.txt "${OUT}.current" | head -60
echo "::error::Schema drift detected — the database schema produced by migrations does not match the entity definitions."
echo "Run 'pnpm run migration:generate' to create a migration for the change, or align the entity definitions."
exit 1
622 changes: 0 additions & 622 deletions scripts/known-drift.txt

This file was deleted.

4 changes: 2 additions & 2 deletions src/assessment/grading/entities/criterion-grade.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export class CriterionGrade {
grade: SubmissionGrade;

@Column({ name: 'grade_id', type: 'uuid' })
@Index()
@Index('IDX_criterion_grades_grade')
gradeId: string;

@Column({ name: 'criterion_id', type: 'uuid' })
@Index()
@Index('IDX_criterion_grades_criterion')
criterionId: string;

/** Selected rubric level (optional when `points` is provided directly). */
Expand Down
10 changes: 5 additions & 5 deletions src/assessment/grading/entities/feedback-template.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,27 @@ export class FeedbackTemplate {
id: string;

@Column()
@Index()
@Index('IDX_feedback_templates_name')
name: string;

@Column({ type: 'text' })
body: string;

@Column({ name: 'owner_id', type: 'uuid', nullable: true })
@Index()
@Index('IDX_feedback_templates_owner')
ownerId?: string;

/** When true, this template is auto-selected when none is supplied. */
@Column({ default: false })
isDefault: boolean;

@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
@Index()
createdAt: Date;

@UpdateDateColumn()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;

@DeleteDateColumn()
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
2 changes: 1 addition & 1 deletion src/assessment/grading/entities/rubric-criterion.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class RubricCriterion {
rubric: Rubric;

@Column({ name: 'rubric_id', type: 'uuid' })
@Index()
@Index('IDX_rubric_criteria_rubric')
rubricId: string;

@Column()
Expand Down
2 changes: 1 addition & 1 deletion src/assessment/grading/entities/rubric-level.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class RubricLevel {
criterion: RubricCriterion;

@Column({ name: 'criterion_id', type: 'uuid' })
@Index()
@Index('IDX_rubric_levels_criterion')
criterionId: string;

/** Short label, e.g. "Excellent". */
Expand Down
12 changes: 6 additions & 6 deletions src/assessment/grading/entities/rubric.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export class Rubric {
id: string;

@Column()
@Index()
@Index('IDX_rubrics_name')
name: string;

@Column({ type: 'text', nullable: true })
description?: string;

/** Optional owner (instructor/admin) — used for ownership checks. */
@Column({ name: 'owner_id', type: 'uuid', nullable: true })
@Index()
@Index('IDX_rubrics_owner')
ownerId?: string;

/**
Expand All @@ -39,7 +39,7 @@ export class Rubric {
* across many assessments by leaving this null.
*/
@Column({ name: 'assessment_id', type: 'uuid', nullable: true })
@Index()
@Index('IDX_rubrics_assessment')
assessmentId?: string;

/**
Expand All @@ -61,13 +61,13 @@ export class Rubric {
})
criteria: RubricCriterion[];

@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
@Index()
createdAt: Date;

@UpdateDateColumn()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;

@DeleteDateColumn()
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
8 changes: 4 additions & 4 deletions src/assessment/grading/entities/submission-grade.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ export class SubmissionGrade {

/** The graded assessment attempt. */
@Column({ name: 'attempt_id', type: 'uuid' })
@Index()
@Index('IDX_submission_grades_attempt')
attemptId: string;

@ManyToOne(() => Rubric, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'rubric_id' })
rubric: Rubric;

@Column({ name: 'rubric_id', type: 'uuid' })
@Index()
@Index('IDX_submission_grades_rubric')
rubricId: string;

/** Optional grader (admin/instructor). Null when auto-graded. */
Expand Down Expand Up @@ -82,9 +82,9 @@ export class SubmissionGrade {
@OneToMany(() => CriterionGrade, (cg) => cg.grade, { cascade: true })
criterionGrades: CriterionGrade[];

@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

@UpdateDateColumn()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
32 changes: 21 additions & 11 deletions src/audit-log/audit-log.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm';
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
VersionColumn,
} from 'typeorm';
import { AuditAction, AuditSeverity, AuditCategory } from './enums/audit-action.enum';
export enum HttpMethod {
GET = 'GET',
Expand All @@ -22,18 +29,21 @@ export enum HttpMethod {
* policy runs without a full table scan.
*/
@Entity('audit_logs')
@Index(['userId', 'timestamp'])
@Index(['action', 'timestamp'])
@Index(['category', 'timestamp'])
@Index(['severity', 'timestamp'])
@Index(['entityType', 'entityId', 'timestamp'])
@Index(['ipAddress', 'timestamp'])
@Index(['timestamp'])
@Index('IDX_audit_logs_user_timestamp', ['userId', 'timestamp'])
@Index('IDX_audit_logs_action_timestamp', ['action', 'timestamp'])
@Index('IDX_audit_logs_category_timestamp', ['category', 'timestamp'])
@Index('IDX_audit_logs_severity_timestamp', ['severity', 'timestamp'])
@Index('IDX_audit_logs_entity', ['entityType', 'entityId', 'timestamp'])
@Index('IDX_audit_logs_ip_address', ['ipAddress', 'timestamp'])
@Index('IDX_audit_logs_timestamp', ['timestamp'])
@Index(['retentionUntil']) // required for efficient retention policy deletes
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;

@VersionColumn({ default: 1 })
version: number;

// ── Actor ──────────────────────────────────────────────────────────────────

@Column({ name: 'user_id', nullable: true })
Expand Down Expand Up @@ -93,7 +103,7 @@ export class AuditLog {
apiEndpoint: string | null;

/** Constrained to known HTTP verbs — free strings invite silent typos. */
@Column({ name: 'http_method', type: 'enum', enum: HttpMethod, nullable: true })
@Column({ name: 'http_method', nullable: true })
httpMethod: HttpMethod | null;

@Column({ name: 'status_code', nullable: true })
Expand All @@ -109,14 +119,14 @@ export class AuditLog {

// ── Timestamps ─────────────────────────────────────────────────────────────

@CreateDateColumn({ name: 'timestamp' })
@CreateDateColumn({ name: 'timestamp', type: 'timestamptz' })
timestamp: Date;

/**
* Absolute expiry date for this record.
* Null means the record is kept indefinitely (e.g. CRITICAL severity logs).
* Indexed — see class-level @Index.
*/
@Column({ name: 'retention_until', nullable: true })
@Column({ name: 'retention_until', type: 'timestamptz', nullable: true })
retentionUntil: Date | null;
}
6 changes: 3 additions & 3 deletions src/courses/entities/bulk-operation.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class BulkOperation {
@JoinColumn({ name: 'initiated_by_id' })
initiatedBy?: User;

@Index()
@Index('IDX_course_bulk_ops_initiator')
@Column({
name: 'initiated_by_id',
type: 'uuid',
Expand All @@ -83,7 +83,7 @@ export class BulkOperation {
})
undoneById?: string;

@Index()
@Index('IDX_course_bulk_ops_type')
@Column({
type: 'enum',
enum: BulkOperationType,
Expand Down Expand Up @@ -161,7 +161,7 @@ export class BulkOperation {
/**
* Optimistic locking to prevent concurrent updates.
*/
@VersionColumn()
@VersionColumn({ default: 1 })
version: number;

@Index()
Expand Down
18 changes: 15 additions & 3 deletions src/courses/entities/course.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,30 @@ export class Course {

/** Optional category/tag used for catalog grouping and bulk operations. */
@Column({ nullable: true })
@Index()
@Index('IDX_course_category')
category?: string;

/** Difficulty level, e.g. 'beginner' | 'intermediate' | 'advanced'. Used by search filtering. */
@Column({ nullable: true })
@Index()
@Index('IDX_course_level')
level?: string;

/** ISO 639-1 language code the course is taught in. Used by search filtering. */
@Column({ nullable: true })
@Index()
@Index('IDX_course_language')
language?: string;

/** ISO 4217 currency code the course is priced in (e.g. "USD"). */
@Column({
name: 'currency',
type: 'varchar',
length: 3,
default: 'USD',
nullable: true,
})
@Index('IDX_course_currency')
currency?: string;

@ManyToOne(() => User, (user) => user.courses)
instructor: User;

Expand Down Expand Up @@ -109,6 +120,7 @@ export class Course {
*/
@Index('IDX_course_search_vector', { synchronize: false })
@Column({
name: 'search_vector',
type: 'tsvector',
select: false,
generatedType: 'STORED',
Expand Down
2 changes: 1 addition & 1 deletion src/entities/schema_change.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ export class SchemaChange {
@CreateDateColumn({ type: 'timestamp with time zone' })
createdAt: Date;

@ManyToOne(() => SchemaVersion, (sv) => sv.changes, { onDelete: 'CASCADE' })
@ManyToOne(() => SchemaVersion, (sv) => sv.changes, { onDelete: 'CASCADE', nullable: false })
schemaVersion: SchemaVersion;
}
4 changes: 2 additions & 2 deletions src/forum/entities/forum-comment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
import { ForumThread } from './forum-thread.entity';

@Entity('forum_comments')
@Index(['threadId', 'createdAt'])
@Index(['parentId'])
@Index('IDX_forum_comments_threadId_createdAt', ['threadId', 'createdAt'])
@Index('IDX_forum_comments_parentId', ['parentId'])
export class ForumComment {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
2 changes: 1 addition & 1 deletion src/forum/entities/forum-thread.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { ForumComment } from './forum-comment.entity';

@Entity('forum_threads')
@Index(['status', 'createdAt'])
@Index('IDX_forum_threads_status_createdAt', ['status', 'createdAt'])
export class ForumThread {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
2 changes: 1 addition & 1 deletion src/forum/entities/forum-vote.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { User } from '../../users/entities/user.entity';

@Entity('forum_votes')
@Unique(['entityType', 'entityId', 'authorId'])
@Index(['entityType', 'entityId'])
@Index('IDX_forum_votes_entityType_entityId', ['entityType', 'entityId'])
export class ForumVote {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
2 changes: 1 addition & 1 deletion src/gamification/entities/challenge.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Entity, PrimaryGeneratedColumn, Column, VersionColumn, Index } from 'ty
* Represents the challenge entity.
*/
@Entity('challenges')
@Index(['type'])
@Index('IDX_challenges_type', ['type'])
export class Challenge {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
4 changes: 2 additions & 2 deletions src/gamification/entities/point-transaction.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { User } from '../../users/entities/user.entity';
* Represents the point Transaction entity.
*/
@Entity('point_transactions')
@Index(['user', 'createdAt'])
@Index(['activityType'])
@Index('IDX_point_transactions_user_createdAt', ['user', 'createdAt'])
@Index('IDX_point_transactions_activityType', ['activityType'])
export class PointTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
6 changes: 3 additions & 3 deletions src/gamification/entities/tier-reward.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { Tier } from '../enums/tier.enum';
* Defines the reward granted when a user reaches a specific tier.
*/
@Entity('tier_rewards')
@Index(['tier'])
@Index('IDX_tier_rewards_tier', ['tier'])
export class TierReward {
@PrimaryGeneratedColumn('uuid')
id: string;

@VersionColumn()
@VersionColumn({ default: 1 })
version: number;

@Column({ type: 'enum', enum: Tier, unique: true })
@Column({ type: 'enum', enum: Tier, unique: true, enumName: 'tier_enum' })
tier: Tier;

@Column()
Expand Down
Loading
Loading