@gensx/storage
Version:
Cloud storage, blobs, sqlite, and vector database providers/hooks for GenSX.
676 lines (601 loc) • 19.1 kB
text/typescript
/* eslint-disable @typescript-eslint/only-throw-error */
import { Readable } from "stream";
import { readConfig } from "@gensx/core";
import { parseErrorResponse } from "../utils/parse-error.js";
import { USER_AGENT } from "../utils/user-agent.js";
import {
Blob,
BlobConflictError,
BlobError,
BlobInternalError,
BlobNetworkError,
BlobOptions,
BlobResponse,
BlobStorage,
DeleteBlobResult,
ListBlobsOptions,
ListBlobsResponse,
} 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 BlobErrors
*/
function handleApiError(err: unknown, operation: string): never {
if (err instanceof BlobError) {
throw err;
}
if (err instanceof Error) {
throw new BlobNetworkError(
`Error during ${operation}: ${err.message}`,
err,
);
}
throw new BlobNetworkError(`Error during ${operation}: ${String(err)}`);
}
/**
* Implementation of Blob interface for remote cloud storage
*/
export class RemoteBlob<T> implements Blob<T> {
private key: string;
private baseUrl: string;
private apiKey: string;
private org: string;
private project: string;
private environment: string;
constructor(
key: string,
baseUrl: string,
apiKey: string,
org: string,
project: string,
environment: string,
) {
this.key = encodeURIComponent(key);
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.org = org;
this.project = project;
this.environment = environment;
}
async getJSON(): Promise<T | null> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json()) as BlobResponse<string>;
// Parse the content as JSON since it's stored as a string
return JSON.parse(data.content) as T;
} catch (err) {
throw handleApiError(err, "getJSON");
}
}
async getString(): Promise<string | null> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json()) as BlobResponse<string>;
return data.content;
} catch (err) {
throw handleApiError(err, "getString");
}
}
async getRaw(): Promise<BlobResponse<Buffer> | null> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json()) as {
content: string;
contentType?: string;
etag?: string;
lastModified?: string;
size?: number;
metadata?: Record<string, string>;
};
const {
content,
contentType,
etag,
lastModified,
size,
metadata = {},
} = data;
// Always decode base64 for raw data
const buffer = Buffer.from(content, "base64");
return {
content: buffer,
contentType,
etag,
lastModified: lastModified ? new Date(lastModified) : undefined,
size,
metadata: Object.fromEntries(
Object.entries(metadata).filter(([key]) => key !== "isBase64"),
),
};
} catch (err) {
throw handleApiError(err, "getRaw");
}
}
async getStream(): Promise<Readable> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to get blob: ${message}`);
}
if (!response.body) {
throw new BlobInternalError("Response body is null");
}
return Readable.from(response.body);
} catch (err) {
throw handleApiError(err, "getStream");
}
}
async putJSON(value: T, options?: BlobOptions): Promise<{ etag: string }> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(options?.etag && { "If-Match": options.etag }),
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
content: JSON.stringify(value),
contentType: "application/json",
metadata: options?.metadata,
}),
},
);
if (!response.ok) {
if (response.status === 412) {
throw new BlobConflictError("ETag mismatch");
}
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new BlobInternalError("No ETag returned from server");
}
return { etag };
} catch (err) {
throw handleApiError(err, "putJSON");
}
}
async putString(
value: string,
options?: BlobOptions,
): Promise<{ etag: string }> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(options?.etag && { "If-Match": options.etag }),
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
content: value,
contentType: "text/plain",
metadata: options?.metadata,
}),
},
);
if (!response.ok) {
if (response.status === 412) {
throw new BlobConflictError("ETag mismatch");
}
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new BlobInternalError("No ETag returned from server");
}
return { etag };
} catch (err) {
throw handleApiError(err, "putString");
}
}
/**
* Put raw binary data into the blob.
* @param value The binary data to store
* @param options Optional metadata and content type
*/
async putRaw(
value: Buffer,
options?: BlobOptions,
): Promise<{ etag: string }> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(options?.etag && { "If-Match": options.etag }),
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
content: value.toString("base64"),
contentType: options?.contentType ?? "application/octet-stream",
metadata: {
...(options?.metadata ?? {}),
isBase64: "true",
},
}),
},
);
if (!response.ok) {
if (response.status === 412) {
throw new BlobConflictError("ETag mismatch");
}
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new BlobInternalError("No ETag returned from server");
}
return { etag };
} catch (err) {
throw handleApiError(err, "putRaw");
}
}
async putStream(
stream: Readable,
options?: BlobOptions,
): Promise<{ etag: string }> {
try {
// Convert stream to buffer - necessary for the current API implementation
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as ArrayBufferLike));
}
const buffer = Buffer.concat(chunks);
// Send the buffer as base64-encoded content
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(options?.etag && { "If-Match": options.etag }),
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
content: buffer.toString("base64"),
contentType: options?.contentType ?? "application/octet-stream",
metadata: {
...(options?.metadata ?? {}),
isBase64: "true",
},
}),
},
);
if (!response.ok) {
if (response.status === 412) {
throw new BlobConflictError("ETag mismatch");
}
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new BlobInternalError("No ETag returned from server");
}
return { etag };
} catch (err) {
throw handleApiError(err, "putStream");
}
}
async delete(): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok && response.status !== 404) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to delete blob: ${message}`);
}
} catch (err) {
throw handleApiError(err, "delete");
}
}
async exists(): Promise<boolean> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "HEAD",
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
},
);
return response.ok;
} catch (err) {
throw handleApiError(err, "exists");
}
}
async getMetadata(): Promise<Record<string, string> | null> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "HEAD",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to get metadata: ${message}`);
}
// Extract standard headers
const metadata: Record<string, string> = {};
// Get etag from standard headers
const etag = response.headers.get("etag");
if (etag) {
metadata.etag = etag;
}
// Get custom metadata from individual x-blob-meta-* headers
for (const [name, value] of Object.entries(
Object.fromEntries(response.headers),
)) {
if (name.toLowerCase().startsWith("x-blob-meta-")) {
const metaKey = name.substring("x-blob-meta-".length);
if (metaKey === "content-type") {
metadata.contentType = value;
} else {
metadata[metaKey] = value;
}
}
}
return Object.keys(metadata).length > 0 ? metadata : null;
} catch (err) {
throw handleApiError(err, "getMetadata");
}
}
async updateMetadata(
metadata: Record<string, string>,
options?: BlobOptions,
): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob/${this.key}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(options?.etag && { "If-Match": options.etag }),
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
metadata,
}),
},
);
if (!response.ok) {
if (response.status === 412) {
throw new BlobConflictError("ETag mismatch");
}
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to update metadata: ${message}`);
}
} catch (err) {
throw handleApiError(err, "updateMetadata");
}
}
}
/**
* Remote implementation of blob storage using GenSX Console API
*/
export class RemoteBlobStorage implements BlobStorage {
private apiKey: string;
private apiBaseUrl: string;
private org: string;
private project: string;
private environment: string;
private defaultPrefix?: string;
constructor(project: string, environment: string, defaultPrefix?: string) {
this.project = project;
this.environment = environment;
this.defaultPrefix = defaultPrefix;
// readConfig has internal error handling and always returns a GensxConfig object
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 must be provided via props or GENSX_ORG environment variable",
);
}
this.apiBaseUrl =
process.env.GENSX_API_BASE_URL ?? config.api?.baseUrl ?? API_BASE_URL;
}
getBlob<T>(key: string): Blob<T> {
const fullKey = this.defaultPrefix ? `${this.defaultPrefix}/${key}` : key;
return new RemoteBlob<T>(
fullKey,
this.apiBaseUrl,
this.apiKey,
this.org,
this.project,
this.environment,
);
}
async listBlobs(options?: ListBlobsOptions): Promise<ListBlobsResponse> {
try {
// Normalize prefixes by removing trailing slashes
const normalizedDefaultPrefix = this.defaultPrefix?.replace(/\/$/, "");
const normalizedPrefix = options?.prefix?.replace(/\/$/, "");
// Build the search prefix
const searchPrefix = normalizedDefaultPrefix
? normalizedPrefix
? `${normalizedDefaultPrefix}/${normalizedPrefix}`
: normalizedDefaultPrefix
: (normalizedPrefix ?? "");
// Build query parameters
const queryParams = new URLSearchParams();
if (searchPrefix) {
queryParams.append("prefix", searchPrefix);
}
// Default to 100 documents if no limit is specified
queryParams.append("limit", (options?.limit ?? 100).toString());
if (options?.cursor) {
queryParams.append("cursor", options.cursor);
}
const response = await fetch(
`${this.apiBaseUrl}/org/${this.org}/projects/${this.project}/environments/${this.environment}/blob?${queryParams.toString()}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"User-Agent": USER_AGENT,
},
},
);
if (!response.ok) {
const message = await parseErrorResponse(response);
throw new BlobInternalError(`Failed to list blobs: ${message}`);
}
const data = (await response.json()) as {
blobs: { key: string; lastModified: string; size: number }[];
nextCursor?: string;
};
// Process blob keys to handle the default prefix
let processedBlobs: {
key: string;
lastModified: string;
size: number;
}[] = [];
if (normalizedDefaultPrefix) {
processedBlobs = data.blobs
.filter(
(blob) =>
blob.key === normalizedDefaultPrefix ||
blob.key.startsWith(`${normalizedDefaultPrefix}/`),
)
.map((blob) => ({
...blob,
key:
blob.key === normalizedDefaultPrefix
? ""
: blob.key.slice(normalizedDefaultPrefix.length + 1),
}));
} else {
processedBlobs = data.blobs;
}
return {
blobs: processedBlobs,
nextCursor: data.nextCursor,
};
} catch (err) {
if (err instanceof BlobError) {
throw err;
}
throw new BlobNetworkError(
`Error during listBlobs operation: ${String(err)}`,
err as Error,
);
}
}
async blobExists(key: string): Promise<boolean> {
const blob = this.getBlob(key);
return blob.exists();
}
async deleteBlob(key: string): Promise<DeleteBlobResult> {
try {
const blob = this.getBlob(key);
await blob.delete();
return { deleted: true };
} catch (err) {
if (err instanceof BlobError) {
throw err;
}
throw new BlobNetworkError(
`Error during deleteBlob operation: ${String(err)}`,
err as Error,
);
}
}
}