DEV Community

Cover image for ACAI — Chapter 37: Database Connection, ORM Configuration, Migrations & First Working Operations
Black Shadow Team ©
Black Shadow Team ©

Posted on

ACAI — Chapter 37: Database Connection, ORM Configuration, Migrations & First Working Operations

#ai

37.1 Introduction

The previous chapter established the database stack and selected PostgreSQL as the primary relational database. This chapter converts that architectural decision into an executable backend implementation.

The objective is not merely to create tables. A production-grade AI platform requires a controlled mechanism for:

  • connecting application code to the database;
  • defining schemas;
  • validating relationships;
  • creating and versioning migrations;
  • inserting initial data;
  • reading and updating records safely;
  • executing transactions;
  • separating development, staging, and production environments;
  • preventing database credentials from reaching the browser;
  • providing a maintainable data-access layer.

For the implementation described in this chapter, Prisma is used as the ORM layer.

The architecture becomes:

Next.js Application
        │
        ▼
Application / Service Layer
        │
        ▼
Repository / Data Access Layer
        │
        ▼
Prisma ORM
        │
        ▼
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The browser must never communicate directly with PostgreSQL.


37.2 Implementation Objectives

At the end of this chapter, the system should support the following flow:

User Request
     │
     ▼
API Route / Server Action
     │
     ▼
Validation
     │
     ▼
Service Layer
     │
     ▼
Repository
     │
     ▼
Prisma
     │
     ▼
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The initial implementation will establish:

  1. PostgreSQL connection
  2. Prisma installation
  3. Prisma schema
  4. Database migration
  5. Prisma Client generation
  6. Database seed
  7. Database singleton
  8. Repository functions
  9. CRUD operations
  10. Transaction handling
  11. API endpoint
  12. Error handling
  13. Development database verification

37.3 Project Structure

A recommended project structure is:

acai/
│
├── src/
│   ├── app/
│   │   └── api/
│   │       └── users/
│   │           └── route.ts
│   │
│   ├── lib/
│   │   ├── prisma.ts
│   │   └── validation.ts
│   │
│   ├── repositories/
│   │   └── user.repository.ts
│   │
│   └── services/
│       └── user.service.ts
│
├── prisma/
│   ├── schema.prisma
│   └── seed.ts
│
├── .env
├── .env.example
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

This separation is important.

The API route should not contain large amounts of database logic.

Instead:

API
 ↓
Service
 ↓
Repository
 ↓
Prisma
Enter fullscreen mode Exit fullscreen mode

This makes the system easier to test and maintain.


37.4 Installing Prisma

From the project root:

npm install prisma @prisma/client
Enter fullscreen mode Exit fullscreen mode

If PowerShell prevents the npm command from running, use:

npm.cmd install prisma @prisma/client
Enter fullscreen mode Exit fullscreen mode

Alternatively, open Command Prompt (cmd.exe) and execute the normal command.

Initialize Prisma:

npx prisma init
Enter fullscreen mode Exit fullscreen mode

If necessary:

npx.cmd prisma init
Enter fullscreen mode Exit fullscreen mode

This creates:

prisma/
└── schema.prisma

.env
Enter fullscreen mode Exit fullscreen mode

37.5 PostgreSQL Database

Create a PostgreSQL database for development.

Example database name:

acai_dev
Enter fullscreen mode Exit fullscreen mode

The connection string conceptually looks like:

postgresql://USERNAME:PASSWORD@HOST:PORT/DATABASE
Enter fullscreen mode Exit fullscreen mode

For a local installation, an example might be:

postgresql://postgres:YOUR_PASSWORD@localhost:5432/acai_dev
Enter fullscreen mode Exit fullscreen mode

The actual password must never be committed to Git.


37.6 Environment Configuration

The .env file should contain the database connection:

DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/acai_dev"
Enter fullscreen mode Exit fullscreen mode

Do not place the real password into source-controlled files.

Create .env.example:

DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DATABASE"
Enter fullscreen mode Exit fullscreen mode

The .env.example file is safe to commit because it contains a placeholder.

The real .env should be excluded from Git.

Example .gitignore:

node_modules
.next
.env
.env.local
.env.production
Enter fullscreen mode Exit fullscreen mode

37.7 Initial Prisma Schema

The initial schema should remain manageable.

A practical first implementation can start with:

  • User
  • Project
  • Conversation
  • Message
  • File
  • AuditLog

Additional models can be introduced through later migrations.

Example:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserStatus {
  ACTIVE
  SUSPENDED
  DELETED
}

