diff --git a/src/app/(outerbase)/local/edit-base/[baseId]/page.tsx b/src/app/(outerbase)/local/edit-base/[baseId]/page.tsx index faaa144e..a2535aec 100644 --- a/src/app/(outerbase)/local/edit-base/[baseId]/page.tsx +++ b/src/app/(outerbase)/local/edit-base/[baseId]/page.tsx @@ -41,12 +41,11 @@ export default function LocalEditBasePage() { setValidateErrors(errors); if (Object.keys(errors).length > 0) return; - setLoading(true); const tmp = await updateLocalConnection(baseId, template.localTo(value)); router.push( tmp?.content.driver === "sqlite-filehandler" ? `/playground/client?s=${tmp?.content.id}` - : `/client/s/${tmp?.content.driver ?? "turso"}?p=${tmp?.content.id}` + : `/client/s/${tmp?.content.driver ?? "turso"}?p=${baseId}` ); }, [template, value, router, baseId]); diff --git a/src/app/(outerbase)/local/hooks.tsx b/src/app/(outerbase)/local/hooks.tsx index dd84a604..8f492dc0 100644 --- a/src/app/(outerbase)/local/hooks.tsx +++ b/src/app/(outerbase)/local/hooks.tsx @@ -1,5 +1,6 @@ import { SavedConnectionRawLocalStorage } from "@/app/(theme)/connect/saved-connection-storage"; import { LocalConnectionData, LocalDashboardData, localDb } from "@/indexdb"; +import { generateId } from "@/lib/generate-id"; import parseSafeJson from "@/lib/json-safe"; import useSWR, { mutate } from "swr"; @@ -18,7 +19,7 @@ export function useLocalDashboardList() { } export async function createLocalDashboard(boardName: string) { - const id = crypto.randomUUID(); + const id = generateId(); const now = Date.now(); const data: LocalDashboardData = { @@ -103,7 +104,7 @@ export async function removeLocalConnection(id: string) { export async function createLocalConnection( config: SavedConnectionRawLocalStorage ): Promise { - const id = crypto.randomUUID(); + const id = generateId(); const data = { id, @@ -133,7 +134,12 @@ export async function updateLocalConnection( }; await localDb.connection.put(data); + mutate("/connections/local"); + mutate("/connections/local/" + id, data, { + optimisticData: data, + revalidate: false, + }); return data; } diff --git a/src/app/(outerbase)/nav-layout.tsx b/src/app/(outerbase)/nav-layout.tsx index a65a0dda..4aefe8e7 100644 --- a/src/app/(outerbase)/nav-layout.tsx +++ b/src/app/(outerbase)/nav-layout.tsx @@ -4,9 +4,10 @@ import { SidebarMenuItem, SidebarMenuLoadingItem, } from "@/components/sidebar-menu"; -import { Database, Plus } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import { Database, List, Plus } from "@phosphor-icons/react"; import { useParams, usePathname, useRouter } from "next/navigation"; -import { PropsWithChildren } from "react"; +import { PropsWithChildren, useState } from "react"; import NavigationProfile from "./nav-profile"; import NavigationSigninBanner from "./nav-signin-banner"; import { useSession } from "./session-provider"; @@ -14,76 +15,101 @@ import { useWorkspaces } from "./workspace-provider"; export default function NavigationLayout({ children }: PropsWithChildren) { const router = useRouter(); + const [mobileToggle, setMobileToggle] = useState(false); const { session } = useSession(); const { workspaces, loading: workspaceLoading } = useWorkspaces(); const pathname = usePathname(); const { workspaceId } = useParams<{ workspaceId?: string }>(); return ( -
-
-
+
+
+
-
- -
- - { + setMobileToggle(!mobileToggle); + }} /> +
- {workspaces.map((workspace) => { - return ( - { - router.push(`/w/${workspace.short_name}`); - } - } - selected={workspace.short_name === workspaceId} - badge={ - !workspace.is_enterprise && - workspace.subscription.plan === "starter" ? ( - - Free - - ) : undefined - } - /> - ); - })} + {mobileToggle && ( +
{ + setMobileToggle(false); + }} + >
+ )} - {workspaceLoading && ( - <> - - - - + + + +
{children} diff --git a/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx b/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx index e7e31e99..4f08abbb 100644 --- a/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx +++ b/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx @@ -8,6 +8,9 @@ import { createPostgreSQLExtensions, createSQLiteExtensions, } from "@/core/standard-extension"; +import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; +import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; +import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; import DataCatalogExtension from "@/extensions/data-catalog"; import OuterbaseExtension from "@/extensions/outerbase"; import { @@ -17,9 +20,7 @@ import { import { OuterbaseAPIBaseCredential } from "@/outerbase-cloud/api-type"; import { getOuterbaseBaseCredential } from "@/outerbase-cloud/api-workspace"; import DataCatalogOuterbaseDriver from "@/outerbase-cloud/data-catalog-driver"; -import { OuterbaseMySQLDriver } from "@/outerbase-cloud/database/mysql"; -import { OuterbasePostgresDriver } from "@/outerbase-cloud/database/postgresql"; -import { OuterbaseSqliteDriver } from "@/outerbase-cloud/database/sqlite"; +import { OuterbaseQueryable } from "@/outerbase-cloud/database/query"; import OuterbaseQueryDriver from "@/outerbase-cloud/query-driver"; import { useParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -74,7 +75,7 @@ export default function OuterbaseSourcePage() { if (dialect === "postgres") { return [ - new OuterbasePostgresDriver(outerbaseConfig), + new PostgresLikeDriver(new OuterbaseQueryable(outerbaseConfig)), new StudioExtensionManager([ ...createPostgreSQLExtensions(), ...outerbaseSpecifiedDrivers, @@ -82,7 +83,10 @@ export default function OuterbaseSourcePage() { ]; } else if (dialect === "mysql") { return [ - new OuterbaseMySQLDriver(outerbaseConfig, credential.database), + new MySQLLikeDriver( + new OuterbaseQueryable(outerbaseConfig), + credential.database + ), new StudioExtensionManager([ ...createMySQLExtensions(), ...outerbaseSpecifiedDrivers, @@ -91,7 +95,7 @@ export default function OuterbaseSourcePage() { } return [ - new OuterbaseSqliteDriver(outerbaseConfig), + new SqliteLikeBaseDriver(new OuterbaseQueryable(outerbaseConfig)), new StudioExtensionManager([ ...createSQLiteExtensions(), ...outerbaseSpecifiedDrivers, diff --git a/src/app/(theme)/client/s/starbase/page.tsx b/src/app/(theme)/client/s/starbase/page.tsx new file mode 100644 index 00000000..0798a4b0 --- /dev/null +++ b/src/app/(theme)/client/s/starbase/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useLocalConnection } from "@/app/(outerbase)/local/hooks"; +import ClientOnly from "@/components/client-only"; +import { Studio } from "@/components/gui/studio"; +import PageLoading from "@/components/page-loading"; +import { StudioExtensionManager } from "@/core/extension-manager"; +import { + createMySQLExtensions, + createPostgreSQLExtensions, + createSQLiteExtensions, + createStandardExtensions, +} from "@/core/standard-extension"; +import { StarbaseQuery } from "@/drivers/database/starbasedb"; +import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; +import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; +import IndexdbSavedDoc from "@/drivers/saved-doc/indexdb-saved-doc"; +import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; +import { useAvailableAIAgents } from "@/lib/ai-agent-storage"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +function StarbasePageBody() { + const params = useSearchParams(); + const baseId = params.get("p") ?? ""; + + const { data: conn } = useLocalConnection(baseId); + const [queryable, setQueryable] = useState(null); + const [driverType, setDriverType] = useState(null); + + useEffect(() => { + if (conn && conn.content.driver === "starbase") { + if (conn.content.starbase_type === "hyperdrive") { + setDriverType("postgres"); + } else if (conn.content.starbase_type !== "external") { + setDriverType("sqlite"); + } + + setQueryable( + new StarbaseQuery( + conn.content.url!, + conn.content.token!, + conn.content.starbase_type ?? "internal" + ) + ); + } + }, [conn]); + + // Starbase is a complicated database. Since we never know + // what is behind it. It can be Postgres, SQLite, MySQL etc... + useEffect(() => { + if (driverType) return; + if (!queryable) return; + + // Make one version call + queryable.query("SELECT VERSION() AS v").then((result) => { + if ((result.rows[0].v as string).includes("PostgreSQL")) + setDriverType("postgres"); + else setDriverType("mysql"); + }); + }, [driverType, queryable]); + + // Load extensions + const extensions = useMemo(() => { + if (driverType === "mysql") { + return new StudioExtensionManager(createMySQLExtensions()); + } else if (driverType === "sqlite") { + return new StudioExtensionManager(createSQLiteExtensions()); + } else if (driverType === "postgres") { + return new StudioExtensionManager(createPostgreSQLExtensions()); + } + + return new StudioExtensionManager(createStandardExtensions()); + }, [driverType]); + + // Load drivers + const driver = useMemo(() => { + if (!queryable) return null; + + if (driverType === "sqlite") { + return new SqliteLikeBaseDriver(queryable); + } else if (driverType === "postgres") { + return new PostgresLikeDriver(queryable); + } else if (driverType === "mysql") { + return new MySQLLikeDriver(queryable); + } + }, [driverType, queryable]); + + const docDriver = useMemo(() => { + if (conn) { + return new IndexdbSavedDoc(conn.id); + } + }, [conn]); + + const agentDriver = useAvailableAIAgents(driver); + + const router = useRouter(); + + const goBack = useCallback(() => { + router.push("/"); + }, [router]); + + if (!driver || !conn || !extensions) { + return Loading Starbase; + } + + return ( + + ); +} + +export default function StarbasePage() { + return ( + + + + ); +} diff --git a/src/app/(theme)/connect/saved-connection-storage.ts b/src/app/(theme)/connect/saved-connection-storage.ts index c45960bb..fee5ad86 100644 --- a/src/app/(theme)/connect/saved-connection-storage.ts +++ b/src/app/(theme)/connect/saved-connection-storage.ts @@ -57,4 +57,5 @@ export interface SavedConnectionRawLocalStorage { file_handler?: string; description?: string; last_used?: number; + starbase_type?: string; } diff --git a/src/app/(theme)/embed/[driver]/page-client.tsx b/src/app/(theme)/embed/[driver]/page-client.tsx index 88fd58b7..0d416e90 100644 --- a/src/app/(theme)/embed/[driver]/page-client.tsx +++ b/src/app/(theme)/embed/[driver]/page-client.tsx @@ -6,12 +6,11 @@ import { createPostgreSQLExtensions, createSQLiteExtensions, } from "@/core/standard-extension"; -import { - IframeMySQLDriver, - IframePostgresDriver, - IframeSQLiteDriver, -} from "@/drivers/iframe-driver"; +import { EmbedQueryable } from "@/drivers/iframe-driver"; +import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; +import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; import ElectronSavedDocs from "@/drivers/saved-doc/electron-saved-doc"; +import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; import DoltExtension from "@/extensions/dolt"; import LocalSettingSidebar from "@/extensions/local-setting-sidebar"; import { useAvailableAIAgents } from "@/lib/ai-agent-storage"; @@ -25,8 +24,9 @@ export default function EmbedPageClient({ }) { const searchParams = useSearchParams(); - const driver = useMemo(() => { - return createDatabaseDriver(driverName); + const [driver, queryable] = useMemo(() => { + const queryable = new EmbedQueryable(); + return [createDatabaseDriver(driverName, queryable), queryable]; }, [driverName]); const savedDocDriver = useMemo(() => { @@ -42,8 +42,8 @@ export default function EmbedPageClient({ const agentDriver = useAvailableAIAgents(driver); useEffect(() => { - return driver.listen(); - }, [driver]); + return queryable.listen(); + }, [queryable]); return ( "(" + [ - crypto.randomUUID(), + generateId(), Date.now(), deviceId, event.name, JSON.stringify(event.data), ] - .map(trackingDb.escapeValue) + .map(escapeSqlValue) .join(", ") + ")" ) diff --git a/src/app/fonts/a b/src/app/fonts/a deleted file mode 100644 index f664a483..00000000 Binary files a/src/app/fonts/a and /dev/null differ diff --git a/src/app/fonts/d.woff b/src/app/fonts/d.woff deleted file mode 100644 index 407f837b..00000000 Binary files a/src/app/fonts/d.woff and /dev/null differ diff --git a/src/app/fonts/l b/src/app/fonts/l deleted file mode 100644 index ed246d32..00000000 Binary files a/src/app/fonts/l and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5ccdb95f..2a207833 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,8 +7,6 @@ import "./globals.css"; const siteDescription = `${WEBSITE_NAME} is a fully-featured, lightweight GUI client for managing SQLite-based databases like Turso, LibSQL, and rqlite. It runs entirely in your browser, so there's no need to download anything`; import { DialogProvider } from "@/components/create-dialog"; -import { Inter } from "next/font/google"; -const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: WEBSITE_NAME, @@ -37,7 +35,7 @@ export default async function RootLayout({ }) { return ( - + {children} diff --git a/src/app/proxy/starbase/route.ts b/src/app/proxy/starbase/route.ts deleted file mode 100644 index 8b543342..00000000 --- a/src/app/proxy/starbase/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 endpoint = headerStore.get("x-starbase-url"); - - if (!endpoint) { - 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 = `${endpoint.replace(/\/$/, "")}/query/raw`; - - const response: { errors: { message: string }[] } = await ( - await fetch(url, { - method: "POST", - headers: { - Authorization: authorizationHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify(await req.json()), - }) - ).json(); - - if (response.errors && response.errors.length > 0) { - return NextResponse.json( - { - error: response.errors[0].message, - }, - { status: HttpStatus.INTERNAL_SERVER_ERROR } - ); - } - - return NextResponse.json(response); - } catch (e) { - return NextResponse.json( - { - error: (e as Error).message, - }, - { status: HttpStatus.BAD_REQUEST } - ); - } -} diff --git a/src/components/connection-config-editor/index.tsx b/src/components/connection-config-editor/index.tsx index de14fe3b..83642da5 100644 --- a/src/components/connection-config-editor/index.tsx +++ b/src/components/connection-config-editor/index.tsx @@ -16,6 +16,7 @@ import { CommonDialogProvider } from "../common-dialog"; import FileHandlerPicker from "../filehandler-picker"; import { Input } from "../orbit/input"; import { Label } from "../orbit/label"; +import { MenuBar } from "../orbit/menu-bar"; import { Toggle } from "../orbit/toggle"; import { Textarea } from "../ui/textarea"; @@ -160,6 +161,26 @@ export function ConnectionConfigEditor({
); + } else if ( + column.type === "options" && + column.options && + column.options.length > 0 + ) { + return ( +
+ { + onChange( + produce(value, (draft) => { + draft[column.name] = e as never; + }) + ); + }} + /> +
+ ); } return ( @@ -193,12 +214,16 @@ export interface CommonConnectionConfig { // Primarily used for loading the SQLite database file // from the browser using the File System Access API filehandler?: string; + + // Starbase specified configuration + starbase_type?: string; } interface CommonConnectionConfigColumn { name: keyof CommonConnectionConfig; label: string; - type: "text" | "password" | "file" | "textarea" | "checkbox"; + type: "text" | "password" | "file" | "textarea" | "checkbox" | "options"; + options?: { value: string; content: string }[]; required?: boolean; placeholder?: string; size?: string; diff --git a/src/components/connection-config-editor/template/starbase.tsx b/src/components/connection-config-editor/template/starbase.tsx index 066297e4..e80b4607 100644 --- a/src/components/connection-config-editor/template/starbase.tsx +++ b/src/components/connection-config-editor/template/starbase.tsx @@ -13,6 +13,20 @@ const template: CommonConnectionConfigTemplate = [ }, ], }, + { + columns: [ + { + name: "starbase_type", + label: "Starbase Type", + type: "options", + options: [ + { value: "internal", content: "Internal" }, + { value: "external", content: "External" }, + { value: "hyperdrive", content: "Hyperdrive" }, + ], + }, + ], + }, { columns: [ { @@ -54,6 +68,7 @@ export const StarbaseConnectionTemplate: ConnectionTemplateList = { name: value.name, host: value.url, token: value.token, + starbase_type: value.starbase_type, }; }, localTo: (value) => { @@ -62,6 +77,7 @@ export const StarbaseConnectionTemplate: ConnectionTemplateList = { driver: "starbase", url: value.host, token: value.token, + starbase_type: value.starbase_type, }; }, remoteFrom: (value) => { diff --git a/src/components/editor/prompt-plugin.tsx b/src/components/editor/prompt-plugin.tsx index d6079b18..467b876c 100644 --- a/src/components/editor/prompt-plugin.tsx +++ b/src/components/editor/prompt-plugin.tsx @@ -1,4 +1,5 @@ import AgentDriverList from "@/drivers/agent/list"; +import { generateId } from "@/lib/generate-id"; import { unifiedMergeView } from "@codemirror/merge"; import { Compartment, @@ -73,7 +74,7 @@ class PromptWidget extends WidgetType { super(); // Generate unique session id for this prompt - const sessionId = crypto.randomUUID(); + const sessionId = generateId(); plugin.lock(); this.container = document.createElement("div"); diff --git a/src/components/filehandler-picker.tsx b/src/components/filehandler-picker.tsx index 002c1fbb..33cde2f3 100644 --- a/src/components/filehandler-picker.tsx +++ b/src/components/filehandler-picker.tsx @@ -1,4 +1,5 @@ import { localDb } from "@/indexdb"; +import { generateId } from "@/lib/generate-id"; import { cn } from "@/lib/utils"; import { LucideFile, LucideFolderClosed } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -52,7 +53,7 @@ async function openFileHandler() { ], }); - const id = crypto.randomUUID(); + const id = generateId(); localDb.file_handler.add({ id, handler: newFileHandler }).then(); return id; diff --git a/src/components/gui/schema-editor/index.tsx b/src/components/gui/schema-editor/index.tsx index 3624ce5e..8a4a97c5 100644 --- a/src/components/gui/schema-editor/index.tsx +++ b/src/components/gui/schema-editor/index.tsx @@ -1,5 +1,6 @@ import { useStudioContext } from "@/context/driver-provider"; import { DatabaseTableSchemaChange } from "@/drivers/base-driver"; +import { generateId } from "@/lib/generate-id"; import { checkSchemaChange } from "@/lib/sql/sql-generate.schema"; import { LucideCode, LucideCopy, LucidePlus, LucideSave } from "lucide-react"; import { Dispatch, SetStateAction, useCallback, useMemo } from "react"; @@ -51,7 +52,7 @@ export default function SchemaEditor({ columns: [ ...value.columns, { - key: window.crypto.randomUUID(), + key: generateId(), old: null, new: newColumn, }, diff --git a/src/components/gui/schema-editor/schema-editor-constraint-list.tsx b/src/components/gui/schema-editor/schema-editor-constraint-list.tsx index 3aecce36..f328e17e 100644 --- a/src/components/gui/schema-editor/schema-editor-constraint-list.tsx +++ b/src/components/gui/schema-editor/schema-editor-constraint-list.tsx @@ -4,6 +4,7 @@ import { DatabaseTableConstraintChange, DatabaseTableSchemaChange, } from "@/drivers/base-driver"; +import { generateId } from "@/lib/generate-id"; import { cn } from "@/lib/utils"; import { LucideArrowUpRight, @@ -410,7 +411,7 @@ export default function SchemaEditorConstraintList({ constraints: [ ...prev.constraints, { - id: window.crypto.randomUUID(), + id: generateId(), new: con, old: null, }, diff --git a/src/components/gui/table-result/context-menu.tsx b/src/components/gui/table-result/context-menu.tsx index bd20f39f..27b809de 100644 --- a/src/components/gui/table-result/context-menu.tsx +++ b/src/components/gui/table-result/context-menu.tsx @@ -5,6 +5,7 @@ import { exportRowsToJson, exportRowsToSqlInsert, } from "@/lib/export-helper"; +import { generateId } from "@/lib/generate-id"; import { KEY_BINDING } from "@/lib/key-matcher"; import { LucidePlus, LucideTrash2 } from "lucide-react"; import { useCallback } from "react"; @@ -34,7 +35,7 @@ export default function useTableResultContextMenu({ state: OptimizeTableState; event: React.MouseEvent; }) => { - const randomUUID = crypto.randomUUID(); + const randomUUID = generateId(); const timestamp = Math.floor(Date.now() / 1000).toString(); const hasFocus = !!state.getFocus(); diff --git a/src/components/gui/tabs/schema-editor-tab.tsx b/src/components/gui/tabs/schema-editor-tab.tsx index 5d2960e6..78c3636e 100644 --- a/src/components/gui/tabs/schema-editor-tab.tsx +++ b/src/components/gui/tabs/schema-editor-tab.tsx @@ -1,6 +1,7 @@ import OpacityLoading from "@/components/gui/loading-opacity"; import { useStudioContext } from "@/context/driver-provider"; import { DatabaseTableSchemaChange } from "@/drivers/base-driver"; +import { generateId } from "@/lib/generate-id"; import { createTableSchemaDraft } from "@/lib/sql/sql-generate.schema"; import { cloneDeep } from "lodash"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -74,7 +75,7 @@ export default function SchemaEditorTab({ })) .filter((col) => col.old), constraints: prev.constraints.map((con) => ({ - id: window.crypto.randomUUID(), + id: generateId(), old: con.old, new: cloneDeep(con.old), })), diff --git a/src/core/builtin-tab/open-query-tab.tsx b/src/core/builtin-tab/open-query-tab.tsx index de78347b..83f2cb80 100644 --- a/src/core/builtin-tab/open-query-tab.tsx +++ b/src/core/builtin-tab/open-query-tab.tsx @@ -1,4 +1,5 @@ import QueryWindow from "@/components/gui/tabs/query-tab"; +import { generateId } from "@/lib/generate-id"; import { Binoculars } from "@phosphor-icons/react"; import { createTabExtension } from "../extension-tab"; @@ -20,11 +21,11 @@ export const builtinOpenQueryTab = createTabExtension< if (options?.saved) { return options.saved.key; } - return window.crypto.randomUUID(); + return generateId(); }, generate: (options) => { const title = options?.saved - ? options.name ?? "Query" + ? (options.name ?? "Query") : "Query " + (QUERY_COUNTER++).toString(); const component = options?.saved ? ( diff --git a/src/drivers/agent/common.ts b/src/drivers/agent/common.ts index f97b0f86..06e49b73 100644 --- a/src/drivers/agent/common.ts +++ b/src/drivers/agent/common.ts @@ -1,3 +1,4 @@ +import { generateId } from "@/lib/generate-id"; import { BaseDriver, DatabaseSchemas, @@ -61,7 +62,7 @@ export default abstract class CommonAgentDriverImplementation extends AgentBaseD option: AgentPromptOption ): Promise { const session = this.history[previousId ?? ""] ?? { - id: previousId || crypto.randomUUID(), + id: previousId || generateId(), createdAt: Date.now(), messages: [], }; diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index 41dc71bc..f9c70fce 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -278,6 +278,15 @@ export interface DatabaseSchemaChange { collate?: string; } +export interface QueryableBaseDriver { + query(stmt: string): Promise; + transaction(stmts: string[]): Promise; + + // This is optional. We can always fallback to multiple query + // This is just optimization for driver that support batch query + batch?(stmts: string[]): Promise; +} + export abstract class BaseDriver { // Flags abstract getFlags(): DriverFlags; diff --git a/src/drivers/board-storage/local.ts b/src/drivers/board-storage/local.ts index 139119e0..4570f1dd 100644 --- a/src/drivers/board-storage/local.ts +++ b/src/drivers/board-storage/local.ts @@ -1,13 +1,14 @@ import { DashboardProps } from "@/components/board"; import { ChartValue } from "@/components/chart/chart-type"; import { LocalDashboardData, localDb } from "@/indexdb"; +import { generateId } from "@/lib/generate-id"; import { IBoardStorageDriver } from "./base"; export default class LocalBoardStorage implements IBoardStorageDriver { constructor(protected board: LocalDashboardData) {} async add(chart: ChartValue): Promise { - const id = crypto.randomUUID(); + const id = generateId(); const now = Date.now(); const data: ChartValue = { diff --git a/src/drivers/cloudflare-d1-driver.ts b/src/drivers/cloudflare-d1-driver.ts deleted file mode 100644 index 922ebfc3..00000000 --- a/src/drivers/cloudflare-d1-driver.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ColumnType } from "@outerbase/sdk-transform"; -import { - DatabaseHeader, - DatabaseResultSet, - DatabaseRow, -} from "./base-driver"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; - -interface CloudflareResult { - results: { - columns: string[]; - rows: unknown[][]; - }; - meta: { - duration: number; - changes: number; - last_row_id: number; - rows_read: number; - rows_written: number; - }; -} - -interface CloudflareResponse { - error: string; - result: CloudflareResult[]; -} - -function transformRawResult(raw: CloudflareResult): DatabaseResultSet { - const columns = raw.results.columns ?? []; - const values = raw.results.rows; - const headerSet = new Set(); - - const headers: DatabaseHeader[] = columns.map((colName) => { - let renameColName = colName; - - for (let i = 0; i < 20; i++) { - if (!headerSet.has(renameColName)) break; - renameColName = `__${colName}_${i}`; - } - - return { - name: renameColName, - displayName: colName, - originalType: "text", - type: ColumnType.TEXT, - }; - }); - - const rows = values - ? values.map((r) => - headers.reduce((a, b, idx) => { - a[b.name] = r[idx]; - return a; - }, {} as DatabaseRow) - ) - : []; - - return { - rows, - stat: { - rowsAffected: raw.meta.changes, - rowsRead: raw.meta.rows_read, - rowsWritten: raw.meta.rows_written, - queryDurationMs: raw.meta.duration, - }, - headers, - lastInsertRowid: - raw.meta.last_row_id === undefined ? undefined : raw.meta.last_row_id, - }; -} - -export default class CloudflareD1Driver extends SqliteLikeBaseDriver { - supportPragmaList: boolean = false; - protected headers: Record = {}; - protected url: string; - - constructor(url: string, headers: Record) { - super(); - this.headers = headers; - this.url = url; - } - - async transaction(stmts: string[]): Promise { - const r = await fetch(this.url, { - method: "POST", - headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify({ - sql: stmts.join(";"), - }), - }); - - const json: CloudflareResponse = await r.json(); - - if (json.error) throw new Error(json.error); - - return json.result.map(transformRawResult); - } - - async query(stmt: string): Promise { - return (await this.transaction([stmt]))[0]; - } -} diff --git a/src/drivers/database/cloudflare-d1.ts b/src/drivers/database/cloudflare-d1.ts new file mode 100644 index 00000000..2fe859da --- /dev/null +++ b/src/drivers/database/cloudflare-d1.ts @@ -0,0 +1,50 @@ +import { transformCloudflareD1 } from "@outerbase/sdk-transform"; +import { DatabaseResultSet, QueryableBaseDriver } from "../base-driver"; + +interface CloudflareResult { + results: { + columns: string[]; + rows: unknown[][]; + }; + meta: { + duration: number; + changes: number; + last_row_id: number; + rows_read: number; + rows_written: number; + }; +} + +interface CloudflareResponse { + error: string; + result: CloudflareResult[]; +} + +export class CloudflareD1Queryable implements QueryableBaseDriver { + constructor( + protected url: string, + protected headers: Record + ) { + this.headers = headers; + this.url = url; + } + + async transaction(stmts: string[]): Promise { + const r = await fetch(this.url, { + method: "POST", + headers: { ...this.headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: stmts.join(";"), + }), + }); + + const json: CloudflareResponse = await r.json(); + + if (json.error) throw new Error(json.error); + return json.result.map(transformCloudflareD1); + } + + async query(stmt: string): Promise { + return (await this.transaction([stmt]))[0]; + } +} diff --git a/src/drivers/database/cloudflare-wae.ts b/src/drivers/database/cloudflare-wae.ts index 55e654c1..d9f5daee 100644 --- a/src/drivers/database/cloudflare-wae.ts +++ b/src/drivers/database/cloudflare-wae.ts @@ -5,6 +5,7 @@ import { DatabaseTableColumn, DatabaseTableSchema, DriverFlags, + QueryableBaseDriver, SelectFromTableOptions, } from "../base-driver"; import PostgresLikeDriver from "../postgres/postgres-driver"; @@ -67,30 +68,11 @@ const WAEGenericColumns: DatabaseTableColumn[] = [ { 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, - }; - } - +class WAEQueryable implements QueryableBaseDriver { constructor( protected accountId: string, protected token: string - ) { - super(); - } + ) {} async query(stmt: string): Promise { const r = await fetch("/proxy/wae", { @@ -138,6 +120,32 @@ export default class CloudflareWAEDriver extends PostgresLikeDriver { async transaction(stmt: string[]): Promise { return Promise.all(stmt.map((s) => this.query(s))); } +} + +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(new WAEQueryable(accountId, token)); + } async schemas(): Promise { const tableList = await this.query("SHOW TABLES"); diff --git a/src/drivers/rqlite-driver.ts b/src/drivers/database/rqlite.ts similarity index 82% rename from src/drivers/rqlite-driver.ts rename to src/drivers/database/rqlite.ts index 5d8cbfc0..410bf098 100644 --- a/src/drivers/rqlite-driver.ts +++ b/src/drivers/database/rqlite.ts @@ -2,9 +2,9 @@ import { DatabaseHeader, DatabaseResultSet, DatabaseRow, + QueryableBaseDriver, } from "@/drivers/base-driver"; -import { convertSqliteType } from "./sqlite/sql-helper"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; +import { convertSqliteType } from "../sqlite/sql-helper"; interface RqliteResult { columns?: string[]; @@ -67,17 +67,12 @@ export function transformRawResult(raw: RqliteResult): DatabaseResultSet { }; } -export default class RqliteDriver extends SqliteLikeBaseDriver { - protected endpoint: string; - protected username?: string; - protected password?: string; - - constructor(url: string, username?: string, password?: string) { - super(); - this.endpoint = url; - this.username = username; - this.password = password; - } +export class RqliteQueryable implements QueryableBaseDriver { + constructor( + protected endpoint: string, + protected username?: string, + protected password?: string + ) {} async transaction(stmts: string[]): Promise { let headers: HeadersInit = { @@ -114,8 +109,4 @@ export default class RqliteDriver extends SqliteLikeBaseDriver { async query(stmt: string): Promise { return (await this.transaction([stmt]))[0]; } - - close(): void { - // do nothing - } } diff --git a/src/drivers/sqljs-driver.ts b/src/drivers/database/sqljs.ts similarity index 78% rename from src/drivers/sqljs-driver.ts rename to src/drivers/database/sqljs.ts index 11e721e7..9d28db3e 100644 --- a/src/drivers/sqljs-driver.ts +++ b/src/drivers/database/sqljs.ts @@ -1,25 +1,17 @@ -import { InStatement } from "@libsql/client"; import { DatabaseHeader, DatabaseResultSet, DatabaseRow, + QueryableBaseDriver, } from "@/drivers/base-driver"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; +import { InStatement } from "@libsql/client"; import { BindParams, Database } from "sql.js"; +import { SqliteLikeBaseDriver } from "../sqlite-base-driver"; -export default class SqljsDriver extends SqliteLikeBaseDriver { - protected db: Database; - protected hasRowsChanged: boolean = false; - - constructor(sqljs: Database) { - super(); - this.db = sqljs; - } +class SqljsQueryable implements QueryableBaseDriver { + public hasRowsChanged: boolean = false; - reload(sqljs: Database) { - this.db = sqljs; - this.hasRowsChanged = false; - } + constructor(protected db: Database) {} async transaction(stmts: InStatement[]): Promise { const r: DatabaseResultSet[] = []; @@ -86,16 +78,28 @@ export default class SqljsDriver extends SqliteLikeBaseDriver { }, }; } +} - resetChange() { - this.hasRowsChanged = false; +export default class SqljsDriver extends SqliteLikeBaseDriver { + protected queryable: SqljsQueryable; + + constructor(sqljs: Database) { + const queryable = new SqljsQueryable(sqljs); + super(queryable); + + this.queryable = queryable; } - hasChanged() { - return this.hasRowsChanged; + reload(sqljs: Database) { + this.queryable = new SqljsQueryable(sqljs); + this._db = this.queryable; } - close(): void { - // do nothing + resetChange() { + this.queryable.hasRowsChanged = false; + } + + hasChanged() { + return this.queryable.hasRowsChanged; } } diff --git a/src/drivers/starbase-driver.ts b/src/drivers/database/starbasedb.ts similarity index 72% rename from src/drivers/starbase-driver.ts rename to src/drivers/database/starbasedb.ts index 1ae8c4a6..a412c092 100644 --- a/src/drivers/starbase-driver.ts +++ b/src/drivers/database/starbasedb.ts @@ -3,8 +3,8 @@ import { DatabaseHeader, DatabaseResultSet, DatabaseRow, -} from "./base-driver"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; + QueryableBaseDriver, +} from "../base-driver"; interface StarbaseResult { columns: string[]; @@ -42,11 +42,11 @@ function transformRawResult(raw: StarbaseResult): DatabaseResultSet { const rows = values ? values.map((r) => - headers.reduce((a, b, idx) => { - a[b.name] = r[idx]; - return a; - }, {} as DatabaseRow) - ) + headers.reduce((a, b, idx) => { + a[b.name] = r[idx]; + return a; + }, {} as DatabaseRow) + ) : []; return { @@ -61,21 +61,30 @@ function transformRawResult(raw: StarbaseResult): DatabaseResultSet { }; } -export default class StarbaseDriver extends SqliteLikeBaseDriver { - supportPragmaList: boolean = false; - protected headers: Record = {}; +export class StarbaseQuery implements QueryableBaseDriver { protected url: string; + protected headers: Record; - constructor(url: string, headers: Record) { - super(); - this.headers = headers; - this.url = url; + constructor( + protected _url: string, + protected token: string, + protected type: string = "internal" + ) { + this.url = `${_url.replace(/\/$/, "")}/query/raw`; + this.headers = { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }; + + if (type !== "internal") { + this.headers["X-Starbase-Source"] = type; + } } async transaction(stmts: string[]): Promise { const r = await fetch(this.url, { method: "POST", - headers: { ...this.headers, "Content-Type": "application/json" }, + headers: this.headers, body: JSON.stringify({ transaction: stmts.map((s) => ({ sql: s })), }), @@ -90,7 +99,7 @@ export default class StarbaseDriver extends SqliteLikeBaseDriver { async query(stmt: string): Promise { const r = await fetch(this.url, { method: "POST", - headers: { ...this.headers, "Content-Type": "application/json" }, + headers: this.headers, body: JSON.stringify({ sql: stmt }), }); diff --git a/src/drivers/turso-driver.tsx b/src/drivers/database/turso.tsx similarity index 66% rename from src/drivers/turso-driver.tsx rename to src/drivers/database/turso.tsx index 4af52a1a..cd799f2c 100644 --- a/src/drivers/turso-driver.tsx +++ b/src/drivers/database/turso.tsx @@ -1,18 +1,14 @@ -import { - createClient, - Client, - InStatement, - ResultSet, -} from "@libsql/client/web"; -import { createClient as createClientStateless } from "libsql-stateless-easy"; import { DatabaseHeader, DatabaseResultSet, DatabaseRow, DriverFlags, + QueryableBaseDriver, } from "@/drivers/base-driver"; -import { convertSqliteType } from "./sqlite/sql-helper"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; +import { Client, InStatement, ResultSet } from "@libsql/client/web"; +import { createClient as createClientStateless } from "libsql-stateless-easy"; +import { SqliteLikeBaseDriver } from "../sqlite-base-driver"; +import { convertSqliteType } from "../sqlite/sql-helper"; export function transformRawResult(raw: ResultSet): DatabaseResultSet { const headerSet = new Set(); @@ -67,43 +63,9 @@ export function transformRawResult(raw: ResultSet): DatabaseResultSet { }; } -export default class TursoDriver extends SqliteLikeBaseDriver { - protected client: Client; - protected endpoint: string = ""; - protected authToken = ""; - protected bigInt = false; - - constructor(url: string, authToken: string, bigInt: boolean = false) { - super(); - this.endpoint = url; - this.authToken = authToken; - this.bigInt = bigInt; - - if ( - url.startsWith("libsql://") || - url.startsWith("http://") || - url.startsWith("https://") - ) { - this.client = createClientStateless({ - url: this.endpoint.replace(/^libsql:\/\//, "https://"), - authToken: this.authToken, - intMode: bigInt ? "bigint" : "number", - }); - } else { - this.client = createClient({ - url: this.endpoint, - authToken: this.authToken, - intMode: bigInt ? "bigint" : "number", - }); - } - } - - override getFlags(): DriverFlags { - return { - ...super.getFlags(), - supportBigInt: this.bigInt, - supportModifyColumn: true, - }; +export class TursoQueryable implements QueryableBaseDriver { + constructor(protected client: Client) { + this.client = client; } async query(stmt: InStatement) { @@ -116,3 +78,35 @@ export default class TursoDriver extends SqliteLikeBaseDriver { return (await this.client.batch(stmt, "write")).map(transformRawResult); } } + +export default class TursoDriver extends SqliteLikeBaseDriver { + constructor( + url: string, + authToken: string, + protected bigInt: boolean = false + ) { + super( + new TursoQueryable( + createClientStateless({ + url: url + .replace(/^libsql:\/\//, "https://") + .replace(/^ws:\/\//, "http://") + .replace(/^wss:\/\//, "https://"), + authToken: authToken, + intMode: bigInt ? "bigint" : "number", + }) + ), + { + supportBigInt: bigInt, + } + ); + } + + override getFlags(): DriverFlags { + return { + ...super.getFlags(), + supportBigInt: this.bigInt, + supportModifyColumn: true, + }; + } +} diff --git a/src/drivers/valtown-driver.ts b/src/drivers/database/valtown.ts similarity index 71% rename from src/drivers/valtown-driver.ts rename to src/drivers/database/valtown.ts index 99607910..95163862 100644 --- a/src/drivers/valtown-driver.ts +++ b/src/drivers/database/valtown.ts @@ -1,15 +1,9 @@ +import { DatabaseResultSet, QueryableBaseDriver } from "@/drivers/base-driver"; import { InStatement, ResultSet } from "@libsql/client"; -import { transformRawResult } from "./turso-driver"; -import { DatabaseResultSet } from "@/drivers/base-driver"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; +import { transformRawResult } from "./turso"; -export default class ValtownDriver extends SqliteLikeBaseDriver { - protected token: string; - - constructor(token: string) { - super(); - this.token = token; - } +export class ValtownQueryable implements QueryableBaseDriver { + constructor(protected token: string) {} async transaction(stmts: InStatement[]): Promise { const r = await fetch(`https://api.val.town/v1/sqlite/batch`, { @@ -42,8 +36,4 @@ export default class ValtownDriver extends SqliteLikeBaseDriver { return transformRawResult(json as ResultSet); } - - close(): void { - // do nothing - } } diff --git a/src/drivers/helpers.ts b/src/drivers/helpers.ts index 3f3b17cb..be5ec0f3 100644 --- a/src/drivers/helpers.ts +++ b/src/drivers/helpers.ts @@ -1,27 +1,29 @@ import { SavedConnectionRawLocalStorage } from "@/app/(theme)/connect/saved-connection-storage"; -import CloudflareD1Driver from "./cloudflare-d1-driver"; +import { CloudflareD1Queryable } from "./database/cloudflare-d1"; import CloudflareWAEDriver from "./database/cloudflare-wae"; -import RqliteDriver from "./rqlite-driver"; -import StarbaseDriver from "./starbase-driver"; -import TursoDriver from "./turso-driver"; -import ValtownDriver from "./valtown-driver"; +import { RqliteQueryable } from "./database/rqlite"; +import { StarbaseQuery } from "./database/starbasedb"; +import TursoDriver from "./database/turso"; +import { ValtownQueryable } from "./database/valtown"; +import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; export function createLocalDriver(conn: SavedConnectionRawLocalStorage) { if (conn.driver === "rqlite") { - return new RqliteDriver(conn.url!, conn.username, conn.password); + return new SqliteLikeBaseDriver( + new RqliteQueryable(conn.url!, conn.username, conn.password) + ); } else if (conn.driver === "valtown") { - return new ValtownDriver(conn.token!); + return new SqliteLikeBaseDriver(new ValtownQueryable(conn.token!)); } else if (conn.driver === "cloudflare-d1") { - return new CloudflareD1Driver("/proxy/d1", { - Authorization: "Bearer " + conn.token, - "x-account-id": conn.username ?? "", - "x-database-id": conn.database ?? "", - }); + return new SqliteLikeBaseDriver( + new CloudflareD1Queryable("/proxy/d1", { + Authorization: "Bearer " + conn.token, + "x-account-id": conn.username ?? "", + "x-database-id": conn.database ?? "", + }) + ); } else if (conn.driver === "starbase") { - return new StarbaseDriver("/proxy/starbase", { - Authorization: "Bearer " + (conn.token ?? ""), - "x-starbase-url": conn.url ?? "", - }); + return new SqliteLikeBaseDriver(new StarbaseQuery(conn.url!, conn.token!)); } else if (conn.driver === "cloudflare-wae") { return new CloudflareWAEDriver(conn.username!, conn.token!); } diff --git a/src/drivers/iframe-driver.ts b/src/drivers/iframe-driver.ts index 1a7e88fe..6df26014 100644 --- a/src/drivers/iframe-driver.ts +++ b/src/drivers/iframe-driver.ts @@ -1,22 +1,19 @@ "use client"; -import { DatabaseResultSet, DriverFlags } from "./base-driver"; -import MySQLLikeDriver from "./mysql/mysql-driver"; -import PostgresLikeDriver from "./postgres/postgres-driver"; -import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; +import { DatabaseResultSet, QueryableBaseDriver } from "./base-driver"; type ParentResponseData = | { - type: "query"; - id: number; - data: DatabaseResultSet; - error?: string; - } + type: "query"; + id: number; + data: DatabaseResultSet; + error?: string; + } | { - type: "transaction"; - id: number; - data: DatabaseResultSet[]; - error?: string; - }; + type: "transaction"; + id: number; + data: DatabaseResultSet[]; + error?: string; + }; type PromiseResolveReject = { resolve: (value: any) => void; @@ -93,98 +90,16 @@ class ElectronConnection { } } -export class IframeSQLiteDriver extends SqliteLikeBaseDriver { +export class EmbedQueryable implements QueryableBaseDriver { protected conn = typeof window !== "undefined" && window?.outerbaseIpc ? new ElectronConnection() : new IframeConnection(); - protected supportBigInt = false; - - constructor(options?: { - supportPragmaList?: boolean; - supportBigInt?: boolean; - }) { - super(); - if (options?.supportPragmaList !== undefined) { - this.supportPragmaList = options.supportPragmaList; - } - - if (options?.supportBigInt !== undefined) { - this.supportBigInt = options.supportBigInt; - } - } - - getFlags(): DriverFlags { - return { - ...super.getFlags(), - supportCreateUpdateTable: true, - supportModifyColumn: true, - supportBigInt: this.supportBigInt, - }; - } - listen() { this.conn.listen(); } - close(): void { } - - async query(stmt: string): Promise { - const r = await this.conn.query(stmt); - return r; - } - - transaction(stmts: string[]): Promise { - const r = this.conn.transaction(stmts); - return r; - } -} - -export class IframeMySQLDriver extends MySQLLikeDriver { - protected conn = - typeof window !== "undefined" && window?.outerbaseIpc - ? new ElectronConnection() - : new IframeConnection(); - - listen() { - this.conn.listen(); - } - - close(): void { } - - async query(stmt: string): Promise { - const r = await this.conn.query(stmt); - return r; - } - - transaction(stmts: string[]): Promise { - const r = this.conn.transaction(stmts); - return r; - } -} - -export class IframeDoltDriver extends IframeMySQLDriver { - getFlags(): DriverFlags { - return { - ...super.getFlags(), - dialect: "dolt", - }; - } -} - -export class IframePostgresDriver extends PostgresLikeDriver { - protected conn = - typeof window !== "undefined" && window?.outerbaseIpc - ? new ElectronConnection() - : new IframeConnection(); - - listen() { - this.conn.listen(); - } - - close(): void { } - async query(stmt: string): Promise { const r = await this.conn.query(stmt); return r; diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index dcfd18bb..6bbb2b64 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -2,6 +2,7 @@ import { ColumnType } from "@outerbase/sdk-transform"; import { format } from "sql-formatter"; import { ColumnTypeSelector, + DatabaseResultSet, DatabaseSchemaChange, DatabaseSchemaItem, DatabaseSchemas, @@ -12,6 +13,7 @@ import { DatabaseTriggerSchema, DatabaseViewSchema, DriverFlags, + QueryableBaseDriver, TriggerOperation, TriggerWhen, } from "../base-driver"; @@ -125,7 +127,7 @@ function mapColumn(column: MySqlColumn): DatabaseTableColumn { return result; } -export default abstract class MySQLLikeDriver extends CommonSQLImplement { +export default class MySQLLikeDriver extends CommonSQLImplement { columnTypeSelector: ColumnTypeSelector = MYSQL_DATA_TYPE_SUGGESTION; // If this is specified, we only show the tables in this database @@ -133,6 +135,30 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { // It does not make sense to show other databases. selectedDatabase: string = ""; + constructor( + protected _db: QueryableBaseDriver, + selectedDatabase = "" + ) { + super(); + this.selectedDatabase = selectedDatabase; + } + + query(stmt: string): Promise { + return this._db.query(stmt); + } + + transaction(stmts: string[]): Promise { + return this._db.transaction(stmts); + } + + batch(stmts: string[]): Promise { + return this._db.batch ? this._db.batch(stmts) : super.batch(stmts); + } + + close(): void { + // Do nothing + } + escapeId(id: string) { return `\`${id.replace(/`/g, "``")}\``; } diff --git a/src/drivers/mysql/mysql-playground-driver.ts b/src/drivers/mysql/mysql-playground-driver.ts index ada9d3e4..a954df71 100644 --- a/src/drivers/mysql/mysql-playground-driver.ts +++ b/src/drivers/mysql/mysql-playground-driver.ts @@ -1,4 +1,4 @@ -import { DatabaseResultSet } from "../base-driver"; +import { DatabaseResultSet, QueryableBaseDriver } from "../base-driver"; import MySQLLikeDriver from "./mysql-driver"; type PromiseResolveReject = { @@ -6,15 +6,14 @@ type PromiseResolveReject = { reject: (value: { message: string }) => void; }; -export default class MySQLPlaygroundDriver extends MySQLLikeDriver { - protected ws: WebSocket; +class MySQLPlaygroundQueryable implements QueryableBaseDriver { protected counter = 0; protected queryPromise: Record = {}; - constructor(roomName: string, { onReady }: { onReady: () => void }) { - super(); - this.ws = new WebSocket(`wss://mysql-playground-ws.fly.dev/${roomName}`); - + constructor( + protected ws: WebSocket, + onReady: () => void + ) { this.ws.addEventListener("message", (e) => { const data = JSON.parse(e.data); @@ -65,6 +64,18 @@ export default class MySQLPlaygroundDriver extends MySQLLikeDriver { ); }); } +} + +export default class MySQLPlaygroundDriver extends MySQLLikeDriver { + protected ws: WebSocket; + protected counter = 0; + protected queryPromise: Record = {}; + + constructor(roomName: string, { onReady }: { onReady: () => void }) { + const ws = new WebSocket(`wss://mysql-playground-ws.fly.dev/${roomName}`); + super(new MySQLPlaygroundQueryable(ws, onReady)); + this.ws = ws; + } ping(): void { console.log("Ping"); diff --git a/src/drivers/postgres/postgres-driver.ts b/src/drivers/postgres/postgres-driver.ts index 3a077747..bf401ac1 100644 --- a/src/drivers/postgres/postgres-driver.ts +++ b/src/drivers/postgres/postgres-driver.ts @@ -1,6 +1,7 @@ import { ColumnType } from "@outerbase/sdk-transform"; import { ColumnTypeSelector, + DatabaseResultSet, DatabaseSchemaItem, DatabaseSchemas, DatabaseTableColumn, @@ -10,6 +11,7 @@ import { DatabaseTriggerSchema, DatabaseViewSchema, DriverFlags, + QueryableBaseDriver, } from "../base-driver"; import CommonSQLImplement from "../common-sql-imp"; import { escapeSqlValue } from "../sqlite/sql-helper"; @@ -59,7 +61,27 @@ interface PostgresConstraintRow { reference_column_name: string; } -export default abstract class PostgresLikeDriver extends CommonSQLImplement { +export default class PostgresLikeDriver extends CommonSQLImplement { + constructor(protected _db: QueryableBaseDriver) { + super(); + } + + query(stmt: string): Promise { + return this._db.query(stmt); + } + + transaction(stmts: string[]): Promise { + return this._db.transaction(stmts); + } + + batch(stmts: string[]): Promise { + return this._db.batch ? this._db.batch(stmts) : super.batch(stmts); + } + + close(): void { + // Do nothing + } + columnTypeSelector: ColumnTypeSelector = POSTGRES_DATA_TYPE_SUGGESTION; escapeId(id: string) { @@ -98,26 +120,12 @@ export default abstract class PostgresLikeDriver extends CommonSQLImplement { } async schemas(): Promise { - const schemaResult = ( - await this.query( - `SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast')` - ) - ).rows as unknown as PostgresSchemaRow[]; - - const tableResult = ( - await this.query( - "SELECT *, pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) AS table_size FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast');" - ) - ).rows as unknown as PostgresTableRow[]; - - const columnsResult = ( - await this.query( - "SELECT * FROM information_schema.columns WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')" - ) - ).rows as unknown as PostgresColumnRow[]; - - const constraintResult = ( - await this.query(`SELECT + const schemaSql = `SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast')`; + const tableSql = + "SELECT *, pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) AS table_size FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast');"; + const columnSql = + "SELECT * FROM information_schema.columns WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')"; + const constraintSql = `SELECT tc.constraint_name, tc.table_schema, tc.table_name, @@ -140,8 +148,20 @@ FROM ccu.constraint_name = kcu.constraint_name ) WHERE - tc.table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')`) - ).rows as unknown as PostgresConstraintRow[]; + tc.table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')`; + + const result = await this.batch([ + schemaSql, + tableSql, + columnSql, + constraintSql, + ]); + + const schemaResult = result[0].rows as unknown as PostgresSchemaRow[]; + const tableResult = result[1].rows as unknown as PostgresTableRow[]; + const columnsResult = result[2].rows as unknown as PostgresColumnRow[]; + const constraintResult = result[3] + .rows as unknown as PostgresConstraintRow[]; const schemas: DatabaseSchemas = {}; diff --git a/src/drivers/saved-doc/electron-saved-doc.ts b/src/drivers/saved-doc/electron-saved-doc.ts index 442df05a..f27ec66e 100644 --- a/src/drivers/saved-doc/electron-saved-doc.ts +++ b/src/drivers/saved-doc/electron-saved-doc.ts @@ -1,4 +1,5 @@ "use client"; +import { generateId } from "@/lib/generate-id"; import { SavedDocData, SavedDocDriver, @@ -32,7 +33,7 @@ export default class ElectronSavedDocs implements SavedDocDriver { this.cacheDocs = { workspace: [], - } + }; return this.cacheNamespaceList; } else { @@ -60,7 +61,7 @@ export default class ElectronSavedDocs implements SavedDocDriver { await this.getNamespaces(); const now = Math.floor(Date.now() / 1000); - const id = window.crypto.randomUUID(); + const id = generateId(); const namespace = { id, @@ -119,7 +120,7 @@ export default class ElectronSavedDocs implements SavedDocDriver { (n) => n.id === namespace )!, type, - id: window.crypto.randomUUID(), + id: generateId(), }; if (this.cacheDocs[r.namespace.id]) { diff --git a/src/drivers/saved-doc/indexdb-saved-doc.ts b/src/drivers/saved-doc/indexdb-saved-doc.ts index 2f8dd646..f09c2eae 100644 --- a/src/drivers/saved-doc/indexdb-saved-doc.ts +++ b/src/drivers/saved-doc/indexdb-saved-doc.ts @@ -1,4 +1,5 @@ import { localDb } from "@/indexdb"; +import { generateId } from "@/lib/generate-id"; import { SavedDocData, SavedDocDriver, @@ -18,7 +19,7 @@ export default class IndexdbSavedDoc implements SavedDocDriver { async createNamespace(name: string): Promise { const now = Math.floor(Date.now() / 1000); - const id = window.crypto.randomUUID(); + const id = generateId(); await localDb.namespace.add({ id, @@ -92,7 +93,7 @@ export default class IndexdbSavedDoc implements SavedDocDriver { data: SavedDocInput ): Promise { const now = Math.floor(Date.now() / 1000); - const id = window.crypto.randomUUID(); + const id = generateId(); const namespaceData = await localDb.namespace.get(namespace); if (!namespaceData) throw new Error("Namespace does not exist"); diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index b0cfbbb9..388b8efd 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -15,6 +15,7 @@ import { DatabaseValue, DatabaseViewSchema, DriverFlags, + QueryableBaseDriver, SelectFromTableOptions, } from "./base-driver"; @@ -25,8 +26,33 @@ import CommonSQLImplement from "./common-sql-imp"; import { parseCreateViewScript } from "./sqlite/sql-parse-view"; import generateSqlSchemaChange from "./sqlite/sqlite-generate-schema"; -export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { - supportPragmaList = true; +export class SqliteLikeBaseDriver extends CommonSQLImplement { + protected supportPragmaList = false; + protected supportBigInt = false; + + constructor( + protected _db: QueryableBaseDriver, + protected options?: { + supportPragmaList?: boolean; + supportBigInt?: boolean; + } + ) { + super(); + this.supportPragmaList = options?.supportPragmaList ?? false; + this.supportBigInt = options?.supportBigInt ?? false; + } + + query(stmt: string): Promise { + return this._db.query(stmt); + } + + transaction(stmts: string[]): Promise { + return this._db.transaction(stmts); + } + + batch(stmts: string[]): Promise { + return this._db.batch ? this._db.batch(stmts) : super.batch(stmts); + } columnTypeSelector: ColumnTypeSelector = { type: "dropdown", @@ -57,7 +83,7 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { getFlags(): DriverFlags { return { supportRowId: true, - supportBigInt: false, + supportBigInt: this.supportBigInt, supportModifyColumn: false, supportInsertReturning: true, supportUpdateReturning: true, @@ -329,12 +355,13 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { const orderPart = options.orderBy && options.orderBy.length > 0 ? options.orderBy - .map((r) => `${this.escapeId(r.columnName)} ${r.by}`) - .join(", ") + .map((r) => `${this.escapeId(r.columnName)} ${r.by}`) + .join(", ") : ""; - const sql = `SELECT ${injectRowIdColumn ? "rowid, " : ""}* FROM ${this.escapeId(schemaName)}.${this.escapeId(tableName)}${whereRaw ? ` WHERE ${whereRaw} ` : "" - } ${orderPart ? ` ORDER BY ${orderPart}` : ""} LIMIT ${escapeSqlValue(options.limit)} OFFSET ${escapeSqlValue(options.offset)};`; + const sql = `SELECT ${injectRowIdColumn ? "rowid, " : ""}* FROM ${this.escapeId(schemaName)}.${this.escapeId(tableName)}${ + whereRaw ? ` WHERE ${whereRaw} ` : "" + } ${orderPart ? ` ORDER BY ${orderPart}` : ""} LIMIT ${escapeSqlValue(options.limit)} OFFSET ${escapeSqlValue(options.offset)};`; let data = await this.query(sql); diff --git a/src/lib/generate-id.ts b/src/lib/generate-id.ts new file mode 100644 index 00000000..116b38c2 --- /dev/null +++ b/src/lib/generate-id.ts @@ -0,0 +1,13 @@ +export function generateId() { + if (crypto.randomUUID) { + return crypto.randomUUID(); + } + + // https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid/2117523#2117523 + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16) + ); +} diff --git a/src/lib/sql/sql-generate.schema.ts b/src/lib/sql/sql-generate.schema.ts index 3a8d9d4f..7acb2965 100644 --- a/src/lib/sql/sql-generate.schema.ts +++ b/src/lib/sql/sql-generate.schema.ts @@ -1,10 +1,11 @@ -import deepEqual from "deep-equal"; import { DatabaseTableColumnChange, DatabaseTableSchema, DatabaseTableSchemaChange, } from "@/drivers/base-driver"; +import deepEqual from "deep-equal"; import { cloneDeep } from "lodash"; +import { generateId } from "../generate-id"; export function checkSchemaColumnChange(change: DatabaseTableColumnChange) { return !deepEqual(change.old, change.new); @@ -33,12 +34,12 @@ export function createTableSchemaDraft( new: schema.tableName, }, columns: schema.columns.map((col) => ({ - key: window.crypto.randomUUID(), + key: generateId(), old: col, new: cloneDeep(col), })), constraints: (schema.constraints ?? []).map((con) => ({ - id: window.crypto.randomUUID(), + id: generateId(), old: con, new: cloneDeep(con), })), diff --git a/src/lib/tracking.ts b/src/lib/tracking.ts index a3a3ec04..f7bcec18 100644 --- a/src/lib/tracking.ts +++ b/src/lib/tracking.ts @@ -1,3 +1,4 @@ +import { generateId } from "./generate-id"; import { throttleEvent } from "./tracking-throttle"; export interface TrackEventItem { @@ -39,7 +40,7 @@ export function sendAnalyticEvents(events: TrackEventItem[]) { let deviceId = localStorage.getItem("od-id"); if (!deviceId) { - deviceId = crypto.randomUUID(); + deviceId = generateId(); localStorage.setItem("od-id", deviceId); } diff --git a/src/outerbase-cloud/database/mysql.ts b/src/outerbase-cloud/database/mysql.ts deleted file mode 100644 index d675bda4..00000000 --- a/src/outerbase-cloud/database/mysql.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { DatabaseResultSet, DriverFlags } from "@/drivers/base-driver"; -import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; -import { runOuterbaseQueryBatch, runOuterbaseQueryRaw } from "../api"; -import { OuterbaseDatabaseConfig } from "../api-type"; -import { transformOuterbaseResult } from "./utils"; - -export class OuterbaseMySQLDriver extends MySQLLikeDriver { - protected workspaceId: string; - protected sourceId: string; - - getFlags(): DriverFlags { - return { - ...super.getFlags(), - supportUseStatement: false, - }; - } - - constructor( - { workspaceId, sourceId }: OuterbaseDatabaseConfig, - selectedDatabase?: string - ) { - super(); - - this.selectedDatabase = selectedDatabase ?? ""; - this.workspaceId = workspaceId; - this.sourceId = sourceId; - } - - async query(stmt: string): Promise { - const jsonResponse = await runOuterbaseQueryRaw( - this.workspaceId, - this.sourceId, - stmt - ); - - return transformOuterbaseResult(jsonResponse); - } - - async batch(stmts: string[]): Promise { - return ( - await runOuterbaseQueryBatch(this.workspaceId, this.sourceId, stmts) - ).map(transformOuterbaseResult); - } - - async transaction(stmts: string[]): Promise { - return this.batch(stmts); - } - - close() { - // Nothing to do here - } -} diff --git a/src/outerbase-cloud/database/postgresql.ts b/src/outerbase-cloud/database/postgresql.ts deleted file mode 100644 index 48b07ac3..00000000 --- a/src/outerbase-cloud/database/postgresql.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { DatabaseResultSet, DriverFlags } from "@/drivers/base-driver"; -import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; -import { runOuterbaseQueryBatch, runOuterbaseQueryRaw } from "../api"; -import { OuterbaseDatabaseConfig } from "../api-type"; -import { transformOuterbaseResult } from "./utils"; - -export class OuterbasePostgresDriver extends PostgresLikeDriver { - supportPragmaList = false; - - protected workspaceId: string; - protected sourceId: string; - - getFlags(): DriverFlags { - return { - ...super.getFlags(), - supportBigInt: false, - }; - } - - constructor({ workspaceId, sourceId }: OuterbaseDatabaseConfig) { - super(); - - this.workspaceId = workspaceId; - this.sourceId = sourceId; - } - - async query(stmt: string): Promise { - const jsonResponse = await runOuterbaseQueryRaw( - this.workspaceId, - this.sourceId, - stmt - ); - - return transformOuterbaseResult(jsonResponse); - } - - async batch(stmts: string[]): Promise { - return ( - await runOuterbaseQueryBatch(this.workspaceId, this.sourceId, stmts) - ).map(transformOuterbaseResult); - } - - async transaction(stmts: string[]): Promise { - return this.batch(stmts); - } - - close() { - // Nothing to do - } -} diff --git a/src/outerbase-cloud/database/sqlite.ts b/src/outerbase-cloud/database/query.ts similarity index 71% rename from src/outerbase-cloud/database/sqlite.ts rename to src/outerbase-cloud/database/query.ts index 167a5883..d0b0ae55 100644 --- a/src/outerbase-cloud/database/sqlite.ts +++ b/src/outerbase-cloud/database/query.ts @@ -1,25 +1,13 @@ -import { DatabaseResultSet, DriverFlags } from "@/drivers/base-driver"; -import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; +import { DatabaseResultSet, QueryableBaseDriver } from "@/drivers/base-driver"; import { runOuterbaseQueryBatch, runOuterbaseQueryRaw } from "../api"; import { OuterbaseDatabaseConfig } from "../api-type"; import { transformOuterbaseResult } from "./utils"; -export class OuterbaseSqliteDriver extends SqliteLikeBaseDriver { - supportPragmaList = false; - +export class OuterbaseQueryable implements QueryableBaseDriver { protected workspaceId: string; protected sourceId: string; - getFlags(): DriverFlags { - return { - ...super.getFlags(), - supportBigInt: false, - }; - } - constructor({ workspaceId, sourceId }: OuterbaseDatabaseConfig) { - super(); - this.workspaceId = workspaceId; this.sourceId = sourceId; } diff --git a/src/outerbase-cloud/database/utils.ts b/src/outerbase-cloud/database/utils.ts index afc728b4..6d584162 100644 --- a/src/outerbase-cloud/database/utils.ts +++ b/src/outerbase-cloud/database/utils.ts @@ -1,8 +1,9 @@ import { DatabaseResultSet } from "@/drivers/base-driver"; +import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; +import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; +import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; import { OuterbaseAPIQueryRaw, OuterbaseDatabaseConfig } from "../api-type"; -import { OuterbaseMySQLDriver } from "./mysql"; -import { OuterbasePostgresDriver } from "./postgresql"; -import { OuterbaseSqliteDriver } from "./sqlite"; +import { OuterbaseQueryable } from "./query"; export function transformOuterbaseResult( result: OuterbaseAPIQueryRaw @@ -24,11 +25,13 @@ export function createOuterbaseDatabaseDriver( type: string, config: OuterbaseDatabaseConfig ) { + const queryable = new OuterbaseQueryable(config); + if (type === "postgres") { - return new OuterbasePostgresDriver(config); + return new PostgresLikeDriver(queryable); } else if (type === "mysql") { - return new OuterbaseMySQLDriver(config); + return new MySQLLikeDriver(queryable); } - return new OuterbaseSqliteDriver(config); + return new SqliteLikeBaseDriver(queryable); }