AI SDK Persistence DB

repository·main·Indexed 42 days ago

https://github.com/vercel-labs/ai-sdk-persistence-db

A demonstration project for persisting AI SDK chat messages in a PostgreSQL database using Next.js and Drizzle ORM. It features a prefix-based schema for storing chat sessions, messages, and granular message parts (text, reasoning, tool calls, etc.), and provides server actions for managing chat lifecycles including createChat, upsertMessage, and loadChat.

Tokens
4.1K
Snippets
13
Records
19
Agent score
53%

What's inside AI SDK Persistence DB

  1. How the database persistence architecture works

    main

    The application uses a prefix-based approach to persist chat messages in PostgreSQL, avoiding complex polymorphic relationships while maintaining type safety and performance.

    Schema Design

    The system relies on three primary tables:

    • chats: Stores chat sessions with auto-generated IDs.
    • messages: Stores individual messages, including their role, timestamp, and a reference to the parent chat.
    • parts: Stores the content of message parts using a prefix-based column naming convention.

    Prefix Convention

    Each message part type uses dedicated columns with specific prefixes to ensure structured storage:

    • text_*: Text content.
    • reasoning_*: Reasoning or thinking process.
    • file_*: File attachments.
    • source_url_*: URL sources.
    • source_document_*: Document sources.
    • tool_[toolName]_*: Tool calls (e.g., tool_getWeatherInformation_*).
    • data_[dataType]_*: Custom data parts (e.g., data_weather_*).

    Data Integrity

    The schema enforces integrity through constraints that require complete part definitions. For example, a source URL part must include both source_url_url and source_url_description. Tool calls must also provide a complete set of columns (input, output, state, etc.) to prevent partial or corrupted data.

  2. Message mapping between UI and Database

    main

    The system implements bidirectional conversion between UI-friendly message objects and the flattened database schema (implemented in lib/utils/message-mapping.ts):

    • UI → Database (DB): Message parts are flattened into database rows. Each part is assigned an order index to preserve the correct sequence of content. Tool states and provider metadata are preserved during this flattening.
    • Database (DB) → UI: Database rows are reconstructed into typed message parts. This process restores tool states and custom data while maintaining full type safety for the application.
  3. Setup the AI SDK Postgres Persistence project

    main

    Follow these steps to initialize the project environment, database, and dependencies:

    1. Create a Postgres database:

      createdb ai-sdk-demo

      (Alternatively, use PgAdmin or other Postgres management tools).

    2. Configure environment variables: Add the following to your .env file:

      • DATABASE_URL: Your Postgres connection string (e.g., postgres://username:password@localhost:5432/ai-sdk-demo).
      • OPENAI_API_KEY: Your OpenAI API key.
    3. Install dependencies:

      pnpm install
    4. Sync the database schema:

      pnpm db:push
    5. Run the development server:

      pnpm run dev
    createdb ai-sdk-demo
    DATABASE_URL=postgres://username:password@localhost:5432/ai-sdk-demo
    OPENAI_API_KEY=your_openai_api_key_here
    pnpm install
    pnpm db:push
    pnpm run dev
  4. Database Schema for AI SDK Persistence

    main

    The persistence layer uses a PostgreSQL schema managed via Drizzle ORM, organized into three primary tables: chats, messages, and parts. This structure allows for hierarchical storage of conversations, where a chat contains multiple messages, and each message contains multiple ordered parts (text, reasoning, files, tool calls, etc.).

    Table Overview

    chats

    Represents a single conversation session.

    • id: Primary key (generated via generateId()).

    messages

    Represents an individual message within a chat.

    • id: Primary key.
    • chatId: Foreign key referencing chats.id (with onDelete: 'cascade').
    • role: The role of the message (e.g., user, assistant, system).
    • createdAt: Timestamp of message creation.

    parts

    Represents the granular content components of a message. This table uses a single-table inheritance pattern where different type values determine which columns are populated.

    • id: Primary key.
    • messageId: Foreign key referencing messages.id (with onDelete: 'cascade').
    • type: The type of part (e.g., text, reasoning, file, tool-getLocation, data-weather).
    • order: Integer used to maintain the sequence of parts within a message.
    • Content Columns: Specific columns are used based on the type (e.g., text_text for text parts, file_url for file parts, or tool_state for tool parts).
    • Constraints: The schema enforces data integrity via SQL check constraints, ensuring that if a part is of a specific type, its required fields are not null.
  5. Define custom UIMessage types for AI SDK persistence

    main

    When using the AI SDK with persistence, you can define custom types for your messages to include specific metadata, data parts (for structured UI state), and tool sets. This ensures type safety when retrieving and storing messages in your database.

    To define your own message types, you should compose UIMessage and UIMessagePart using your custom Zod schemas for metadata and data parts, and the InferUITools utility for your tool definitions.

    import { InferUITools, JSONValue, UIMessage, UIMessagePart } from "ai";
    import z from "zod";
    
    // 1. Define your metadata schema
    export const metadataSchema = z.object({});
    type MyMetadata = z.infer<typeof metadataSchema>;
    
    // 2. Define your data part schema (e.g., for weather widgets or other UI state)
    export const dataPartSchema = z.object({
      weather: z.object({
        weather: z.string().optional(),
        location: z.string().optional(),
        temperature: z.number().optional(),
        loading: z.boolean().default(true),
      }),
    });
    type MyDataPart = z.infer<typeof dataPartSchema>;
    
    // 3. Define your tool set (assuming 'tools' is imported from your tool definitions)
    export type MyToolSet = InferUITools<ReturnType<typeof tools>>;
    
    // 4. Compose the final UIMessage and UIMessagePart types
    export type MyUIMessage = UIMessage<MyMetadata, MyDataPart, MyToolSet>;
    export type MyUIMessagePart = UIMessagePart<MyDataPart, MyToolSet>;
  6. Available database actions

    main

    Server-side operations are handled by functions in lib/db/actions.ts. Use these functions to manage chat sessions and message history:

    • createChat(): Creates a new chat session.
    • upsertMessage(): Inserts or updates messages using atomic transactions.
    • loadChat(chatId): Retrieves the full message history for a specific chat.
    • getChats(): Lists all available chat sessions.
    • deleteChat(chatId): Removes a specific chat and all its associated messages and parts.
    • deleteMessage(messageId): Deletes a specific message and all subsequent messages in that chat sequence.
  7. Configure Drizzle ORM for Postgres

    main

    The project uses drizzle-kit for database migrations and schema management. The configuration is defined in drizzle.config.ts and requires a DATABASE_URL environment variable to connect to your PostgreSQL instance.

    Key configuration settings:

    • out: The directory where migration files are generated (set to ./drizzle).
    • schema: The path to the source of truth for your database schema (set to ./lib/db/schema.ts).
    • dialect: Set to postgresql.
    • dbCredentials.url: The connection string for your database, retrieved from process.env.DATABASE_URL.
    import 'dotenv/config';
    import { defineConfig } from 'drizzle-kit';
    
    export default defineConfig({
      out: './drizzle',
      schema: './lib/db/schema.ts',
      dialect: 'postgresql',
      dbCredentials: {
        url: process.env.DATABASE_URL!,
      },
    });
  8. Map database parts to UI message format with mapDBPartToUIMessagePart

    main

    Use mapDBPartToUIMessagePart to convert a single database record (MyDBUIMessagePartSelect) back into a UI-friendly message part (MyUIMessagePart). This is essential when retrieving persisted messages from the database to display them in your application. The function reconstructs complex objects like tool call states (input-streaming, input-available, output-available, output-error) and data objects (e.g., data-weather) from the flat database columns.

    import { mapDBPartToUIMessagePart } from '@/lib/utils/message-mapping';
    
    // Assuming 'part' is a row retrieved from your Postgres database
    const uiPart = mapDBPartToUIMessagePart(part);
  9. Map UI message parts to database format with mapUIMessagePartsToDBParts

    main

    Use mapUIMessagePartsToDBParts to convert an array of UI-friendly message parts (MyUIMessagePart[]) into a format suitable for database persistence (MyDBUIMessagePart[]). This function handles the transformation of various part types—such as text, reasoning, file, source-document, source-url, step-start, and specific tool calls—into the flat schema required by the database. It automatically assigns the messageId and an order index to each part to maintain sequence.

    import { mapUIMessagePartsToDBParts } from '@/lib/utils/message-mapping';
    
    const dbParts = mapUIMessagePartsToDBParts(uiMessageParts, currentMessageId);
    // dbParts is now ready to be inserted into the database
  10. Reference: `parts` table columns and types

    main

    The parts table uses specific columns to store different types of AI SDK message parts. The type column dictates which of these columns must be populated according to database check constraints.

    // Text & Reasoning
    text_text: text(),
    reasoning_text: text(),
    
    // File
    file_mediaType: varchar(),
    file_filename: varchar(),
    file_url: varchar(),
    
    // Source URL
    source_url_sourceId: varchar(),
    source_url_url: varchar(),
    source_url_title: varchar(),
    
    // Source Document
    source_document_sourceId: varchar(),
    source_document_mediaType: varchar(),
    source_document_title: varchar(),
    source_document_filename: varchar(),
    
    // Tool Calls
    tool_toolCallId: varchar(),
    tool_state: varchar(),
    tool_errorText: varchar(),
    tool_getWeatherInformation_input: jsonb(),
    tool_getWeatherInformation_output: jsonb(),
    tool_getLocation_input: jsonb(),
    tool_getLocation_output: jsonb(),
    
    // Data Parts
    data_weather_id: varchar(),
    data_weather_location: varchar(),
    data_weather_weather: varchar(),
    data_weather_temperature: real(),
    
    // Metadata
    providerMetadata: jsonb(),
  11. Load all messages for a chat

    main

    Use loadChat to retrieve the full conversation history for a given chatId. The function returns an array of MyUIMessage objects, with messages ordered by creation time and message parts ordered by their sequence. This is the primary method for reconstructing a chat UI from the database.

    import { loadChat } from '@/lib/db/actions';
    
    const messages = await loadChat('chat-id-123');
    // Returns MyUIMessage[]