Skip to content

StorageAdapter Pattern

The StorageAdapter interface is the architectural foundation of CyberEco. It decouples all domain logic from any specific database, enabling testing without emulators, future migration to different backends, and multi-backend deployments.

The Interface

Every storage backend implements this interface, defined in @cyber-eco/types:

packages/types/src/storage-adapter.ts
interface StorageAdapter {
  // Document CRUD
  getDocument<T>(collection: string, id: string): Promise<T | null>;
  setDocument<T>(collection: string, id: string, data: T, options?: WriteOptions): Promise<WriteResult>;
  updateDocument(collection: string, id: string, data: Record<string, unknown>): Promise<WriteResult>;
  deleteDocument(collection: string, id: string): Promise<WriteResult>;

  // Queries
  query<T>(
    collection: string,
    filters: QueryFilter[],
    options?: QueryOptions,
  ): Promise<PaginatedResult<T>>;

  // Batch operations
  batchWrite(operations: BatchOperation[]): Promise<BatchResult>;

  // Real-time subscriptions
  subscribe<T>(
    collection: string,
    id: string,
    callback: (data: T | null) => void,
  ): Unsubscribe;
  subscribeToQuery<T>(
    collection: string,
    filters: QueryFilter[],
    callback: (data: T[]) => void,
  ): Unsubscribe;

  // Utilities
  serverTimestamp(): unknown;
  generateId(collection: string): string;
}

Supporting Types

interface WriteOptions {
  merge?: boolean;    // Merge with existing document instead of overwriting
}

interface WriteResult {
  id: string;
  success: boolean;
}

interface BatchOperation {
  type: 'set' | 'update' | 'delete';
  collection: string;
  id: string;
  data?: Record<string, unknown>;
  options?: WriteOptions;
}

interface BatchResult {
  success: boolean;
  count: number;
  errors?: Array<{ index: number; error: string }>;
}

interface QueryFilter {
  field: string;
  operator: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'array-contains' | 'array-contains-any';
  value: unknown;
}

interface QueryOptions {
  sort?: QuerySort[];
  limit?: number;
  offset?: number;
  cursor?: string;
}

interface PaginatedResult<T> {
  data: T[];
  total?: number;
  hasMore: boolean;
  cursor?: string;
}

type Unsubscribe = () => void;

Why It Exists

Without the adapter, every service would import firebase/firestore directly:

Without adapter (tightly coupled)
// Every service has a hard dependency on Firebase
import { doc, getDoc, getFirestore } from 'firebase/firestore';

async function getUser(id: string) {
  const db = getFirestore();
  const snap = await getDoc(doc(db, 'users', id));
  return snap.data();
}

With the adapter, services depend only on an interface:

With adapter (decoupled)
// Service depends on StorageAdapter interface, not Firebase
async function getUser(adapter: StorageAdapter, id: string) {
  return adapter.getDocument('users', id);
}

Benefits

  • Testability: Swap in MockStorageAdapter for instant, emulator-free tests
  • Portability: Migrate from Firebase to PostgreSQL, IPFS, or a blockchain without changing application code
  • Multi-backend: Run different adapters in different environments (e.g., Firebase for web, SQLite for mobile)

FirebaseStorageAdapter

The production implementation that maps StorageAdapter methods to Firestore SDK calls.

Lazy Initialization

FirebaseStorageAdapter takes a getter function instead of a Firestore instance. This is critical for avoiding module-level initialization crashes:

packages/firebase/src/adapter.ts
export class FirebaseStorageAdapter implements StorageAdapter {
  private db: Firestore | null = null;

  constructor(private getFirestore: () => Firestore) {}

  private ensureDb(): Firestore {
    if (!this.db) {
      this.db = this.getFirestore();
    }
    return this.db;
  }

  async getDocument<T>(collectionName: string, id: string): Promise<T | null> {
    const db = this.ensureDb();
    const docRef = doc(db, collectionName, id);
    const docSnap = await getDoc(docRef);
    return docSnap.exists()
      ? ({ id: docSnap.id, ...docSnap.data() } as T)
      : null;
  }

  // ... other methods follow the same pattern
}

Do not pass a Firestore instance directly

// Wrong -- may crash at import time
const adapter = new FirebaseStorageAdapter(getFirestore(app));

// Correct -- deferred initialization
const adapter = new FirebaseStorageAdapter(() => getFirestore(app));

Usage

import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { FirebaseStorageAdapter } from '@cyber-eco/firebase';
import { createDataLayer } from '@cyber-eco/services';

