ObjectStackObjectStack Protocol

Server Drivers

Connect ObjectStack to any database or data source by implementing the DriverInterface protocol.

Server Drivers

Drivers are the bridge between ObjectQL and your data storage. By implementing the DriverInterface, you can connect ObjectStack to any database (SQL, NoSQL, in-memory, or even external APIs).

ObjectStack comes with built-in drivers for PostgreSQL, MySQL, MongoDB, and Redis, but you can create custom drivers for proprietary databases, legacy systems, or specialized data sources.


🎯 What Are Drivers?

Drivers translate ObjectQL queries into database-specific operations and normalize results back into the ObjectStack protocol format.

database

SQL Databases

PostgreSQL, MySQL, SQL Server, Oracle

layers

NoSQL Databases

MongoDB, Cassandra, DynamoDB, Firestore

zap

Cache Stores

Redis, Memcached, In-Memory

cloud

External APIs

Salesforce, Airtable, REST APIs, GraphQL


📋 Prerequisites

  • TypeScript knowledge
  • Understanding of ObjectQL Protocol
  • Familiarity with target database (SQL, MongoDB, etc.)
  • Node.js 18+ and npm 9+

🚀 Quick Start

Step 1: Create a Driver Project

# Using the official generator
npm create @objectstack/driver my-custom-db

cd my-custom-db
npm install

This generates:

my-custom-db/
├── src/
│   ├── index.ts              # Driver entry point
│   ├── driver.ts             # Driver implementation
│   ├── query-builder.ts      # Query translation
│   ├── connection.ts         # Connection management
│   └── types.ts              # TypeScript types
├── tests/
│   └── driver.test.ts
├── package.json
└── tsconfig.json

Step 2: Implement the DriverInterface

// src/driver.ts
import { DriverInterface } from '@objectstack/core';
import {
  DriverOptions,
  DriverCapabilities,
} from '@objectstack/spec';
import { Query, QueryResult } from '@objectstack/objectql';

export class MyCustomDriver implements DriverInterface {
  name = 'MyCustomDB';
  version = '1.0.0';
  
  private connection: any;
  
  // ============================================================================
  // Connection Management
  // ============================================================================
  
  async connect(config: any): Promise<void> {
    this.connection = await createConnection(config);
  }
  
  async disconnect(): Promise<void> {
    await this.connection.close();
  }
  
  async ping(): Promise<boolean> {
    return this.connection.isAlive();
  }
  
  // ============================================================================
  // Capabilities Declaration
  // ============================================================================
  
  getCapabilities(): DriverCapabilities {
    return {
      // Transaction support
      transactions: true,
      
      // Query features
      queryFilters: true,
      queryAggregations: true,
      querySorting: true,
      queryPagination: true,
      queryJoins: true,
      
      // Data operations
      bulkInsert: true,
      bulkUpdate: true,
      bulkDelete: true,
      
      // Advanced features
      fullTextSearch: true,
      geospatialQueries: false,
      jsonQuerying: true,
    };
  }
  
  // ============================================================================
  // CRUD Operations (See below)
  // ============================================================================
  
  async find(object: string, query: Query, options?: DriverOptions): Promise<QueryResult> {
    // Implementation
  }
  
  async findOne(object: string, id: string, options?: DriverOptions): Promise<any> {
    // Implementation
  }
  
  async insert(object: string, data: any, options?: DriverOptions): Promise<any> {
    // Implementation
  }
  
  async update(object: string, id: string, data: any, options?: DriverOptions): Promise<any> {
    // Implementation
  }
  
  async delete(object: string, id: string, options?: DriverOptions): Promise<void> {
    // Implementation
  }
}

🔍 Implementing CRUD Operations

Find (Query)

The find method receives a Query object and returns matching records:

