• SettleMintSettleMint
    • Introduction
    • Market pain points
    • Lifecycle platform approach
    • Platform capabilities
    • Use cases
    • Compliance & security
    • Glossary
    • Core component overview
    • Frontend layer
    • API layer
    • Blockchain layer
    • Data layer
    • Deployment layer
    • System architecture
    • Smart contracts
    • Application layer
    • Data & indexing
      • Blockchain indexing
      • Database model
    • Integration & operations
    • Performance
    • Quality
    • Getting started
    • Asset issuance
    • Platform operations
    • Troubleshooting
    • Development environment
    • Code structure
    • Smart contracts
    • API integration
    • Data model
    • Deployment & ops
    • Testing and QA
    • Developer FAQ
Back to the application
  1. Documentation
  2. Architecture
  3. Data & indexing

Database model - Application database schema and relationships

The Asset Tokenization Kit uses PostgreSQL with Drizzle ORM to store application-specific data that doesn't belong on-chain. This database layer handles user preferences, KYC workflows, session management, cached exchange rates, and other mutable state that requires fast updates and complex queries.

Schema organization

Database schemas are modular and domain-focused:

Rendering chart...

Schemas are defined in kit/dapp/src/lib/db/schemas/:

schemas/
├── auth.ts              # Authentication and session management
├── kyc.ts               # KYC/AML verification workflows
├── settings.ts          # User preferences and configuration
└── exchange-rates.ts    # Currency conversion rates cache

Authentication schema

Managed by Better Auth with custom extensions:

users

Core user accounts:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYUnique user identifier (UUID)
emailtextUNIQUE, NOT NULLUser email address
emailVerifiedbooleanNOT NULL, DEFAULT falseEmail verification status
nametextDisplay name
imagetextAvatar URL
createdAttimestampNOT NULLAccount creation time
updatedAttimestampNOT NULLLast profile update
walletAddresstextUNIQUEConnected Ethereum address
twoFactorEnabledbooleanDEFAULT falseMFA enabled flag

Indexes:

  • Primary key on id
  • Unique index on email
  • Unique index on walletAddress

sessions

Active authentication sessions:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYSession ID (stored in HTTP-only cookie)
userIdtextNOT NULL, FK → users.idUser owning this session
expiresAttimestampNOT NULLSession expiration time
ipAddresstextIP address when created
userAgenttextBrowser user agent
createdAttimestampNOT NULLSession start time

Indexes:

  • Primary key on id
  • Foreign key index on userId
  • Index on expiresAt for cleanup queries

Session lifecycle:

  • Created on login
  • Renewed on activity (sliding expiration)
  • Deleted on logout
  • Auto-expired by background job

accounts

OAuth provider connections:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYAccount linkage ID
userIdtextNOT NULL, FK → users.idUser this account belongs to
providertextNOT NULLOAuth provider (google, github, etc.)
providerAccountIdtextNOT NULLProvider's user ID
accessTokentextOAuth access token (encrypted)
refreshTokentextOAuth refresh token (encrypted)
expiresAttimestampToken expiration
scopetextGranted scopes
tokenTypetextToken type (bearer, etc.)

Indexes:

  • Primary key on id
  • Unique index on (provider, providerAccountId)
  • Foreign key index on userId

verification

One-time codes for email/phone verification:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYVerification request ID
identifiertextNOT NULLEmail or phone being verified
valuetextNOT NULLVerification code (hashed)
expiresAttimestampNOT NULLCode expiration (typically 15 min)
createdAttimestampNOT NULLRequest creation time

Indexes:

  • Primary key on id
  • Index on identifier for lookup
  • Index on expiresAt for cleanup

Codes are single-use and deleted after successful verification.

KYC schema

Manages identity verification workflows:

kyc_applications

