@gensx/storage
Version:
Cloud storage, blobs, sqlite, and vector database providers/hooks for GenSX.
419 lines (369 loc) • 11.5 kB
text/typescript
/* eslint-disable @typescript-eslint/only-throw-error */
import { readConfig } from "@gensx/core";
import { InArgs } from "@libsql/client";
import { parseErrorResponse } from "../utils/parse-error.js";
import { USER_AGENT } from "../utils/user-agent.js";
import {
Database,
DatabaseBatchResult,
DatabaseError,
DatabaseInfo,
DatabaseInternalError,
DatabaseNetworkError,
DatabaseResult,
DatabaseStatement,
DatabaseStorage,
DeleteDatabaseResult,
EnsureDatabaseResult,
} from "./types.js";
/**
* Base URL for the GenSX Console API
*/
const API_BASE_URL = "https://api.gensx.com";
/**
* Helper to convert between API errors and DatabaseErrors
*/
function handleApiError(err: unknown, operation: string): never {
if (err instanceof DatabaseError) {
throw err;
}
if (err instanceof Error) {
throw new DatabaseNetworkError(
`Error during ${operation}: ${err.message}`,
err,
);
}
throw new DatabaseNetworkError(`Error during ${operation}: ${String(err)}`);
}
/**
* Implementation of Database interface for remote cloud storage
*/
export class RemoteDatabase implements Database {
private databaseName: string;
private baseUrl: string;
private apiKey: string;
private org: string;
private project: string;
private environment: string;
constructor(
databaseName: string,
baseUrl: string,
apiKey: string,
org: string,
project: string,
environment: string,
) {
this.databaseName = encodeURIComponent(databaseName);
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.org = org;
this.project = project;
this.environment = environment;
}
async execute(sql: string, params?: InArgs): Promise<DatabaseResult> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${this.databaseName}/execute`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
sql,
params,
}),
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(`Failed to execute SQL: ${message}`);
}
const data = (await response.json()) as DatabaseResult;
return data;
} catch (err) {
throw handleApiError(err, "execute");
}
}
async batch(statements: DatabaseStatement[]): Promise<DatabaseBatchResult> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${this.databaseName}/batch`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ statements }),
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(`Failed to execute batch: ${message}`);
}
const data = (await response.json()) as DatabaseBatchResult;
return data;
} catch (err) {
throw handleApiError(err, "batch");
}
}
async executeMultiple(sql: string): Promise<DatabaseBatchResult> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${this.databaseName}/multiple`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ sql }),
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(
`Failed to execute multiple: ${message}`,
);
}
const data = (await response.json()) as DatabaseBatchResult;
return data;
} catch (err) {
throw handleApiError(err, "executeMultiple");
}
}
async migrate(sql: string): Promise<DatabaseBatchResult> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${this.databaseName}/migrate`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ sql }),
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(
`Failed to execute migration: ${message}`,
);
}
const data = (await response.json()) as DatabaseBatchResult;
return data;
} catch (err) {
throw handleApiError(err, "migrate");
}
}
async getInfo(): Promise<DatabaseInfo> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${this.databaseName}/info`,
{
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(
`Failed to get database info: ${message}`,
);
}
const data = (await response.json()) as DatabaseInfo;
// Convert date string to Date object
const { lastModified, ...rest } = data;
return {
...rest,
lastModified: new Date(lastModified as unknown as string),
};
} catch (err) {
throw handleApiError(err, "getInfo");
}
}
close() {
// No-op for remote database - connection is managed by API
return;
}
}
/**
* Implementation of DatabaseStorage interface for remote cloud storage
*/
export class RemoteDatabaseStorage implements DatabaseStorage {
private apiKey: string;
private apiBaseUrl: string;
private org: string;
private project: string;
private environment: string;
private databases: Map<string, RemoteDatabase> = new Map<
string,
RemoteDatabase
>();
constructor(project: string, environment: string) {
this.project = project;
this.environment = environment;
const config = readConfig();
this.apiKey = process.env.GENSX_API_KEY ?? config.api?.token ?? "";
if (!this.apiKey) {
throw new Error(
"GENSX_API_KEY environment variable must be set for cloud storage",
);
}
this.org = process.env.GENSX_ORG ?? config.api?.org ?? "";
if (!this.org) {
throw new Error(
"Organization ID must be provided via props or GENSX_ORG environment variable",
);
}
this.apiBaseUrl =
process.env.GENSX_API_BASE_URL ?? config.api?.baseUrl ?? API_BASE_URL;
}
getDatabase(name: string): Database {
if (!this.databases.has(name)) {
this.databases.set(
name,
new RemoteDatabase(
name,
this.apiBaseUrl,
this.apiKey,
this.org,
this.project,
this.environment,
),
);
}
return this.databases.get(name)!;
}
async listDatabases(options?: { limit?: number; cursor?: string }): Promise<{
databases: { name: string; createdAt: Date }[];
nextCursor?: string;
}> {
try {
const url = new URL(
`${this.apiBaseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database`,
);
if (options?.limit) {
url.searchParams.append("limit", options.limit.toString());
}
if (options?.cursor) {
url.searchParams.append("cursor", options.cursor);
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
});
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(`Failed to list databases: ${message}`);
}
const data = (await response.json()) as {
databases: { name: string; createdAt: string }[];
nextCursor?: string;
};
return {
databases: data.databases.map((db) => ({
name: decodeURIComponent(db.name),
createdAt: new Date(db.createdAt),
})),
...(data.nextCursor && { nextCursor: data.nextCursor }),
};
} catch (err) {
throw handleApiError(err, "listDatabases");
}
}
async ensureDatabase(name: string): Promise<EnsureDatabaseResult> {
try {
const response = await fetch(
`${this.apiBaseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${encodeURIComponent(
name,
)}/ensure`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(
`Failed to ensure database: ${message}`,
);
}
const data = (await response.json()) as EnsureDatabaseResult;
// Make sure the database is in our cache
if (!this.databases.has(name)) {
this.getDatabase(name);
}
return data;
} catch (err) {
if (err instanceof DatabaseError) {
throw err;
}
throw new DatabaseNetworkError(
`Error during ensureDatabase operation: ${String(err)}`,
err as Error,
);
}
}
hasEnsuredDatabase(name: string): boolean {
return this.databases.has(name);
}
async deleteDatabase(name: string): Promise<DeleteDatabaseResult> {
try {
const response = await fetch(
`${this.apiBaseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/database/${encodeURIComponent(
name,
)}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new DatabaseInternalError(
`Failed to delete database: ${message}`,
);
}
const data = (await response.json()) as DeleteDatabaseResult;
// Remove database from caches if it was successfully deleted
if (data.deleted) {
if (this.databases.has(name)) {
const db = this.databases.get(name);
if (db) {
db.close();
this.databases.delete(name);
}
}
}
return data;
} catch (err) {
if (err instanceof DatabaseError) {
throw err;
}
throw new DatabaseNetworkError(
`Error during deleteDatabase operation: ${String(err)}`,
err as Error,
);
}
}
}