async find(
  object: string,
  query: Query,
  options?: DriverOptions
): Promise<QueryResult> {
  const { filters, sort, limit, offset, fields } = query;
  
  // ============================================================================
  // 1. Build the database query
  // ============================================================================
  
  let dbQuery = this.connection.from(object);
  
  // Apply filters
  if (filters) {
    dbQuery = this.applyFilters(dbQuery, filters);
  }
  
  // Apply sorting
  if (sort) {
    for (const [field, direction] of Object.entries(sort)) {
      dbQuery = dbQuery.orderBy(field, direction);
    }
  }
  
  // Apply pagination
  if (limit) {
    dbQuery = dbQuery.limit(limit);
  }
  if (offset) {
    dbQuery = dbQuery.offset(offset);
  }
  
  // Select specific fields
  if (fields && fields.length > 0) {
    dbQuery = dbQuery.select(fields);
  }
  
  // ============================================================================
  // 2. Execute the query
  // ============================================================================
  
  const results = await dbQuery.execute();
  
  // ============================================================================
  // 3. Normalize IDs
  // ============================================================================
  
  return results.map(record => this.normalizeRecord(record));
}

/**
 * Normalize database-specific IDs to ObjectStack format.
 * All records MUST have a string `id` field.
 */
private normalizeRecord(record: any): any {
  // Example for MongoDB-like databases with _id
  if (record._id) {
    return {
      ...record,
      id: record._id.toString(), // Convert to string
      _id: undefined,            // Remove internal ID
    };
  }
  
  // Example for SQL databases with numeric IDs
  if (typeof record.id === 'number') {
    return {
      ...record,
      id: record.id.toString(), // Convert to string
    };
  }
  
  return record;
}

Apply Filters

Convert ObjectQL filters to database-specific syntax:

private applyFilters(dbQuery: any, filters: any): any {
  for (const [field, condition] of Object.entries(filters)) {
    if (typeof condition === 'object') {
      // Complex conditions
      for (const [operator, value] of Object.entries(condition)) {
        switch (operator) {
          case '$eq':
            dbQuery = dbQuery.where(field, '=', value);
            break;
          case '$ne':
            dbQuery = dbQuery.where(field, '!=', value);
            break;
          case '$gt':
            dbQuery = dbQuery.where(field, '>', value);
            break;
          case '$gte':
            dbQuery = dbQuery.where(field, '>=', value);
            break;
          case '$lt':
            dbQuery = dbQuery.where(field, '<', value);
            break;
          case '$lte':
            dbQuery = dbQuery.where(field, '<=', value);
            break;
          case '$in':
            dbQuery = dbQuery.whereIn(field, value);
            break;
          case '$nin':
            dbQuery = dbQuery.whereNotIn(field, value);
            break;
          case '$like':
            dbQuery = dbQuery.where(field, 'LIKE', value);
            break;
          case '$regex':
            dbQuery = dbQuery.whereRaw(`${field} ~ ?`, [value]);
            break;
        }
      }
    } else {
      // Simple equality
      dbQuery = dbQuery.where(field, '=', condition);
    }
  }
  
  return dbQuery;
}

FindOne (Get by ID)

async findOne(
  object: string,
  id: string,
  options?: DriverOptions
): Promise<any> {
  const result = await this.connection
    .from(object)
    .where('id', '=', id)
    .first();
  
  if (!result) {
    throw new Error(`Record not found: ${object}/${id}`);
  }
  
  return this.normalizeRecord(result);
}

Insert (Create)

async insert(
  object: string,
  data: any,
  options?: DriverOptions
): Promise<any> {
  // ============================================================================
  // 1. Generate ID if not provided
  // ============================================================================
  
  if (!data.id) {
    data.id = this.generateId();
  }
  
  // ============================================================================
  // 2. Add timestamps
  // ============================================================================
  
  const now = new Date();
  const recordWithMeta = {
    ...data,
    created_at: now,
    updated_at: now,
  };
  
  // ============================================================================
  // 3. Insert into database
  // ============================================================================
  
  await this.connection
    .into(object)
    .insert(recordWithMeta);
  
  // ============================================================================
  // 4. Return the created record
  // ============================================================================
  
  return this.normalizeRecord(recordWithMeta);
}

private generateId(): string {
  // Use UUID, ULID, or database-specific ID generation
  return crypto.randomUUID();
}