enum ProjectStatus {
  ACTIVE
  ARCHIVED
  DELETED
}

enum ConversationStatus {
  ACTIVE
  ARCHIVED
  DELETED
}

enum MessageRole {
  SYSTEM
  USER
  ASSISTANT
  TOOL
}

enum FileStatus {
  UPLOADING
  UPLOADED
  PROCESSING
  READY
  FAILED
  DELETED
}

model User {
  id           String       @id @default(uuid())
  email        String       @unique
  name         String?
  status       UserStatus   @default(ACTIVE)

  projects     Project[]
  conversations Conversation[]
  files        File[]
  auditLogs    AuditLog[]

  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt
}

model Project {
  id           String        @id @default(uuid())
  name         String
  description  String?
  status       ProjectStatus  @default(ACTIVE)

  ownerId      String
  owner        User          @relation(fields: [ownerId], references: [id], onDelete: Cascade)

  conversations Conversation[]
  files         File[]

  createdAt    DateTime      @default(now())
  updatedAt    DateTime      @updatedAt

  @@index([ownerId])
  @@index([status])
}

model Conversation {
  id           String             @id @default(uuid())
  title        String?
  status       ConversationStatus @default(ACTIVE)

  userId       String
  user          User              @relation(fields: [userId], references: [id], onDelete: Cascade)

  projectId    String?
  project      Project?           @relation(fields: [projectId], references: [id], onDelete: SetNull)

  messages     Message[]

  createdAt    DateTime           @default(now())
  updatedAt    DateTime           @updatedAt

  @@index([userId])
  @@index([projectId])
}

model Message {
  id             String       @id @default(uuid())
  role           MessageRole
  content        String

  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)

  metadata       Json?

  createdAt      DateTime     @default(now())

  @@index([conversationId])
}

model File {
  id          String     @id @default(uuid())
  name        String
  mimeType    String
  sizeBytes   BigInt
  status      FileStatus @default(UPLOADING)

  ownerId     String
  owner       User       @relation(fields: [ownerId], references: [id], onDelete: Cascade)

  projectId   String?
  project     Project?   @relation(fields: [projectId], references: [id], onDelete: SetNull)

  storageKey  String?

  metadata    Json?

  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([ownerId])
  @@index([projectId])
  @@index([status])
}

model AuditLog {
  id          String   @id @default(uuid())

  userId      String?
  user        User?    @relation(fields: [userId], references: [id], onDelete: SetNull)

  action      String
  resource    String?
  resourceId  String?
  metadata    Json?

  createdAt   DateTime @default(now())

  @@index([userId])
  @@index([action])
  @@index([resource])
  @@index([createdAt])
}
Enter fullscreen mode Exit fullscreen mode

This schema represents the first executable database layer.


37.8 Understanding Relationships

The most important relationships are:

User
 │
 ├── Projects
 │
 ├── Conversations
 │      │
 │      └── Messages
 │
 ├── Files
 │
 └── Audit Logs
Enter fullscreen mode Exit fullscreen mode

A user can own multiple projects.

A project can contain multiple conversations.

A conversation can contain multiple messages.

A user can own multiple files.

Audit records can optionally identify the user responsible for an action.


37.9 Running the First Migration

After configuring the schema:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Or:

npx.cmd prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Prisma will create a migration directory similar to:

prisma/
└── migrations/
    └── 20260904_init/
        └── migration.sql
Enter fullscreen mode Exit fullscreen mode

The exact generated folder name may differ.

The migration is the version-controlled history of database structure.


37.10 Why Migrations Matter

Without migrations, developers may manually change databases and eventually lose track of what changed.

With migrations:

Migration 001
     ↓
Migration 002
     ↓
Migration 003
     ↓
Migration 004
Enter fullscreen mode Exit fullscreen mode

Every environment can progressively reach the same schema state.

A migration should be treated as part of the application's source code.


37.11 Prisma Client

After the schema has been processed, Prisma Client provides typed database access.

Example:

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

However, creating a new Prisma client repeatedly during development can create too many database connections.

Therefore the application should use a singleton pattern.


37.12 Prisma Singleton

Create:

src/lib/prisma.ts
Enter fullscreen mode Exit fullscreen mode

Code:

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
Enter fullscreen mode Exit fullscreen mode

The application can now import:

import { prisma } from "@/lib/prisma";
Enter fullscreen mode Exit fullscreen mode

This creates a consistent database access point.


37.13 Repository Layer

Create:

src/repositories/user.repository.ts
Enter fullscreen mode Exit fullscreen mode

Example:

import { prisma } from "@/lib/prisma";

export async function createUser(
  email: string,
  name?: string
) {
  return prisma.user.create({
    data: {
      email,
      name,
    },
  });
}

export async function findUserById(id: string) {
  return prisma.user.findUnique({
    where: {
      id,
    },
  });
}

export async function findUserByEmail(email: string) {
  return prisma.user.findUnique({
    where: {
      email,
    },
  });
}

export async function updateUser(
  id: string,
  data: {
    name?: string;
  }
) {
  return prisma.user.update({
    where: {
      id,
    },
    data,
  });
}
Enter fullscreen mode Exit fullscreen mode

The repository abstracts database operations.

The API does not need to know how Prisma queries PostgreSQL.


37.14 Service Layer

Create:

src/services/user.service.ts
Enter fullscreen mode Exit fullscreen mode

Example:

import {
  createUser,
  findUserByEmail,
} from "@/repositories/user.repository";

export async function registerUser(
  email: string,
  name?: string
) {
  const existingUser =
    await findUserByEmail(email);

  if (existingUser) {
    throw new Error("User already exists");
  }

  return createUser(email, name);
}
Enter fullscreen mode Exit fullscreen mode

The service layer contains business rules.

The repository contains database access.

This distinction is important.

Repository = HOW data is stored/retrieved

Service = WHY and WHEN an operation is allowed
Enter fullscreen mode Exit fullscreen mode

37.15 API Route

Create:

src/app/api/users/route.ts
Enter fullscreen mode Exit fullscreen mode

Example:

import { NextResponse } from "next/server";
import { registerUser } from "@/services/user.service";

