Database Versioning Guide

CONA implements four different tracking approaches for database versioning, each designed for specific use cases. This guide provides detailed implementation guidelines for each approach.

1. Audit Trail Versioning

Used for: entities, addresses, organization details, payment methods

Implementation

The Audit Trail approach creates a complete history of all changes to a record by maintaining a parent-child relationship with a root record. Each update creates a new record while preserving the history through these relationships.

// Schema example
model Entity {
  id                String    @id @default(cuid())
  name              String
  // Other fields
  created_at        DateTime  @default(now())
  updated_at        DateTime  @updatedAt
  modified_by_actor_id String?
  label             String
  revision          Int       @default(1)
  is_current        Boolean   @default(true)
  root_entity_id    String?
  is_deleted        Boolean   @default(false)
  deleted_at        DateTime?
  deleted_by_actor_id String?
  deleted_reason    String?
  
  // Relations
  modified_by       actors?   @relation("EntitiesModifiedBy", fields: [modified_by_actor_id], references: [id])
  deleted_by        actors?   @relation("EntitiesDeletedBy", fields: [deleted_by_actor_id], references: [id])
  root_entity       Entity?   @relation("EntityVersions", fields: [root_entity_id], references: [id])
  child_entities    Entity[]  @relation("EntityVersions")
}

Implementation Pattern

"use server";

import { prisma } from "@cona/database";
import { auth } from "lib/auth";

export async function updateEntity(id: string, data: EntityUpdateData) {
  // Get current user
  const user = await auth.getCurrentUser();
  
  // Create transaction to handle versioning with serializable isolation
  const result = await prisma.$transaction(async (tx) => {
    // Fetch current entity record - serializable isolation level prevents concurrent modifications
    const currentEntity = await tx.entity.findUnique({
      where: { 
        id, 
        is_current: true 
      }
    });
    
    if (!currentEntity) throw new Error("Entity not found");
    
    // Determine root entity ID
    const rootEntityId = currentEntity.root_entity_id || currentEntity.id;
    
    // Mark current entity as not current
    await tx.entity.update({
      where: { id },
      data: {
        is_current: false,
        modified_by_actor_id: user.id
      }
    });
    
    // Get the latest revision number to ensure we're creating the correct next revision
    const latestRevision = await tx.entity.findFirst({
      where: {
        OR: [
          { id: rootEntityId },
          { root_entity_id: rootEntityId }
        ]
      },
      orderBy: { revision: 'desc' },
      select: { revision: true }
    });
    
    const nextRevision = (latestRevision?.revision || currentEntity.revision) + 1;
    
    // Create new entity version with the correct revision number
    const newEntity = await tx.entity.create({
      data: {
        ...currentEntity,
        ...data,
        id: undefined, // Let the database generate a new ID
        revision: nextRevision,
        is_current: true,
        root_entity_id: rootEntityId,
        modified_by_actor_id: user.id
      }
    });
    
    return newEntity;
  }, {
    isolationLevel: 'Serializable' // Ensures the highest isolation level for the transaction
  });
  
  return result;
}

Query Patterns

// Get current entity version
export async function getCurrentEntity(id: string) {
  // Get the entity if it's the current version
  const entity = await prisma.entity.findFirst({
    where: { 
      id,
      is_current: true 
    }
  });
  
  if (entity) return entity;
  
  // If not current, find the current version from the root
  return prisma.entity.findFirst({
    where: {
      OR: [
        { root_entity_id: id, is_current: true },
        { id, child_entities: { some: { is_current: true } } }
      ]
    }
  });
}

// Get entity history
export async function getEntityHistory(id: string) {
  // First determine if this is a root entity or a child entity
  const entity = await prisma.entity.findUnique({
    where: { id },
    include: { root_entity: true }
  });
  
  if (!entity) return [];
  
  // If this is a child entity, get the root ID
  const rootId = entity.root_entity_id || entity.id;
  
  // Get all versions of this entity
  return prisma.entity.findMany({
    where: {
      OR: [
        { id: rootId },
        { root_entity_id: rootId }
      ]
    },
    orderBy: { revision: 'desc' }
  });
}

