██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

Building a Storage Adapter

This guide walks through implementing a custom StorageAdapter for Verevoir. The storage adapter is the bridge between your application and your database — Verevoir doesn't care which database you use, as long as your adapter implements the interface.

The Interface

Every storage adapter must implement the StorageAdapter interface from @verevoir/storage:

import type { Document, StorageAdapter, ListOptions } from '@verevoir/storage';
interface StorageAdapter {
  connect(): Promise<void>;
  disconnect(): Promise<void>;
  migrate(): Promise<void>;

  create(blockType: string, data: Record<string, unknown>): Promise<Document>;
  get(id: string): Promise<Document | null>;
  update(id: string, data: Record<string, unknown>): Promise<Document>;
  delete(id: string): Promise<void>;

  list(blockType: string, options?: ListOptions): Promise<Document[]>;
  getMany(ids: string[]): Promise<Map<string, Document>>;
}

The Document Type

Every method that returns documents must return objects matching the Document interface:

interface Document<T = Record<string, unknown>> {
  id: string;         // Unique identifier (UUID recommended)
  blockType: string;  // Content type name (e.g. 'article', 'author')
  data: T;            // The content payload — arbitrary JSON
  createdAt: Date;    // Set on creation, never changes
  updatedAt: Date;    // Set on creation, updated on every update
}

Key rules:

Step-by-Step Implementation

1. Lifecycle Methods

class MyAdapter implements StorageAdapter {
  async connect(): Promise<void> {
    // Open connection pool, verify connectivity
    // Throw if the connection fails
  }

  async disconnect(): Promise<void> {
    // Close connection pool, release resources
    // Safe to call multiple times
  }

  async migrate(): Promise<void> {
    // Create tables/collections/indexes if they don't exist
    // Must be idempotent — safe to call repeatedly
  }
}

connect() should verify the connection works (e.g. run a ping query). migrate() should use IF NOT EXISTS or equivalent to be safely re-runnable.

2. CRUD Methods

async create(blockType: string, data: Record<string, unknown>): Promise<Document> {
  // Generate a UUID for the new document
  // Set createdAt and updatedAt to now
  // Store the document
  // Return the full Document object
}

async get(id: string): Promise<Document | null> {
  // Look up by ID
  // Return the Document if found, null if not
  // Never throw for a missing document
}

async update(id: string, data: Record<string, unknown>): Promise<Document> {
  // Replace the document's data
  // Update updatedAt to now
  // Throw Error(`Document not found: ${id}`) if the ID doesn't exist
  // Return the updated Document
}

async delete(id: string): Promise<void> {
  // Remove the document
  // Throw Error(`Document not found: ${id}`) if the ID doesn't exist
}

Error conventions:

3. List with Querying

async list(blockType: string, options?: ListOptions): Promise<Document[]> {
  // Always filter by blockType first
  // Then apply options.where, options.orderBy, options.limit, options.offset
  // Return empty array if no matches (never throw)
  // Default sort by createdAt ascending when no orderBy specified
}

The ListOptions object:

interface ListOptions {
  where?: WhereClause;      // Field filters
  orderBy?: OrderByClause;  // Sort direction
  limit?: number;           // Max results
  offset?: number;          // Skip N results
}

Where clause — fields can be document-level (createdAt, updatedAt, blockType, id) or data-level (any key inside data). Values are either exact matches or operator objects:

// Exact match
{ status: 'published' }

// Operators
{ createdAt: { $gt: new Date('2025-01-01') } }
{ title: { $contains: 'nextlake' } }

Operators: $gt, $gte, $lt, $lte, $ne, $contains (case-insensitive substring).

OrderBy clause — field names mapped to 'asc' or 'desc':

{ createdAt: 'desc' }
{ title: 'asc' }

4. Batch Fetch

async getMany(ids: string[]): Promise<Map<string, Document>> {
  // Fetch all documents whose ID is in the array
  // Return a Map<string, Document> keyed by ID
  // Silently omit any IDs that don't exist (no errors)
  // Return empty Map for empty input array
}

This exists to avoid N+1 queries when resolving references. Use your database's batch lookup (e.g. WHERE id = ANY($1) in Postgres, $in in MongoDB).

Data Storage Strategy

The adapter stores content as a flexible JSON payload. How you map this to your database depends on the database:

DatabaseRecommended Approach
PostgreSQLSingle documents table with JSONB data column
MongoDBSingle documents collection, data spread into document fields
SQLiteSingle table with JSON data column
DynamoDBSingle table, data serialized as JSON attribute
File systemOne JSON file per document in a directory tree

The metadata columns (id, blockType, createdAt, updatedAt) should be proper typed columns/fields for indexing. The data payload is stored as-is.

Testing Your Adapter

Use the MemoryAdapter tests as a reference. Your adapter should pass the same test cases:

  1. Create — generates ID and timestamps, returns full Document
  2. Get — returns Document for valid ID, null for missing
  3. Update — updates data and updatedAt, preserves createdAt, throws for missing
  4. Delete — removes document, throws for missing
  5. List — filters by blockType, returns empty array for no matches
  6. List with options — where filters, orderBy, limit, offset all work
  7. GetMany — batch fetch by IDs, omits missing
  8. Migrate — idempotent (safe to call twice)
  9. Lifecycle — connect/disconnect work cleanly

For databases that need infrastructure (like Postgres), use testcontainers to spin up a real instance in tests.

Example: Skeleton for a MongoDB Adapter

import { MongoClient, type Db } from 'mongodb';
import { randomUUID } from 'node:crypto';
import type { Document, StorageAdapter, ListOptions } from '@verevoir/storage';

export class MongoAdapter implements StorageAdapter {
  private client: MongoClient;
  private db: Db;

  constructor(uri: string, dbName: string) {
    this.client = new MongoClient(uri);
    this.db = this.client.db(dbName);
  }

  async connect() {
    await this.client.connect();
  }

  async disconnect() {
    await this.client.close();
  }

  async migrate() {
    const col = this.db.collection('documents');
    await col.createIndex({ blockType: 1 });
  }

  async create(blockType: string, data: Record<string, unknown>): Promise<Document> {
    const now = new Date();
    const doc: Document = {
      id: randomUUID(),
      blockType,
      data,
      createdAt: now,
      updatedAt: now,
    };
    await this.db.collection('documents').insertOne({ _id: doc.id, ...doc });
    return doc;
  }

  // ... implement get, update, delete, list, getMany
}

Registering Your Adapter

Once implemented, your adapter is used like any other:

import { MyAdapter } from './my-adapter';

const storage = new MyAdapter({ connectionString: '...' });
await storage.connect();
await storage.migrate();

// Use it with Verevoir
const doc = await storage.create('article', { title: 'Hello' });
const articles = await storage.list('article', {
  where: { status: 'published' },
  orderBy: { createdAt: 'desc' },
  limit: 10,
});

No registration step needed — the adapter is a plain class implementing an interface.