Main KYC verification requests:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYApplication ID (UUID)
userIdtextNOT NULL, FK → users.idUser submitting KYC
statustextNOT NULLCurrent status (see enum below)
firstNametextNOT NULLLegal first name
lastNametextNOT NULLLegal last name
dateOfBirthdateNOT NULLBirth date for age verification
nationalitytextNOT NULLISO 3166-1 alpha-2 country code
residenceCountrytextNOT NULLISO 3166-1 alpha-2 country code
residenceAddressjsonbNOT NULLStructured address object
idDocumentTypetextNOT NULLpassport, drivers_license, national_id
idDocumentNumbertextNOT NULLDocument number
idDocumentExpirydateDocument expiration date
submittedAttimestampNOT NULLSubmission timestamp
reviewedAttimestampReview completion time
reviewedBytextFK → users.idAdmin who reviewed
rejectionReasontextReason if rejected
expiresAttimestampKYC expiration (typically 1 year)
createdAttimestampNOT NULLRecord creation
updatedAttimestampNOT NULLLast update

Status enum values:

  • draft - User started but didn't submit
  • submitted - Awaiting review
  • under_review - Compliance team reviewing
  • additional_info_required - Needs more documents
  • approved - KYC passed
  • rejected - KYC failed
  • expired - Approval period ended

Indexes:

  • Primary key on id
  • Foreign key index on userId
  • Index on status for filtering queues
  • Index on submittedAt for sorting
  • Index on expiresAt for expiration checks

kyc_documents

Uploaded identity documents:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYDocument ID (UUID)
applicationIdtextNOT NULL, FK → kyc_applications.idParent application
documentTypetextNOT NULLid_front, id_back, selfie, proof_of_address
fileNametextNOT NULLOriginal filename
fileSizeintegerNOT NULLSize in bytes
mimeTypetextNOT NULLMIME type (image/jpeg, etc.)
storageKeytextNOT NULLMinIO/S3 object key
uploadedAttimestampNOT NULLUpload timestamp

Indexes:

  • Primary key on id
  • Foreign key index on applicationId
  • Index on documentType for grouping

Files stored in MinIO with encryption at rest, access controlled via pre-signed URLs with short expiration.

kyc_status_history

Audit trail for KYC state changes:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYHistory entry ID
applicationIdtextNOT NULL, FK → kyc_applications.idApplication being tracked
fromStatustextPrevious status (null on creation)
toStatustextNOT NULLNew status
changedBytextFK → users.idUser who made the change
reasontextReason for change
metadatajsonbAdditional context
createdAttimestampNOT NULLChange timestamp

Indexes:

  • Primary key on id
  • Foreign key index on applicationId
  • Index on createdAt for timeline queries

Settings schema

User preferences and configuration:

user_settings

General user preferences:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYSettings ID
userIdtextUNIQUE, NOT NULL, FK → users.idUser owning settings
languagetextDEFAULT 'en'UI language (BCP 47 code)
currencytextDEFAULT 'USD'Display currency (ISO 4217)
timezonetextDEFAULT 'UTC'IANA timezone
themetextDEFAULT 'system'light, dark, system
dateFormattextDEFAULT 'YYYY-MM-DD'Preferred date format
numberFormattextDEFAULT 'en-US'Number formatting locale
createdAttimestampNOT NULLRecord creation
updatedAttimestampNOT NULLLast update

Indexes:

  • Primary key on id
  • Unique index on userId

notification_settings

Notification preferences per channel:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYSettings ID
userIdtextUNIQUE, NOT NULL, FK → users.idUser owning settings
emailEnabledbooleanDEFAULT trueEmail notifications enabled
emailTokenTransfersbooleanDEFAULT trueNotify on token transfers
emailComplianceUpdatesbooleanDEFAULT trueNotify on compliance changes
emailSystemAlertsbooleanDEFAULT trueNotify on system issues
pushEnabledbooleanDEFAULT falseBrowser push enabled
pushTokenTransfersbooleanDEFAULT truePush on transfers
pushComplianceUpdatesbooleanDEFAULT falsePush on compliance
createdAttimestampNOT NULLRecord creation
updatedAttimestampNOT NULLLast update

Indexes:

  • Primary key on id
  • Unique index on userId

Exchange rates schema

Caches external currency conversion rates:

exchange_rates

Current conversion rates:

ColumnTypeConstraintsDescription
idtextPRIMARY KEYRate pair ID
baseCurrencytextNOT NULLBase currency (ISO 4217)
quoteCurrencytextNOT NULLQuote currency (ISO 4217)
ratedecimal(20, 10)NOT NULLConversion rate (high precision)
sourcetextNOT NULLRate provider (coinbase, binance, etc.)
fetchedAttimestampNOT NULLWhen rate was fetched
expiresAttimestampNOT NULLRate expiration (typically 5 min)

