Backend Development

Database

PostgreSQL database with Lucid ORM.

Nuda Kit uses PostgreSQL as its database and Lucid ORM for database operations. Lucid provides an Active Record implementation with full TypeScript support.

Configuration

Database connection is configured in config/database.ts and uses environment variables:

# backend/.env

DB_HOST=localhost
DB_PORT=5432
DB_USER=root
DB_PASSWORD=root
DB_DATABASE=app
The Docker Compose setup automatically creates a PostgreSQL container with these default credentials.

Models

Models are located in app/models/ and represent database tables:

ModelTablePurpose
UserusersUser accounts
TeamteamsTeams/organizations
TeamMemberteam_membersTeam membership (pivot)
InvitationinvitationsPending team invitations
PlanplansSubscription plans
SubscriptionsubscriptionsUser subscriptions
UserEmailVerificationTokenuser_email_verification_tokensEmail verification
UserPasswordResetTokenuser_password_reset_tokensPassword reset

Model Structure

Models extend BaseModel and use decorators to define columns and relationships:

import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'

export default class Team extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare name: string

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime
}

Relationships

Nuda Kit models use these relationship types:

TypeExample
hasOneUser → EmailVerificationToken
hasManyUser → Subscriptions
manyToManyUser ↔ Teams (through team_members)
belongsToInvitation → Team

Computed Properties

Use @computed() for derived values:

@column({ serializeAs: null })
declare avatar: string | null

@computed()
get avatarUrl() {
  if (!this.avatar) return null
  return `${env.get('APP_URL')}/uploads/${this.avatar}`
}

Hiding Fields

Use serializeAs: null to exclude fields from JSON output:

@column({ serializeAs: null })
declare password: string | null

@column({ serializeAs: null })
declare isDeleted: boolean

Migrations

Migrations are located in database/migrations/ and run in chronological order.

Existing Migrations

MigrationCreates
create_users_tableUser accounts
create_access_tokens_tableAuth tokens
create_user_email_verification_tokens_tableEmail verification
create_rate_limits_tableRate limiting
create_user_password_reset_tokens_tablePassword reset
create_teams_tableTeams
create_team_members_tableTeam membership
create_invitations_tableTeam invitations
create_subscriptions_tableSubscriptions
create_plans_tableSubscription plans

Running Migrations

# Run all pending migrations
node ace migration:run

# Rollback last batch
node ace migration:rollback

# Fresh database (rollback all + migrate)
node ace migration:fresh

# Check migration status
node ace migration:status

Creating Migrations

node ace make:migration create_posts_table

This creates a new migration file:

import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'posts'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')
      table.string('title').notNullable()
      table.text('content')
      table.integer('user_id').unsigned().references('users.id')
      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

Seeders

Seeders populate the database with initial or test data.

Running Seeders

# Run all seeders
node ace db:seed

# Run specific seeder
node ace db:seed --files=database/seeders/plan_seeder.ts

Plan Seeder

The PlanSeeder creates subscription plans:

import { BaseSeeder } from '@adonisjs/lucid/seeders'
import Plan, { PlanMode } from '#models/plan'

export default class extends BaseSeeder {
  async run() {
    await Plan.createMany([
      {
        name: 'Pro',
        key: 'pro',
        stripePriceId: 'price_xxx', // From Stripe
        mode: PlanMode.SUBSCRIPTION,
        isActive: true,
      },
      {
        name: 'Ultimate',
        key: 'ultimate',
        stripePriceId: 'price_xxx', // From Stripe
        mode: PlanMode.SUBSCRIPTION,
        isActive: true,
      },
    ])
  }
}

Creating Seeders

node ace make:seeder Post

Common Operations

Creating Records

// Single record
const user = await User.create({
  email: 'user@example.com',
  firstName: 'John',
})

// Multiple records
const teams = await Team.createMany([
  { name: 'Team A' },
  { name: 'Team B' },
])

Querying Records

// Find by ID
const user = await User.find(1)
const user = await User.findOrFail(1) // Throws if not found

// Find by column
const user = await User.findBy('email', 'user@example.com')

// Query builder
const users = await User.query()
  .where('isDeleted', false)
  .orderBy('createdAt', 'desc')
  .limit(10)

Updating Records

// Find and update
const user = await User.findOrFail(1)
user.firstName = 'Jane'
await user.save()

// Or use merge
await user.merge({ firstName: 'Jane' }).save()

Deleting Records

const user = await User.findOrFail(1)
await user.delete()

Working with Relationships

// Load relationship
const user = await User.query()
  .where('id', 1)
  .preload('teams')
  .firstOrFail()

// Access related records
user.teams.forEach(team => console.log(team.name))

// Create related record
await user.related('subscriptions').create({
  planId: 1,
  status: 'active',
})

// Attach many-to-many
await user.related('teams').attach({
  [teamId]: { role: 'member' }
})

// Detach many-to-many
await user.related('teams').detach([teamId])

Transactions

import db from '@adonisjs/lucid/services/db'

const team = await db.transaction(async (trx) => {
  const newTeam = await Team.create({ name: 'New Team' }, { client: trx })
  
  await user.related('teams').attach({
    [newTeam.id]: { role: 'owner' }
  }, trx)
  
  return newTeam
})

Database Schema

┌─────────────────┐     ┌─────────────────┐
│     users       │     │     teams       │
├─────────────────┤     ├─────────────────┤
│ id              │     │ id              │
│ first_name      │     │ name            │
│ last_name       │     │ avatar          │
│ email           │     │ created_at      │
│ password        │     │ updated_at      │
│ avatar          │     └────────┬────────┘
│ stripe_customer │              │
│ is_deleted      │              │
│ email_verified  │     ┌────────┴────────┐
│ created_at      │     │  team_members   │
│ updated_at      │     ├─────────────────┤
└────────┬────────┘     │ user_id (FK)    │
         │              │ team_id (FK)    │
         └──────────────│ role            │
                        │ default         │
                        └─────────────────┘

┌─────────────────┐     ┌─────────────────┐
│  invitations    │     │     plans       │
├─────────────────┤     ├─────────────────┤
│ id              │     │ id              │
│ team_id (FK)    │     │ name            │
│ email           │     │ key             │
│ role            │     │ stripe_price_id │
│ token           │     │ price           │
│ expires_at      │     │ mode            │
│ created_at      │     │ is_active       │
└─────────────────┘     └─────────────────┘

┌─────────────────┐
│  subscriptions  │
├─────────────────┤
│ id              │
│ user_id (FK)    │
│ stripe_id       │
│ stripe_status   │
│ stripe_price_id │
│ ends_at         │
└─────────────────┘

Resources