Update

async update(
  object: string,
  id: string,
  data: any,
  options?: DriverOptions
): Promise<any> {
  // ============================================================================
  // 1. Add updated timestamp
  // ============================================================================
  
  const updates = {
    ...data,
    updated_at: new Date(),
  };
  
  // Remove id from updates (can't update primary key)
  delete updates.id;
  
  // ============================================================================
  // 2. Execute update
  // ============================================================================
  
  const result = await this.connection
    .table(object)
    .where('id', '=', id)
    .update(updates);
  
  if (result === 0) {
    throw new Error(`Record not found: ${object}/${id}`);
  }
  
  // ============================================================================
  // 3. Return updated record
  // ============================================================================
  
  return this.findOne(object, id, options);
}

Delete

async delete(
  object: string,
  id: string,
  options?: DriverOptions
): Promise<void> {
  const result = await this.connection
    .table(object)
    .where('id', '=', id)
    .delete();
  
  if (result === 0) {
    throw new Error(`Record not found: ${object}/${id}`);
  }
}

🔄 Transaction Support

If your database supports transactions, implement these methods:

export class MyCustomDriver implements DriverInterface {
  // ... other methods
  
  async beginTransaction(options?: DriverOptions): Promise<any> {
    const tx = await this.connection.transaction();
    return tx;
  }
  
  async commit(transaction: any): Promise<void> {
    await transaction.commit();
  }
  
  async rollback(transaction: any): Promise<void> {
    await transaction.rollback();
  }
}

Usage:

const tx = await driver.beginTransaction();

try {
  await driver.insert('account', { name: 'Acme' }, { transaction: tx });
  await driver.insert('contact', { account: 'acme-id' }, { transaction: tx });
  await driver.commit(tx);
} catch (error) {
  await driver.rollback(tx);
  throw error;
}

📊 Advanced Features

Aggregations

async aggregate(
  object: string,
  aggregations: any,
  options?: DriverOptions
): Promise<any> {
  let query = this.connection.from(object);
  
  const results: any = {};
  
  for (const [alias, agg] of Object.entries(aggregations)) {
    const { function: fn, field } = agg as any;
    
    switch (fn) {
      case 'COUNT':
        results[alias] = await query.count(field || '*');
        break;
      case 'SUM':
        results[alias] = await query.sum(field);
        break;
      case 'AVG':
        results[alias] = await query.avg(field);
        break;
      case 'MIN':
        results[alias] = await query.min(field);
        break;
      case 'MAX':
        results[alias] = await query.max(field);
        break;
    }
  }
  
  return results;
}
async search(
  object: string,
  searchTerm: string,
  fields: string[],
  options?: DriverOptions
): Promise<QueryResult> {
  // PostgreSQL example using tsvector
  const results = await this.connection
    .from(object)
    .whereRaw(
      `to_tsvector('english', ${fields.join(' || ')}) @@ plainto_tsquery('english', ?)`,
      [searchTerm]
    );
  
  return results.map(r => this.normalizeRecord(r));
}

Geospatial Queries

async findNearby(
  object: string,
  coordinates: { lat: number; lng: number },
  radiusKm: number,
  options?: DriverOptions
): Promise<QueryResult> {
  // PostGIS example
  const results = await this.connection
    .from(object)
    .whereRaw(
      `ST_DWithin(location::geography, ST_MakePoint(?, ?)::geography, ?)`,
      [coordinates.lng, coordinates.lat, radiusKm * 1000]
    );
  
  return results.map(r => this.normalizeRecord(r));
}

🏗️ Example: In-Memory Driver

Complete implementation of a simple in-memory driver:

// src/memory-driver.ts
import { DriverInterface } from '@objectstack/core';
import { DriverCapabilities } from '@objectstack/spec';
import { Query, QueryResult } from '@objectstack/objectql';

export class InMemoryDriver implements DriverInterface {
  name = 'InMemory';
  version = '1.0.0';
  