Indexes:

  • Primary key on id
  • Unique index on (baseCurrency, quoteCurrency, source)
  • Index on expiresAt for cleanup

Rates refreshed by background job every 5 minutes.

Type safety with Drizzle

Schema definition

Example schema definition in TypeScript:

// kit/dapp/src/lib/db/schemas/kyc.ts
import { pgTable, text, timestamp, boolean, jsonb } from "drizzle-orm/pg-core";
import { users } from "./auth";

export const kycApplications = pgTable("kyc_applications", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  status: text("status", {
    enum: [
      "draft",
      "submitted",
      "under_review",
      "approved",
      "rejected",
      "expired",
    ],
  }).notNull(),
  firstName: text("first_name").notNull(),
  lastName: text("last_name").notNull(),
  dateOfBirth: text("date_of_birth").notNull(), // Stored as ISO 8601 string
  nationality: text("nationality").notNull(),
  residenceCountry: text("residence_country").notNull(),
  residenceAddress: jsonb("residence_address")
    .$type<{
      street: string;
      city: string;
      state?: string;
      postalCode: string;
      country: string;
    }>()
    .notNull(),
  submittedAt: timestamp("submitted_at"),
  reviewedAt: timestamp("reviewed_at"),
  reviewedBy: text("reviewed_by").references(() => users.id),
  rejectionReason: text("rejection_reason"),
  expiresAt: timestamp("expires_at"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

Type inference

Drizzle automatically infers TypeScript types:

import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
import { kycApplications } from "./schemas/kyc";

// Type for querying
type KYCApplication = InferSelectModel<typeof kycApplications>;

// Type for inserting
type NewKYCApplication = InferInsertModel<typeof kycApplications>;

// Use in queries
const application: KYCApplication = await db
  .select()
  .from(kycApplications)
  .where(eq(kycApplications.id, applicationId))
  .get();

Query builders

Type-safe query construction:

import { db } from "@/lib/db";
import { kycApplications, kycDocuments } from "@/lib/db/schemas/kyc";
import { eq, and, desc } from "drizzle-orm";

// Simple select
const pending = await db
  .select()
  .from(kycApplications)
  .where(eq(kycApplications.status, "submitted"))
  .orderBy(desc(kycApplications.submittedAt));

// Join with related table
const withDocuments = await db
  .select({
    application: kycApplications,
    documents: kycDocuments,
  })
  .from(kycApplications)
  .leftJoin(kycDocuments, eq(kycApplications.id, kycDocuments.applicationId))
  .where(eq(kycApplications.userId, userId));

// Transaction
await db.transaction(async (tx) => {
  await tx
    .update(kycApplications)
    .set({ status: "approved", reviewedAt: new Date() })
    .where(eq(kycApplications.id, applicationId));

  await tx.insert(kycStatusHistory).values({
    applicationId,
    fromStatus: "submitted",
    toStatus: "approved",
    changedBy: reviewerId,
  });
});

Migrations

Generation

Create migrations from schema changes:

cd kit/dapp

# Generate migration
bun run db:generate

Drizzle compares current schema to database and generates SQL:

-- drizzle/0008_add_kyc_expiration.sql
ALTER TABLE "kyc_applications" ADD COLUMN "expires_at" timestamp;
CREATE INDEX "kyc_applications_expires_at_idx" ON "kyc_applications" ("expires_at");

Application

Apply migrations:

# Apply all pending migrations
bun run db:migrate

# Apply specific migration
bun run db:migrate --to 0008

Migrations are idempotent and safe to re-run.

Rollback

Manual rollback by reverting migration SQL:

-- Manually create rollback script
ALTER TABLE "kyc_applications" DROP COLUMN "expires_at";
DROP INDEX "kyc_applications_expires_at_idx";

Production rollbacks require careful testing to avoid data loss.

Performance optimization

Indexing strategy

Indexes created for common query patterns:

// Composite index for common filter
export const kycApplications = pgTable(
  "kyc_applications",
  {
    // ... columns
  },
  (table) => ({
    statusSubmittedIdx: index("kyc_status_submitted_idx").on(
      table.status,
      table.submittedAt
    ),
  })
);

Index guidelines:

  • Primary keys automatically indexed
  • Foreign keys need explicit indexes
  • Add indexes for WHERE, ORDER BY, JOIN columns
  • Composite indexes for multi-column queries
  • Don't over-index (slows writes)

Connection pooling

PgBouncer configuration:

[databases]
atk = host=postgres port=5432 dbname=atk

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3

Pool sizing:

  • Transaction mode: Connection reused after each transaction
  • Pool size: 25 connections per database
  • Max clients: 1000 simultaneous connections

Query optimization

Use EXPLAIN ANALYZE to optimize slow queries:

EXPLAIN ANALYZE
SELECT *
FROM kyc_applications
WHERE status = 'submitted'
  AND submitted_at > NOW() - INTERVAL '7 days'
ORDER BY submitted_at DESC
LIMIT 20;

Optimization techniques:

  • Add indexes for WHERE/ORDER BY columns
  • Use covering indexes to avoid table lookups
  • Limit result sets with LIMIT/OFFSET
  • Avoid SELECT * in production queries

Backup and recovery

Continuous archiving

PostgreSQL WAL (Write-Ahead Log) archiving:

# postgresql.conf
wal_level = replica
archive_mode = on
archive_command = 'cp %p /backup/wal/%f'
archive_timeout = 300  # Force WAL switch every 5 minutes

Point-in-time recovery

Restore to specific timestamp:

# Restore base backup
pg_restore -d atk /backup/base/latest.dump

# Apply WAL logs up to target time
recovery_target_time = '2025-01-15 14:30:00'

Recovery time: <1 hour for typical database size.

Monitoring

Query performance

Track slow queries:

-- Enable slow query logging
ALTER DATABASE atk SET log_min_duration_statement = 100;  -- Log queries &gt;100ms

-- View slow queries
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
WHERE mean_time > 100
ORDER BY mean_time DESC
LIMIT 20;

Table statistics

Monitor table growth:

SELECT
  schemaname,
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size,
  n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;

Connection monitoring

Track connection usage:

SELECT
  state,
  COUNT(*) AS connections
FROM pg_stat_activity
WHERE datname = 'atk'
GROUP BY state;

Security considerations

Encryption

  • At rest: PostgreSQL encrypted volumes via LUKS/dm-crypt
  • In transit: SSL/TLS required for all connections
  • Application secrets: Better Auth handles encryption of OAuth tokens

Access control

Row-level security for multi-tenant isolation:

-- Enable RLS on user-specific tables
ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only access their own settings
CREATE POLICY user_settings_isolation ON user_settings
  FOR ALL
  USING (user_id = current_setting('app.user_id'));

Audit logging

Database-level audit trail:

CREATE TABLE audit_log (
  id SERIAL PRIMARY KEY,
  table_name TEXT NOT NULL,
  operation TEXT NOT NULL,  -- INSERT, UPDATE, DELETE
  old_values JSONB,
  new_values JSONB,
  changed_by TEXT NOT NULL,
  changed_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Trigger on sensitive tables
CREATE TRIGGER kyc_audit_trigger
  AFTER INSERT OR UPDATE OR DELETE ON kyc_applications
  FOR EACH ROW EXECUTE FUNCTION log_audit_event();

Related documentation

  • Blockchain indexing - TheGraph subgraph for on-chain data
  • Backend API - ORPC procedures using database
  • Scalability patterns - Database optimization
Blockchain indexing
External integrations
llms-full.txt

On this page

Schema organizationAuthentication schemauserssessionsaccountsverificationKYC schemakyc_applicationskyc_documentskyc_status_historySettings schemauser_settingsnotification_settingsExchange rates schemaexchange_ratesType safety with DrizzleSchema definitionType inferenceQuery buildersMigrationsGenerationApplicationRollbackPerformance optimizationIndexing strategyConnection poolingQuery optimizationBackup and recoveryContinuous archivingPoint-in-time recoveryMonitoringQuery performanceTable statisticsConnection monitoringSecurity considerationsEncryptionAccess controlAudit loggingRelated documentation