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.
SQL Databases
PostgreSQL, MySQL, SQL Server, Oracle
NoSQL Databases
MongoDB, Cassandra, DynamoDB, Firestore
Cache Stores
Redis, Memcached, In-Memory
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 installThis 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.jsonStep 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;
}Full-Text Search
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 ObjectIds2. 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 request4. 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);
}🔗 Related Resources
🆘 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 stringReady to build your driver? Start with the Quick Start guide! 🚀