feat(#2): Implement PostgreSQL 17 + pgvector database schema
Establishes multi-tenant database layer with vector similarity search for AI-powered memory features. Includes Docker infrastructure, Prisma ORM integration, NestJS services, and shared types across the monorepo. Key changes: - Docker: PostgreSQL 17 + pgvector v0.7.4, Valkey cache - Schema: 8 models (User, Workspace, Task, Event, Project, ActivityLog, MemoryEmbedding) with RLS preparation - NestJS: PrismaModule, DatabaseModule, EmbeddingsService - Shared: Type-safe enums, constants, and database types Fixes #2 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,23 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config ./vitest.e2e.config.ts"
|
||||
"test:e2e": "vitest run --config ./vitest.e2e.config.ts",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:migrate:prod": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"prisma:reset": "prisma migrate reset"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/shared": "workspace:*",
|
||||
"@nestjs/common": "^11.1.12",
|
||||
"@nestjs/core": "^11.1.12",
|
||||
"@nestjs/platform-express": "^11.1.12",
|
||||
"@mosaic/shared": "workspace:*",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -33,6 +43,8 @@
|
||||
"@swc/core": "^1.10.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"prisma": "^6.19.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"unplugin-swc": "^1.5.2",
|
||||
"vitest": "^3.0.8"
|
||||
|
||||
261
apps/api/prisma/migrations/20260128212935_init/migration.sql
Normal file
261
apps/api/prisma/migrations/20260128212935_init/migration.sql
Normal file
@@ -0,0 +1,261 @@
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TaskStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('PLANNING', 'ACTIVE', 'PAUSED', 'COMPLETED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'GUEST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'ASSIGNED', 'COMMENTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EntityType" AS ENUM ('TASK', 'EVENT', 'PROJECT', 'WORKSPACE', 'USER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"auth_provider_id" TEXT,
|
||||
"preferences" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"owner_id" UUID NOT NULL,
|
||||
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspace_members" (
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"role" "WorkspaceMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workspace_members_pkey" PRIMARY KEY ("workspace_id","user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tasks" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "TaskStatus" NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"due_date" TIMESTAMPTZ,
|
||||
"assignee_id" UUID,
|
||||
"creator_id" UUID NOT NULL,
|
||||
"project_id" UUID,
|
||||
"parent_id" UUID,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
"completed_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "events" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"start_time" TIMESTAMPTZ NOT NULL,
|
||||
"end_time" TIMESTAMPTZ,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"location" TEXT,
|
||||
"recurrence" JSONB,
|
||||
"creator_id" UUID NOT NULL,
|
||||
"project_id" UUID,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "ProjectStatus" NOT NULL DEFAULT 'PLANNING',
|
||||
"start_date" DATE,
|
||||
"end_date" DATE,
|
||||
"creator_id" UUID NOT NULL,
|
||||
"color" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "activity_logs" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"action" "ActivityAction" NOT NULL,
|
||||
"entity_type" "EntityType" NOT NULL,
|
||||
"entity_id" UUID NOT NULL,
|
||||
"details" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "activity_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "memory_embeddings" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"embedding" vector(1536),
|
||||
"entity_type" "EntityType",
|
||||
"entity_id" UUID,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "memory_embeddings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_auth_provider_id_key" ON "users"("auth_provider_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "workspaces_owner_id_idx" ON "workspaces"("owner_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "workspace_members_user_id_idx" ON "workspace_members"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_workspace_id_idx" ON "tasks"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_workspace_id_status_idx" ON "tasks"("workspace_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_workspace_id_due_date_idx" ON "tasks"("workspace_id", "due_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_assignee_id_idx" ON "tasks"("assignee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_project_id_idx" ON "tasks"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_parent_id_idx" ON "tasks"("parent_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "events_workspace_id_idx" ON "events"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "events_workspace_id_start_time_idx" ON "events"("workspace_id", "start_time");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "events_creator_id_idx" ON "events"("creator_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "events_project_id_idx" ON "events"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_workspace_id_idx" ON "projects"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_workspace_id_status_idx" ON "projects"("workspace_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_creator_id_idx" ON "projects"("creator_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_workspace_id_idx" ON "activity_logs"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_workspace_id_created_at_idx" ON "activity_logs"("workspace_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_entity_type_entity_id_idx" ON "activity_logs"("entity_type", "entity_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_user_id_idx" ON "activity_logs"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "memory_embeddings_workspace_id_idx" ON "memory_embeddings"("workspace_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "memory_embeddings" ADD CONSTRAINT "memory_embeddings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add HNSW index for fast vector similarity search on memory_embeddings table
|
||||
-- Using cosine distance operator for semantic similarity
|
||||
-- Parameters: m=16 (max connections per layer), ef_construction=64 (build quality)
|
||||
|
||||
CREATE INDEX IF NOT EXISTS memory_embeddings_embedding_idx
|
||||
ON memory_embeddings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64);
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
253
apps/api/prisma/schema.prisma
Normal file
253
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,253 @@
|
||||
// Mosaic Stack Database Schema
|
||||
// PostgreSQL 17 with pgvector extension
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
extensions = [pgvector(map: "vector"), uuid_ossp(map: "uuid-ossp")]
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
enum TaskStatus {
|
||||
NOT_STARTED
|
||||
IN_PROGRESS
|
||||
PAUSED
|
||||
COMPLETED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum TaskPriority {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
PLANNING
|
||||
ACTIVE
|
||||
PAUSED
|
||||
COMPLETED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum WorkspaceMemberRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
MEMBER
|
||||
GUEST
|
||||
}
|
||||
|
||||
enum ActivityAction {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
COMPLETED
|
||||
ASSIGNED
|
||||
COMMENTED
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
TASK
|
||||
EVENT
|
||||
PROJECT
|
||||
WORKSPACE
|
||||
USER
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODELS
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique
|
||||
name String
|
||||
authProviderId String? @unique @map("auth_provider_id")
|
||||
preferences Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
|
||||
workspaceMemberships WorkspaceMember[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
createdEvents Event[] @relation("EventCreator")
|
||||
createdProjects Project[] @relation("ProjectCreator")
|
||||
activityLogs ActivityLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
ownerId String @map("owner_id") @db.Uuid
|
||||
settings Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
members WorkspaceMember[]
|
||||
tasks Task[]
|
||||
events Event[]
|
||||
projects Project[]
|
||||
activityLogs ActivityLog[]
|
||||
memoryEmbeddings MemoryEmbedding[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model WorkspaceMember {
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role WorkspaceMemberRole @default(MEMBER)
|
||||
joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, userId])
|
||||
@@index([userId])
|
||||
@@map("workspace_members")
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
title String
|
||||
description String? @db.Text
|
||||
status TaskStatus @default(NOT_STARTED)
|
||||
priority TaskPriority @default(MEDIUM)
|
||||
dueDate DateTime? @map("due_date") @db.Timestamptz
|
||||
assigneeId String? @map("assignee_id") @db.Uuid
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
projectId String? @map("project_id") @db.Uuid
|
||||
parentId String? @map("parent_id") @db.Uuid
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
completedAt DateTime? @map("completed_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
parent Task? @relation("TaskSubtasks", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
subtasks Task[] @relation("TaskSubtasks")
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([workspaceId, dueDate])
|
||||
@@index([assigneeId])
|
||||
@@index([projectId])
|
||||
@@index([parentId])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
title String
|
||||
description String? @db.Text
|
||||
startTime DateTime @map("start_time") @db.Timestamptz
|
||||
endTime DateTime? @map("end_time") @db.Timestamptz
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
location String?
|
||||
recurrence Json?
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
projectId String? @map("project_id") @db.Uuid
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
creator User @relation("EventCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, startTime])
|
||||
@@index([creatorId])
|
||||
@@index([projectId])
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
name String
|
||||
description String? @db.Text
|
||||
status ProjectStatus @default(PLANNING)
|
||||
startDate DateTime? @map("start_date") @db.Date
|
||||
endDate DateTime? @map("end_date") @db.Date
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
color String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
creator User @relation("ProjectCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
events Event[]
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([creatorId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model ActivityLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
action ActivityAction
|
||||
entityType EntityType @map("entity_type")
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
details Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
@@index([userId])
|
||||
@@map("activity_logs")
|
||||
}
|
||||
|
||||
model MemoryEmbedding {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
content String @db.Text
|
||||
// Note: vector dimension (1536) must match EMBEDDING_DIMENSION constant in @mosaic/shared
|
||||
embedding Unsupported("vector(1536)")?
|
||||
entityType EntityType? @map("entity_type")
|
||||
entityId String? @map("entity_id") @db.Uuid
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@map("memory_embeddings")
|
||||
}
|
||||
153
apps/api/prisma/seed.ts
Normal file
153
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
PrismaClient,
|
||||
TaskStatus,
|
||||
TaskPriority,
|
||||
ProjectStatus,
|
||||
WorkspaceMemberRole,
|
||||
} from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
// Create test user
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: "dev@mosaic.local" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "dev@mosaic.local",
|
||||
name: "Development User",
|
||||
preferences: {
|
||||
theme: "system",
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Created user: ${user.email}`);
|
||||
|
||||
// Create workspace
|
||||
const workspace = await prisma.workspace.upsert({
|
||||
where: { id: "00000000-0000-0000-0000-000000000001" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
name: "Development Workspace",
|
||||
ownerId: user.id,
|
||||
settings: {
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Created workspace: ${workspace.name}`);
|
||||
|
||||
// Add user as workspace owner
|
||||
await prisma.workspaceMember.upsert({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||
await prisma.task.deleteMany({ where: { workspaceId: workspace.id } });
|
||||
await prisma.event.deleteMany({ where: { workspaceId: workspace.id } });
|
||||
await prisma.project.deleteMany({ where: { workspaceId: workspace.id } });
|
||||
|
||||
// Create sample project
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
name: "Sample Project",
|
||||
description: "A sample project for development",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
creatorId: user.id,
|
||||
color: "#3B82F6",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Created project: ${project.name}`);
|
||||
|
||||
// Create sample tasks
|
||||
const tasks = [
|
||||
{
|
||||
title: "Set up development environment",
|
||||
status: TaskStatus.COMPLETED,
|
||||
priority: TaskPriority.HIGH,
|
||||
},
|
||||
{
|
||||
title: "Review project requirements",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
},
|
||||
{
|
||||
title: "Design database schema",
|
||||
status: TaskStatus.COMPLETED,
|
||||
priority: TaskPriority.HIGH,
|
||||
},
|
||||
{
|
||||
title: "Implement NestJS integration",
|
||||
status: TaskStatus.COMPLETED,
|
||||
priority: TaskPriority.HIGH,
|
||||
},
|
||||
{
|
||||
title: "Create seed data",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
},
|
||||
];
|
||||
|
||||
for (const taskData of tasks) {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
title: taskData.title,
|
||||
status: taskData.status,
|
||||
priority: taskData.priority,
|
||||
creatorId: user.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Created ${tasks.length} sample tasks`);
|
||||
|
||||
// Create sample event
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(10, 0, 0, 0);
|
||||
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
title: "Morning standup",
|
||||
description: "Daily team sync",
|
||||
startTime: tomorrow,
|
||||
endTime: new Date(tomorrow.getTime() + 30 * 60000), // 30 minutes later
|
||||
creatorId: user.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Created sample event");
|
||||
console.log("Seeding completed successfully!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("Error seeding database:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { AppService } from "./app.service";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
import type { ApiResponse, HealthStatus } from "@mosaic/shared";
|
||||
import { successResponse } from "@mosaic/shared";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
constructor(
|
||||
private readonly appService: AppService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
@@ -13,10 +17,25 @@ export class AppController {
|
||||
}
|
||||
|
||||
@Get("health")
|
||||
getHealth(): ApiResponse<HealthStatus> {
|
||||
async getHealth(): Promise<ApiResponse<HealthStatus>> {
|
||||
const dbHealthy = await this.prisma.isHealthy();
|
||||
const dbInfo = await this.prisma.getConnectionInfo();
|
||||
|
||||
const overallStatus = dbHealthy ? "healthy" : "degraded";
|
||||
const packageVersion = process.env.npm_package_version;
|
||||
|
||||
return successResponse({
|
||||
status: "healthy",
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(packageVersion && { version: packageVersion }),
|
||||
checks: {
|
||||
database: {
|
||||
status: dbHealthy ? "healthy" : "unhealthy",
|
||||
message: dbInfo.connected
|
||||
? `Connected to ${dbInfo.database} (${dbInfo.version})`
|
||||
: "Database connection failed",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [PrismaModule, DatabaseModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
12
apps/api/src/database/database.module.ts
Normal file
12
apps/api/src/database/database.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmbeddingsService } from "./embeddings.service";
|
||||
|
||||
/**
|
||||
* Database utilities module
|
||||
* Provides services for specialized database operations
|
||||
*/
|
||||
@Module({
|
||||
providers: [EmbeddingsService],
|
||||
exports: [EmbeddingsService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
262
apps/api/src/database/embeddings.service.ts
Normal file
262
apps/api/src/database/embeddings.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { EntityType } from "@prisma/client";
|
||||
import { EMBEDDING_DIMENSION } from "@mosaic/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Result from similarity search
|
||||
*/
|
||||
export interface SimilarEmbedding {
|
||||
id: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
entityType: EntityType | null;
|
||||
entityId: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing vector embeddings using pgvector
|
||||
* Uses raw SQL for vector operations since Prisma doesn't support vector types natively
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddingsService {
|
||||
private readonly logger = new Logger(EmbeddingsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Validate that an embedding array contains only finite numbers
|
||||
* @param embedding Array to validate
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
private validateEmbedding(embedding: number[]): void {
|
||||
if (!Array.isArray(embedding)) {
|
||||
throw new Error("Embedding must be an array");
|
||||
}
|
||||
|
||||
if (
|
||||
!embedding.every((val) => typeof val === "number" && Number.isFinite(val))
|
||||
) {
|
||||
throw new Error("Embedding array must contain only finite numbers");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an embedding vector for content
|
||||
* @param params Embedding parameters
|
||||
* @returns ID of the created embedding
|
||||
*/
|
||||
async storeEmbedding(params: {
|
||||
workspaceId: string;
|
||||
content: string;
|
||||
embedding: number[];
|
||||
entityType?: EntityType;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
const { workspaceId, content, embedding, entityType, entityId, metadata } =
|
||||
params;
|
||||
|
||||
// Validate embedding array
|
||||
this.validateEmbedding(embedding);
|
||||
|
||||
if (embedding.length !== EMBEDDING_DIMENSION) {
|
||||
throw new Error(
|
||||
`Invalid embedding dimension: expected EMBEDDING_DIMENSION, got ${embedding.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const vectorString = `[${embedding.join(",")}]`;
|
||||
|
||||
try {
|
||||
const result = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
INSERT INTO memory_embeddings (
|
||||
id, workspace_id, content, embedding, entity_type, entity_id, metadata, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
${workspaceId}::uuid,
|
||||
${content},
|
||||
${vectorString}::vector,
|
||||
${entityType ?? null}::"EntityType",
|
||||
${entityId ?? null}::uuid,
|
||||
${JSON.stringify(metadata ?? {})}::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
RETURNING id::text
|
||||
`;
|
||||
|
||||
const embeddingId = result[0]?.id;
|
||||
if (!embeddingId) {
|
||||
throw new Error("Failed to get embedding ID from insert result");
|
||||
}
|
||||
this.logger.debug(
|
||||
`Stored embedding ${embeddingId} for workspace ${workspaceId}`
|
||||
);
|
||||
return embeddingId;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to store embedding", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar embeddings using cosine similarity
|
||||
* @param params Search parameters
|
||||
* @returns Array of similar embeddings sorted by similarity (descending)
|
||||
*/
|
||||
async findSimilar(params: {
|
||||
workspaceId: string;
|
||||
embedding: number[];
|
||||
limit?: number;
|
||||
threshold?: number;
|
||||
entityType?: EntityType;
|
||||
}): Promise<SimilarEmbedding[]> {
|
||||
const {
|
||||
workspaceId,
|
||||
embedding,
|
||||
limit = 10,
|
||||
threshold = 0.7,
|
||||
entityType,
|
||||
} = params;
|
||||
|
||||
// Validate embedding array
|
||||
this.validateEmbedding(embedding);
|
||||
|
||||
if (embedding.length !== EMBEDDING_DIMENSION) {
|
||||
throw new Error(
|
||||
`Invalid embedding dimension: expected EMBEDDING_DIMENSION, got ${embedding.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const vectorString = `[${embedding.join(",")}]`;
|
||||
|
||||
try {
|
||||
let results: SimilarEmbedding[];
|
||||
|
||||
if (entityType) {
|
||||
results = await this.prisma.$queryRaw<SimilarEmbedding[]>`
|
||||
SELECT
|
||||
id::text,
|
||||
content,
|
||||
1 - (embedding <=> ${vectorString}::vector) as similarity,
|
||||
entity_type as "entityType",
|
||||
entity_id::text as "entityId",
|
||||
metadata
|
||||
FROM memory_embeddings
|
||||
WHERE workspace_id = ${workspaceId}::uuid
|
||||
AND embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> ${vectorString}::vector) >= ${threshold}
|
||||
AND entity_type = ${entityType}::"EntityType"
|
||||
ORDER BY embedding <=> ${vectorString}::vector
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else {
|
||||
results = await this.prisma.$queryRaw<SimilarEmbedding[]>`
|
||||
SELECT
|
||||
id::text,
|
||||
content,
|
||||
1 - (embedding <=> ${vectorString}::vector) as similarity,
|
||||
entity_type as "entityType",
|
||||
entity_id::text as "entityId",
|
||||
metadata
|
||||
FROM memory_embeddings
|
||||
WHERE workspace_id = ${workspaceId}::uuid
|
||||
AND embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> ${vectorString}::vector) >= ${threshold}
|
||||
ORDER BY embedding <=> ${vectorString}::vector
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Found ${results.length} similar embeddings for workspace ${workspaceId}`
|
||||
);
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find similar embeddings", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete embeddings for a specific entity
|
||||
* @param params Entity identifiers
|
||||
* @returns Number of embeddings deleted
|
||||
*/
|
||||
async deleteByEntity(params: {
|
||||
workspaceId: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
}): Promise<number> {
|
||||
const { workspaceId, entityType, entityId } = params;
|
||||
|
||||
try {
|
||||
const result = await this.prisma.$executeRaw`
|
||||
DELETE FROM memory_embeddings
|
||||
WHERE workspace_id = ${workspaceId}::uuid
|
||||
AND entity_type = ${entityType}::"EntityType"
|
||||
AND entity_id = ${entityId}::uuid
|
||||
`;
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted ${result} embeddings for ${entityType}:${entityId} in workspace ${workspaceId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to delete embeddings", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all embeddings for a workspace
|
||||
* @param workspaceId Workspace ID
|
||||
* @returns Number of embeddings deleted
|
||||
*/
|
||||
async deleteByWorkspace(workspaceId: string): Promise<number> {
|
||||
try {
|
||||
const result = await this.prisma.$executeRaw`
|
||||
DELETE FROM memory_embeddings
|
||||
WHERE workspace_id = ${workspaceId}::uuid
|
||||
`;
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted ${result} embeddings for workspace ${workspaceId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to delete workspace embeddings", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embedding by ID
|
||||
* @param id Embedding ID
|
||||
* @returns Embedding or null if not found
|
||||
*/
|
||||
async getById(id: string): Promise<SimilarEmbedding | null> {
|
||||
try {
|
||||
const results = await this.prisma.$queryRaw<SimilarEmbedding[]>`
|
||||
SELECT
|
||||
id::text,
|
||||
content,
|
||||
0 as similarity,
|
||||
entity_type as "entityType",
|
||||
entity_id::text as "entityId",
|
||||
metadata
|
||||
FROM memory_embeddings
|
||||
WHERE id = ${id}::uuid
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return results.length > 0 ? (results[0] ?? null) : null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get embedding ${id}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/api/src/database/index.ts
Normal file
2
apps/api/src/database/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./database.module";
|
||||
export * from "./embeddings.service";
|
||||
13
apps/api/src/prisma/prisma.module.ts
Normal file
13
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
/**
|
||||
* Global Prisma module providing database access throughout the application
|
||||
* Marked as @Global() so PrismaService is available in all modules without importing
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
95
apps/api/src/prisma/prisma.service.ts
Normal file
95
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Prisma service that manages database connection lifecycle
|
||||
* Extends PrismaClient to provide connection management and health checks
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
log:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ["query", "info", "warn", "error"]
|
||||
: ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to database when NestJS module initializes
|
||||
*/
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.$connect();
|
||||
this.logger.log("Database connection established");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to connect to database", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database when NestJS module is destroyed
|
||||
*/
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
this.logger.log("Database connection closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for database connectivity
|
||||
* @returns true if database is accessible, false otherwise
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
await this.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error("Database health check failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database connection info for debugging
|
||||
* @returns Connection status and basic info
|
||||
*/
|
||||
async getConnectionInfo(): Promise<{
|
||||
connected: boolean;
|
||||
database?: string;
|
||||
version?: string;
|
||||
}> {
|
||||
try {
|
||||
const result = await this.$queryRaw<
|
||||
Array<{ current_database: string; version: string }>
|
||||
>`
|
||||
SELECT current_database(), version()
|
||||
`;
|
||||
|
||||
if (result && result.length > 0 && result[0]) {
|
||||
const dbVersion = result[0].version?.split(" ")[0];
|
||||
return {
|
||||
connected: true,
|
||||
database: result[0].current_database,
|
||||
...(dbVersion && { version: dbVersion }),
|
||||
};
|
||||
}
|
||||
|
||||
return { connected: false };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get connection info", error);
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user