export async function POST(request: Request) {
  try {
    const body = await request.json();

    const email = body.email;
    const name = body.name;

    if (!email || typeof email !== "string") {
      return NextResponse.json(
        {
          error: "Valid email is required",
        },
        {
          status: 400,
        }
      );
    }

    const user = await registerUser(
      email,
      name
    );

    return NextResponse.json(
      {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      {
        status: 201,
      }
    );
  } catch (error) {
    console.error(error);

    return NextResponse.json(
      {
        error: "Unable to create user",
      },
      {
        status: 500,
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The browser now communicates with the API rather than directly with PostgreSQL.


37.16 Database Read Operation

A server-side operation can retrieve a user:

import { prisma } from "@/lib/prisma";

const user = await prisma.user.findUnique({
  where: {
    email: "user@example.com",
  },
});
Enter fullscreen mode Exit fullscreen mode

The result can then be processed by the application.


37.17 Database Update Operation

Example:

const updatedUser =
  await prisma.user.update({
    where: {
      id: userId,
    },
    data: {
      name: "Updated Name",
    },
  });
Enter fullscreen mode Exit fullscreen mode

Only the intended fields should be updated.


37.18 Database Delete Operation

A hard delete can be performed using:

await prisma.user.delete({
  where: {
    id: userId,
  },
});
Enter fullscreen mode Exit fullscreen mode

However, for important records, hard deletion should be considered carefully.

For many production systems, a soft-delete strategy is preferable.

For example:

status = DELETED
Enter fullscreen mode Exit fullscreen mode

rather than immediately destroying the record.


37.19 Transactions

Some operations require multiple database changes to succeed together.

For example:

Create Project
     +
Create Audit Log
Enter fullscreen mode Exit fullscreen mode

If project creation succeeds but audit logging fails, the system may become inconsistent.

A transaction can solve this.

Example:

const result = await prisma.$transaction(
  async (tx) => {
    const project = await tx.project.create({
      data: {
        name: "Research Project",
        ownerId: userId,
      },
    });

    await tx.auditLog.create({
      data: {
        userId,
        action: "PROJECT_CREATED",
        resource: "project",
        resourceId: project.id,
      },
    });

    return project;
  }
);
Enter fullscreen mode Exit fullscreen mode

The intended principle is:

Transaction starts
       ↓
Operation A
       ↓
Operation B
       ↓
Success
       ↓
Commit
Enter fullscreen mode Exit fullscreen mode

If a required operation fails:

Transaction starts
       ↓
Operation A
       ↓
Operation B fails
       ↓
Rollback
Enter fullscreen mode Exit fullscreen mode

This prevents partially completed database workflows.


37.20 Seed Data

Development environments benefit from predictable sample data.

Create:

prisma/seed.ts
Enter fullscreen mode Exit fullscreen mode

Example:

import {
  PrismaClient,
  UserStatus,
} from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.upsert({
    where: {
      email: "demo@example.com",
    },
    update: {},
    create: {
      email: "demo@example.com",
      name: "Demo User",
      status: UserStatus.ACTIVE,
    },
  });

  const project =
    await prisma.project.create({
      data: {
        name: "Demo Project",
        description:
          "Development demonstration project",
        ownerId: user.id,
      },
    });

  const conversation =
    await prisma.conversation.create({
      data: {
        title: "Demo Conversation",
        userId: user.id,
        projectId: project.id,
      },
    });

  await prisma.message.create({
    data: {
      conversationId: conversation.id,
      role: "USER",
      content: "Hello ACAI.",
    },
  });

  console.log("Seed completed.");
}

main()
  .catch((error) => {
    console.error(error);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });
Enter fullscreen mode Exit fullscreen mode

The seed system should only contain non-sensitive development data.

Do not insert real user credentials, API keys, payment information, or private files.


37.21 Database Inspection

Prisma provides a visual database interface:

npx prisma studio
Enter fullscreen mode Exit fullscreen mode

This allows developers to inspect records during development.

For example:

User
 ├── demo@example.com
 │
Project
 └── Demo Project
     │
Conversation
     └── Demo Conversation
         │
Message
         └── Hello ACAI.
Enter fullscreen mode Exit fullscreen mode

This is particularly useful when learning database relationships.


37.22 Development Workflow

The recommended workflow is:

1. Edit schema.prisma
        ↓
2. Create migration
        ↓
3. Apply migration
        ↓
4. Generate Prisma Client
        ↓
5. Seed development data
        ↓
6. Start application
        ↓
7. Test API
        ↓
8. Inspect database
Enter fullscreen mode Exit fullscreen mode

Typical development commands:

npx prisma migrate dev
Enter fullscreen mode Exit fullscreen mode
npx prisma generate
Enter fullscreen mode Exit fullscreen mode
npx prisma studio
Enter fullscreen mode Exit fullscreen mode

The exact seed command/configuration should follow the Prisma version installed in the project.


37.23 Database Security Boundary

The architecture must enforce:

Browser
   │
   │ HTTPS
   ▼
Next.js API
   │
   │ Server-side credentials
   ▼
Prisma
   │
   ▼
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Never:

Browser
   │
   ▼
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Never expose:

DATABASE_URL
POSTGRES_PASSWORD
DATABASE_USERNAME
PRIVATE_DATABASE_HOST
Enter fullscreen mode Exit fullscreen mode

to client-side JavaScript.


37.24 Input Validation

Database constraints are not a replacement for application validation.

For example:

Application validation
        +
Database constraints
        +
Authorization
Enter fullscreen mode Exit fullscreen mode

should work together.

An email field should be validated before attempting the database operation.

A project ID should be checked for ownership before allowing modifications.

A user should not be able to modify another user's project merely because they know its identifier.


37.25 Authorization

A request such as:

PATCH /api/projects/123
Enter fullscreen mode Exit fullscreen mode

must not mean:

"If project 123 exists, modify it."
Enter fullscreen mode Exit fullscreen mode

Instead:

Authenticate user
      ↓
Find project
      ↓
Check ownership/access
      ↓
Validate requested change
      ↓
Update project
      ↓
Write audit event
Enter fullscreen mode Exit fullscreen mode

This prevents a common class of access-control failures.


37.26 Audit Logging

Security-sensitive operations should be auditable.

Examples include:

USER_CREATED
PROJECT_CREATED
PROJECT_UPDATED
PROJECT_DELETED
FILE_UPLOADED
FILE_DELETED
API_KEY_CREATED
API_KEY_REVOKED
MODEL_REQUEST_STARTED
MODEL_REQUEST_COMPLETED
MODEL_REQUEST_FAILED
ADMIN_ACTION
Enter fullscreen mode Exit fullscreen mode

Audit logs should avoid storing unnecessary sensitive information.

For example, do not place passwords or secret API keys inside:

metadata
Enter fullscreen mode Exit fullscreen mode

37.27 Error Handling

Database errors should not be exposed directly to end users.

Bad:

{
  "error": "PrismaClientKnownRequestError: ..."
}
Enter fullscreen mode Exit fullscreen mode

Better:

{
  "error": "Unable to complete the requested operation."
}
Enter fullscreen mode Exit fullscreen mode

Detailed technical errors should be logged securely on the server.

The user receives a safe error message.


37.28 Development, Staging and Production

The database environment should be separated:

Development
    ↓
PostgreSQL Development DB

Staging
    ↓
PostgreSQL Staging DB

Production
    ↓
PostgreSQL Production DB
Enter fullscreen mode Exit fullscreen mode

Never use the production database as a testing playground.

A developer should not run destructive reset commands against production.


37.29 Database Reset During Development

During early development, developers may need to reset the local database.

This is acceptable only for disposable development data.

Production databases must use controlled migration and recovery procedures instead.

The fundamental rule is:

Development data = disposable

Production data = protected
Enter fullscreen mode Exit fullscreen mode

37.30 First End-to-End Test

Start PostgreSQL.

Verify:

DATABASE_URL
Enter fullscreen mode Exit fullscreen mode

Then run migrations.

Start the Next.js application:

npm run dev
Enter fullscreen mode Exit fullscreen mode

If PowerShell blocks npm scripts:

npm.cmd run dev
Enter fullscreen mode Exit fullscreen mode

Then send a POST request to:

/api/users
Enter fullscreen mode Exit fullscreen mode

with:

{
  "email": "test@example.com",
  "name": "Test User"
}
Enter fullscreen mode Exit fullscreen mode

The expected conceptual flow is:

HTTP Request
     ↓
Next.js API
     ↓
Validation
     ↓
User Service
     ↓
User Repository
     ↓
Prisma
     ↓
PostgreSQL
     ↓
Created User
     ↓
JSON Response
Enter fullscreen mode Exit fullscreen mode

37.31 Verification Checklist

Before continuing to the next chapter, verify:

[ ] PostgreSQL is running
[ ] DATABASE_URL exists
[ ] Prisma is installed
[ ] schema.prisma exists
[ ] Migration completed
[ ] Prisma Client generated
[ ] Database tables exist
[ ] Seed data can be inserted
[ ] Prisma Studio opens
[ ] Prisma singleton works
[ ] Repository works
[ ] Service works
[ ] API route works
[ ] Transaction example works
[ ] .env is ignored by Git
[ ] No credentials are exposed to the browser
Enter fullscreen mode Exit fullscreen mode

37.32 Failure Isolation

If the system fails, diagnose from the bottom upward.

Case 1 — PostgreSQL unavailable

Check:

PostgreSQL service
     ↓
Host
     ↓
Port
Enter fullscreen mode Exit fullscreen mode

Case 2 — Authentication failure

Check:

Username
Password
Database
Enter fullscreen mode Exit fullscreen mode

Case 3 — Prisma migration failure

Check:

schema.prisma
DATABASE_URL
database permissions
existing schema
Enter fullscreen mode Exit fullscreen mode

Case 4 — API failure

Check:

API route
     ↓
Service
     ↓
Repository
     ↓
Prisma
Enter fullscreen mode Exit fullscreen mode

Case 5 — Client-side database error

This usually indicates an architectural mistake.

Database access should remain server-side.


37.33 Architectural Result

At the end of Chapter 37, the architecture has moved from conceptual design to a working implementation:

                 ACAI
                   │
          ┌────────┴────────┐
          │                 │
       Frontend           Backend
          │                 │
          │             API Routes
          │                 │
          │             Services
          │                 │
          │            Repositories
          │                 │
          │              Prisma
          │                 │
          └────────── PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The database is now a real executable component rather than merely a design document.


37.34 Security Principle

The most important principle introduced in this chapter is separation of responsibility.

UI
 ↓
API
 ↓
Validation
 ↓
Authorization
 ↓
Service
 ↓
Repository
 ↓
ORM
 ↓
Database
Enter fullscreen mode Exit fullscreen mode

Each layer has a defined responsibility.

This makes the system easier to audit, test, secure, and extend.


37.35 Conclusion

Chapter 37 established the first concrete database implementation for ACAI.

The system now has:

  • PostgreSQL as the relational database;
  • Prisma as the ORM;
  • version-controlled migrations;
  • typed database access;
  • a reusable Prisma singleton;
  • repository abstraction;
  • service-layer business logic;
  • API integration;
  • transactions;
  • development seed data;
  • database inspection;
  • security boundaries;
  • environment separation;
  • audit logging foundations.

This provides the foundation required for the next major subsystem.

The next chapter will extend the database layer into authentication, sessions, user identity, authorization, ownership checks, role-based access control, and secure API access, while keeping database credentials and privileged operations strictly server-side.

END OF CHAPTER 37

Top comments (0)