-
+
)}
@@ -95,9 +96,9 @@ export default function SchemaSaveDialog({
Cancel
{isExecuting ? (
-
+
) : (
-
+
)}
Continue
diff --git a/src/components/resource-card/utils.tsx b/src/components/resource-card/utils.tsx
index 5b66c1ef..9ec419e0 100644
--- a/src/components/resource-card/utils.tsx
+++ b/src/components/resource-card/utils.tsx
@@ -27,6 +27,7 @@ export function getDatabaseFriendlyName(type: string) {
if (type === "motherduck") return "Motherduck";
if (type === "duckdb") return "DuckDB";
if (type === "cloudflare" || type === "cloudflare-d1") return "Cloudflare";
+ if (type === "cloudflare-wae") return "Worker Analytics Engine";
if (type === "starbasedb") return "StarbaseDB";
if (type === "starbase") return "StarbaseDB";
if (type === "bigquery") return "BigQuery";
@@ -39,7 +40,12 @@ export function getDatabaseFriendlyName(type: string) {
export function getDatabaseIcon(type: string) {
if (type === "mysql") return MySQLIcon;
if (type === "postgres") return PostgreIcon;
- if (type === "cloudflare" || type === "cloudflare-d1") return CloudflareIcon;
+ if (
+ type === "cloudflare" ||
+ type === "cloudflare-d1" ||
+ type === "cloudflare-wae"
+ )
+ return CloudflareIcon;
if (type === "valtown") return ValTownIcon;
if (type === "starbasedb" || type === "starbase") return StarbaseIcon;
if (type === "libsql" || type === "turso") return TursoIcon;
diff --git a/src/context/schema-provider.tsx b/src/context/schema-provider.tsx
index 4d62d66a..b62695da 100644
--- a/src/context/schema-provider.tsx
+++ b/src/context/schema-provider.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from "react";
import { useAutoComplete } from "./auto-complete-provider";
+import { useConfig } from "./config-provider";
import { useDatabaseDriver } from "./driver-provider";
type AutoCompletionSchema = Record
| string[]>;
@@ -68,6 +69,7 @@ export function SchemaProvider({ children }: Readonly) {
const [error, setError] = useState();
const [loading, setLoading] = useState(true);
const { databaseDriver } = useDatabaseDriver();
+ const { extensions } = useConfig();
const [schema, setSchema] = useState({});
const [currentSchema, setCurrentSchema] = useState([]);
@@ -119,6 +121,18 @@ export function SchemaProvider({ children }: Readonly) {
}
}, [currentSchemaName, schema, setCurrentSchema]);
+ /**
+ * Triggered when re-fetching the database schema.
+ * This is particularly useful for Outerbase Cloud,
+ * which needs to update its data catalog to provide
+ * the schema to the AI.
+ */
+ useEffect(() => {
+ if (schema && Object.entries(schema).length > 0) {
+ extensions.triggerAfterFetchSchemaCallback(schema);
+ }
+ }, [schema, extensions]);
+
useEffect(() => {
const sortedTableList = [...currentSchema];
sortedTableList.sort((a, b) => {
diff --git a/src/core/extension-manager.tsx b/src/core/extension-manager.tsx
index 5071e8bb..55c53898 100644
--- a/src/core/extension-manager.tsx
+++ b/src/core/extension-manager.tsx
@@ -1,5 +1,5 @@
import { OptimizeTableHeaderProps } from "@/components/gui/table-optimized";
-import { DatabaseSchemaItem } from "@/drivers/base-driver";
+import { DatabaseSchemaItem, DatabaseSchemas } from "@/drivers/base-driver";
import { ReactElement } from "react";
import { IStudioExtension } from "./extension-base";
import { BeforeQueryPipeline } from "./query-pipeline";
@@ -31,6 +31,8 @@ type QueryHeaderResultMenuHandler = (
header: OptimizeTableHeaderProps
) => StudioExtensionMenuItem | undefined;
+type AfterFetchSchemaHandler = (schema: DatabaseSchemas) => void;
+
type QueryResultCellMenuHandler = () => StudioExtensionMenuItem | undefined;
export class StudioExtensionContext {
@@ -38,6 +40,7 @@ export class StudioExtensionContext {
protected beforeQueryHandlers: BeforeQueryHandler[] = [];
protected afterQueryHandlers: AfterQueryHandler[] = [];
+ protected afterFetchSchemaHandlers: AfterFetchSchemaHandler[] = [];
protected queryResultHeaderContextMenu: QueryHeaderResultMenuHandler[] = [];
protected queryResultCellContextMenu: QueryResultCellMenuHandler[] = [];
@@ -58,6 +61,10 @@ export class StudioExtensionContext {
this.afterQueryHandlers.push(handler);
}
+ registerAfterFetchSchema(handler: AfterFetchSchemaHandler) {
+ this.afterFetchSchemaHandlers.push(handler);
+ }
+
registerSidebar(option: RegisterSidebarOption) {
this.sidebars.push(option);
}
@@ -125,6 +132,12 @@ export class StudioExtensionManager extends StudioExtensionContext {
.filter(Boolean) as StudioExtensionMenuItem[];
}
+ triggerAfterFetchSchemaCallback(schema: DatabaseSchemas) {
+ for (const handler of this.afterFetchSchemaHandlers) {
+ handler(schema);
+ }
+ }
+
async beforeQuery(payload: BeforeQueryPipeline) {
for (const handler of this.beforeQueryHandlers) {
await handler(payload);
diff --git a/src/drivers/database/cloudflare-wae.ts b/src/drivers/database/cloudflare-wae.ts
new file mode 100644
index 00000000..55e654c1
--- /dev/null
+++ b/src/drivers/database/cloudflare-wae.ts
@@ -0,0 +1,221 @@
+import { ColumnHeader, ColumnType } from "@outerbase/sdk-transform";
+import {
+ DatabaseResultSet,
+ DatabaseSchemas,
+ DatabaseTableColumn,
+ DatabaseTableSchema,
+ DriverFlags,
+ SelectFromTableOptions,
+} from "../base-driver";
+import PostgresLikeDriver from "../postgres/postgres-driver";
+
+interface CloudflareWAEResponseMeta {
+ name: string;
+ type: "UInt32" | "String" | "Float64" | "DateTime";
+}
+
+interface CloudflareWAEResponse {
+ meta: CloudflareWAEResponseMeta[];
+ data: Record[];
+ error?: string;
+}
+
+const WAEGenericColumns: DatabaseTableColumn[] = [
+ { name: "_sample_interval", type: "UInt32" },
+ { name: "timestamp", type: "DateTime" },
+ { name: "dataset", type: "String" },
+ { name: "index1", type: "String" },
+ { name: "blob1", type: "String" },
+ { name: "blob2", type: "String" },
+ { name: "blob3", type: "String" },
+ { name: "blob4", type: "String" },
+ { name: "blob5", type: "String" },
+ { name: "blob6", type: "String" },
+ { name: "blob7", type: "String" },
+ { name: "blob8", type: "String" },
+ { name: "blob9", type: "String" },
+ { name: "blob10", type: "String" },
+ { name: "blob11", type: "String" },
+ { name: "blob12", type: "String" },
+ { name: "blob13", type: "String" },
+ { name: "blob14", type: "String" },
+ { name: "blob15", type: "String" },
+ { name: "blob16", type: "String" },
+ { name: "blob17", type: "String" },
+ { name: "blob18", type: "String" },
+ { name: "blob19", type: "String" },
+ { name: "blob20", type: "String" },
+ { name: "double1", type: "Float64" },
+ { name: "double2", type: "Float64" },
+ { name: "double3", type: "Float64" },
+ { name: "double4", type: "Float64" },
+ { name: "double5", type: "Float64" },
+ { name: "double6", type: "Float64" },
+ { name: "double7", type: "Float64" },
+ { name: "double8", type: "Float64" },
+ { name: "double9", type: "Float64" },
+ { name: "double10", type: "Float64" },
+ { name: "double11", type: "Float64" },
+ { name: "double12", type: "Float64" },
+ { name: "double13", type: "Float64" },
+ { name: "double14", type: "Float64" },
+ { name: "double15", type: "Float64" },
+ { name: "double16", type: "Float64" },
+ { name: "double17", type: "Float64" },
+ { name: "double18", type: "Float64" },
+ { name: "double19", type: "Float64" },
+ { name: "double20", type: "Float64" },
+];
+
+export default class CloudflareWAEDriver extends PostgresLikeDriver {
+ getFlags(): DriverFlags {
+ return {
+ defaultSchema: "main",
+ dialect: "sqlite",
+ optionalSchema: true,
+ supportRowId: false,
+ supportBigInt: false,
+ supportModifyColumn: false,
+ supportCreateUpdateTable: false,
+ supportCreateUpdateDatabase: false,
+ supportInsertReturning: false,
+ supportUpdateReturning: false,
+ supportCreateUpdateTrigger: false,
+ supportUseStatement: false,
+ };
+ }
+
+ constructor(
+ protected accountId: string,
+ protected token: string
+ ) {
+ super();
+ }
+
+ async query(stmt: string): Promise {
+ const r = await fetch("/proxy/wae", {
+ method: "POST",
+ headers: {
+ "Content-Type": "text/plain",
+ Authorization: "Bearer " + this.token,
+ "x-account-id": this.accountId,
+ },
+ body: stmt,
+ });
+
+ const json: CloudflareWAEResponse = await r.json();
+
+ if (json.error) {
+ throw new Error(json.error);
+ }
+
+ return {
+ rows: json.data,
+ headers: json.meta.map(
+ (m) =>
+ ({
+ name: m.name,
+ displayName: m.name,
+ originalType: m.type,
+ type:
+ {
+ UInt32: ColumnType.INTEGER,
+ String: ColumnType.TEXT,
+ Float64: ColumnType.REAL,
+ DateTime: ColumnType.TEXT,
+ }[m.type] ?? ColumnType.TEXT,
+ }) as ColumnHeader
+ ),
+ stat: {
+ rowsAffected: 0,
+ rowsRead: 0,
+ rowsWritten: 0,
+ queryDurationMs: 0,
+ },
+ };
+ }
+
+ async transaction(stmt: string[]): Promise {
+ return Promise.all(stmt.map((s) => this.query(s)));
+ }
+
+ async schemas(): Promise {
+ const tableList = await this.query("SHOW TABLES");
+ const tableListRows = tableList.rows as { dataset: string; type: string }[];
+
+ return {
+ main: tableListRows.map((r) => ({
+ name: r.dataset,
+ schemaName: "main",
+ type: "table",
+ tableName: r.dataset,
+ tableSchema: {
+ tableName: r.dataset,
+ columns: structuredClone(WAEGenericColumns),
+ pk: [],
+ autoIncrement: false,
+ schemaName: "main",
+ },
+ })),
+ };
+ }
+
+ async tableSchema(
+ schemaName: string,
+ tableName: string
+ ): Promise {
+ return {
+ columns: structuredClone(WAEGenericColumns),
+ tableName,
+ pk: [],
+ autoIncrement: false,
+ schemaName,
+ };
+ }
+
+ async selectTable(
+ schemaName: string,
+ tableName: string,
+ options: SelectFromTableOptions
+ ): Promise<{ data: DatabaseResultSet; schema: DatabaseTableSchema }> {
+ // Similar to the common SQL driver implementation,
+ // but without the schema name as Cloudflare Worker Analytics Engine does not support schemas
+
+ const whereRaw = options.whereRaw?.trim();
+
+ // By default sort by timestamp in descending order
+ options.orderBy =
+ !options.orderBy || options.orderBy.length === 0
+ ? [{ columnName: "timestamp", by: "DESC" }]
+ : options.orderBy;
+
+ const orderPart =
+ options.orderBy && options.orderBy.length > 0
+ ? options.orderBy
+ .map((r) => `${this.escapeId(r.columnName)} ${r.by}`)
+ .join(", ")
+ : "";
+
+ const sql = `SELECT * FROM ${this.escapeId(tableName)}${
+ whereRaw ? ` WHERE ${whereRaw} ` : ""
+ } ${orderPart ? ` ORDER BY ${orderPart}` : ""}`;
+
+ const schema = await this.tableSchema(schemaName, tableName);
+ const data = await this.query(sql);
+ data.headers = schema.columns.map((c) => ({
+ name: c.name,
+ displayName: c.name,
+ originalType: c.type,
+ type: ColumnType.TEXT,
+ }));
+
+ return {
+ data: data,
+ schema,
+ };
+ }
+
+ close(): void {
+ // do nothing
+ }
+}
diff --git a/src/drivers/helpers.ts b/src/drivers/helpers.ts
index 50bad7f9..3f3b17cb 100644
--- a/src/drivers/helpers.ts
+++ b/src/drivers/helpers.ts
@@ -1,5 +1,6 @@
import { SavedConnectionRawLocalStorage } from "@/app/(theme)/connect/saved-connection-storage";
import CloudflareD1Driver from "./cloudflare-d1-driver";
+import CloudflareWAEDriver from "./database/cloudflare-wae";
import RqliteDriver from "./rqlite-driver";
import StarbaseDriver from "./starbase-driver";
import TursoDriver from "./turso-driver";
@@ -21,6 +22,8 @@ export function createLocalDriver(conn: SavedConnectionRawLocalStorage) {
Authorization: "Bearer " + (conn.token ?? ""),
"x-starbase-url": conn.url ?? "",
});
+ } else if (conn.driver === "cloudflare-wae") {
+ return new CloudflareWAEDriver(conn.username!, conn.token!);
}
return new TursoDriver(conn.url!, conn.token!, true);
diff --git a/src/extensions/data-catalog/table-result-header.tsx b/src/extensions/data-catalog/table-result-header.tsx
index e1e88fd3..469a06ef 100644
--- a/src/extensions/data-catalog/table-result-header.tsx
+++ b/src/extensions/data-catalog/table-result-header.tsx
@@ -22,17 +22,16 @@ export default function DataCatalogResultHeader({
const [loading, setLoading] = useState(false);
const onSaveClicked = useCallback(() => {
- if (!column) return;
setLoading(true);
driver
.updateColumn(schemaName, tableName, columnName, {
definition,
- hide: column.hide || false,
- samples: column.samples,
+ hide: column?.hide || false,
+ samples: column?.samples ?? [],
})
.then(() => {
- toast.success("updated");
+ toast.success("Column Definition Updated");
})
.catch()
.finally(() => setLoading(false));
diff --git a/src/extensions/outerbase/index.tsx b/src/extensions/outerbase/index.tsx
new file mode 100644
index 00000000..9e80f5c9
--- /dev/null
+++ b/src/extensions/outerbase/index.tsx
@@ -0,0 +1,35 @@
+/**
+ * Outerbase Cloud extension for studio functionality.
+ */
+import { StudioExtension } from "@/core/extension-base";
+import { StudioExtensionContext } from "@/core/extension-manager";
+import { OuterbaseDatabaseConfig } from "@/outerbase-cloud/api-type";
+import { updateOuterbaseSchemas } from "@/outerbase-cloud/api-workspace";
+
+export default class OuterbaseExtension extends StudioExtension {
+ extensionName = "outerbase-cloud-studio";
+
+ constructor(protected config: OuterbaseDatabaseConfig) {
+ super();
+ }
+
+ init(studio: StudioExtensionContext): void {
+ // The Outerbase Data Catalog and AI-powered features
+ // require the schema to be stored in the Outerbase API database.
+ // Originally, Outerbase fetched the schema from an API endpoint
+ // and stored it in its database.
+ //
+ // Our studio differs because we obtain the schema from
+ // raw queries. The Outerbase API does not know when to refetch
+ // the updated schema and store it in its database.
+ // We hook into the event when the studio finishes fetching the schema
+ // and notify Outerbase Cloud to refetch the schema on their side.
+ //
+ // TECHNICAL DEBT: This is redundant work and should be optimized in the future.
+ studio.registerAfterFetchSchema(() => {
+ updateOuterbaseSchemas(this.config.workspaceId, this.config.sourceId)
+ .then()
+ .catch();
+ });
+ }
+}
diff --git a/src/outerbase-cloud/api-data-catalog.ts b/src/outerbase-cloud/api-data-catalog.ts
index 04bafe49..8b8beeca 100644
--- a/src/outerbase-cloud/api-data-catalog.ts
+++ b/src/outerbase-cloud/api-data-catalog.ts
@@ -11,11 +11,11 @@ import {
export async function getOuterbaseSchemas(
workspaceId: string,
- sourceId: string,
- baseId?: string
+ sourceId: string
) {
+ //
return await requestOuterbase>(
- `/api/v1/workspace/${workspaceId}/source/${sourceId}/schema?baseId=${baseId}`
+ `/api/v1/workspace/${workspaceId}/source/${sourceId}/schema?baseId=`
);
}
diff --git a/src/outerbase-cloud/api-type.ts b/src/outerbase-cloud/api-type.ts
index 7827ad11..441a6ac0 100644
--- a/src/outerbase-cloud/api-type.ts
+++ b/src/outerbase-cloud/api-type.ts
@@ -66,6 +66,7 @@ export interface OuterbaseAPISource {
model: "source";
type: string;
id: string;
+ base_id: string;
}
export interface OuterbaseAPIBase {
model: "base";
diff --git a/src/outerbase-cloud/api-workspace.ts b/src/outerbase-cloud/api-workspace.ts
index 4c66dde3..1bab7077 100644
--- a/src/outerbase-cloud/api-workspace.ts
+++ b/src/outerbase-cloud/api-workspace.ts
@@ -1,3 +1,4 @@
+import { mutate } from "swr";
import { requestOuterbase } from "./api";
import {
OuterbaseAPIBase,
@@ -65,6 +66,18 @@ export function testOuterbaseSource(
);
}
+export function updateOuterbaseSource(
+ workspaceId: string,
+ sourceId: string,
+ source: OuterbaseAPISourceInput
+) {
+ return requestOuterbase(
+ `/api/v1/workspace/${workspaceId}/source/${sourceId}`,
+ "PUT",
+ source
+ );
+}
+
export function createOuterbaseSource(
workspaceId: string,
source: OuterbaseAPISourceInput
@@ -75,3 +88,37 @@ export function createOuterbaseSource(
source
);
}
+
+export async function updateOuterbaseSchemas(
+ workspaceId: string,
+ sourceId: string
+) {
+ return await requestOuterbase(
+ `/api/v1/workspace/${workspaceId}/source/${sourceId}/schema?baseId=`,
+ "POST"
+ );
+}
+
+export async function getOuterbaseBaseCredential(
+ workspaceId: string,
+ sourceId: string
+) {
+ return await requestOuterbase(
+ `/api/v1/workspace/${workspaceId}/source/${sourceId}/credential`,
+ "GET"
+ );
+}
+
+export async function updateOuterbaseCredential(
+ workspaceId: string,
+ sourceId: string,
+ source: OuterbaseAPISourceInput
+) {
+ mutate("/source/${sourceId}/credential");
+
+ return await requestOuterbase(
+ `/api/v1/workspace/${workspaceId}/source/${sourceId}`,
+ "PUT",
+ source
+ );
+}
diff --git a/src/outerbase-cloud/hook.ts b/src/outerbase-cloud/hook.ts
index 783d3aa1..964dcaaa 100644
--- a/src/outerbase-cloud/hook.ts
+++ b/src/outerbase-cloud/hook.ts
@@ -1,7 +1,8 @@
import { useState } from "react";
import useSWR from "swr";
-import { getOuterbaseDashboardList } from "./api";
+import { getOuterbaseBase, getOuterbaseDashboardList } from "./api";
import { OuterbaseAPIError } from "./api-type";
+import { getOuterbaseBaseCredential } from "./api-workspace";
export default function useOuterbaseMutation<
Arguments extends unknown[] = unknown[],
@@ -45,3 +46,42 @@ export function useOuterbaseDashboardList() {
return { data, isLoading, mutate };
}
+
+export function useOuterbaseBase(workspaceId: string, baseId: string) {
+ const { data, isLoading, mutate } = useSWR(
+ `/base/${baseId}`,
+ async () => {
+ const result = await getOuterbaseBase(workspaceId, baseId);
+ return result;
+ },
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ dedupingInterval: 5000,
+ revalidateIfStale: false,
+ }
+ );
+
+ return { data, isLoading, mutate };
+}
+
+export function useOuterbaseBaseCredential(
+ workspaceId: string,
+ sourceId: string
+) {
+ const { data, isLoading, mutate } = useSWR(
+ sourceId ? `/source/${sourceId}/credential` : null,
+ async () => {
+ const result = await getOuterbaseBaseCredential(workspaceId, sourceId);
+ return result;
+ },
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ dedupingInterval: 5000,
+ revalidateIfStale: false,
+ }
+ );
+
+ return { data, isLoading, mutate };
+}
diff --git a/wrangler.jsonc.example b/wrangler.jsonc.example
new file mode 100644
index 00000000..394dd2b0
--- /dev/null
+++ b/wrangler.jsonc.example
@@ -0,0 +1,16 @@
+{
+ "main": ".open-next/worker.js",
+ "name": "outerbase-studio",
+ "compatibility_date": "2024-09-23",
+ "compatibility_flags": ["nodejs_compat"],
+ "assets": {
+ "directory": ".open-next/assets",
+ "binding": "ASSETS",
+ },
+ "kv_namespaces": [
+ {
+ "binding": "NEXT_CACHE_WORKERS_KV",
+ "id": "xxxxx",
+ },
+ ],
+}