  // Storage: Map<objectName, Map<id, record>>
  private store = new Map<string, Map<string, any>>();
  
  // ============================================================================
  // Connection (No-op for in-memory)
  // ============================================================================
  
  async connect(): Promise<void> {
    // Nothing to connect
  }
  
  async disconnect(): Promise<void> {
    this.store.clear();
  }
  
  async ping(): Promise<boolean> {
    return true;
  }
  
  // ============================================================================
  // Capabilities
  // ============================================================================
  
  getCapabilities(): DriverCapabilities {
    return {
      transactions: false,        // No transaction support
      queryFilters: true,         // Basic filtering
      queryAggregations: true,    // In-memory aggregations
      querySorting: true,         // In-memory sorting
      queryPagination: true,      // Simple pagination
      queryJoins: false,          // No joins
      bulkInsert: true,
      bulkUpdate: true,
      bulkDelete: true,
      fullTextSearch: false,
      geospatialQueries: false,
      jsonQuerying: true,
    };
  }
  
  // ============================================================================
  // CRUD Operations
  // ============================================================================
  
  async find(object: string, query: Query): Promise<QueryResult> {
    const table = this.store.get(object) || new Map();
    let records = Array.from(table.values());
    
    // Apply filters
    if (query.filters) {
      records = records.filter(record => this.matchesFilters(record, query.filters));
    }
    
    // Apply sorting
    if (query.sort) {
      records = this.sortRecords(records, query.sort);
    }
    
    // Apply pagination
    const offset = query.offset || 0;
    const limit = query.limit || records.length;
    records = records.slice(offset, offset + limit);
    
    // Select fields
    if (query.fields && query.fields.length > 0) {
      records = records.map(record =>
        query.fields!.reduce((acc, field) => {
          acc[field] = record[field];
          return acc;
        }, {} as any)
      );
    }
    
    return records;
  }
  
  async findOne(object: string, id: string): Promise<any> {
    const table = this.store.get(object);
    if (!table) {
      throw new Error(`Object not found: ${object}`);
    }
    
    const record = table.get(id);
    if (!record) {
      throw new Error(`Record not found: ${object}/${id}`);
    }
    
    return record;
  }
  
  async insert(object: string, data: any): Promise<any> {
    if (!this.store.has(object)) {
      this.store.set(object, new Map());
    }
    
    const table = this.store.get(object)!;
    
    const id = data.id || this.generateId();
    const record = {
      ...data,
      id,
      created_at: new Date(),
      updated_at: new Date(),
    };
    
    table.set(id, record);
    
    return record;
  }
  
  async update(object: string, id: string, data: any): Promise<any> {
    const table = this.store.get(object);
    if (!table || !table.has(id)) {
      throw new Error(`Record not found: ${object}/${id}`);
    }
    
    const existing = table.get(id)!;
    const updated = {
      ...existing,
      ...data,
      id,
      created_at: existing.created_at,
      updated_at: new Date(),
    };
    
    table.set(id, updated);
    
    return updated;
  }
  
  async delete(object: string, id: string): Promise<void> {
    const table = this.store.get(object);
    if (!table || !table.has(id)) {
      throw new Error(`Record not found: ${object}/${id}`);
    }
    
    table.delete(id);
  }
  
  // ============================================================================
  // Bulk Operations
  // ============================================================================
  
  async bulkInsert(object: string, records: any[]): Promise<any[]> {
    return Promise.all(records.map(r => this.insert(object, r)));
  }
  
  async bulkUpdate(object: string, updates: Array<{ id: string; data: any }>): Promise<any[]> {
    return Promise.all(updates.map(u => this.update(object, u.id, u.data)));
  }
  
  async bulkDelete(object: string, ids: string[]): Promise<void> {
    await Promise.all(ids.map(id => this.delete(object, id)));
  }
  
  // ============================================================================
  // Helper Methods
  // ============================================================================
  
