Phase 14: Client SDKs - JavaScript/TypeScript Architecture¶
Package Structure¶
@aerodb/client/
├── src/
│ ├── index.ts # Main entry point
│ ├── AeroDBClient.ts # Client class
│ ├── auth/ # Authentication
│ │ ├── AuthClient.ts
│ │ └── types.ts
│ ├── database/ # CRUD operations
│ │ ├── QueryBuilder.ts
│ │ ├── PostgrestClient.ts # PostgREST-style API
│ │ └── types.ts
│ ├── realtime/ # WebSocket
│ │ ├── RealtimeClient.ts
│ │ ├── RealtimeChannel.ts
│ │ └── types.ts
│ ├── storage/ # File uploads
│ │ ├── StorageClient.ts
│ │ └── types.ts
│ ├── functions/ # Serverless
│ │ ├── FunctionsClient.ts
│ │ └── types.ts
│ ├── lib/ # Shared utilities
│ │ ├── fetch.ts
│ │ ├── constants.ts
│ │ └── helpers.ts
│ └── types/ # Global types
│ └── index.ts
├── tests/ # Unit tests
├── package.json
├── tsconfig.json
└── README.md
Core Classes¶
AeroDBClient¶
Main entry point, exposes all sub-clients:
// src/AeroDBClient.ts
import { AuthClient } from './auth/AuthClient';
import { PostgrestClient } from './database/PostgrestClient';
import { RealtimeClient } from './realtime/RealtimeClient';
import { StorageClient } from './storage/StorageClient';
import { FunctionsClient } from './functions/FunctionsClient';
export interface AeroDBClientOptions {
url: string; // Base URL (e.g., https://api.aerodb.com)
key?: string; // API key (optional, can use signIn)
schema?: string; // Database schema (default: 'public')
headers?: Record<string, string>;
realtime?: { url: string }; // WebSocket URL override
}
export class AeroDBClient {
auth: AuthClient;
private db: PostgrestClient;
realtime: RealtimeClient;
storage: StorageClient;
functions: FunctionsClient;
constructor(options: AeroDBClientOptions) {
this.auth = new AuthClient(options);
this.db = new PostgrestClient(options);
this.realtime = new RealtimeClient(options);
this.storage = new StorageClient(options);
this.functions = new FunctionsClient(options);
}
// Shorthand for database queries
from<T = any>(collection: string) {
return this.db.from<T>(collection);
}
// Create a real-time channel
channel(name: string) {
return this.realtime.channel(name);
}
}
AuthClient¶
// src/auth/AuthClient.ts
import type { User, Session, AuthResponse } from './types';
export class AuthClient {
private url: string;
constructor(options: AeroDBClientOptions) {
this.url = `${options.url}/auth`;
}
async signUp(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${this.url}/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
return { data: null, error: { message: data.error, status: res.status } };
}
return { data: { user: data.user, session: data.session }, error: null };
}
async signIn(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${this.url}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
return { data: null, error: { message: data.error, status: res.status } };
}
// Store tokens
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return { data: { user: data.user, session: data.session }, error: null };
}
async signOut(): Promise<{ error: null | { message: string } }> {
const token = localStorage.getItem('access_token');
await fetch(`${this.url}/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
return { error: null };
}
async getUser(): Promise<{ data: User | null; error: any }> {
const token = localStorage.getItem('access_token');
if (!token) {
return { data: null, error: { message: 'Not authenticated' } };
}
const res = await fetch(`${this.url}/user`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
return { data: null, error: { message: 'Failed to fetch user' } };
}
const user = await res.json();
return { data: user, error: null };
}
onAuthStateChange(callback: (event: 'SIGNED_IN' | 'SIGNED_OUT', session: Session | null) => void) {
// Listen for storage events (multi-tab sync)
window.addEventListener('storage', (e) => {
if (e.key === 'access_token') {
if (e.newValue) {
callback('SIGNED_IN', { access_token: e.newValue });
} else {
callback('SIGNED_OUT', null);
}
}
});
}
}
QueryBuilder¶
// src/database/QueryBuilder.ts
export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in';
export class QueryBuilder<T = any> {
private collection: string;
private selectFields: string = '*';
private filters: Array<{ field: string; op: FilterOperator; value: any }> = [];
private orderFields: Array<{ field: string; ascending: boolean }> = [];
private limitValue?: number;
private offsetValue?: number;
private baseUrl: string;
constructor(collection: string, baseUrl: string) {
this.collection = collection;
this.baseUrl = baseUrl;
}
select(fields: string = '*'): this {
this.selectFields = fields;
return this;
}
eq(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'eq', value });
return this;
}
neq(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'neq', value });
return this;
}
gt(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'gt', value });
return this;
}
gte(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'gte', value });
return this;
}
lt(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'lt', value });
return this;
}
lte(field: keyof T, value: any): this {
this.filters.push({ field: field as string, op: 'lte', value });
return this;
}
like(field: keyof T, pattern: string): this {
this.filters.push({ field: field as string, op: 'like', value: pattern });
return this;
}
order(field: keyof T, options: { ascending?: boolean } = {}): this {
this.orderFields.push({ field: field as string, ascending: options.ascending ?? true });
return this;
}
limit(count: number): this {
this.limitValue = count;
return this;
}
offset(count: number): this {
this.offsetValue = count;
return this;
}
private buildQueryString(): string {
const params = new URLSearchParams();
if (this.selectFields) {
params.set('select', this.selectFields);
}
this.filters.forEach(({ field, op, value }) => {
params.set(field, `${op}.${value}`);
});
if (this.orderFields.length > 0) {
const order = this.orderFields.map(({ field, ascending }) =>
`${field}.${ascending ? 'asc' : 'desc'}`
).join(',');
params.set('order', order);
}
if (this.limitValue) {
params.set('limit', String(this.limitValue));
}
if (this.offsetValue) {
params.set('offset', String(this.offsetValue));
}
return params.toString();
}
async execute(): Promise<{ data: T[] | null; error: any }> {
const token = localStorage.getItem('access_token');
const queryString = this.buildQueryString();
const url = `${this.baseUrl}/rest/v1/${this.collection}?${queryString}`;
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const json = await res.json();
if (!res.ok) {
return { data: null, error: { message: json.error, status: res.status } };
}
return { data: json.data, error: null };
}
}
RealtimeChannel¶
// src/realtime/RealtimeChannel.ts
export type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE';
export class RealtimeChannel {
private name: string;
private ws: WebSocket;
private callbacks: Map<RealtimeEvent, Array<(payload: any) => void>> = new Map();
constructor(name: string, ws: WebSocket) {
this.name = name;
this.ws = ws;
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'event' && message.channel === this.name) {
const eventType = message.payload.type as RealtimeEvent;
const handlers = this.callbacks.get(eventType) || [];
handlers.forEach((cb) => cb(message.payload));
}
};
}
on(event: RealtimeEvent, callback: (payload: any) => void): this {
if (!this.callbacks.has(event)) {
this.callbacks.set(event, []);
}
this.callbacks.get(event)!.push(callback);
return this;
}
subscribe(): this {
this.ws.send(JSON.stringify({ type: 'subscribe', channel: this.name }));
return this;
}
unsubscribe(): void {
this.ws.send(JSON.stringify({ type: 'unsubscribe', channel: this.name }));
this.ws.close();
}
}
Type Definitions¶
// src/types/index.ts
export interface AeroDBResponse<T> {
data: T | null;
error: AeroDBError | null;
}
export interface AeroDBError {
message: string;
status?: number;
code?: string;
}
export interface User {
id: string;
email: string;
created_at: string;
}
export interface Session {
access_token: string;
refresh_token?: string;
expires_at?: number;
}
Usage Example¶
import { AeroDBClient } from '@aerodb/client';
const client = new AeroDBClient({
url: 'https://api.aerodb.com',
});
// Auth
const { data, error } = await client.auth.signIn('user@example.com', 'password');
// Query
const { data: users } = await client
.from('users')
.select('id, name, email')
.eq('role', 'admin')
.limit(10)
.execute();
// Real-time
client.channel('users')
.on('INSERT', (payload) => {
console.log('New user:', payload.new);
})
.subscribe();
// Storage
const { data: file } = await client.storage
.from('avatars')
.upload('user-123.png', fileBlob);
Build Configuration¶
// package.json
{
"name": "@aerodb/client",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"test": "vitest",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
}
}
Testing¶
// tests/QueryBuilder.test.ts
import { describe, it, expect } from 'vitest';
import { QueryBuilder } from '../src/database/QueryBuilder';
describe('QueryBuilder', () => {
it('builds simple query', () => {
const qb = new QueryBuilder('users', 'https://api.example.com');
qb.select('id, name').eq('role', 'admin').limit(10);
const query = qb['buildQueryString'](); // Access private for testing
expect(query).toContain('select=id,name');
expect(query).toContain('role=eq.admin');
expect(query).toContain('limit=10');
});
});