diff --git a/src/app/(outerbase)/new-resource-list.tsx b/src/app/(outerbase)/new-resource-list.tsx index 4857cb7d..34fb151b 100644 --- a/src/app/(outerbase)/new-resource-list.tsx +++ b/src/app/(outerbase)/new-resource-list.tsx @@ -38,6 +38,11 @@ export function getCreateResourceTypeList( icon: CloudflareIcon, href: workspaceId ? "" : "/local/new-base/cloudflare-d1", }, + { + name: "Worker Analytics Engine", + icon: CloudflareIcon, + href: workspaceId ? "" : "/local/new-base/cloudflare-wae", + }, { name: "Neon", icon: NeonIcon, diff --git a/src/app/(theme)/connect/saved-connection-storage.ts b/src/app/(theme)/connect/saved-connection-storage.ts index 6a79cc00..4aeee182 100644 --- a/src/app/(theme)/connect/saved-connection-storage.ts +++ b/src/app/(theme)/connect/saved-connection-storage.ts @@ -1,246 +1,4 @@ -import { - CloudflareIcon, - RqliteIcon, - SQLiteIcon, - StarbaseIcon, - TursoIcon, - ValtownIcon, -} from "@/components/icons/outerbase-icon"; import { ApiUser } from "@/lib/api/api-database-response"; -import { FunctionComponent } from "react"; - -export interface DriverDetailField { - name: keyof SavedConnectionItemConfigConfig; - type: "text" | "textarea" | "password" | "filehandler"; - secret?: boolean; - required?: boolean; - title?: string; - placeholder?: string; - description?: string; - prefill?: string; - invalidate?: (value: string) => string | null; -} - -export interface DriverDetail { - displayName: string; - name: string; - icon: FunctionComponent<{ className: string }>; - disableRemote?: boolean; - fields: DriverDetailField[]; -} - -export const DRIVER_DETAIL: Record = - Object.freeze({ - "sqlite-filehandler": { - displayName: "SQLite", - name: "sqlite-filehandler", - icon: SQLiteIcon, - disableRemote: true, - fields: [ - { - name: "filehandler", - required: true, - type: "filehandler", - title: "File", - description: "", - }, - ], - }, - turso: { - name: "turso", - displayName: "Turso", - icon: TursoIcon, - fields: [ - { - name: "url", - required: true, - type: "text", - title: "URL", - description: "Example: libsql://example.turso.io", - invalidate: (url: string): null | string => { - const trimmedUrl = url.trim(); - const valid = - trimmedUrl.startsWith("https://") || - trimmedUrl.startsWith("http://") || - trimmedUrl.startsWith("ws://") || - trimmedUrl.startsWith("wss://") || - trimmedUrl.startsWith("libsql://"); - - if (!valid) { - return "Endpoint must start with libsql://, https://, http://, wss:// or ws://"; - } - - return null; - }, - }, - { name: "token", title: "Token", type: "textarea", secret: true }, - ], - }, - valtown: { - name: "valtown", - displayName: "Valtown", - icon: ValtownIcon, - prefill: "", - fields: [ - { - name: "token", - title: "API Token", - required: true, - type: "text", - secret: true, - }, - ], - }, - starbase: { - name: "starbase", - displayName: "StarbaseDB", - icon: StarbaseIcon, - disableRemote: true, - prefill: "", - fields: [ - { - name: "url", - title: "Endpoint", - required: true, - type: "text", - secret: false, - invalidate: (url: string): null | string => { - const trimmedUrl = url.trim(); - const valid = - trimmedUrl.startsWith("https://") || - trimmedUrl.startsWith("http://"); - - if (!valid) { - return "Endpoint must start with https:// or http://"; - } - - return null; - }, - }, - { - name: "token", - title: "API Token", - required: true, - type: "text", - secret: true, - }, - ], - }, - "cloudflare-d1": { - name: "cloudflare-d1", - displayName: "Cloudflare D1", - icon: CloudflareIcon, - fields: [ - { - name: "username", - type: "text", - title: "Account ID", - required: true, - placeholder: "Account ID", - }, - { - name: "database", - type: "text", - title: "Database ID", - required: true, - placeholder: "Database ID", - }, - { - name: "token", - title: "API Token", - required: true, - type: "text", - secret: true, - }, - ], - }, - rqlite: { - name: "rqlite", - displayName: "rqlite", - icon: RqliteIcon, - fields: [ - { - name: "url", - required: true, - type: "text", - title: "URL", - prefill: "http://localhost:4001", - description: "Example: http://localhost:4001", - invalidate: (url: string): null | string => { - const trimmedUrl = url.trim(); - const valid = - trimmedUrl.startsWith("https://") || - trimmedUrl.startsWith("http://"); - - if (!valid) { - return "Endpoint must start with https://, http://"; - } - - return null; - }, - }, - { - name: "username", - type: "text", - title: "Username", - placeholder: "Username", - }, - { - name: "password", - type: "password", - title: "Password", - secret: true, - placeholder: "Password", - }, - ], - }, - }); - -export function validateConnectionString( - driver: DriverDetail, - connectionString?: SavedConnectionItemConfigConfig -) { - if (!connectionString) return false; - - for (const field of driver.fields) { - if ( - field.invalidate && - field.invalidate(connectionString[field.name] ?? "") - ) { - return false; - } - } - - return true; -} - -export function prefillConnectionString( - driver: DriverDetail, - defaultValue?: SavedConnectionItemConfigConfig -): SavedConnectionItemConfigConfig { - return { - url: - defaultValue?.url ?? - driver.fields.find((f) => f.name === "url")?.prefill ?? - "", - token: - defaultValue?.token ?? - driver.fields.find((f) => f.name === "token")?.prefill ?? - "", - database: - defaultValue?.database ?? - driver.fields.find((f) => f.name === "database")?.prefill ?? - "", - username: - defaultValue?.username ?? - driver.fields.find((f) => f.name === "username")?.prefill ?? - "", - password: - defaultValue?.token ?? - driver.fields.find((f) => f.name === "password")?.prefill ?? - "", - }; -} export type SupportedDriver = | "turso" @@ -248,6 +6,7 @@ export type SupportedDriver = | "valtown" | "starbase" | "cloudflare-d1" + | "cloudflare-wae" | "sqlite-filehandler"; export type SavedConnectionStorage = "remote" | "local"; @@ -290,14 +49,6 @@ export interface SavedConnectionItemConfig { config: SavedConnectionItemConfigConfig; } -export type SavedConnectionItemWithoutId = { - storage: SavedConnectionStorage; -} & SavedConnectionItemConfig; - -export type SavedConnectionItemDetail = { - id: string; -} & SavedConnectionItemWithoutId; - export interface SavedConnectionRawLocalStorage { id?: string; name: string; diff --git a/src/app/proxy/wae/route.tsx b/src/app/proxy/wae/route.tsx new file mode 100644 index 00000000..6bd6b179 --- /dev/null +++ b/src/app/proxy/wae/route.tsx @@ -0,0 +1,60 @@ +import { HttpStatus } from "@/constants/http-status"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const headerStore = await headers(); + + // Get the account id and database id from header + const accountId = headerStore.get("x-account-id"); + + if (!accountId) { + return NextResponse.json( + { + error: "Please provide account id or database id", + }, + { status: HttpStatus.BAD_REQUEST } + ); + } + + const authorizationHeader = headerStore.get("Authorization"); + if (!authorizationHeader) { + return NextResponse.json( + { + error: "Please provide authorization header", + }, + { status: HttpStatus.BAD_REQUEST } + ); + } + + try { + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: authorizationHeader, + "Content-Type": "text/plain", + }, + body: await req.text(), + }); + + if (!response.ok) { + return NextResponse.json( + { + error: await response.text(), + }, + { status: response.status } + ); + } + + return NextResponse.json(await response.json()); + } catch (e) { + return NextResponse.json( + { + error: (e as Error).message, + }, + { status: HttpStatus.BAD_REQUEST } + ); + } +} diff --git a/src/components/connection-config-editor/template/cloudflare-wae.tsx b/src/components/connection-config-editor/template/cloudflare-wae.tsx new file mode 100644 index 00000000..badbf913 --- /dev/null +++ b/src/components/connection-config-editor/template/cloudflare-wae.tsx @@ -0,0 +1,49 @@ +import { ConnectionTemplateList } from "@/app/(outerbase)/base-template"; +import { CommonConnectionConfigTemplate } from ".."; + +const template: CommonConnectionConfigTemplate = [ + { + columns: [ + { + name: "username", + label: "Account ID", + type: "text", + required: true, + placeholder: "Account ID", + }, + ], + }, + { + columns: [ + { + name: "token", + label: "API Token", + type: "textarea", + required: true, + placeholder: "API Token", + }, + ], + }, +]; + +export const CloudflareWAEConnectionTemplate: ConnectionTemplateList = { + localFrom: (value) => { + return { + name: value.name, + database: value.database, + token: value.token, + username: value.username, + }; + }, + localTo: (value) => { + return { + name: value.name, + driver: "cloudflare-wae", + database: value.database, + token: value.token, + username: value.username, + }; + }, + template, + instruction:
, +}; diff --git a/src/components/connection-config-editor/template/index.ts b/src/components/connection-config-editor/template/index.ts index c41be828..5ea3acf2 100644 --- a/src/components/connection-config-editor/template/index.ts +++ b/src/components/connection-config-editor/template/index.ts @@ -1,5 +1,6 @@ import { ConnectionTemplateList } from "@/app/(outerbase)/base-template"; import { CloudflareConnectionTemplate } from "./cloudflare"; +import { CloudflareWAEConnectionTemplate } from "./cloudflare-wae"; import { MySQLConnectionTemplate } from "./mysql"; import { PostgresConnectionTemplate } from "./postgres"; import { RqliteConnectionTemplate } from "./rqlite"; @@ -12,6 +13,7 @@ export const ConnectionTemplateDictionary: Record< string, ConnectionTemplateList > = { + "cloudflare-wae": CloudflareWAEConnectionTemplate, "cloudflare-d1": CloudflareConnectionTemplate, rqlite: RqliteConnectionTemplate, "sqlite-filehandler": SqliteConnectionTemplate, 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/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);