const app = initializeApp({ /* config */ });
const adapter = new FirebaseStorageAdapter(() => getFirestore(app));
const dataLayer = createDataLayer({ adapter });

Pagination

The Firebase adapter returns Firestore document IDs as cursors for pagination:

const page1 = await dataLayer.core.query(userId, 'tasks', [], { limit: 10 });

if (page1.hasMore && page1.cursor) {
  const page2 = await dataLayer.core.query(userId, 'tasks', [], {
    limit: 10,
    cursor: page1.cursor,
  });
}

Auto-Generated IDs

When createDataLayer().core.create() is called without an explicit ID, the adapter generates one:

// FirebaseStorageAdapter generates a Firestore auto-ID
generateId(collectionName: string): string {
  const db = this.ensureDb();
  return doc(collection(db, collectionName)).id;
}

MockStorageAdapter

The testing implementation from @cyber-eco/services/testing. Provides a complete in-memory StorageAdapter with additional testing utilities.

Import

import { MockStorageAdapter } from '@cyber-eco/services/testing';

Core Features

  • In-memory storage: All data stored in Map<string, Map<string, unknown>>
  • Full query support: Filters, sorting, pagination, all operators
  • Real-time subscriptions: Callbacks fire on write operations
  • Deterministic IDs: Generated as ${collection}_${counter} for predictable tests

Testing Utilities

seed(collection, id, data)

Pre-populate data without going through the data layer (bypasses permissions and metadata enrichment):

const adapter = new MockStorageAdapter();
adapter.seed('users', 'user-1', {
  name: 'Alice',
  role: 'admin',
  apps: ['my-app'],
});

dump()

Inspect all stored data for assertions:

const allData = adapter.dump();
console.log(allData);
// {
//   users: { 'user-1': { name: 'Alice', role: 'admin', apps: ['my-app'] } },
//   tasks: { 'tasks_1': { title: 'Task 1', ... } }
// }

clear()

Reset all state between tests:

beforeEach(() => {
  adapter.clear();
});

Example Test

import { describe, it, expect, beforeEach } from 'vitest';
import { MockStorageAdapter } from '@cyber-eco/services/testing';
import { createDataLayer } from '@cyber-eco/services';

describe('MyService', () => {
  let adapter: MockStorageAdapter;

  beforeEach(() => {
    adapter = new MockStorageAdapter();
  });

  it('should create and retrieve a document', async () => {
    const { core } = createDataLayer({
      adapter,
      permissions: { enabled: false },
    });

    const id = await core.create('user-1', 'items', { name: 'Widget' });
    const item = await core.get('user-1', 'items', id);

    expect(item).toMatchObject({ name: 'Widget' });
  });

  it('should support query filters', async () => {
    const { core } = createDataLayer({
      adapter,
      permissions: { enabled: false },
    });

    adapter.seed('products', 'p1', { name: 'A', price: 10 });
    adapter.seed('products', 'p2', { name: 'B', price: 25 });
    adapter.seed('products', 'p3', { name: 'C', price: 50 });

    const expensive = await core.query('user-1', 'products', [
      { field: 'price', operator: '>=', value: 25 },
    ]);

    expect(expensive.data).toHaveLength(2);
  });
});

Creating Custom Adapters

To support a new backend, implement the StorageAdapter interface:

PostgreSQL Example

src/PostgresStorageAdapter.ts
import type {
  StorageAdapter,
  WriteOptions,
  WriteResult,
  BatchOperation,
  BatchResult,
  QueryFilter,
  QueryOptions,
  PaginatedResult,
  Unsubscribe,
} from '@cyber-eco/types';
import { Pool } from 'pg';
import { randomUUID } from 'crypto';

export class PostgresStorageAdapter implements StorageAdapter {
  constructor(private pool: Pool) {}

  async getDocument<T>(collection: string, id: string): Promise<T | null> {
    const result = await this.pool.query(
      `SELECT data FROM ${collection} WHERE id = $1`,
      [id],
    );
    return result.rows[0]?.data ?? null;
  }

  async setDocument<T>(
    collection: string,
    id: string,
    data: T,
    options?: WriteOptions,
  ): Promise<WriteResult> {
    if (options?.merge) {
      await this.pool.query(
        `INSERT INTO ${collection} (id, data) VALUES ($1, $2)
         ON CONFLICT (id) DO UPDATE SET data = ${collection}.data || $2`,
        [id, JSON.stringify(data)],
      );
    } else {
      await this.pool.query(
        `INSERT INTO ${collection} (id, data) VALUES ($1, $2)
         ON CONFLICT (id) DO UPDATE SET data = $2`,
        [id, JSON.stringify(data)],
      );
    }
    return { id, success: true };
  }