// Get entity at specific revision
export async function getEntityAtRevision(id: string, revision: number) {
  // First determine if this is a root entity or a child entity
  const entity = await prisma.entity.findUnique({
    where: { id }
  });
  
  if (!entity) return null;
  
  // If this is a child entity, get the root ID
  const rootId = entity.root_entity_id || entity.id;
  
  // Find the specific revision
  return prisma.entity.findFirst({
    where: {
      OR: [
        { id: rootId, revision },
        { root_entity_id: rootId, revision }
      ]
    }
  });
}

2. Activity Log Tracking

Used for: product_variants, documents, inventory adjustments, price changes

Implementation

Activity Log Tracking records specific notable activities rather than capturing every change to a record. In CONA, this is implemented using a centralized activity_logs table that tracks changes across different entity types.

// Schema example
model activity_logs {
  id                   String      @id @default(cuid())
  timestamp            DateTime    @default(now())
  action               String
  actor_id             String
  details              String?
  created_at           DateTime    @default(now())
  updated_at           DateTime    @updatedAt
  documents_id         String?
  entities_id          String?
  items_id             String?
  product_variantsId   String?
  changed_field        String?
  new_value            String?
  old_value            String?
  org_id               String
  
  // Relations
  actor                actors      @relation(fields: [actor_id], references: [id])
  documents            documents?  @relation(fields: [documents_id], references: [id])
  entities             entities?   @relation(fields: [entities_id], references: [id])
  items                items?      @relation(fields: [items_id], references: [id])
  organization         organization @relation(fields: [org_id], references: [id])
  product_variants     product_variants? @relation(fields: [product_variantsId], references: [id])
  // Other relations...
}

Implementation Pattern

"use server";

import { prisma } from "@cona/database";
import { auth } from "lib/auth";

export async function updateProductPrice(id: string, newPrice: number) {
  // Get current user
  const user = await auth.getCurrentUser();
  
  // Fetch current product variant
  const productVariant = await prisma.productVariant.findUnique({
    where: { id }
  });
  
  if (!productVariant) throw new Error("Product variant not found");
  
  const oldPrice = productVariant.price;
  
  // Create transaction to update product and log activity
  const result = await prisma.$transaction(async (tx) => {
    // Update product variant
    const updatedVariant = await tx.productVariant.update({
      where: { id },
      data: { price: newPrice }
    });
    
    // Create activity log entry
    await tx.activity_logs.create({
      data: {
        action: "PRICE_CHANGE",
        actor_id: user.id,
        product_variantsId: id,
        changed_field: "price",
        old_value: oldPrice.toString(),
        new_value: newPrice.toString(),
        details: "Manual price update",
        org_id: user.active_organization_id
      }
    });
    
    return updatedVariant;
  });
  
  return result;
}

Query Patterns

// Get price change history for a product
export async function getProductPriceHistory(id: string) {
  return prisma.activity_logs.findMany({
    where: { 
      product_variantsId: id,
      action: "PRICE_CHANGE"
    },
    orderBy: { timestamp: 'desc' }
  });
}

// Get all activities for a product
export async function getProductActivities(id: string) {
  return prisma.activity_logs.findMany({
    where: { product_variantsId: id },
    orderBy: { timestamp: 'desc' }
  });
}

3. Simple Update

Used for: sales_channels, locations, tax rates, shipping methods

Implementation

Simple Update tracking only maintains the current state with basic timestamp tracking.

// Schema example
model SalesChannel {
  id            String   @id @default(uuid())
  name          String
  // Other fields
  created_at    DateTime @default(now())
  created_by    String
  updated_at    DateTime @updatedAt
  updated_by    String?
}

Implementation Pattern

"use server";

import { prisma } from "@cona/database";
import { auth } from "lib/auth";

export async function updateSalesChannel(id: string, data: SalesChannelUpdateData) {
  // Get current user
  const user = await auth.getCurrentUser();
  
  // Update with timestamp and user tracking
  const updatedChannel = await prisma.salesChannel.update({
    where: { id },
    data: {
      ...data,
      updated_by: user.id
    }
  });
  
  return updatedChannel;
}

