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:
idmust be unique across all documents, not just within a block typecreatedAtmust never change after creationupdatedAtmust be set to the current time on everyupdate()calldatais opaque — the adapter stores and returns it without validation
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:
get()returnsnullfor missing documents (it's a query, not a command)update()anddelete()throwError('Document not found: <id>')for missing documents
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:
| Database | Recommended Approach |
|---|---|
| PostgreSQL | Single documents table with JSONB data column |
| MongoDB | Single documents collection, data spread into document fields |
| SQLite | Single table with JSON data column |
| DynamoDB | Single table, data serialized as JSON attribute |
| File system | One 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:
- Create — generates ID and timestamps, returns full Document
- Get — returns Document for valid ID, null for missing
- Update — updates data and updatedAt, preserves createdAt, throws for missing
- Delete — removes document, throws for missing
- List — filters by blockType, returns empty array for no matches
- List with options — where filters, orderBy, limit, offset all work
- GetMany — batch fetch by IDs, omits missing
- Migrate — idempotent (safe to call twice)
- 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.