@gensx/storage
Version:
Cloud storage, blobs, sqlite, and vector database providers/hooks for GenSX.
512 lines (507 loc) • 20.3 kB
JavaScript
;
/**
* Check out the docs at https://www.gensx.com/docs
* Find us on Github https://github.com/gensx-inc/gensx
* Find us on Discord https://discord.gg/F5BSU8Kc
*/
var stream = require('stream');
var core = require('@gensx/core');
var parseError = require('../utils/parse-error.cjs');
var userAgent = require('../utils/user-agent.cjs');
var types = require('./types.cjs');
/* eslint-disable @typescript-eslint/only-throw-error */
/**
* 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, operation) {
if (err instanceof types.BlobError) {
throw err;
}
if (err instanceof Error) {
throw new types.BlobNetworkError(`Error during ${operation}: ${err.message}`, err);
}
throw new types.BlobNetworkError(`Error during ${operation}: ${String(err)}`);
}
/**
* Implementation of Blob interface for remote cloud storage
*/
class RemoteBlob {
key;
baseUrl;
apiKey;
org;
project;
environment;
constructor(key, baseUrl, apiKey, org, project, environment) {
this.key = encodeURIComponent(key);
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.org = org;
this.project = project;
this.environment = environment;
}
async getJSON() {
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": userAgent.USER_AGENT,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json());
// Parse the content as JSON since it's stored as a string
return JSON.parse(data.content);
}
catch (err) {
throw handleApiError(err, "getJSON");
}
}
async getString() {
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": userAgent.USER_AGENT,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json());
return data.content;
}
catch (err) {
throw handleApiError(err, "getString");
}
}
async getRaw() {
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": userAgent.USER_AGENT,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to get blob: ${message}`);
}
const data = (await response.json());
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() {
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": userAgent.USER_AGENT,
},
});
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to get blob: ${message}`);
}
if (!response.body) {
throw new types.BlobInternalError("Response body is null");
}
return stream.Readable.from(response.body);
}
catch (err) {
throw handleApiError(err, "getStream");
}
}
async putJSON(value, options) {
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": userAgent.USER_AGENT,
},
body: JSON.stringify({
content: JSON.stringify(value),
contentType: "application/json",
metadata: options?.metadata,
}),
});
if (!response.ok) {
if (response.status === 412) {
throw new types.BlobConflictError("ETag mismatch");
}
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new types.BlobInternalError("No ETag returned from server");
}
return { etag };
}
catch (err) {
throw handleApiError(err, "putJSON");
}
}
async putString(value, options) {
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": userAgent.USER_AGENT,
},
body: JSON.stringify({
content: value,
contentType: "text/plain",
metadata: options?.metadata,
}),
});
if (!response.ok) {
if (response.status === 412) {
throw new types.BlobConflictError("ETag mismatch");
}
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new types.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, options) {
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": userAgent.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 types.BlobConflictError("ETag mismatch");
}
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new types.BlobInternalError("No ETag returned from server");
}
return { etag };
}
catch (err) {
throw handleApiError(err, "putRaw");
}
}
async putStream(stream, options) {
try {
// Convert stream to buffer - necessary for the current API implementation
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
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": userAgent.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 types.BlobConflictError("ETag mismatch");
}
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to put blob: ${message}`);
}
const etag = response.headers.get("etag");
if (!etag) {
throw new types.BlobInternalError("No ETag returned from server");
}
return { etag };
}
catch (err) {
throw handleApiError(err, "putStream");
}
}
async delete() {
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": userAgent.USER_AGENT,
},
});
if (!response.ok && response.status !== 404) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to delete blob: ${message}`);
}
}
catch (err) {
throw handleApiError(err, "delete");
}
}
async exists() {
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() {
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": userAgent.USER_AGENT,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to get metadata: ${message}`);
}
// Extract standard headers
const metadata = {};
// 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, options) {
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": userAgent.USER_AGENT,
},
body: JSON.stringify({
metadata,
}),
});
if (!response.ok) {
if (response.status === 412) {
throw new types.BlobConflictError("ETag mismatch");
}
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to update metadata: ${message}`);
}
}
catch (err) {
throw handleApiError(err, "updateMetadata");
}
}
}
/**
* Remote implementation of blob storage using GenSX Console API
*/
class RemoteBlobStorage {
apiKey;
apiBaseUrl;
org;
project;
environment;
defaultPrefix;
constructor(project, environment, defaultPrefix) {
this.project = project;
this.environment = environment;
this.defaultPrefix = defaultPrefix;
// readConfig has internal error handling and always returns a GensxConfig object
const config = core.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(key) {
const fullKey = this.defaultPrefix ? `${this.defaultPrefix}/${key}` : key;
return new RemoteBlob(fullKey, this.apiBaseUrl, this.apiKey, this.org, this.project, this.environment);
}
async listBlobs(options) {
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": userAgent.USER_AGENT,
},
});
if (!response.ok) {
const message = await parseError.parseErrorResponse(response);
throw new types.BlobInternalError(`Failed to list blobs: ${message}`);
}
const data = (await response.json());
// Process blob keys to handle the default prefix
let processedBlobs = [];
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 types.BlobError) {
throw err;
}
throw new types.BlobNetworkError(`Error during listBlobs operation: ${String(err)}`, err);
}
}
async blobExists(key) {
const blob = this.getBlob(key);
return blob.exists();
}
async deleteBlob(key) {
try {
const blob = this.getBlob(key);
await blob.delete();
return { deleted: true };
}
catch (err) {
if (err instanceof types.BlobError) {
throw err;
}
throw new types.BlobNetworkError(`Error during deleteBlob operation: ${String(err)}`, err);
}
}
}
exports.RemoteBlob = RemoteBlob;
exports.RemoteBlobStorage = RemoteBlobStorage;
//# sourceMappingURL=remote.cjs.map