  async updateDocument(
    collection: string,
    id: string,
    data: Record<string, unknown>,
  ): Promise<WriteResult> {
    await this.pool.query(
      `UPDATE ${collection} SET data = data || $2 WHERE id = $1`,
      [id, JSON.stringify(data)],
    );
    return { id, success: true };
  }

  async deleteDocument(collection: string, id: string): Promise<WriteResult> {
    await this.pool.query(`DELETE FROM ${collection} WHERE id = $1`, [id]);
    return { id, success: true };
  }

  async query<T>(
    collection: string,
    filters: QueryFilter[],
    options?: QueryOptions,
  ): Promise<PaginatedResult<T>> {
    // Build WHERE clause from filters
    // Build ORDER BY from options.sort
    // Apply LIMIT/OFFSET from options
    // ... implementation depends on your schema
    return { data: [], hasMore: false };
  }

  async batchWrite(operations: BatchOperation[]): Promise<BatchResult> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      for (const op of operations) {
        switch (op.type) {
          case 'set':
            await this.setDocument(op.collection, op.id, op.data!, op.options);
            break;
          case 'update':
            await this.updateDocument(op.collection, op.id, op.data!);
            break;
          case 'delete':
            await this.deleteDocument(op.collection, op.id);
            break;
        }
      }
      await client.query('COMMIT');
      return { success: true, count: operations.length };
    } catch (error) {
      await client.query('ROLLBACK');
      return { success: false, count: 0, errors: [{ index: 0, error: String(error) }] };
    } finally {
      client.release();
    }
  }

  subscribe<T>(
    _collection: string,
    _id: string,
    _callback: (data: T | null) => void,
  ): Unsubscribe {
    // PostgreSQL: Use LISTEN/NOTIFY for real-time
    // Or polling as a fallback
    throw new Error('Real-time subscriptions not implemented');
  }

  subscribeToQuery<T>(
    _collection: string,
    _filters: QueryFilter[],
    _callback: (data: T[]) => void,
  ): Unsubscribe {
    throw new Error('Query subscriptions not implemented');
  }

  serverTimestamp(): unknown {
    return new Date().toISOString();
  }

  generateId(_collection: string): string {
    return randomUUID();
  }
}

Using a Custom Adapter

import { createDataLayer } from '@cyber-eco/services';
import { Pool } from 'pg';
import { PostgresStorageAdapter } from './PostgresStorageAdapter';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PostgresStorageAdapter(pool);
const dataLayer = createDataLayer({ adapter });

Subscription methods

The subscribe and subscribeToQuery methods are optional in practice. If your backend does not support real-time updates natively, you can throw an error or implement polling. The DataLayerService will still work for all non-subscription operations.

Future Adapters

The CyberEco roadmap includes several planned adapter implementations:

P2PStorageAdapter

Defined in the P2P Architecture document, this adapter will use CRDTs and the TransportAdapter interface for peer-to-peer data synchronization across multiple transport networks (libp2p, WebRTC, Bluetooth).

EncryptedStorageAdapter

Defined in the Encrypted Computation document, this adapter will wrap any existing adapter with encryption, supporting:

  • FHE (Fully Homomorphic Encryption): Compute on encrypted data without decryption
  • TEE (Trusted Execution Environments): Run computations in hardware-isolated enclaves
  • MPC (Multi-Party Computation): Distribute computations across multiple parties

IPFSStorageAdapter

A content-addressed storage adapter using IPFS for immutable, distributed storage. Useful for data that should be permanent and verifiable.

Architecture Diagram

Application Code
       |
       v
DataLayerService (permissions, cache, sync, webhooks)
       |
       v
StorageAdapter (interface)
       |
       +-- FirebaseStorageAdapter (production)
       +-- MockStorageAdapter (testing)
       +-- PostgresStorageAdapter (custom)
       +-- P2PStorageAdapter (planned)
       +-- EncryptedStorageAdapter (planned)
       +-- IPFSStorageAdapter (planned)

Core architectural constraint

@cyber-eco/services has zero imports from firebase/*. This is verified in CI:

grep -r "firebase" packages/services/src/ --include="*.ts" --include="*.tsx"
# Must return zero results
All Firebase interaction happens inside @cyber-eco/firebase through the StorageAdapter interface.

Next Steps