Query Patterns

// Get sales channel details
export async function getSalesChannel(id: string) {
  return prisma.salesChannel.findUnique({
    where: { id }
  });
}

4. Soft Delete with New Record

Used for: contacts, email_addresses, phone_numbers, shipping addresses

Implementation

This approach maintains historical records while preserving data integrity by marking records as deleted rather than removing them from the database.

// Schema example
model email_addresses {
  id                   String       @id @default(cuid())
  label                String
  email                String
  contacts_id          String?
  is_deleted           Boolean      @default(false)
  deleted_at           DateTime?
  deleted_by_actor_id  String?
  deleted_reason       String?
  modified_by_actor_id String?
  org_id               String
  
  // Relations
  contacts             contacts?    @relation(fields: [contacts_id], references: [id])
  deleted_by           actors?      @relation("EmailAddressesDeletedBy", fields: [deleted_by_actor_id], references: [id])
  modified_by          actors?      @relation("EmailAddressesModifiedBy", fields: [modified_by_actor_id], references: [id])
  organization         organization @relation(fields: [org_id], references: [id])
}

Implementation Pattern

"use server";

import { prisma } from "@cona/database";
import { auth } from "lib/auth";

export async function updateEmailAddress(id: string, data: EmailAddressUpdateData) {
  // Get current user
  const user = await auth.getCurrentUser();
  
  // Fetch current email address
  const currentEmail = await prisma.email_addresses.findUnique({
    where: { id, is_deleted: false }
  });
  
  if (!currentEmail) throw new Error("Email address not found");
  
  // Create transaction to soft delete old record and create new one
  const result = await prisma.$transaction(async (tx) => {
    // Mark old record as deleted
    await tx.email_addresses.update({
      where: { id },
      data: {
        is_deleted: true,
        deleted_at: new Date(),
        deleted_by_actor_id: user.id,
        deleted_reason: "Updated with new information"
      }
    });
    
    // Create new email record
    const newEmail = await tx.email_addresses.create({
      data: {
        label: data.label || currentEmail.label,
        email: data.email || currentEmail.email,
        contacts_id: currentEmail.contacts_id,
        modified_by_actor_id: user.id,
        org_id: currentEmail.org_id
      }
    });
    
    return newEmail;
  });
  
  return result;
}

Query Patterns

// Get active email addresses
export async function getActiveEmailAddresses(contactId: string) {
  return prisma.email_addresses.findMany({
    where: { 
      contacts_id: contactId,
      is_deleted: false
    }
  });
}

// Get all email addresses including deleted ones
export async function getAllEmailAddresses(contactId: string) {
  return prisma.email_addresses.findMany({
    where: { contacts_id: contactId },
    orderBy: { deleted_at: 'desc' }
  });
}

// Get deleted email addresses
export async function getDeletedEmailAddresses(contactId: string) {
  return prisma.email_addresses.findMany({
    where: { 
      contacts_id: contactId,
      is_deleted: true
    },
    orderBy: { deleted_at: 'desc' }
  });
}

When to Use Each Approach

Audit Trail Versioning

Best for: Core business entities where all changes must be tracked for compliance or auditing purposes.

Characteristics:

  • Complete change history is critical
  • Legal or compliance requirements
  • Need to restore to any previous state
  • Changes are relatively infrequent

Activity Log Tracking

Best for: Records where specific activities are more important than tracking every change.

Characteristics:

  • Domain-specific activities need tracking
  • More concerned with what happened than complete change history
  • Frequent minor changes that don’t need full history
  • Need to track who performed specific actions

Simple Update

Best for: Configuration or reference data where only the current state matters.

Characteristics:

  • Only current state is relevant
  • Change history is not needed for business operations
  • Support data rather than core business entities
  • Frequent changes where history is not valuable

Soft Delete with New Record

Best for: Records where historical values may still be referenced by other records.

Characteristics:

  • Need to maintain referential integrity with historical data
  • Both current and previous versions may be needed
  • Changes create effectively new entities
  • Changes are infrequent but meaningful