  private matchesFilters(record: any, filters: any): boolean {
    for (const [field, condition] of Object.entries(filters)) {
      const value = record[field];
      
      if (typeof condition === 'object') {
        for (const [operator, filterValue] of Object.entries(condition)) {
          switch (operator) {
            case '$eq':
              if (value !== filterValue) return false;
              break;
            case '$ne':
              if (value === filterValue) return false;
              break;
            case '$gt':
              if (!(value > filterValue)) return false;
              break;
            case '$gte':
              if (!(value >= filterValue)) return false;
              break;
            case '$lt':
              if (!(value < filterValue)) return false;
              break;
            case '$lte':
              if (!(value <= filterValue)) return false;
              break;
            case '$in':
              if (!filterValue.includes(value)) return false;
              break;
            case '$nin':
              if (filterValue.includes(value)) return false;
              break;
          }
        }
      } else {
        if (value !== condition) return false;
      }
    }
    
    return true;
  }
  
  private sortRecords(records: any[], sort: any): any[] {
    return records.sort((a, b) => {
      for (const [field, direction] of Object.entries(sort)) {
        const aVal = a[field];
        const bVal = b[field];
        
        if (aVal < bVal) return direction === 'asc' ? -1 : 1;
        if (aVal > bVal) return direction === 'asc' ? 1 : -1;
      }
      return 0;
    });
  }
  
  private generateId(): string {
    return crypto.randomUUID();
  }
}

🗄️ Example: PostgreSQL Driver

// src/postgres-driver.ts
import { Pool } from 'pg';
import { DriverInterface } from '@objectstack/core';
import { Query } from '@objectstack/objectql';

export class PostgresDriver implements DriverInterface {
  name = 'PostgreSQL';
  version = '1.0.0';
  
  private pool: Pool;
  
  async connect(config: any): Promise<void> {
    this.pool = new Pool({
      host: config.host,
      port: config.port,
      database: config.database,
      user: config.user,
      password: config.password,
    });
  }
  
  async disconnect(): Promise<void> {
    await this.pool.end();
  }
  
  async find(object: string, query: Query): Promise<any[]> {
    const { sql, params } = this.buildSelectQuery(object, query);
    const result = await this.pool.query(sql, params);
    return result.rows.map(row => ({
      ...row,
      id: row.id.toString(),
    }));
  }
  
  async insert(object: string, data: any): Promise<any> {
    const id = data.id || crypto.randomUUID();
    const fields = Object.keys(data);
    const values = Object.values(data);
    
    const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
    
    const sql = `
      INSERT INTO ${object} (id, ${fields.join(', ')}, created_at, updated_at)
      VALUES ($${values.length + 1}, ${placeholders}, NOW(), NOW())
      RETURNING *
    `;
    
    const result = await this.pool.query(sql, [...values, id]);
    return result.rows[0];
  }
  
  private buildSelectQuery(object: string, query: Query): { sql: string; params: any[] } {
    let sql = `SELECT * FROM ${object}`;
    const params: any[] = [];
    
    // WHERE clause
    if (query.filters) {
      const { clause, filterParams } = this.buildWhereClause(query.filters);
      sql += ` WHERE ${clause}`;
      params.push(...filterParams);
    }
    
    // ORDER BY clause
    if (query.sort) {
      const orderBy = Object.entries(query.sort)
        .map(([field, dir]) => `${field} ${dir}`)
        .join(', ');
      sql += ` ORDER BY ${orderBy}`;
    }
    
    // LIMIT and OFFSET
    if (query.limit) {
      sql += ` LIMIT $${params.length + 1}`;
      params.push(query.limit);
    }
    if (query.offset) {
      sql += ` OFFSET $${params.length + 1}`;
      params.push(query.offset);
    }
    
    return { sql, params };
  }
  
