diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/.env b/examples/nextjs-import-airbyte-github-export-seafowl/.env new file mode 100644 index 0000000..9ac0d11 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/.env @@ -0,0 +1,7 @@ +# This file contains public environment variables and is therefore checked into the repo +# For secret environment variables, see `.env.local` which is _not_ checked into the repo +# Read env-vars.d.ts for expected variable names +# See more: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables + +NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY=github-analytics-metadata +NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_COMPLETED_TABLE=completed_repositories diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/.env.test.local b/examples/nextjs-import-airbyte-github-export-seafowl/.env.test.local new file mode 100644 index 0000000..85b47c9 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/.env.test.local @@ -0,0 +1,31 @@ +# IMPORTANT: Put your own values in `.env.local` (a git-ignored file) when running this locally +# Configure them in Vercel settings when running in production +# This file is mostly to show which variables exist, since it's the only one checked into the repo. +# SEE: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables + +# Create your own API key and secret: https://www.splitgraph.com/connect +SPLITGRAPH_API_KEY="********************************" +SPLITGRAPH_API_SECRET="********************************" + +# This should match the username associated with the API key +NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE="*****" + +# Create a GitHub token that can query the repositories you want to connect +# For example, a token with read-only access to public repos is sufficient +# CREATE ONE HERE: https://github.com/settings/personal-access-tokens/new +GITHUB_PAT_SECRET="github_pat_**********************_***********************************************************" + +# OPTIONAL: Set this environment variable to a proxy address to capture requests from API routes +# e.g. To intercept requests to Splitgraph API sent from madatdata libraries in API routes +# You can also set this by running: yarn dev-mitm (see package.json) +# MITMPROXY_ADDRESS="http://localhost:7979" + +# OPTIONAL: Set Seafowl environment variables to use for creating fallback tables when exports fail +# NOTE 1: At the moment the instance URL must be https://demo.seafowl.cloud because that's where +# the Splitgraph export API exports tables to when no instance URL is specified, and we are +# currently not specifying the instance URL when starting exports, and only use it when creating fallback tables. +# NOTE 2: The dbname (SEAFOWL_INSTANCE_DATABASE) MUST match NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE +# +# SEAFOWL_INSTANCE_URL="https://demo.seafowl.cloud" +# SEAFOWL_INSTANCE_SECRET="********************************" +# SEAFOWL_INSTANCE_DATABASE="**********" diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/.gitignore b/examples/nextjs-import-airbyte-github-export-seafowl/.gitignore new file mode 100644 index 0000000..d8dcd4f --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/.gitignore @@ -0,0 +1,4 @@ +.next +.env.local +.env.*.local +!.env.test.local diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/README.md b/examples/nextjs-import-airbyte-github-export-seafowl/README.md new file mode 100644 index 0000000..97e13da --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/README.md @@ -0,0 +1,26 @@ +# End-to-End Example: Use `airbyte-github` to import GitHub repository into Splitgraph, then export it to Seafowl, via Next.js API routes + +This is a full end-to-end example demonstrating importing data to Splitgraph +(using the `airbyte-github` plugin), exporting it to Seafowl (using the +`export-to-seafowl` plugin), and then querying it (with `DbSeafowl` and React +hooks from `@madatdata/react`). The importers and exporting of data is triggered +by backend API routes (e.g. the Vecel runtime), which execute in an environment +with secrets (an `API_SECRET` for Splitgraph, and a GitHub access token for +`airbyte-github`). The client side queries Seafowl directly by sending raw SQL +queries in HTP requests, which is what Seafowl is ultimately designed for. + +## Try Now + +### Preview Immediately + +_No signup required, just click the button!_ + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/splitgraph/madatdata/tree/main/examples/nextjs-import-airbyte-github-export-seafowl?file=pages/index.tsx) + +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/splitgraph/madatdata/main/examples/nextjs-import-airbyte-github-export-seafowl?file=pages/index.tsx&hardReloadOnChange=true&startScript=dev&node=16&port=3000) + +### Or, deploy to Vercel (signup required) + +_Signup, fork the repo, and import it_ + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/splitgraph/madatdata/tree/main/examples/nextjs-import-airbyte-github-export-seafowl&project-name=madatdata-basic-hooks&repository-name=madatdata-nextjs-basic-hooks) diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.module.css new file mode 100644 index 0000000..76f89c8 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.module.css @@ -0,0 +1,38 @@ +.container { + display: flex; + flex-direction: column; + height: 100vh; + background-color: var(--background); +} + +.header { + width: 100%; + position: sticky; + top: 0; + z-index: 100; + background-color: var(--header); + color: var(--text); +} + +.main { + display: flex; + flex-grow: 1; + overflow: hidden; +} + +.sidebar { + width: 20%; /* adjust as per your needs */ + overflow-y: auto; + /* add additional styles for your sidebar */ + color: var(--text); +} + +.content { + width: 80%; /* adjust as per your needs */ + overflow-y: auto; + position: relative; + /* add additional styles for your content area */ + color: var(--text); + background-color: var(--background); + padding: 24px; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.tsx new file mode 100644 index 0000000..c996b35 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/BaseLayout.tsx @@ -0,0 +1,21 @@ +import styles from "./BaseLayout.module.css"; +import { Header } from "./Header"; + +export const BaseLayout = ({ + children, + sidebar, +}: React.PropsWithChildren<{ + sidebar: React.ReactNode; +}>) => { + return ( +
+
+
+
+
+
{sidebar}
+
{children}
+
+
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.module.css new file mode 100644 index 0000000..47c5be5 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.module.css @@ -0,0 +1,13 @@ +.embeddedPreviewHeading { + margin-bottom: 0; +} + +.embeddedPreviewDescription { + margin-bottom: 1rem; +} + +.note { + font-size: small; + /* color: red !important; */ + display: block; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.tsx new file mode 100644 index 0000000..81600a1 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedPreviews.tsx @@ -0,0 +1,153 @@ +import styles from "./EmbeddedPreviews.module.css"; +import type { + ExportTable, + ExportQueryInput, + ExportTableInput, +} from "../../types"; + +import { EmbedPreviewTableOrQuery } from "../EmbeddedQuery/EmbeddedQuery"; +import { ComponentProps } from "react"; + +export const EmbeddedTablePreviewHeadingAndDescription = ({ + exportComplete, +}: { + exportComplete: boolean; +}) => { + return ( + <> + {!exportComplete ? ( + <> +

Tables to Export

+

+ These are the tables that we'll export from Splitgraph to Seafowl. + You can query them in Splitgraph now, and then when the export is + complete, you'll be able to query them in Seafowl too. +

+ + ) : ( + <> +

Exported Tables

+

+ We successfully exported the tables to Seafowl, so now you can query + them in Seafowl too. +

+ + )} + + ); +}; + +export const EmbeddedTablePreviews = ({ + tablesToExport, + splitgraphRepository, + splitgraphNamespace, + useLoadingOrCompleted, +}: { + tablesToExport: ExportTableInput[]; + splitgraphRepository: string; + splitgraphNamespace: string; + useLoadingOrCompleted: ComponentProps< + typeof EmbedPreviewTableOrQuery + >["useLoadingOrCompleted"]; +}) => { + return ( + <> + {tablesToExport.map((exportTable) => ( + + `SELECT * FROM "${splitgraphNamespace}/${splitgraphRepository}"."${table}";` + } + useLoadingOrCompleted={useLoadingOrCompleted} + makeMatchInputToExported={(exportTableInput) => (exportTable) => { + return ( + exportTable.destinationSchema === exportTableInput.repository && + exportTable.destinationTable === exportTableInput.table + ); + }} + /> + ))} + + ); +}; + +export const EmbeddedQueryPreviewHeadingAndDescription = ({ + exportComplete, +}: { + exportComplete: boolean; +}) => { + return ( + <> + {" "} + {!exportComplete ? ( + <> +

Queries to Export

+

+ We've prepared a few queries to export from Splitgraph to Seafowl, + so that we can use them to render the charts that we want. + Splitgraph will execute the query and insert its result into + Seafowl. You can query them in Splitgraph now, and then when the + export is complete, you'll be able to query them in Seafowl too. +

+ + ) : ( + <> +

Exported Queries

+

+ We successfully exported these queries from Splitgraph to Seafowl, + so now you can query them in Seafowl too.{" "} + + Note: If some queries failed to export, it's probably because they + had empty result sets (e.g. the table of issue reactions) + +

+ + )} + + ); +}; + +export const EmbeddedQueryPreviews = ({ + queriesToExport, + splitgraphRepository, + splitgraphNamespace, + useLoadingOrCompleted, +}: { + queriesToExport: ExportQueryInput[]; + splitgraphRepository: string; + splitgraphNamespace: string; + useLoadingOrCompleted: ComponentProps< + typeof EmbedPreviewTableOrQuery + >["useLoadingOrCompleted"]; +}) => { + return ( + <> + {queriesToExport.map((exportQuery) => ( + sourceQuery} + // But once it's exported, we can just select from its table in Seafowl (and + // besides, the sourceQuery might not be compatible with Seafowl anyway) + makeSeafowlQuery={({ destinationSchema, destinationTable }) => + `SELECT * FROM "${destinationSchema}"."${destinationTable}";` + } + useLoadingOrCompleted={useLoadingOrCompleted} + makeMatchInputToExported={(exportQueryInput) => + (exportTable: ExportTable) => { + return ( + exportTable.destinationSchema === + exportQueryInput.destinationSchema && + exportTable.destinationTable === + exportQueryInput.destinationTable + ); + }} + /> + ))} + + ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.module.css new file mode 100644 index 0000000..3708609 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.module.css @@ -0,0 +1,45 @@ +.embeddedQuery { + border-left: 4px solid var(--muted); + padding-left: 10px; + margin-bottom: 2rem; +} + +.topBar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.consoleFlavorButtonsAndLoadingBar { + display: inline-flex; + flex-direction: row; +} + +.embeddedQuery iframe { + width: 100%; +} + +.heading { + font-size: 1.5rem; + font-weight: bold; + display: inline-flex; + align-items: center; + padding-top: 10px; + padding-bottom: 10px; +} + +.embedControls { + background: inherit; +} + +.openInConsoleLink { + display: inline-flex; + align-items: center; + font-size: small; +} + +.openInConsoleLink svg { + margin-right: 4px; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.tsx new file mode 100644 index 0000000..68790a7 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/EmbeddedQuery.tsx @@ -0,0 +1,216 @@ +import EmbeddedQueryStyles from "./EmbeddedQuery.module.css"; +import type { + ExportTable, + ExportQueryInput, + ExportTableInput, +} from "../../types"; +import { useState, useMemo } from "react"; +import { + makeSplitgraphQueryHref, + makeSeafowlQueryHref, +} from "../RepositoryAnalytics/ImportedRepoMetadata"; +import { + SplitgraphEmbeddedQuery, + SeafowlEmbeddedQuery, +} from "../RepositoryAnalytics/ImportedRepoMetadata"; + +import { LoadingBar } from "../LoadingBar"; + +import { TabButton } from "./TabButton"; +import { useDebug } from "../../lib/util"; + +export const EmbedPreviewTableOrQuery = < + ExportInputShape extends ExportQueryInput | ExportTableInput +>({ + importedRepository, + exportInput, + makeQuery, + makeSeafowlQuery, + makeMatchInputToExported, + useLoadingOrCompleted, +}: { + useLoadingOrCompleted?: ( + isMatch?: (candidateTable: ExportTable) => boolean + ) => { loading: boolean; completed: boolean }; + exportInput: ExportInputShape; + makeQuery: ( + tableOrQueryInput: ExportInputShape & { + splitgraphNamespace: string; + splitgraphRepository: string; + } + ) => string; + makeSeafowlQuery?: ( + tableOrQueryInput: ExportInputShape & { + splitgraphNamespace: string; + splitgraphRepository: string; + } + ) => string; + makeMatchInputToExported: ( + tableOrQueryInput: ExportInputShape + ) => (exported: ExportTable) => boolean; + importedRepository: { + splitgraphNamespace: string; + splitgraphRepository: string; + }; +}) => { + const debug = useDebug(); + + const embedProps = { + importedRepository, + tableName: + "destinationTable" in exportInput + ? exportInput.destinationTable + : exportInput.table, + makeQuery: () => makeQuery({ ...exportInput, ...importedRepository }), + }; + + const { loading, completed } = useLoadingOrCompleted( + makeMatchInputToExported(exportInput) + ); + + const heading = + "table" in exportInput + ? exportInput.table + : `${exportInput.destinationSchema}.${exportInput.destinationTable}`; + + const [selectedTab, setSelectedTab] = useState<"splitgraph" | "seafowl">( + "splitgraph" + ); + + const linkToConsole = useMemo(() => { + switch (selectedTab) { + case "splitgraph": + return { + anchor: "Open in Console", + href: makeSplitgraphQueryHref( + makeQuery({ ...exportInput, ...importedRepository }) + ), + }; + + case "seafowl": + return { + anchor: "Open in Console", + href: makeSeafowlQueryHref( + (makeSeafowlQuery ?? makeQuery)({ + ...exportInput, + ...importedRepository, + }) + ), + }; + } + }, [selectedTab]); + + return ( +
+

+ {heading} +

+
+
+ setSelectedTab("splitgraph")} + active={selectedTab === "splitgraph"} + style={{ marginRight: "1rem" }} + title={ + selectedTab === "splitgraph" + ? "" + : "Query the imported data in Splitgraph" + } + > + data.splitgraph.com + + setSelectedTab("seafowl")} + active={selectedTab === "seafowl"} + disabled={!completed} + style={{ marginRight: "1rem" }} + title={ + selectedTab === "seafowl" + ? "" + : completed + ? "Query the exported data in Seafowl" + : "Once you export the data to Seafowl, you can send the same query to Seafowl" + } + > + demo.seafowl.cloud + + {loading && ( + + `Export to Seafowl: Started ${seconds} seconds ago...` + } + /> + )} +
+
+ + + {linkToConsole.anchor} + +
+
+ + {debug &&
{JSON.stringify({ completed, loading }, null, 2)}
} + { +
+ +
+ } + {completed && ( +
+ + makeSeafowlQuery({ ...exportInput, ...importedRepository }) + : embedProps.makeQuery + } + /> +
+ )} +
+ ); +}; + +export const IconOpenInConsole = ({ size }: { size: number | string }) => ( + + + + + + +); diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.module.css new file mode 100644 index 0000000..56ed32a --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.module.css @@ -0,0 +1,22 @@ +.tab-button { + border-width: 3px; + border-radius: 10px; + border-style: solid; + background: transparent; + color: var(--text); +} + +.tab-button-active { + border-color: var(--primary); + color: var(--primary); +} + +.tab-button-inactive:not(.tab-button-disabled):hover { + border-color: var(--text); + cursor: pointer; +} + +.tab-button-disabled { + color: var(--muted); + border-color: var(--muted); +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.tsx new file mode 100644 index 0000000..ae6368f --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/EmbeddedQuery/TabButton.tsx @@ -0,0 +1,41 @@ +import type { ButtonHTMLAttributes, CSSProperties } from "react"; + +import TabButtonStyle from "./TabButton.module.css"; + +interface TabButtonProps extends ButtonHTMLAttributes { + active: boolean; + onClick: () => void; + size?: CSSProperties["fontSize"]; +} + +export const TabButton = ({ + active, + onClick, + disabled: alwaysDisabled, + children, + size, + ...rest +}: React.PropsWithChildren) => { + const className = [ + TabButtonStyle["tab-button"], + ...(active + ? [TabButtonStyle["tab-button-active"]] + : [TabButtonStyle["tab-button-inactive"]]), + ...(alwaysDisabled ? [TabButtonStyle["tab-button-disabled"]] : []), + ].join(" "); + + return ( + + ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.module.css new file mode 100644 index 0000000..1b7d443 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.module.css @@ -0,0 +1,44 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; +} + +.logo a { + text-decoration: none; +} + +.logo a:hover .wordmark { + text-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.logo .wordmark { + color: var(--primary); + font-size: large; +} + +.logo .logo_link { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.logo svg { + height: 36px; + margin: 8px; +} + +.nav { + margin: 16px; +} + +.nav a { + margin-left: 20px; + color: var(--secondary); + text-decoration: none; +} + +.nav a:hover { + text-decoration: underline; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.tsx new file mode 100644 index 0000000..71de3fa --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/Header.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import Link from "next/link"; +import styles from "./Header.module.css"; +import { LogoSVG } from "./Logo"; + +interface HeaderProps {} + +export const Header: React.FC = () => { + return ( +
+
+ + +
GitHub Analytics
+ +
+ +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ChartsPanel.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ChartsPanel.tsx new file mode 100644 index 0000000..3939286 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ChartsPanel.tsx @@ -0,0 +1,55 @@ +import { Charts } from "../RepositoryAnalytics/Charts"; +import { StepDescription } from "./StepDescription"; +import { StepTitle } from "./StepTitle"; +import { useStepper } from "./StepperContext"; + +export const ChartsPanel = () => { + const [ + { + repository: { namespace: githubNamespace, repository: githubRepository }, + splitgraphNamespace, + splitgraphRepository, + stepperState, + }, + ] = useStepper(); + + const stepStatus = (() => { + switch (stepperState) { + case "export_complete": + return "active"; + default: + return "unstarted"; + } + })(); + + return ( +
+ + + Once the data is loaded into Seafowl, we can query it with{" "} + + madatdata + {" "} + and render some charts using{" "} + + Observable Plot + + . + + {stepStatus === "active" && ( + + )} +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/DebugPanel.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/DebugPanel.tsx new file mode 100644 index 0000000..25193f1 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/DebugPanel.tsx @@ -0,0 +1,25 @@ +import { useStepper } from "./StepperContext"; + +export const DebugPanel = () => { + const [state, _] = useStepper(); + + return ( +
+
+        {JSON.stringify(
+          state,
+          (_key, value) => {
+            if (value instanceof Set) {
+              return Array.from(value);
+            } else if (value instanceof Map) {
+              return Object.fromEntries(value);
+            } else {
+              return value;
+            }
+          },
+          2
+        )}
+      
+
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.module.css new file mode 100644 index 0000000..7ac88a4 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.module.css @@ -0,0 +1,34 @@ +.exportPanel { + background: inherit; + margin-top: 2rem; +} + +.startExportButton { + color: var(--background); + background-color: var(--secondary); + padding: 8px; + border-radius: 8px; + text-decoration: none; + font-weight: bold; + border-style: none; + margin-bottom: 1rem; +} + +.startExportButtonLoading { + /* color: var(--muted); */ + /* background-color: transparent; */ + opacity: 0.5; +} + +.startExportButton:hover { + text-shadow: 0 0 5px rgba(43, 0, 255, 0.5); + cursor: pointer; +} + +.exportCompleteInfo p { + margin-bottom: 1rem; +} + +.exportInfo p { + margin-bottom: 1rem; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.tsx new file mode 100644 index 0000000..ab8f124 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ExportPanel.tsx @@ -0,0 +1,237 @@ +import { useStepper } from "./StepperContext"; +import styles from "./ExportPanel.module.css"; + +import { useTablesToExport } from "../../lib/config/github-tables"; +import { + genericDemoQuery, + useQueriesToExport, +} from "../../lib/config/queries-to-export"; +import type { + StartExportToSeafowlRequestShape, + StartExportToSeafowlResponseData, +} from "../../types"; +import { useCallback } from "react"; +import { StepTitle } from "./StepTitle"; +import { StepDescription } from "./StepDescription"; +import { makeSplitgraphQueryHref } from "../RepositoryAnalytics/ImportedRepoMetadata"; + +import { usePollExportTasks } from "./export-hooks"; + +import { + EmbeddedTablePreviews, + EmbeddedQueryPreviews, + EmbeddedTablePreviewHeadingAndDescription, + EmbeddedQueryPreviewHeadingAndDescription, +} from "../EmbeddedQuery/EmbeddedPreviews"; +import { useFindMatchingExportTable } from "./export-hooks"; + +export const ExportPanel = () => { + const [ + { stepperState, exportError, splitgraphRepository, splitgraphNamespace }, + dispatch, + ] = useStepper(); + + usePollExportTasks(); + + const queriesToExport = useQueriesToExport({ + splitgraphNamespace, + splitgraphRepository, + }); + + const tablesToExport = useTablesToExport({ + splitgraphNamespace, + splitgraphRepository, + }); + + const handleStartExport = useCallback(async () => { + const abortController = new AbortController(); + + try { + const response = await fetch("/api/start-export-to-seafowl", { + method: "POST", + body: JSON.stringify({ + tables: tablesToExport, + queries: queriesToExport, + } as StartExportToSeafowlRequestShape), + headers: { + "Content-Type": "application/json", + }, + signal: abortController.signal, + }); + const data = (await response.json()) as StartExportToSeafowlResponseData; + + if ("error" in data && data["error"]) { + throw new Error(data["error"]); + } + + if (!("tables" in data) || !("queries" in data)) { + throw new Error("Response missing tables"); + } + + dispatch({ + type: "start_export", + tables: [ + ...data["queries"].map( + ({ sourceQuery, taskId, destinationSchema, destinationTable }) => ({ + taskId, + destinationTable, + destinationSchema, + sourceQuery, + fallbackCreateTableQuery: queriesToExport.find( + (q) => + q.destinationSchema === destinationSchema && + q.destinationTable === destinationTable + )?.fallbackCreateTableQuery, + }) + ), + ...data["tables"].map( + ({ destinationTable, destinationSchema, taskId }) => ({ + taskId, + destinationTable, + destinationSchema, + }) + ), + ], + }); + } catch (error) { + if (error.name === "AbortError") { + return; + } + + dispatch({ type: "export_error", error: error.message }); + } + + return () => abortController.abort(); + }, [queriesToExport, tablesToExport, dispatch]); + + const stepStatus = (() => { + switch (stepperState) { + case "import_complete": + return "active"; + case "awaiting_export": + return "loading"; + case "export_complete": + return "completed"; + default: + return "unstarted"; + } + })(); + + return ( +
+ + + {stepStatus === "completed" ? ( +
+

+ ✓ Export complete! We successfully imported tables and + queries from Splitgraph to our{" "} + + Seafowl + {" "} + instance running at https://demo.seafowl.cloud. Now + we can query it and get cache-optimized responses for rendering + charts and analytics.{" "} +

+
+ ) : ( +
+ {["uninitialized", "unstarted", "awaiting_import"].includes( + stepperState + ) + ? "Next we'll " + : "Now let's "} + export some tables and pre-made queries from our staging area in + Splitgraph to our cache-optimized{" "} + + Seafowl + {" "} + instance running at https://demo.seafowl.cloud. This + demo exports them programatically with{" "} + + madatdata + {" "} + calling the Splitgraph API from a Next.js API route, but you can + write your own queries and manually export them from the{" "} + + Splitgraph Console + {" "} + (once you've created an account and logged into Splitgraph). + {stepStatus === "active" && ( + <> +
+
Click the button to start the export. While it's + running, you can use the embedded query editors to play with the + imported Splitgraph data, and when it's complete, you can run + the same queries in Seafowl. + + )} +
+ )} +
+ {["import_complete", "awaiting_export", "export_complete"].includes( + stepperState + ) && ( + + )} + {exportError &&

{exportError}

} + {["import_complete", "awaiting_export", "export_complete"].includes( + stepperState + ) && ( + <> + + + + + + )} +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportLoadingBar.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportLoadingBar.tsx new file mode 100644 index 0000000..443210c --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportLoadingBar.tsx @@ -0,0 +1,112 @@ +import { useEffect } from "react"; +import { useStepper } from "./StepperContext"; +import { LoadingBar } from "../LoadingBar"; + +type ImportLoadingBarProps = { + taskId: string; + splitgraphNamespace: string; + splitgraphRepository: string; + githubNamespace: string; + githubRepository: string; +}; + +export const ImportLoadingBar: React.FC = ({ + taskId, + splitgraphNamespace, + splitgraphRepository, + githubNamespace, + githubRepository, +}) => { + const [{ stepperState }, dispatch] = useStepper(); + + useEffect(() => { + if (!taskId || !splitgraphNamespace || !splitgraphRepository) { + console.log("Don't check import until we have all the right variables"); + console.table({ + taskId: taskId ?? "no task id", + splitgraphNamespace: splitgraphNamespace ?? "no namespace", + splitgraphRepository: splitgraphRepository ?? "no repo", + }); + return; + } + + if (stepperState !== "awaiting_import") { + console.log("Done waiting"); + return; + } + + const checkImportStatus = async () => { + try { + const response = await fetch("/api/await-import-from-github", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + taskId, + splitgraphNamespace, + splitgraphRepository, + }), + }); + const data = await response.json(); + + if (data.completed) { + dispatch({ type: "import_complete" }); + } else if (data.error) { + dispatch({ type: "import_error", error: data.error }); + } + } catch (error) { + dispatch({ + type: "import_error", + error: "An error occurred during the import process", + }); + } + }; + + const interval = setInterval(checkImportStatus, 3000); + + return () => clearInterval(interval); + }, [ + stepperState, + taskId, + splitgraphNamespace, + splitgraphRepository, + dispatch, + ]); + + return ( + + } + > +

+ This might take 5-10 minutes depending on the size of the GitHub + repository. +

+ + + ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.module.css new file mode 100644 index 0000000..ecca0bc --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.module.css @@ -0,0 +1,38 @@ +.importPanel { + background: inherit; +} + +.error { + background-color: var(--danger); + padding: 8px; + border: 1px solid var(--sidebar); + margin-bottom: 8px; +} + +.repoNameInput { + margin-right: 0.25rem; + width: 50ch; +} + +.repoNameForm { + display: flex; +} + +.startImportButton { + color: var(--background); + background-color: var(--secondary); + padding: 8px; + border-radius: 8px; + text-decoration: none; + font-weight: bold; + border-style: none; +} + +.startImportButton:hover { + text-shadow: 0 0 5px rgba(43, 0, 255, 0.5); + cursor: pointer; +} + +.importCompleteInfo p { + margin-bottom: 1rem; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.tsx new file mode 100644 index 0000000..9693305 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/ImportPanel.tsx @@ -0,0 +1,195 @@ +import { useState } from "react"; +import { useStepper } from "./StepperContext"; +import { ImportLoadingBar } from "./ImportLoadingBar"; +import { StepTitle } from "./StepTitle"; + +import styles from "./ImportPanel.module.css"; +import { StepDescription } from "./StepDescription"; +import { + GitHubRepoLink, + SplitgraphStargazersQueryLink, +} from "../RepositoryAnalytics/ImportedRepoMetadata"; + +export const ImportPanel = () => { + const [ + { + stepperState, + importTaskId, + importError, + splitgraphNamespace, + splitgraphRepository, + repository: githubRepositoryFromStepper, + }, + dispatch, + ] = useStepper(); + const [inputValue, setInputValue] = useState(""); + + const handleInputSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValidRepoName(inputValue)) { + dispatch({ + type: "import_error", + error: + "Invalid GitHub repository name. Format must be 'namespace/repository'", + }); + return; + } + + const [githubNamespace, githubRepository] = inputValue.split("/"); + + try { + const response = await fetch(`/api/start-import-from-github`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ githubSourceRepository: inputValue }), + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + + if (!data.taskId) { + throw new Error("Response missing taskId"); + } + + if (!data.destination || !data.destination.splitgraphNamespace) { + throw new Error("Response missing destination.splitgraphNamespace"); + } + + if (!data.destination || !data.destination.splitgraphRepository) { + throw new Error("Response missing destination.splitgraphRepository"); + } + + dispatch({ + type: "start_import", + repository: { + namespace: githubNamespace, + repository: githubRepository, + }, + taskId: data.taskId, + splitgraphRepository: data.destination.splitgraphRepository as string, + splitgraphNamespace: data.destination.splitgraphNamespace as string, + }); + } catch (error) { + dispatch({ type: "import_error", error: error.message }); + } + }; + + const isValidRepoName = (repoName: string) => { + // A valid GitHub repo name should contain exactly one '/' + return /^[\w-.]+\/[\w-.]+$/.test(repoName); + }; + + const stepStatus: React.ComponentProps["status"] = (() => { + switch (stepperState) { + case "import_complete": + case "awaiting_export": + case "export_complete": + return "completed"; + case "awaiting_import": + return "loading"; + default: + return "active"; + } + })(); + + return ( +
+ + + {stepStatus === "completed" ? ( +
+

+ ✓ Import complete! We successfully imported data from{" "} + {" "} + into Splitgraph. +

+

+ Browse Data:{" "} + + splitgraph.com/ + {`${splitgraphNamespace}/${splitgraphRepository}`} + +

+

+ Query Data: + {" "}  + +

+
+ ) : ( + <> + We'll use the{" "} + + airbyte-github + {" "} + plugin to import data about this GitHub repository into the + Splitgraph Data Delivery Network (DDN). Then you'll be able to + browse the data in the Splitgraph catalog, or query it with{" "} + + your favorite Postgres Client + {" "} + or with the{" "} + + Splitgraph Query Console + + . + + )} +
+ {stepperState === "unstarted" && ( + <> + {importError &&

{importError}

} +
+ setInputValue(e.target.value)} + className={styles.repoNameInput} + tabIndex={1} + autoFocus={true} + /> + +
+ + )} + {stepperState === "awaiting_import" && ( + + )} +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.module.css new file mode 100644 index 0000000..4b64378 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.module.css @@ -0,0 +1,12 @@ +.stepDescriptionContainer { + margin-bottom: 1rem; +} + +.stepDescriptionContainer a { + color: var(--primary); + text-decoration: none; +} + +.stepDescriptionContainer a:hover { + text-decoration: underline; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.tsx new file mode 100644 index 0000000..c13aac8 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepDescription.tsx @@ -0,0 +1,19 @@ +import styles from "./StepDescription.module.css"; + +export const StepDescription = ({ + children, + status, +}: React.PropsWithChildren<{ + status: "active" | "completed" | "unstarted" | "loading"; +}>) => { + return ( +
+ {typeof children === "string" ?

{children}

: children} +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.module.css new file mode 100644 index 0000000..2659538 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.module.css @@ -0,0 +1,20 @@ +div.stepTitleContainer { + display: flex; + align-items: baseline; + border-bottom: 1px solid var(--muted); + margin-bottom: 1rem; +} + +h2.stepNumber { + color: var(--sidebar); + opacity: 50%; + margin-right: 1rem; +} + +h2.stepTitle { + color: var(--subtext); +} + +.step-status-active .stepTitle { + color: var(--text); +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.tsx new file mode 100644 index 0000000..3255bc4 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepTitle.tsx @@ -0,0 +1,27 @@ +import styles from "./StepTitle.module.css"; + +export const StepTitle = ({ + stepNumber, + stepTitle, + status, +}: { + stepNumber: number; + stepTitle: string; + status: "active" | "completed" | "unstarted" | "loading"; +}) => { + return ( +
+

+ {stepNumber.toString()} +

+

+ {stepTitle} +

+
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.module.css new file mode 100644 index 0000000..a1eb9c2 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.module.css @@ -0,0 +1,4 @@ +.stepper { + /* Add styling as necessary */ + background: inherit; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.tsx new file mode 100644 index 0000000..504eca4 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/Stepper.tsx @@ -0,0 +1,38 @@ +import { StepperContextProvider, useStepper } from "./StepperContext"; +import { DebugPanel } from "./DebugPanel"; +import { ImportPanel } from "./ImportPanel"; +import { ExportPanel } from "./ExportPanel"; + +import styles from "./Stepper.module.css"; +import { ChartsPanel } from "./ChartsPanel"; + +const StepperOrLoading = ({ children }: { children: React.ReactNode }) => { + const [{ stepperState, debug }] = useStepper(); + + return ( + <> + {stepperState === "uninitialized" ? ( +
........
+ ) : ( + <> + {debug && } + {children} + + )} + + ); +}; + +export const Stepper = () => { + return ( + +
+ + + + + +
+
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepperContext.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepperContext.tsx new file mode 100644 index 0000000..9eb3b0c --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/StepperContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext } from "react"; +import { + StepperState, + StepperAction, + useStepperReducer, +} from "./stepper-states"; + +// Define the context +const StepperContext = createContext< + [StepperState, React.Dispatch] | undefined +>(undefined); + +export const StepperContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [state, dispatch] = useStepperReducer(); + + return ( + + {children} + + ); +}; + +// Custom hook for using the stepper context +export const useStepper = () => { + const context = useContext(StepperContext); + if (!context) { + throw new Error("useStepper must be used within a StepperContextProvider"); + } + return context; +}; + +export const useStepperDebug = () => useStepper()[0].debug; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/export-hooks.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/export-hooks.tsx new file mode 100644 index 0000000..310f9db --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/export-hooks.tsx @@ -0,0 +1,246 @@ +import { useEffect, useMemo } from "react"; +import type { ExportTable } from "../../types"; +import { useStepper } from "./StepperContext"; + +/** + * Given a function to match a candidate `ExportTable` to (presumably) an `ExportTableInput`, + * determine if the table (which could also be a query - it's keyed by `destinationSchema` + * and `destinationTable`) is currently exporting (`loading`) or has exported (`completed`). + * + * Return `{ loading, completed, unstarted }`, where: + * + * * `loading` is `true` if there is a match in the `exportedTablesLoading` set, + * * `completed` is `true` if there is a match in the `exportedTablesCompleted` set + * (or if `stepperState` is `export_complete`), + * * `unstarted` is `true` if there is no match in either set. + * + */ +export const useFindMatchingExportTable = ( + isMatch: (candidateTable: ExportTable) => boolean +) => { + const [{ stepperState, exportedTablesLoading, exportedTablesCompleted }] = + useStepper(); + + const matchingCompletedTable = useMemo( + () => Array.from(exportedTablesCompleted).find(isMatch), + [exportedTablesCompleted, isMatch] + ); + const matchingLoadingTable = useMemo( + () => Array.from(exportedTablesLoading).find(isMatch), + [exportedTablesLoading, isMatch] + ); + + // If the state is export_complete, we might have loaded the page directly + // and thus we don't have the sets of exportedTablesCompleted, but we know they exist + const exportFullyCompleted = stepperState === "export_complete"; + + const completed = !!matchingCompletedTable ?? (exportFullyCompleted || false); + const loading = !!matchingLoadingTable ?? false; + const unstarted = !completed && !loading; + + return { + completed, + loading, + unstarted, + }; +}; + +export const usePollExportTasks = () => { + const [ + { stepperState, loadingExportTasks, exportedTablesLoading }, + dispatch, + ] = useStepper(); + + useEffect(() => { + if (stepperState !== "awaiting_export") { + return; + } + + const taskIds = Array.from(loadingExportTasks).map(({ taskId }) => taskId); + + if (taskIds.length === 0) { + return; + } + + const abortController = new AbortController(); + + const pollEachTaskOnce = () => + Promise.all( + taskIds.map((taskId) => + pollExportTaskOnce({ + taskId, + onSuccess: ({ taskId }) => + dispatch({ + type: "export_task_complete", + completedTask: { taskId }, + }), + onError: async ({ taskId, error }) => { + // If the task failed but we're not going to retry, then check if + // there is a fallback query to create the table, and if so, + // create it before marking the task as complete. + if (!error.retryable) { + // NOTE: There is an implicit assumption that `exportedTablesLoading` + // and `loadingExportTasks` are updated at the same time, which they + // are, by the reducer that handles the `export_task_start` and + // `export_task_complete` actions. + const maybeExportedQueryWithCreateTableFallback = Array.from( + exportedTablesLoading + ).find( + (t) => t.taskId === taskId && t.fallbackCreateTableQuery + ); + + if (maybeExportedQueryWithCreateTableFallback) { + await createFallbackTableAfterFailedExport({ + destinationSchema: + maybeExportedQueryWithCreateTableFallback.destinationSchema, + destinationTable: + maybeExportedQueryWithCreateTableFallback.destinationTable, + fallbackCreateTableQuery: + maybeExportedQueryWithCreateTableFallback.fallbackCreateTableQuery, + + // On error or success, we mutate the error variable which + // will be passed by `dispatch` outside of this conditional. + onError: (errorCreatingFallbackTable) => { + error.message = `${error.message} (and also error creating fallback: ${errorCreatingFallbackTable.message})`; + }, + onSuccess: () => { + error = undefined; // No error because we consider the task complete after creating the fallback table. + }, + }); + } + } + + dispatch({ + type: "export_task_complete", + completedTask: { + taskId, + error, + }, + }); + }, + abortSignal: abortController.signal, + }) + ) + ); + + const interval = setInterval(pollEachTaskOnce, 3000); + return () => { + clearInterval(interval); + abortController.abort(); + }; + }, [loadingExportTasks, exportedTablesLoading, stepperState, dispatch]); +}; + +const pollExportTaskOnce = async ({ + taskId, + onSuccess, + onError, + abortSignal, +}: { + taskId: string; + onSuccess: ({ taskId }: { taskId: string }) => void; + onError: ({ + taskId, + error, + }: { + taskId: string; + error: { message: string; retryable: boolean }; + }) => void; + abortSignal: AbortSignal; +}) => { + try { + const response = await fetch("/api/await-export-to-seafowl-task", { + signal: abortSignal, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + taskId, + }), + }); + const data = await response.json(); + + if (data.completed) { + onSuccess({ taskId }); + } else if (data.error) { + if (!data.completed) { + console.log("WARN: Failed status, not completed:", data.error); + onError({ taskId, error: { message: data.error, retryable: false } }); + } else { + throw new Error(data.error); + } + } + } catch (error) { + if (error.name === "AbortError") { + return; + } + + onError({ + taskId, + error: { + message: `Error exporting ${taskId}: ${ + error.message ?? error.name ?? "unknown" + }`, + retryable: true, + }, + }); + } +}; + +/** + * Call the API route to create a fallback table after a failed export. + * + * Note that both `destinationTable` and `destinationSchema` should already + * be included in the `fallbackCreateTableQuery`, but we need them so that + * the endpoint can separately `CREATE SCHEMA` and `DROP TABLE` in case the + * schema does not yet exist, or the table already exists (we overwrite it to + * be consistent with behavior of Splitgraph export API). + */ +const createFallbackTableAfterFailedExport = async ({ + destinationSchema, + destinationTable, + fallbackCreateTableQuery, + onSuccess, + onError, +}: Required< + Pick< + ExportTable, + "destinationSchema" | "destinationTable" | "fallbackCreateTableQuery" + > +> & { + onSuccess: () => void; + onError: (error: { message: string }) => void; +}) => { + try { + const response = await fetch( + "/api/create-fallback-table-after-failed-export", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + destinationSchema, + destinationTable, + fallbackCreateTableQuery, + }), + } + ); + const data = await response.json(); + if (data.error || !data.success) { + console.log( + `FAIL: error from endpoint creating fallback table: ${data.error}` + ); + onError({ message: data.error ?? "unknown" }); + } else { + console.log("SUCCESS: created fallback table"); + onSuccess(); + } + } catch (error) { + console.log(`FAIL: caught error while creating fallback table: ${error}`); + onError({ + message: `${error.message ?? error.name ?? "unknown"}`, + }); + } +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/stepper-states.ts b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/stepper-states.ts new file mode 100644 index 0000000..fc2ac40 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/ImportExportStepper/stepper-states.ts @@ -0,0 +1,470 @@ +import { useRouter, type NextRouter } from "next/router"; +import { ParsedUrlQuery } from "querystring"; +import { useEffect, useReducer } from "react"; +import type { ExportTable } from "../../types"; +export type GitHubRepository = { namespace: string; repository: string }; +import { getQueryParamAsString, requireKeys } from "../../lib/util"; + +// NOTE: Multiple tables can have the same taskId, so we track them separately +// in order to not need to redundantly poll the API for each table individually +export type ExportTask = { + taskId: string; + error?: { message: string; retryable: boolean }; +}; + +export type StepperState = { + stepperState: + | "uninitialized" + | "unstarted" + | "awaiting_import" + | "import_complete" + | "awaiting_export" + | "export_complete"; + repository?: GitHubRepository | null; + importTaskId?: string | null; + importError?: string; + splitgraphRepository?: string; + splitgraphNamespace?: string; + exportedTablesLoading?: Set; + exportedTablesCompleted?: Set; + exportError?: string; + debug?: string | null; + loadingExportTasks?: Set; + completedExportTasks?: Set; + tasksWithError?: Map; // taskId -> errors +}; + +export type StepperAction = + | { + type: "start_import"; + repository: GitHubRepository; + taskId: string; + splitgraphRepository: string; + splitgraphNamespace: string; + } + | { type: "import_complete" } + | { type: "start_export"; tables: ExportTable[] } + | { type: "export_table_task_complete"; completedTable: ExportTable } + | { type: "export_task_complete"; completedTask: ExportTask } + | { type: "export_complete" } + | { type: "export_error"; error: string } + | { type: "import_error"; error: string } + | { type: "reset" } + | { type: "initialize_from_url"; parsedFromUrl: StepperState }; + +const initialState: StepperState = { + stepperState: "unstarted", + repository: null, + splitgraphRepository: null, + splitgraphNamespace: null, + importTaskId: null, + exportedTablesLoading: new Set(), + exportedTablesCompleted: new Set(), + loadingExportTasks: new Set(), + completedExportTasks: new Set(), + tasksWithError: new Map(), + importError: null, + exportError: null, + debug: null, +}; + +const queryParamParsers: { + [K in keyof StepperState]: (query: ParsedUrlQuery) => StepperState[K]; +} = { + stepperState: (query) => + getQueryParamAsString( + query, + "stepperState" + ) ?? "unstarted", + repository: (query) => ({ + namespace: getQueryParamAsString(query, "githubNamespace"), + repository: getQueryParamAsString(query, "githubRepository"), + }), + importTaskId: (query) => getQueryParamAsString(query, "importTaskId"), + importError: (query) => getQueryParamAsString(query, "importError"), + exportError: (query) => getQueryParamAsString(query, "exportError"), + splitgraphNamespace: (query) => + getQueryParamAsString(query, "splitgraphNamespace"), + splitgraphRepository: (query) => + getQueryParamAsString(query, "splitgraphRepository"), + debug: (query) => getQueryParamAsString(query, "debug"), +}; + +const stepperStateValidators: { + [K in StepperState["stepperState"]]: (stateFromQuery: StepperState) => void; +} = { + uninitialized: () => {}, + unstarted: () => {}, + awaiting_import: (stateFromQuery) => + requireKeys(stateFromQuery, [ + "repository", + "importTaskId", + "splitgraphNamespace", + "splitgraphRepository", + ]), + import_complete: (stateFromQuery) => + requireKeys(stateFromQuery, [ + "repository", + "splitgraphNamespace", + "splitgraphRepository", + ]), + awaiting_export: (stateFromQuery) => + requireKeys(stateFromQuery, [ + "repository", + "splitgraphNamespace", + "splitgraphRepository", + ]), + export_complete: (stateFromQuery) => + requireKeys(stateFromQuery, [ + "repository", + "splitgraphNamespace", + "splitgraphRepository", + ]), +}; + +const parseStateFromRouter = (router: NextRouter): StepperState => { + const { query } = router; + + const stepperState = queryParamParsers.stepperState(query); + + const stepper = { + stepperState: stepperState, + repository: queryParamParsers.repository(query), + importTaskId: queryParamParsers.importTaskId(query), + importError: queryParamParsers.importError(query), + exportError: queryParamParsers.exportError(query), + splitgraphNamespace: queryParamParsers.splitgraphNamespace(query), + splitgraphRepository: queryParamParsers.splitgraphRepository(query), + debug: queryParamParsers.debug(query), + }; + + void stepperStateValidators[stepperState](stepper); + + return stepper; +}; + +const serializeStateToQueryParams = (stepper: StepperState) => { + return JSON.parse( + JSON.stringify({ + stepperState: stepper.stepperState, + githubNamespace: stepper.repository?.namespace ?? undefined, + githubRepository: stepper.repository?.repository ?? undefined, + importTaskId: stepper.importTaskId ?? undefined, + importError: stepper.importError ?? undefined, + exportError: stepper.exportError ?? undefined, + splitgraphNamespace: stepper.splitgraphNamespace ?? undefined, + splitgraphRepository: stepper.splitgraphRepository ?? undefined, + debug: stepper.debug ?? undefined, + }) + ); +}; + +const stepperReducer = ( + state: StepperState, + action: StepperAction +): StepperState => { + switch (action.type) { + case "start_import": + return { + ...state, + stepperState: "awaiting_import", + repository: action.repository, + importTaskId: action.taskId, + splitgraphNamespace: action.splitgraphNamespace, + splitgraphRepository: action.splitgraphRepository, + }; + case "import_complete": + return { + ...state, + stepperState: "import_complete", + }; + case "start_export": + const { tables } = action; + const exportedTablesLoading = new Set(); + const exportedTablesCompleted = new Set(); + + for (const { + destinationTable, + destinationSchema, + sourceQuery, + fallbackCreateTableQuery, + taskId, + } of tables) { + exportedTablesLoading.add({ + destinationTable, + destinationSchema, + sourceQuery, + fallbackCreateTableQuery, + taskId, + }); + } + + // The API returns a list of tables where multiple can have the same taskId + // We want a unique set of taskIds so that we only poll for each taskId once + // (but note that we're storing a set of {taskId} objects but need to compare uniqueness on taskId) + const loadingExportTasks = new Set( + Array.from( + new Set( + Array.from(tables).map(({ taskId }) => taskId) + ) + ).map((taskId) => ({ taskId })) + ); + const completedExportTasks = new Set(); + + return { + ...state, + exportedTablesLoading, + exportedTablesCompleted, + loadingExportTasks, + completedExportTasks, + stepperState: "awaiting_export", + }; + + /** + * NOTE: A task is "completed" even if it received an error, in which case + * we will retry it up to maxRetryCount if `error.retryable` is `true` + * + * That is, _all tasks_ will eventually "complete," whether successfully or not. + */ + case "export_task_complete": + const { + completedTask: { taskId: completedTaskId, error: maybeError }, + } = action; + + const maxRetryCount = 3; + + const updatedTasksWithError = new Map(state.tasksWithError); + const previousErrors = updatedTasksWithError.get(completedTaskId) ?? []; + const hadPreviousError = previousErrors.length > 0; + + if (!maybeError && hadPreviousError) { + updatedTasksWithError.delete(completedTaskId); + } else if (maybeError) { + updatedTasksWithError.set(completedTaskId, [ + ...previousErrors, + maybeError.message, + ]); + const numAttempts = updatedTasksWithError.get(completedTaskId).length; + + if (maybeError.retryable && numAttempts < maxRetryCount) { + console.log("RETRY: ", completedTaskId, `(${numAttempts} so far)`); + return { + ...state, + tasksWithError: updatedTasksWithError, + }; + } else { + console.log( + "FAIL: ", + completedTaskId, + `(${numAttempts} reached max ${maxRetryCount})` + ); + } + } + + // One taskId could match multiple tables, so find reference to each of them + // and then use that reference to delete them from loading set and add them to completed set + const completedTables = Array.from(state.exportedTablesLoading).filter( + ({ taskId }) => taskId === completedTaskId + ); + const loadingTablesAfterRemoval = new Set(state.exportedTablesLoading); + const completedTablesAfterAdded = new Set(state.exportedTablesCompleted); + for (const completedTable of completedTables) { + loadingTablesAfterRemoval.delete(completedTable); + completedTablesAfterAdded.add(completedTable); + } + + // There should only be one matching task, so find it and create new updated sets + const completedTask = Array.from(state.loadingExportTasks).find( + ({ taskId }) => taskId === completedTaskId + ); + const loadingTasksAfterRemoval = new Set(state.loadingExportTasks); + const completedTasksAfterAdded = new Set(state.completedExportTasks); + loadingTasksAfterRemoval.delete(completedTask); + completedTasksAfterAdded.add(completedTask); + + return { + ...state, + exportedTablesLoading: loadingTablesAfterRemoval, + exportedTablesCompleted: completedTablesAfterAdded, + loadingExportTasks: loadingTasksAfterRemoval, + completedExportTasks: completedTasksAfterAdded, + stepperState: + loadingTasksAfterRemoval.size === 0 + ? "export_complete" + : "awaiting_export", + }; + + case "export_complete": + return { + ...state, + stepperState: "export_complete", + }; + case "import_error": + return { + ...state, + splitgraphRepository: null, + splitgraphNamespace: null, + importTaskId: null, + stepperState: "unstarted", + importError: action.error, + }; + case "export_error": + return { + ...state, + loadingExportTasks: new Set(), + completedExportTasks: new Set(), + exportedTablesLoading: new Set(), + exportedTablesCompleted: new Set(), + stepperState: "import_complete", + exportError: action.error, + }; + + case "reset": + return initialState; + + case "initialize_from_url": + return { + ...state, + ...action.parsedFromUrl, + }; + + default: + return state; + } +}; + +const urlNeedsChange = (state: StepperState, router: NextRouter) => { + const parsedFromUrl = parseStateFromRouter(router); + + return ( + state.stepperState !== parsedFromUrl.stepperState || + state.repository?.namespace !== parsedFromUrl.repository?.namespace || + state.repository?.repository !== parsedFromUrl.repository?.repository || + state.importTaskId !== parsedFromUrl.importTaskId || + state.splitgraphNamespace !== parsedFromUrl.splitgraphNamespace || + state.splitgraphRepository !== parsedFromUrl.splitgraphRepository + ); +}; + +/** + * When the export has completed, send a request to /api/mark-import-export-complete + * which will insert the repository into the metadata table, which we query to + * render the sidebar + */ +const useMarkAsComplete = ( + state: StepperState, + dispatch: React.Dispatch +) => { + useEffect(() => { + if (state.stepperState !== "export_complete") { + return; + } + + const { + repository: { + namespace: githubSourceNamespace, + repository: githubSourceRepository, + }, + splitgraphRepository: splitgraphDestinationRepository, + } = state; + + // NOTE: Make sure to abort request so that in React 18 development mode, + // when effect runs twice, the second request is aborted and we don't have + // a race condition with two requests inserting into the table (where we have no transactional + // integrity and manually do a SELECT before the INSERT to check if the row already exists) + const abortController = new AbortController(); + + const markImportExportComplete = async () => { + try { + const response = await fetch("/api/mark-import-export-complete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + githubSourceNamespace, + githubSourceRepository, + splitgraphDestinationRepository, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error("Failed to mark import/export as complete"); + } + + if (response.status === 204) { + console.log("Repository already exists in metadata table"); + return; + } + + const data = await response.json(); + + if (!data.status) { + throw new Error( + "Got unexpected response shape when marking import/export complete" + ); + } + + if (data.error) { + throw new Error( + `Failed to mark import/export complete: ${data.error}` + ); + } + + console.log("Marked import/export as complete"); + } catch (error) { + if (error.name === "AbortError") { + return; + } + + dispatch({ + type: "export_error", + error: error.message ?? error.toString(), + }); + } + }; + + markImportExportComplete(); + + return () => abortController.abort(); + }, [state, dispatch]); +}; + +export const useStepperReducer = () => { + const router = useRouter(); + const [state, dispatch] = useReducer(stepperReducer, { + ...initialState, + stepperState: "uninitialized", + }); + + useMarkAsComplete(state, dispatch); + + useEffect(() => { + dispatch({ + type: "initialize_from_url", + parsedFromUrl: parseStateFromRouter(router), + }); + }, [router.query]); + + useEffect(() => { + if (!urlNeedsChange(state, router)) { + return; + } + + if (state.stepperState === "uninitialized") { + return; + } + + router.push( + { + pathname: router.pathname, + query: serializeStateToQueryParams(state), + }, + undefined, + { shallow: true } + ); + }, [state.stepperState]); + + return [state, dispatch] as const; +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.module.css new file mode 100644 index 0000000..9cb9c33 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.module.css @@ -0,0 +1,92 @@ +.loaderContainer { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.loadingBox { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + border: 1px dashed var(--primary); + padding: 2rem; +} + +.loadingTitle { + font-size: medium; +} + +.loaderInBox { + margin-bottom: 2rem !important; + margin-top: 2rem !important; +} + +.loaderIsolated { + margin: 0 auto; +} + +.timeElapsed { + font-size: small; + color: var(--muted); +} + +/* + FOLLOWING CODE IS COPIED FROM: https://codepen.io/stoepke/pen/QOOqGW +*/ + +.loader { + width: 100%; + margin: 0 auto; + border-radius: 10px; + border: 4px solid transparent; + position: relative; + padding: 1px; +} +.loader:before { + content: ""; + border: 1px solid var(--primary); + border-radius: 10px; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; +} +.loader .loaderBar { + position: absolute; + border-radius: 10px; + top: 0; + right: 100%; + bottom: 0; + left: 0; + background: var(--primary); + width: 0; + animation: borealisBar 2s linear infinite; +} + +@keyframes borealisBar { + 0% { + left: 0%; + right: 100%; + width: 0%; + } + 10% { + left: 0%; + right: 75%; + width: 25%; + } + 90% { + right: 0%; + left: 75%; + width: 25%; + } + 100% { + left: 100%; + right: 0%; + width: 0%; + } +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.tsx new file mode 100644 index 0000000..70f2777 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/LoadingBar.tsx @@ -0,0 +1,89 @@ +import styles from "./LoadingBar.module.css"; +import { useState, useEffect, ComponentProps } from "react"; + +export const LoadingBar = ({ + children, + title, + formatTimeElapsed, +}: { + children?: React.ReactNode; + title?: React.ReactNode; + formatTimeElapsed?: FormatTimeElapsed; +}) => { + return children || title ? ( + + {children} + + ) : ( + + ); +}; + +export const JustLoadingBar = ({ + inBox, + formatTimeElapsed, +}: { + inBox: boolean; + formatTimeElapsed?: FormatTimeElapsed; +}) => { + return ( +
+
+
+
+ +
+ ); +}; + +export const LoadingBarWithText = ({ + children, + title, + formatTimeElapsed, +}: { + children?: React.ReactNode; + title?: React.ReactNode; + formatTimeElapsed?: FormatTimeElapsed; +}) => { + return ( +
+ {title ?

{title}

: null} + + {children} +
+ ); +}; + +type FormatTimeElapsed = (seconds: number) => React.ReactNode; + +const defaultFormatTimeElapsed: FormatTimeElapsed = (seconds) => + `Time elapsed: ${seconds} seconds...`; + +const TimeElapsed = ({ + formatTimeElapsed = defaultFormatTimeElapsed, +}: { + formatTimeElapsed?: FormatTimeElapsed; +}) => { + const [seconds, setSeconds] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => { + setSeconds((prevSeconds) => prevSeconds + 1); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, []); + + return ( +
+ {seconds < 2 ? <>  : <>{formatTimeElapsed(seconds)}} +
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/Logo.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/Logo.tsx new file mode 100644 index 0000000..a0e82b8 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/Logo.tsx @@ -0,0 +1,46 @@ +export const LogoSVG = ({ size }: { size: number }) => ( + + + + + + + + + + + + +); diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.module.css new file mode 100644 index 0000000..2142170 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.module.css @@ -0,0 +1,3 @@ +.charts { + background: inherit; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx new file mode 100644 index 0000000..afc76c9 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx @@ -0,0 +1,41 @@ +import style from "./Charts.module.css"; + +import type { ImportedRepository } from "../../types"; +import { SqlProvider, makeSeafowlHTTPContext } from "@madatdata/react"; + +import { useMemo } from "react"; + +import { StargazersChart } from "./charts/StargazersChart"; +import { IssueReactsByMonth } from "./charts/IssueReactsByMonth"; +import { UserCodeVsComment } from "./charts/UserCodeVsComment"; + +export interface ChartsProps { + importedRepository: ImportedRepository; +} + +// Assume meta namespace contains both the meta tables, and all imported repositories and tables +const META_NAMESPACE = + process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE; + +export const Charts = ({ importedRepository }: ChartsProps) => { + const seafowlDataContext = useMemo( + () => + makeSeafowlHTTPContext("https://demo.seafowl.cloud", { + dbname: META_NAMESPACE, + }), + [] + ); + + return ( +
+ +

Stargazers

+ +

Issue Reacts by Month

+ +

Code vs. Comment Length

+ +
+
+ ); +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.module.css new file mode 100644 index 0000000..9c4f1c7 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.module.css @@ -0,0 +1,3 @@ +.importedRepoMetadata { + background: inherit; +} diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.tsx new file mode 100644 index 0000000..81c708d --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/ImportedRepoMetadata.tsx @@ -0,0 +1,213 @@ +import { ComponentProps } from "react"; +import type { ImportedRepository } from "../../types"; +import style from "./ImportedRepoMetadata.module.css"; + +import { makeStargazersTableQuery } from "./sql-queries"; + +// Assume meta namespace contains both the meta tables, and all imported repositories and tables +const META_NAMESPACE = + process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE; + +interface ImportedRepoMetadataProps { + importedRepository: ImportedRepository; +} + +type SplitgraphRepository = Pick< + ImportedRepository, + "splitgraphNamespace" | "splitgraphRepository" +>; + +export const ImportedRepoMetadata = ({ + importedRepository, +}: ImportedRepoMetadataProps) => { + return ( +
+

+ +

+

GitHub Analytics

+ +
    +
  • + Browse the data: +
  • +
  • + +
  • +
  • + +
  • +
+
+ ); +}; + +export const SplitgraphRepoLink = ({ + splitgraphNamespace, + splitgraphRepository, +}: Pick< + ImportedRepository, + "splitgraphNamespace" | "splitgraphRepository" +>) => { + return ( + + {`splitgraph.com/${splitgraphNamespace}/${splitgraphRepository}`} + + ); +}; + +export const GitHubRepoLink = ({ + githubNamespace, + githubRepository, +}: Pick) => { + return ( + {`github.com/${githubNamespace}/${githubRepository}`} + ); +}; + +export const SplitgraphQueryLink = ({ + importedRepository, + makeQuery, + tableName, +}: { + importedRepository: SplitgraphRepository; + makeQuery: (repo: SplitgraphRepository) => string; + tableName: string; +}) => { + return ( + + Query the {tableName} table in the Splitgraph Console + + ); +}; + +export const SplitgraphStargazersQueryLink = ({ + ...importedRepository +}: SplitgraphRepository) => { + return ( + + ); +}; + +export const SeafowlStargazersQueryLink = ({ + ...importedRepository +}: SplitgraphRepository) => { + return ( + + ); +}; + +const SeafowlQueryLink = ({ + importedRepository, + makeQuery, + tableName, +}: { + importedRepository: SplitgraphRepository; + makeQuery: (repo: SplitgraphRepository) => string; + tableName: string; +}) => { + return ( + + Query Seafowl table {tableName} using the Splitgraph Console + + ); +}; + +/** Return the URL to Splitgraph Console pointing to Splitgraph DDN */ +export const makeSplitgraphQueryHref = (sqlQuery: string) => { + const url = `https://www.splitgraph.com/query?${new URLSearchParams({ + sqlQuery: sqlQuery, + flavor: "splitgraph", + }).toString()}`; + + return url; +}; + +/** Return the URL to Splitgraph Console pointing to Seafowl db where we export tables */ +export const makeSeafowlQueryHref = (sqlQuery: string) => { + return `https://www.splitgraph.com/query?${new URLSearchParams({ + sqlQuery: sqlQuery, + flavor: "seafowl", + // Splitgraph exports to Seafowl dbname matching the username of the exporting user + "database-name": META_NAMESPACE, + })}`; +}; + +export interface EmbeddedQueryProps { + importedRepository: SplitgraphRepository; + makeQuery: (repo: SplitgraphRepository) => string; + tableName: string; +} + +export const SplitgraphEmbeddedQuery = ({ + importedRepository, + makeQuery, +}: EmbeddedQueryProps) => { + return ( +