@dataql/supabase-adapter
Version:
Supabase adapter for DataQL with zero API changes
619 lines (558 loc) • 15.3 kB
text/typescript
import {
Data,
DataOptions,
ID,
String,
Int,
Boolean,
Decimal,
Date as DataQLDate,
} from "@dataql/core";
// Supabase-like configuration
export interface SupabaseClientOptions {
// Required for DataQL infrastructure routing
appToken?: string;
// Database name for client isolation
dbName?: string;
// Environment routing
env?: "dev" | "prod";
// Development prefix
devPrefix?: string;
// Optional custom connection
customConnection?: any;
// Supabase-specific options (for compatibility)
auth?: {
autoRefreshToken?: boolean;
persistSession?: boolean;
detectSessionInUrl?: boolean;
};
realtime?: {
params?: Record<string, any>;
};
global?: {
headers?: Record<string, string>;
};
}
// Supabase response types
export interface SupabaseResponse<T> {
data: T | null;
error: SupabaseError | null;
count?: number | null;
status: number;
statusText: string;
}
export interface SupabaseError {
message: string;
details?: string;
hint?: string;
code?: string;
}
// Filter operators
export type FilterOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "like"
| "ilike"
| "match"
| "imatch"
| "in"
| "cs"
| "cd"
| "sl"
| "sr"
| "nxl"
| "nxr"
| "adj"
| "ov"
| "fts"
| "plfts"
| "phfts"
| "wfts";
// Real-time events
export type RealtimeSubscriptionEvent = "INSERT" | "UPDATE" | "DELETE" | "*";
export interface RealtimePayload<T = any> {
commit_timestamp: string;
eventType: RealtimeSubscriptionEvent;
schema: string;
table: string;
new?: T;
old?: T;
errors?: string[];
}
export interface RealtimeSubscription {
unsubscribe: () => void;
}
// Query builder class
export class SupabaseQueryBuilder<T = any> {
private _data: Data;
private _tableName: string;
private _collection: any;
private _selectFields?: string[];
private _filters: Array<{
column: string;
operator: FilterOperator;
value: any;
}> = [];
private _orderBy?: { column: string; ascending: boolean };
private _limit?: number;
private _offset?: number;
private _single = false;
private _count?: "exact" | "planned" | "estimated";
constructor(data: Data, tableName: string, schema?: any) {
this._data = data;
this._tableName = tableName;
this._collection = data.collection(
tableName,
schema || this._getDefaultSchema()
);
}
private _getDefaultSchema() {
// Default schema for tables without explicit schema
return {
id: { type: "ID", required: true },
created_at: { type: "Date", default: "now" },
updated_at: { type: "Date", default: "now" },
};
}
// Select columns
select(
columns?: string,
options?: { count?: "exact" | "planned" | "estimated" }
): this {
if (columns) {
this._selectFields = columns.split(",").map((col) => col.trim());
}
if (options?.count) {
this._count = options.count;
}
return this;
}
// Filtering methods
eq(column: string, value: any): this {
this._filters.push({ column, operator: "eq", value });
return this;
}
neq(column: string, value: any): this {
this._filters.push({ column, operator: "neq", value });
return this;
}
gt(column: string, value: any): this {
this._filters.push({ column, operator: "gt", value });
return this;
}
gte(column: string, value: any): this {
this._filters.push({ column, operator: "gte", value });
return this;
}
lt(column: string, value: any): this {
this._filters.push({ column, operator: "lt", value });
return this;
}
lte(column: string, value: any): this {
this._filters.push({ column, operator: "lte", value });
return this;
}
like(column: string, pattern: string): this {
this._filters.push({ column, operator: "like", value: pattern });
return this;
}
ilike(column: string, pattern: string): this {
this._filters.push({ column, operator: "ilike", value: pattern });
return this;
}
in(column: string, values: any[]): this {
this._filters.push({ column, operator: "in", value: values });
return this;
}
contains(column: string, value: any): this {
this._filters.push({ column, operator: "cs", value });
return this;
}
containedBy(column: string, value: any): this {
this._filters.push({ column, operator: "cd", value });
return this;
}
textSearch(column: string, query: string): this {
this._filters.push({ column, operator: "fts", value: query });
return this;
}
// Range filtering
range(from: number, to: number): this {
this._offset = from;
this._limit = to - from + 1;
return this;
}
// Ordering
order(
column: string,
options?: { ascending?: boolean; nullsFirst?: boolean }
): this {
this._orderBy = { column, ascending: options?.ascending !== false };
return this;
}
// Limit
limit(count: number): this {
this._limit = count;
return this;
}
// Single result
single(): this {
this._single = true;
this._limit = 1;
return this;
}
// Maybe single result
maybeSingle(): this {
this._limit = 1;
return this;
}
// Execute query and return response
async execute(): Promise<SupabaseResponse<T | T[]>> {
try {
const filter = this._buildDataQLFilter();
let results = await this._collection.find(filter);
// Apply ordering
if (this._orderBy) {
results = this._applyOrderBy(results);
}
// Apply pagination
if (this._offset) {
results = results.slice(this._offset);
}
if (this._limit) {
results = results.slice(0, this._limit);
}
// Apply field selection
if (this._selectFields) {
results = results.map((item: any) => this._applyFieldSelection(item));
}
// Return single or array based on query type
const data = this._single ? results[0] || null : results;
const count = this._count ? results.length : null;
return {
data: data as T | T[],
error: null,
count,
status: 200,
statusText: "OK",
};
} catch (error) {
return {
data: null,
error: {
message: error instanceof Error ? error.message : "Unknown error",
code: "DATAQL_ERROR",
},
status: 500,
statusText: "Internal Server Error",
};
}
}
private _buildDataQLFilter(): any {
const filter: any = {};
for (const filterItem of this._filters) {
const { column, operator, value } = filterItem;
switch (operator) {
case "eq":
filter[column] = value;
break;
case "neq":
filter[column] = { $ne: value };
break;
case "gt":
filter[column] = { $gt: value };
break;
case "gte":
filter[column] = { $gte: value };
break;
case "lt":
filter[column] = { $lt: value };
break;
case "lte":
filter[column] = { $lte: value };
break;
case "like":
case "ilike":
filter[column] = {
$regex: value,
$options: operator === "ilike" ? "i" : "",
};
break;
case "in":
filter[column] = { $in: value };
break;
case "cs":
filter[column] = { $in: Array.isArray(value) ? value : [value] };
break;
case "fts":
filter[column] = { $text: { $search: value } };
break;
default:
filter[column] = value;
}
}
return filter;
}
private _applyOrderBy(results: any[]): any[] {
if (!this._orderBy) return results;
const { column, ascending } = this._orderBy;
return results.sort((a, b) => {
const aVal = a[column];
const bVal = b[column];
if (aVal < bVal) return ascending ? -1 : 1;
if (aVal > bVal) return ascending ? 1 : -1;
return 0;
});
}
private _applyFieldSelection(item: any): any {
if (!this._selectFields) return item;
const result: any = {};
for (const field of this._selectFields) {
if (field.includes(".")) {
// Handle nested field selection (simplified)
const parts = field.split(".");
let value = item;
for (const part of parts) {
value = value?.[part];
}
result[field] = value;
} else {
result[field] = item[field];
}
}
return result;
}
// Insert data
async insert(
data: Partial<T> | Partial<T>[]
): Promise<SupabaseResponse<T | T[]>> {
try {
const result = await this._collection.create(data);
return {
data: result,
error: null,
status: 201,
statusText: "Created",
};
} catch (error) {
return {
data: null,
error: {
message: error instanceof Error ? error.message : "Insert failed",
code: "INSERT_ERROR",
},
status: 400,
statusText: "Bad Request",
};
}
}
// Update data
async update(data: Partial<T>): Promise<SupabaseResponse<T | T[]>> {
try {
const filter = this._buildDataQLFilter();
const result = await this._collection.update(filter, data);
return {
data: result,
error: null,
status: 200,
statusText: "OK",
};
} catch (error) {
return {
data: null,
error: {
message: error instanceof Error ? error.message : "Update failed",
code: "UPDATE_ERROR",
},
status: 400,
statusText: "Bad Request",
};
}
}
// Upsert data
async upsert(
data: Partial<T> | Partial<T>[],
options?: { onConflict?: string }
): Promise<SupabaseResponse<T | T[]>> {
try {
const result = await this._collection.upsert(data);
return {
data: result,
error: null,
status: 200,
statusText: "OK",
};
} catch (error) {
return {
data: null,
error: {
message: error instanceof Error ? error.message : "Upsert failed",
code: "UPSERT_ERROR",
},
status: 400,
statusText: "Bad Request",
};
}
}
// Delete data
async delete(): Promise<SupabaseResponse<T | T[]>> {
try {
const filter = this._buildDataQLFilter();
const result = await this._collection.delete(filter);
return {
data: result,
error: null,
status: 200,
statusText: "OK",
};
} catch (error) {
return {
data: null,
error: {
message: error instanceof Error ? error.message : "Delete failed",
code: "DELETE_ERROR",
},
status: 400,
statusText: "Bad Request",
};
}
}
// Real-time subscription
on(
event: RealtimeSubscriptionEvent,
callback: (payload: RealtimePayload<T>) => void
): RealtimeSubscription {
// Mock real-time subscription for now
// In a real implementation, this would use DataQL's real-time capabilities
console.log(`Subscribed to ${event} events on table ${this._tableName}`);
return {
unsubscribe: () => {
console.log(
`Unsubscribed from ${event} events on table ${this._tableName}`
);
},
};
}
}
// Supabase client class
export class SupabaseClient {
private _data: Data;
private _schemas: Map<string, any> = new Map();
constructor(
supabaseUrl: string,
supabaseKey: string,
options?: SupabaseClientOptions
) {
// Ensure proper DataQL infrastructure routing (Client → Worker → Lambda → Database)
const dataqlOptions = {
appToken: options?.appToken || "default", // Required for DataQL routing
env: options?.env || "prod", // Routes to production infrastructure
devPrefix: options?.devPrefix || "dev_", // Optional prefix for dev environments
dbName: options?.dbName, // Database isolation per client
customConnection: options?.customConnection, // Optional custom connection
};
this._data = new Data(dataqlOptions);
}
// Register table schema
registerTable(tableName: string, schema: any): void {
this._schemas.set(tableName, schema);
}
// Get table query builder
from<T = any>(table: string): SupabaseQueryBuilder<T> {
const schema = this._schemas.get(table);
return new SupabaseQueryBuilder<T>(this._data, table, schema);
}
// Authentication methods (mocked for now)
get auth() {
return {
signUp: async (credentials: any) => ({ data: null, error: null }),
signIn: async (credentials: any) => ({ data: null, error: null }),
signOut: async () => ({ error: null }),
getUser: async () => ({ data: null, error: null }),
getSession: async () => ({ data: null, error: null }),
onAuthStateChange: (callback: Function) => ({
data: { subscription: { unsubscribe: () => {} } },
}),
};
}
// Storage methods (mocked for now)
get storage() {
return {
from: (bucket: string) => ({
upload: async (path: string, file: any) => ({
data: null,
error: null,
}),
download: async (path: string) => ({ data: null, error: null }),
remove: async (paths: string[]) => ({ data: null, error: null }),
list: async (path?: string) => ({ data: [], error: null }),
getPublicUrl: (path: string) => ({ data: { publicUrl: "" } }),
}),
};
}
// RPC (Remote Procedure Call) methods
async rpc<T = any>(
fn: string,
args?: Record<string, any>,
options?: { count?: "exact" | "planned" | "estimated" }
): Promise<SupabaseResponse<T>> {
// Mock RPC calls for now
return {
data: null,
error: {
message: "RPC not yet supported in DataQL adapter",
code: "RPC_NOT_SUPPORTED",
},
status: 501,
statusText: "Not Implemented",
};
}
// Channel for real-time
channel(name: string, options?: { config?: Record<string, any> }) {
return {
on: (
type: "postgres_changes",
filter: {
event: RealtimeSubscriptionEvent;
schema: string;
table: string;
},
callback: (payload: RealtimePayload) => void
) => {
// Mock channel subscription
console.log(
`Subscribed to postgres_changes on ${filter.schema}.${filter.table}`
);
return {
unsubscribe: () => {
console.log(
`Unsubscribed from postgres_changes on ${filter.schema}.${filter.table}`
);
},
};
},
subscribe: () => {
console.log(`Subscribed to channel ${name}`);
return Promise.resolve();
},
unsubscribe: () => {
console.log(`Unsubscribed from channel ${name}`);
return Promise.resolve();
},
};
}
}
// Create Supabase client
export function createClient(
supabaseUrl: string,
supabaseKey: string,
options?: SupabaseClientOptions
): SupabaseClient {
return new SupabaseClient(supabaseUrl, supabaseKey, options);
}
// Export main client
export default createClient;