  private buildWhereClause(filters: any): { clause: string; filterParams: any[] } {
    const conditions: string[] = [];
    const filterParams: any[] = [];
    
    for (const [field, condition] of Object.entries(filters)) {
      if (typeof condition === 'object') {
        for (const [operator, value] of Object.entries(condition)) {
          const paramIndex = filterParams.length + 1;
          
          switch (operator) {
            case '$eq':
              conditions.push(`${field} = $${paramIndex}`);
              filterParams.push(value);
              break;
            case '$gt':
              conditions.push(`${field} > $${paramIndex}`);
              filterParams.push(value);
              break;
            case '$in':
              conditions.push(`${field} = ANY($${paramIndex})`);
              filterParams.push(value);
              break;
          }
        }
      } else {
        const paramIndex = filterParams.length + 1;
        conditions.push(`${field} = $${paramIndex}`);
        filterParams.push(condition);
      }
    }
    
    return {
      clause: conditions.join(' AND '),
      filterParams,
    };
  }
}

🧪 Testing Drivers

// tests/driver.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { InMemoryDriver } from '../src/memory-driver';

describe('InMemoryDriver', () => {
  let driver: InMemoryDriver;
  
  beforeAll(async () => {
    driver = new InMemoryDriver();
    await driver.connect();
  });
  
  afterAll(async () => {
    await driver.disconnect();
  });
  
  it('should insert and find records', async () => {
    const record = await driver.insert('account', {
      name: 'Acme Corp',
      status: 'active',
    });
    
    expect(record.id).toBeDefined();
    expect(record.name).toBe('Acme Corp');
    
    const found = await driver.findOne('account', record.id);
    expect(found.name).toBe('Acme Corp');
  });
  
  it('should filter records', async () => {
    await driver.insert('account', { name: 'A', status: 'active' });
    await driver.insert('account', { name: 'B', status: 'inactive' });
    
    const results = await driver.find('account', {
      filters: { status: 'active' },
    });
    
    expect(results.length).toBe(1);
    expect(results[0].name).toBe('A');
  });
  
  it('should update records', async () => {
    const record = await driver.insert('account', { name: 'Old Name' });
    
    const updated = await driver.update('account', record.id, { name: 'New Name' });
    
    expect(updated.name).toBe('New Name');
  });
  
  it('should delete records', async () => {
    const record = await driver.insert('account', { name: 'To Delete' });
    
    await driver.delete('account', record.id);
    
    await expect(driver.findOne('account', record.id)).rejects.toThrow();
  });
});

📦 Packaging & Distribution

{
  "name": "@mycompany/objectstack-driver-custom",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "keywords": ["objectstack", "driver", "database"],
  "files": ["dist"],
  
  "objectstack": {
    "type": "driver",
    "entry": "./dist/index.js"
  },
  
  "dependencies": {
    "@objectstack/spec": "^1.0.0"
  }
}

✅ Best Practices

1. Always Normalize IDs

// ✅ Good
return records.map(r => ({ ...r, id: r.id.toString() }));

// ❌ Bad
return records; // IDs might be numbers or ObjectIds

2. Handle Errors Gracefully

try {
  await this.connection.query(sql);
} catch (error) {
  if (error.code === 'UNIQUE_VIOLATION') {
    throw new ObjectStackError('DUPLICATE_RECORD', { field: 'email' });
  }
  throw error;
}

3. Use Connection Pooling

// ✅ Good
this.pool = new Pool({ max: 20 });

// ❌ Bad
const connection = await connect(); // New connection per request

4. Implement Bulk Operations

async bulkInsert(object: string, records: any[]): Promise<any[]> {
  // ✅ Single query
  return this.connection.insert(object).values(records);
  
  // ❌ Multiple queries
  // return Promise.all(records.map(r => this.insert(object, r)));
}

5. Support Transactions

const tx = await driver.beginTransaction();
try {
  // Operations use transaction
  await driver.commit(tx);
} catch (error) {
  await driver.rollback(tx);
}


🆘 Troubleshooting

Connection Issues

async ping(): Promise<boolean> {
  try {
    await this.connection.query('SELECT 1');
    return true;
  } catch {
    return false;
  }
}

ID Mapping Problems

Ensure all IDs are strings:

// MongoDB
id: doc._id.toString()

// SQL with numeric IDs
id: row.id.toString()

// UUID
id: row.id // Already a string

Ready to build your driver? Start with the Quick Start guide! 🚀

On this page