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...`
+ }
+ />
+ )}
+
+
+
+
+ {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 (
+
+ {typeof size !== "undefined" ? (
+ {children}
+ ) : (
+ children
+ )}
+
+ );
+};
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
+
+
+
+ Link 1
+ Link 2
+ Link 3
+ {/* Add more links as needed */}
+
+
+ );
+};
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
+ ) && (
+
+ {stepperState === "awaiting_export"
+ ? "Exporting Tables and Queries to Seafowl..."
+ : stepperState === "export_complete"
+ ? "Restart Export of Tables and Queries to Seafowl"
+ : "Start Export of Tables and Queries to Seafowl"}
+
+ )}
+ {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" ? (
+
+ ) : (
+ <>
+ 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}
}
+
+ >
+ )}
+ {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 (
+
+ );
+};
+
+export const SeafowlEmbeddedQuery = ({
+ importedRepository,
+ makeQuery,
+}: EmbeddedQueryProps) => {
+ return (
+
+ );
+};
+
+/** Return the URL to Splitgraph Console pointing to Seafowl db where we export tables */
+const makeSeafowlEmbeddableQueryHref = (sqlQuery: string) => {
+ return `https://www.splitgraph.com/embeddable-seafowl-console/query-editor?${new URLSearchParams(
+ {
+ "sql-query": sqlQuery,
+ // Splitgraph exports to Seafowl dbname matching the username of the exporting user
+ database: META_NAMESPACE,
+ }
+ )}`;
+};
+
+const makeSplitgraphEmbeddableQueryHref = (sqlQuery: string) => {
+ return `https://www.splitgraph.com/embed/workspace/ddn?${new URLSearchParams({
+ layout: "query",
+ query: sqlQuery,
+ })}`;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/IssueReactsByMonth.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/IssueReactsByMonth.tsx
new file mode 100644
index 0000000..a99d27f
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/IssueReactsByMonth.tsx
@@ -0,0 +1,170 @@
+import * as Plot from "@observablehq/plot";
+import { useSqlPlot } from "../useSqlPlot";
+import type { ImportedRepository, TargetSplitgraphRepo } from "../../../types";
+
+// 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;
+
+type Reaction =
+ | "plus_one"
+ | "minus_one"
+ | "laugh"
+ | "confused"
+ | "heart"
+ | "hooray"
+ | "rocket"
+ | "eyes";
+
+type MappedIssueReactsByMonthRow = IssueReactsByMonthRow & {
+ created_at_month: Date;
+};
+
+/**
+ * A stacked bar chart of the number of reactions each month, grouped by reaction type
+ */
+export const IssueReactsByMonth = ({
+ splitgraphNamespace,
+ splitgraphRepository,
+}: ImportedRepository) => {
+ const renderPlot = useSqlPlot({
+ sqlParams: { splitgraphNamespace, splitgraphRepository },
+ buildQuery: monthlyIssueStatsTableQuery,
+ mapRows: (r: IssueReactsByMonthRow) => ({
+ ...r,
+ created_at_month: new Date(r.created_at_month),
+ }),
+ reduceRows: (rows: MappedIssueReactsByMonthRow[]) => {
+ const reactions = new Map<
+ Reaction,
+ { created_at_month: Date; count: number }[]
+ >();
+
+ for (const row of rows) {
+ for (const reaction of [
+ "plus_one",
+ "minus_one",
+ "laugh",
+ "confused",
+ "heart",
+ "hooray",
+ "rocket",
+ "eyes",
+ ] as Reaction[]) {
+ if (!reactions.has(reaction)) {
+ reactions.set(reaction, []);
+ }
+
+ reactions.get(reaction)!.push({
+ created_at_month: row.created_at_month,
+ count: (() => {
+ switch (reaction) {
+ case "plus_one":
+ return row.total_plus_ones;
+ case "minus_one":
+ return row.total_minus_ones;
+ case "laugh":
+ return row.total_laughs;
+ case "confused":
+ return row.total_confused;
+ case "heart":
+ return row.total_hearts;
+ case "hooray":
+ return row.total_hoorays;
+ case "rocket":
+ return row.total_rockets;
+ case "eyes":
+ return row.total_eyes;
+ }
+ })(),
+ });
+ }
+ }
+
+ return Array.from(reactions.entries()).flatMap(([reaction, series]) =>
+ series.map((d) => ({ reaction, ...d }))
+ );
+ },
+ isRenderable: (p) => !!p.splitgraphRepository,
+
+ makePlotOptions: (issueStats) => ({
+ y: { grid: true, label: "Number of Reactions" },
+ x: {
+ label: "Month",
+ },
+ color: {
+ legend: true,
+ label: "Reaction",
+ tickFormat: (reaction) => {
+ switch (reaction) {
+ case "plus_one":
+ return "👍 plus_one";
+ case "minus_one":
+ return "👎 minus_one";
+ case "laugh":
+ return "😄 laugh";
+ case "confused":
+ return "😕 confused";
+ case "heart":
+ return "❤️ heart";
+ case "hooray":
+ return "🎉 hooray";
+ case "rocket":
+ return "🚀 rocket";
+ case "eyes":
+ return "👀 eyes";
+ }
+ },
+ },
+ marks: [
+ Plot.rectY(issueStats, {
+ x: "created_at_month",
+ y: "count",
+ interval: "month",
+ fill: "reaction",
+ tip: true,
+ }),
+ Plot.ruleY([0]),
+ ],
+ }),
+ });
+
+ return renderPlot();
+};
+
+/** Shape of row returned by {@link monthlyIssueStatsTableQuery} */
+export type IssueReactsByMonthRow = {
+ created_at_month: string;
+ num_issues: number;
+ total_reacts: number;
+ total_plus_ones: number;
+ total_minus_ones: number;
+ total_laughs: number;
+ total_confused: number;
+ total_hearts: number;
+ total_hoorays: number;
+ total_rockets: number;
+ total_eyes: number;
+};
+
+/** Time series of GitHub stargazers for the given repository */
+export const monthlyIssueStatsTableQuery = ({
+ splitgraphNamespace = META_NAMESPACE,
+ splitgraphRepository,
+}: TargetSplitgraphRepo) => {
+ return `SELECT
+ created_at_month,
+ count(issue_number) as num_issues,
+ sum(total_reacts) as total_reacts,
+ sum(no_plus_one) as total_plus_ones,
+ sum(no_minus_one) as total_minus_ones,
+ sum(no_laugh) as total_laughs,
+ sum(no_confused) as total_confused,
+ sum(no_heart) as total_hearts,
+ sum(no_hooray) as total_hoorays,
+ sum(no_rocket) as total_rockets,
+ sum(no_eyes) as total_eyes
+FROM "${splitgraphNamespace}/${splitgraphRepository}"."monthly_issue_stats"
+GROUP BY created_at_month
+ORDER BY created_at_month ASC;`;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx
new file mode 100644
index 0000000..f45f928
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx
@@ -0,0 +1,56 @@
+import * as Plot from "@observablehq/plot";
+import { useSqlPlot } from "../useSqlPlot";
+import type { ImportedRepository, TargetSplitgraphRepo } from "../../../types";
+
+// 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;
+
+/**
+ * A simple line graph showing the number of stargazers over time
+ */
+export const StargazersChart = ({
+ splitgraphNamespace,
+ splitgraphRepository,
+}: ImportedRepository) => {
+ const renderPlot = useSqlPlot({
+ sqlParams: { splitgraphNamespace, splitgraphRepository },
+ buildQuery: stargazersLineChartQuery,
+ mapRows: (r: StargazersLineChartRow) => ({
+ ...r,
+ starred_at: new Date(r.starred_at),
+ }),
+ isRenderable: (p) => !!p.splitgraphRepository,
+ makePlotOptions: (stargazers) => ({
+ y: { grid: true },
+ color: { scheme: "burd" },
+ marks: [
+ Plot.lineY(stargazers, {
+ x: "starred_at",
+ y: "cumulative_stars",
+ }),
+ ],
+ }),
+ });
+
+ return renderPlot();
+};
+
+/** Shape of row returned by {@link stargazersLineChartQuery} */
+export type StargazersLineChartRow = {
+ username: string;
+ cumulative_stars: number;
+ starred_at: string;
+};
+
+/** Time series of GitHub stargazers for the given repository */
+export const stargazersLineChartQuery = ({
+ splitgraphNamespace = META_NAMESPACE,
+ splitgraphRepository,
+}: TargetSplitgraphRepo) => {
+ return `SELECT
+ COUNT(*) OVER (ORDER BY starred_at) AS cumulative_stars,
+ starred_at
+FROM "${splitgraphNamespace}/${splitgraphRepository}"."stargazers"
+ORDER BY starred_at;`;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/UserCodeVsComment.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/UserCodeVsComment.tsx
new file mode 100644
index 0000000..f6b88a1
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/UserCodeVsComment.tsx
@@ -0,0 +1,102 @@
+import * as Plot from "@observablehq/plot";
+import { useSqlPlot } from "../useSqlPlot";
+import type { ImportedRepository, TargetSplitgraphRepo } from "../../../types";
+
+// 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;
+
+type CommentLengthRow = {
+ username: string;
+ comment_length: number;
+ net_lines_added: number;
+ total_lines_added: number;
+ total_lines_deleted: number;
+};
+
+const sum = (arr: number[]) => arr.reduce((a, b) => a + b, 0);
+const mean = (arr: number[]) => sum(arr) / arr.length;
+
+/**
+ * A scatter plot of user comment length vs. lines of code
+ */
+export const UserCodeVsComment = ({
+ splitgraphNamespace,
+ splitgraphRepository,
+}: ImportedRepository) => {
+ const renderPlot = useSqlPlot({
+ sqlParams: { splitgraphNamespace, splitgraphRepository },
+ buildQuery: userStatsQuery,
+ mapRows: (r: UserStatsRow) =>
+ ({
+ username: r.username,
+ comment_length: r.total_comment_length,
+ total_lines_added: r.total_lines_added,
+ total_lines_deleted: r.total_lines_deleted,
+ net_lines_added: r.total_lines_added - r.total_lines_deleted,
+ } as CommentLengthRow),
+ isRenderable: (p) => !!p.splitgraphRepository,
+ reduceRows: (rows: CommentLengthRow[]) =>
+ rows.filter((r) => r.username && !r.username.endsWith("[bot]")),
+
+ makePlotOptions: (userStats: CommentLengthRow[]) => ({
+ y: {
+ label: "Length of Comments",
+ type: "symlog",
+ constant: mean(userStats.map((u) => u.comment_length)),
+ },
+ x: {
+ label: "Lines of Code",
+ type: "symlog",
+ constant: mean(userStats.map((u) => u.total_lines_added)),
+ },
+ color: {
+ scheme: "Turbo",
+ },
+ marks: [
+ Plot.dot(userStats, {
+ x: "comment_length",
+ y: "total_lines_added",
+ stroke: "username",
+ fill: "username",
+ tip: true,
+ }),
+ Plot.ruleY([0]),
+ ],
+ }),
+ });
+
+ return renderPlot();
+};
+
+/** Shape of row returned by {@link userStatsQuery} */
+export type UserStatsRow = {
+ username: string;
+ total_commits: number;
+ total_pull_request_comments: number;
+ total_issue_comments: number;
+ total_comment_length: number;
+ total_merged_pull_requests: number;
+ total_pull_requests: number;
+ total_lines_added: number;
+ total_lines_deleted: number;
+};
+
+/** Time series of GitHub stargazers for the given repository */
+export const userStatsQuery = ({
+ splitgraphNamespace = META_NAMESPACE,
+ splitgraphRepository,
+}: TargetSplitgraphRepo) => {
+ return `SELECT
+ username,
+ sum(no_commits) as total_commits,
+ sum(no_pull_request_comments) as total_pull_request_comments,
+ sum(no_issue_comments) as total_issue_comments,
+ sum(total_comment_length) as total_comment_length,
+ sum(merged_pull_requests) as total_merged_pull_requests,
+ sum(total_pull_requests) as total_pull_requests,
+ sum(lines_added) as total_lines_added,
+ sum(lines_deleted) as total_lines_deleted
+FROM "${splitgraphNamespace}/${splitgraphRepository}"."monthly_user_stats"
+GROUP BY username;`;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts
new file mode 100644
index 0000000..3463d3b
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts
@@ -0,0 +1,29 @@
+import type { TargetSplitgraphRepo } from "../../types";
+
+// 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;
+
+/**
+ * Raw query to select all columns in the stargazers table, which can be
+ * run on both Splitgraph and Seafowl.
+ *
+ * This is meant for linking to the query editor, not for rendering charts.
+ */
+export const makeStargazersTableQuery = ({
+ splitgraphNamespace = META_NAMESPACE,
+ splitgraphRepository,
+}: TargetSplitgraphRepo) => {
+ return `SELECT
+ "repository",
+ "user_id",
+ "starred_at",
+ "user",
+ "_airbyte_ab_id",
+ "_airbyte_emitted_at",
+ "_airbyte_normalized_at",
+ "_airbyte_stargazers_hashid"
+FROM
+ "${splitgraphNamespace}/${splitgraphRepository}"."stargazers"
+LIMIT 100;`;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx
new file mode 100644
index 0000000..3e93b3d
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx
@@ -0,0 +1,123 @@
+import { useCallback, useEffect, useRef } from "react";
+
+import { UnknownObjectShape, useSql } from "@madatdata/react";
+
+import * as Plot from "@observablehq/plot";
+import { useMemo } from "react";
+
+/**
+ * A hook that returns a render function for a Plot chart built from the
+ * results of a SQL query. All of the generic parameters should be inferrable
+ * based on the parameters passed to the `sqlParams` parameter.
+ *
+ * @returns A render function which returns a value that can be returned from a Component
+ */
+export const useSqlPlot = <
+ RowShape extends UnknownObjectShape,
+ SqlParams extends object,
+ MappedRow extends UnknownObjectShape,
+ ReducedRow extends UnknownObjectShape
+>({
+ sqlParams,
+ mapRows,
+ reduceRows,
+ buildQuery,
+ makePlotOptions,
+ isRenderable,
+}: {
+ /**
+ * The input parameters, an object that should match the first and only parameter
+ * of the `buildQuery` callback
+ * */
+ sqlParams: SqlParams;
+ /**
+ * An optional function to map the rows returned by the SQL query to a different
+ * row shape, which is most often useful for things like converting a string column
+ * to a `Date` object.
+ */
+ mapRows?: (row: RowShape) => MappedRow;
+
+ /**
+ * An optional function to transform the mapped rows into a different aggregation
+ */
+ reduceRows?: (rows: MappedRow[]) => ReducedRow[];
+ /**
+ * A builder function that returns a SQL query given a set of parameters, which
+ * will be the parameters passed as the `sqlParams` parameter.
+ */
+ buildQuery: (sqlParams: SqlParams) => string;
+ /**
+ * A function to call after receiving the result of the SQL query (and mapping
+ * its rows if applicable), to create the options given to Observable {@link Plot.plot}
+ */
+ makePlotOptions: (rows: ReducedRow[]) => Plot.PlotOptions;
+ /**
+ * A function to call to determine if the chart is renderable. This is helpful
+ * during server side rendering, when Observable Plot doesn't typically work well,
+ * and also when the response from the query is empty, for example because the `useSql`
+ * hook executed before its parameters were set (this works around an inconvenience in
+ * `useSql` where it does not take any parameters and so always executes on first render)
+ */
+ isRenderable?: (sqlParams: SqlParams) => boolean;
+}) => {
+ const containerRef = useRef();
+
+ const sqlQueryIfReady = () => {
+ if (isRenderable && !isRenderable(sqlParams)) {
+ return null;
+ }
+
+ return buildQuery(sqlParams);
+ };
+
+ const { response, error } = useSql(sqlQueryIfReady());
+
+ const mappedRows = useMemo(() => {
+ return !response || error
+ ? []
+ : (response.rows ?? []).map(
+ mapRows ?? ((r) => r as unknown as MappedRow)
+ );
+ }, [response, error]);
+
+ const reducedRows = useMemo(() => {
+ if (mappedRows.length === 0) {
+ return [];
+ }
+
+ if (!reduceRows) {
+ return mappedRows as unknown as ReducedRow[];
+ }
+
+ return reduceRows(mappedRows);
+ }, [mappedRows]);
+
+ const plotOptions = useMemo(
+ () => makePlotOptions(reducedRows),
+ [reducedRows]
+ );
+
+ useEffect(() => {
+ if (reducedRows === undefined) {
+ return;
+ }
+
+ const plot = Plot.plot(plotOptions);
+
+ // There is a bug(?) in useSql where, since we can't give it dependencies, it
+ // will re-run even with splitgraphNamespace and splitgraphRepository are undefined,
+ // which results in an error querying Seafowl. So just don't render the chart in that case.
+ if (!isRenderable || isRenderable(sqlParams)) {
+ containerRef.current.append(plot);
+ }
+
+ return () => plot.remove();
+ }, [reducedRows]);
+
+ const renderPlot = useCallback(
+ () =>
,
+ [containerRef]
+ );
+
+ return renderPlot;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.module.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.module.css
new file mode 100644
index 0000000..5846221
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.module.css
@@ -0,0 +1,54 @@
+.sidebar {
+ background-color: var(--background);
+ border-right: 1px solid var(--header);
+ position: relative;
+}
+
+.importButtonContainer {
+ position: sticky;
+ top: 0;
+ width: 100%;
+ background-color: var(--background);
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border-bottom: 1px dotted var(--sidebar);
+}
+
+.importButton {
+ color: var(--background) !important;
+ background-color: var(--secondary);
+ padding: 16px;
+ border-radius: 16px;
+}
+
+.importButton {
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.importButton:hover {
+ text-shadow: 0 0 5px rgba(43, 0, 255, 0.5);
+}
+
+.repoList {
+ list-style: none;
+ padding: 0;
+}
+
+.repoList li {
+ margin-left: 0;
+ border-bottom: 1px dotted var(--sidebar);
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 16px;
+ padding-right: 16px;
+}
+
+.repoList li a {
+ text-decoration: none;
+}
+
+.repoList li a:hover {
+ text-decoration: underline;
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.tsx
new file mode 100644
index 0000000..65f7c2c
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/Sidebar.tsx
@@ -0,0 +1,96 @@
+import React, { useMemo } from "react";
+import Link from "next/link";
+import styles from "./Sidebar.module.css";
+import { SqlProvider, makeSplitgraphHTTPContext } from "@madatdata/react";
+import { useSql } from "@madatdata/react";
+
+import type { ImportedRepository } from "../types";
+
+const META_REPOSITORY =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY;
+const META_NAMESPACE =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE;
+const META_TABLE =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_COMPLETED_TABLE;
+
+const useImportedRepositories = (): ImportedRepository[] => {
+ const { response, error } = useSql<{
+ githubNamespace: string;
+ githubRepository: string;
+ splitgraphNamespace: string;
+ splitgraphRepository: string;
+ }>(
+ `
+ WITH ordered_repos AS (
+ SELECT
+ github_namespace,
+ github_repository,
+ splitgraph_namespace,
+ splitgraph_repository,
+ completed_at
+ FROM "${META_NAMESPACE}/${META_REPOSITORY}"."${META_TABLE}"
+ ORDER BY completed_at DESC
+ )
+ SELECT DISTINCT
+ github_namespace AS "githubNamespace",
+ github_repository AS "githubRepository",
+ splitgraph_namespace AS "splitgraphNamespace",
+ splitgraph_repository AS "splitgraphRepository"
+ FROM ordered_repos;
+ `
+ );
+
+ const repositories = useMemo(() => {
+ if (error) {
+ console.warn("Error fetching repositories:", error);
+ return [];
+ }
+
+ if (!response) {
+ return [];
+ }
+
+ return response.rows ?? [];
+ }, [error, response]);
+
+ return repositories;
+};
+
+const RepositoriesList = () => {
+ const repositories = useImportedRepositories();
+
+ return (
+
+ {repositories.map((repo, index) => (
+
+
+ {repo.githubNamespace}/{repo.githubRepository}
+
+
+ ))}
+
+ );
+};
+
+export const Sidebar = () => {
+ const splitgraphDataContext = useMemo(
+ () => makeSplitgraphHTTPContext({ credential: null }),
+ []
+ );
+
+ return (
+
+ );
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/reset.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/reset.css
new file mode 100644
index 0000000..dd0e1d8
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/reset.css
@@ -0,0 +1,71 @@
+/* https://www.joshwcomeau.com/css/custom-css-reset/ */
+
+/*
+ 1. Use a more-intuitive box-sizing model.
+*/
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+/*
+ 2. Remove default margin
+*/
+* {
+ margin: 0;
+}
+/*
+ 3. Allow percentage-based heights in the application
+*/
+html,
+body {
+ height: 100%;
+}
+/*
+ Typographic tweaks!
+ 4. Add accessible line-height
+ 5. Improve text rendering
+*/
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+/*
+ 6. Improve media defaults
+*/
+img,
+picture,
+video,
+canvas,
+svg {
+ display: block;
+ max-width: 100%;
+}
+/*
+ 7. Remove built-in form typography styles
+*/
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
+/*
+ 8. Avoid text overflows
+*/
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ overflow-wrap: break-word;
+}
+/*
+ 9. Create a root stacking context
+*/
+#root,
+#__next {
+ isolation: isolate;
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/theme.css b/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/theme.css
new file mode 100644
index 0000000..70f1893
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/global-styles/theme.css
@@ -0,0 +1,33 @@
+:root {
+ --primary: #007bff;
+ --secondary: #ef00a7;
+ --background: #ffffff;
+ --header: #f2f6ff;
+ /* --header: #718096; */
+ --sidebar: #718096;
+ --text: #1a202c;
+ --subtext: #718096;
+ --danger: #eb8585;
+ --muted: rgba(113, 128, 150, 0.5);
+}
+
+body {
+ font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
+}
+
+body a {
+ color: var(--primary);
+ text-decoration: none;
+}
+
+body a:hover {
+ text-decoration: underline;
+}
+
+body a:visited {
+ color: var(--primary);
+}
+
+code {
+ font-size: 1rem;
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/env-vars.d.ts b/examples/nextjs-import-airbyte-github-export-seafowl/env-vars.d.ts
new file mode 100644
index 0000000..8781703
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/env-vars.d.ts
@@ -0,0 +1,118 @@
+namespace NodeJS {
+ interface ProcessEnv {
+ /**
+ * The API key of an existing Splitgraph account.
+ *
+ * This should be defined in `.env.local` (a git-ignored file) or in Vercel settings.
+ *
+ * Get credentials: https://www.splitgraph.com/connect
+ */
+ SPLITGRAPH_API_KEY: string;
+
+ /**
+ * The API secret of an existing Splitgraph account.
+ *
+ * This should be defined in `.env.local` (a git-ignored file) or in Vercel settings.
+ *
+ * Get credentials: https://www.splitgraph.com/connect
+ */
+ SPLITGRAPH_API_SECRET: string;
+
+ /**
+ * A GitHub personal access token that can be used for importing repositories.
+ * It will be passed to the Airbyte connector that runs on Splitgraph servers
+ * and ingests data from GitHub into Splitgraph.
+ *
+ * This should be defined in `.env.local` (a git-ignored file) or in Vercel settings.
+ *
+ * Create one here: https://github.com/settings/personal-access-tokens/new
+ */
+ GITHUB_PAT_SECRET: string;
+
+ /**
+ * Optional environment variable containing the address of a proxy instance
+ * through which to forward requests from API routes. See next.config.js
+ * for where it's setup.
+ *
+ * This is useful for debugging and development.
+ */
+ MITMPROXY_ADDRESS?: string;
+
+ /**
+ * Optionally provide the SEAFOWL_INSTANCE_URL to use for creating fallback tables
+ * when an export fails.
+ *
+ * Note that at the moment, this must only be set to https://demo.seafowl.cloud
+ * because that's where Splitgraph exports to by default, and we are not currently
+ * passing any instance URL to the Splitgraph export API.
+ */
+ SEAFOWL_INSTANCE_URL?: "https://demo.seafowl.cloud";
+
+ /**
+ * Optionally provide the SEAFOWL_INSTANCE_SECRET to use for creating fallback tables
+ * when an export fails.
+ */
+ SEAFOWL_INSTANCE_SECRET?: string;
+
+ /**
+ * Optionally provide the dbname to use for creating fallback tables
+ * when an export fails.
+ *
+ * Note this MUST match the NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE
+ */
+ SEAFOWL_INSTANCE_DATABASE?: string;
+
+ /**
+ * The namespace of the repository in Splitgraph where metadata is stored
+ * containing the state of imported GitHub repositories, which should contain
+ * the repository `NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY`.
+ *
+ * This should be defined in `.env.local`, since it's not checked into Git
+ * and can vary between users. It should match the username associated with
+ * the `SPLITGRAPH_API_KEY`
+ *
+ * Example:
+ *
+ * ```
+ * miles/splitgraph-github-analytics.completed_repositories
+ * ^^^^^
+ * NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE=miles
+ * ```
+ */
+ NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE: string;
+
+ /**
+ * The repository (no namespace) in Splitgraph where metadata is stored
+ * containing the state of imported GitHub repositories, which should be a
+ * repository contained inside `NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE`.
+ *
+ * This is defined by default in `.env` which is checked into Git.
+ *
+ * * Example:
+ *
+ * ```
+ * miles/splitgraph-github-analytics.completed_repositories
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ * NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY=splitgraph-github-analytics
+ * ```
+ */
+ NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY: string;
+
+ /**
+ * The name of the table containing completed repositories, which are inserted
+ * when the import/export is complete, and which can be queried to render the
+ * sidebar containing previously imported github repositories.
+ *
+ * This is defined by default in `.env` which is checked into Git.
+ *
+ * Example:
+ *
+ * ```
+ * miles/splitgraph-github-analytics.completed_repositories
+ * ^^^^^^^^^^^^^^^^^^^^^^
+ * NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_COMPLETED_TABLE=completed_repositories
+ * ```
+ */
+ NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_COMPLETED_TABLE: string;
+ }
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/seafowl-db.ts b/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/seafowl-db.ts
new file mode 100644
index 0000000..8ae7af1
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/seafowl-db.ts
@@ -0,0 +1,83 @@
+import { makeSeafowlHTTPContext } from "@madatdata/core";
+
+export const makeAuthenticatedSeafowlHTTPContext = () => {
+ const { instanceURL, instanceDatabase, instanceSecret } =
+ getRequiredValidAuthenticatedSeafowlInstanceConfig();
+
+ // NOTE: This config object is a mess and will be simplified in a future madatdata update
+ // It's only necessary here because we're passing a secret
+ return makeSeafowlHTTPContext({
+ database: {
+ dbname: instanceDatabase,
+ },
+ authenticatedCredential: {
+ token: instanceSecret,
+ anonymous: false,
+ },
+ host: {
+ baseUrls: {
+ sql: instanceURL,
+ gql: "...",
+ auth: "...",
+ },
+ dataHost: new URL(instanceURL).host,
+ apexDomain: "...",
+ apiHost: "...",
+ postgres: {
+ host: "127.0.0.1",
+ port: 6432,
+ ssl: false,
+ },
+ },
+ });
+};
+
+const getRequiredValidAuthenticatedSeafowlInstanceConfig = () => {
+ const instanceURL = process.env.SEAFOWL_INSTANCE_URL;
+
+ if (!instanceURL) {
+ throw new Error("Missing SEAFOWL_INSTANCE_URL");
+ }
+
+ // This could be temporary if we want to allow configuring the instance URL,
+ // but for now we export to Splitgraph using no instance URL, which means
+ // it exports to demo.seafowl.cloud, and we only use this for creating
+ // fallback tables on failed exports (which is mostly a workaround anyway)
+ if (instanceURL && instanceURL !== "https://demo.seafowl.cloud") {
+ throw new Error(`If SEAFOWL_INSTANCE_URL is set, it should be set to https://demo.seafowl.cloud,
+ because that's where Splitgraph exports to by default, and we are not currently passing
+ any instance URL to the Splitgraph export API (though we could do that).
+ `);
+ }
+
+ const instanceSecret = process.env.SEAFOWL_INSTANCE_SECRET;
+ if (!instanceSecret) {
+ throw new Error("Missing SEAFOWL_INSTANCE_SECRET");
+ }
+
+ // This is at the config level, just like SPLITGRAPH_NAMESPACE, since the two
+ // of them are supposed to match
+ const instanceDatabase = process.env.SEAFOWL_INSTANCE_DATABASE;
+ if (!instanceDatabase) {
+ throw new Error("Missing SEAFOWL_INSTANCE_DATABASE");
+ }
+
+ const META_NAMESPACE =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE;
+ if (!META_NAMESPACE) {
+ throw new Error(
+ "Missing NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE"
+ );
+ }
+
+ if (instanceDatabase !== META_NAMESPACE) {
+ throw new Error(`SEAFOWL_INSTANCE_DATABASE (${instanceDatabase}) should match
+ NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE (${META_NAMESPACE})`);
+ }
+
+ return {
+ instanceURL,
+ instanceSecret,
+ instanceDatabase,
+ };
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/splitgraph-db.ts b/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/splitgraph-db.ts
new file mode 100644
index 0000000..5aa95b9
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/lib/backend/splitgraph-db.ts
@@ -0,0 +1,62 @@
+import { makeSplitgraphDb, makeSplitgraphHTTPContext } from "@madatdata/core";
+
+// TODO: fix plugin exports
+import { makeDefaultPluginList } from "@madatdata/db-splitgraph";
+import { defaultSplitgraphHost } from "@madatdata/core";
+
+const SPLITGRAPH_API_KEY = process.env.SPLITGRAPH_API_KEY;
+const SPLITGRAPH_API_SECRET = process.env.SPLITGRAPH_API_SECRET;
+
+// Throw top level error on missing keys because these are _always_ required app to run
+if (!SPLITGRAPH_API_KEY || !SPLITGRAPH_API_SECRET) {
+ throw new Error(
+ "Environment variable SPLITGRAPH_API_KEY or SPLITGRAPH_API_SECRET is not set." +
+ " See env-vars.d.ts for instructions."
+ );
+}
+
+const authenticatedCredential: Parameters<
+ typeof makeSplitgraphDb
+>[0]["authenticatedCredential"] = {
+ apiKey: SPLITGRAPH_API_KEY,
+ apiSecret: SPLITGRAPH_API_SECRET,
+ anonymous: false,
+};
+
+// TODO: The access token can expire and silently fail?
+
+export const makeAuthenticatedSplitgraphDb = () =>
+ makeSplitgraphDb({
+ authenticatedCredential,
+ plugins: makeDefaultPluginList({
+ graphqlEndpoint: defaultSplitgraphHost.baseUrls.gql,
+ authenticatedCredential,
+ }),
+ });
+
+export const makeAuthenticatedSplitgraphHTTPContext = () =>
+ makeSplitgraphHTTPContext({
+ authenticatedCredential,
+ plugins: makeDefaultPluginList({
+ graphqlEndpoint: defaultSplitgraphHost.baseUrls.gql,
+ authenticatedCredential,
+ }),
+ });
+
+// TODO: export this utility function from the library
+export const claimsFromJWT = (jwt?: string) => {
+ if (!jwt) {
+ return {};
+ }
+
+ const [_header, claims, _signature] = jwt
+ .split(".")
+ .map(fromBase64)
+ .slice(0, -1) // Signature is not parseable JSON
+ .map((o) => JSON.parse(o));
+
+ return claims;
+};
+
+const fromBase64 = (input: string) =>
+ !!globalThis.Buffer ? Buffer.from(input, "base64").toString() : atob(input);
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/github-tables.ts b/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/github-tables.ts
new file mode 100644
index 0000000..76813cb
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/github-tables.ts
@@ -0,0 +1,280 @@
+import { useMemo } from "react";
+import type { ExportTableInput } from "../../types";
+
+/**
+ * List of GitHub table names that we want to import with the Airbyte connector
+ * into Splitgraph. By default, there are 163 tables available. But we only want
+ * some of them, and by selecting them explicitly, the import will be much faster,
+ * especially for large repositories.
+ *
+ * Note that Airbyte will still import tables that depend on these tables due
+ * to foreign keys, and will also import airbyte metaata tables.
+ */
+export const relevantGitHubTableNamesForImport = `stargazers
+commits
+comments
+pull_requests
+pull_request_stats
+issue_reactions`
+ .split("\n")
+ .filter((t) => !!t);
+
+export const splitgraphTablesToExportToSeafowl = [
+ "stargazers",
+ "stargazers_user",
+];
+
+export const useTablesToExport = ({
+ splitgraphNamespace,
+ splitgraphRepository,
+}: {
+ splitgraphNamespace: string;
+ splitgraphRepository: string;
+}) => {
+ return useMemo(
+ () =>
+ splitgraphTablesToExportToSeafowl.map((tableName) => ({
+ namespace: splitgraphNamespace,
+ repository: splitgraphRepository,
+ table: tableName,
+ })),
+ [
+ splitgraphNamespace,
+ splitgraphRepository,
+ splitgraphTablesToExportToSeafowl,
+ ]
+ );
+};
+
+/**
+ * List of "downstream" GitHub table names that will be imported by default by
+ * the `airbyte-github` connector, given the list of `relevantGitHubTableNamesForImport`,
+ * because they're either an Airbyte meta table or a table that depends on
+ * one of the "relevant" tables.
+ *
+ * This is manually curated and might not be totally accurate. It's up to date
+ * given the following list of `relevantGitHubTableNamesForImport`:
+ *
+ * ```
+ * commits
+ * comments
+ * pull_requests
+ * pull_request_stats
+ * issue_reactions
+ * ```
+ */
+export const expectedImportedTableNames = `_airbyte_raw_comments
+_airbyte_raw_commits
+_airbyte_raw_issue_reactions
+_airbyte_raw_pull_request_stats
+_airbyte_raw_pull_requests
+_sg_ingestion_state
+comments
+comments_user
+commits
+commits_author
+commits_commit
+commits_commit_author
+commits_commit_committer
+commits_commit_tree
+commits_commit_verification
+commits_committer
+commits_parents
+issue_reactions
+issue_reactions_user
+pull_request_stats
+pull_request_stats_merged_by
+pull_requests
+pull_requests__links
+pull_requests__links_comments
+pull_requests__links_commits
+pull_requests__links_html
+pull_requests__links_issue
+pull_requests__links_review_comment
+pull_requests__links_review_comments
+pull_requests__links_self
+pull_requests__links_statuses
+pull_requests_assignee
+pull_requests_assignees
+pull_requests_auto_merge
+pull_requests_auto_merge_enabled_by
+pull_requests_base
+pull_requests_head
+pull_requests_labels
+pull_requests_milestone
+pull_requests_milestone_creator
+pull_requests_requested_reviewers
+pull_requests_requested_teams
+pull_requests_user
+`;
+
+/**
+ * This is the list of all tables imported by Airbyte by default when no tables
+ * are explicitly provided to the plugin.
+ *
+ * This is not consumed anywhere, but is useful for referencing, and if you'd
+ * like to extend or modify the code, you can choose tables from here to include.
+ */
+export const allGitHubTableNames = `_airbyte_raw_assignees
+_airbyte_raw_branches
+_airbyte_raw_collaborators
+_airbyte_raw_comments
+_airbyte_raw_commit_comment_reactions
+_airbyte_raw_commit_comments
+_airbyte_raw_commits
+_airbyte_raw_deployments
+_airbyte_raw_events
+_airbyte_raw_issue_comment_reactions
+_airbyte_raw_issue_events
+_airbyte_raw_issue_labels
+_airbyte_raw_issue_milestones
+_airbyte_raw_issue_reactions
+_airbyte_raw_issues
+_airbyte_raw_organizations
+_airbyte_raw_project_cards
+_airbyte_raw_project_columns
+_airbyte_raw_projects
+_airbyte_raw_pull_request_comment_reactions
+_airbyte_raw_pull_request_commits
+_airbyte_raw_pull_request_stats
+_airbyte_raw_pull_requests
+_airbyte_raw_releases
+_airbyte_raw_repositories
+_airbyte_raw_review_comments
+_airbyte_raw_reviews
+_airbyte_raw_stargazers
+_airbyte_raw_tags
+_airbyte_raw_team_members
+_airbyte_raw_team_memberships
+_airbyte_raw_teams
+_airbyte_raw_users
+_airbyte_raw_workflow_jobs
+_airbyte_raw_workflow_runs
+_airbyte_raw_workflows
+_sg_ingestion_state
+assignees
+branches
+branches_commit
+branches_protection
+branches_protection_required_status_checks
+collaborators
+collaborators_permissions
+comments
+comments_user
+commit_comment_reactions
+commit_comment_reactions_user
+commit_comments
+commit_comments_user
+commits
+commits_author
+commits_commit
+commits_commit_author
+commits_commit_committer
+commits_commit_tree
+commits_commit_verification
+commits_committer
+commits_parents
+deployments
+deployments_creator
+events
+events_actor
+events_org
+events_repo
+issue_comment_reactions
+issue_comment_reactions_user
+issue_events
+issue_events_actor
+issue_events_issue
+issue_events_issue_user
+issue_labels
+issue_milestones
+issue_milestones_creator
+issue_reactions
+issue_reactions_user
+issues
+issues_assignee
+issues_assignees
+issues_labels
+issues_milestone
+issues_milestone_creator
+issues_pull_request
+issues_user
+organizations
+organizations_plan
+project_cards
+project_cards_creator
+project_columns
+projects
+projects_creator
+pull_request_comment_reactions
+pull_request_comment_reactions_user
+pull_request_commits
+pull_request_commits_author
+pull_request_commits_commit
+pull_request_commits_commit_author
+pull_request_commits_commit_committer
+pull_request_commits_commit_tree
+pull_request_commits_commit_verification
+pull_request_commits_committer
+pull_request_commits_parents
+pull_request_stats
+pull_request_stats_merged_by
+pull_requests
+pull_requests__links
+pull_requests__links_comments
+pull_requests__links_commits
+pull_requests__links_html
+pull_requests__links_issue
+pull_requests__links_review_comment
+pull_requests__links_review_comments
+pull_requests__links_self
+pull_requests__links_statuses
+pull_requests_assignee
+pull_requests_assignees
+pull_requests_auto_merge
+pull_requests_auto_merge_enabled_by
+pull_requests_base
+pull_requests_head
+pull_requests_labels
+pull_requests_milestone
+pull_requests_milestone_creator
+pull_requests_requested_reviewers
+pull_requests_requested_teams
+pull_requests_user
+releases
+releases_assets
+releases_author
+repositories
+repositories_license
+repositories_owner
+repositories_permissions
+review_comments
+review_comments__links
+review_comments__links_html
+review_comments__links_pull_request
+review_comments__links_self
+review_comments_user
+reviews
+reviews__links
+reviews__links_html
+reviews__links_pull_request
+reviews_user
+stargazers
+stargazers_user
+tags
+tags_commit
+team_members
+team_memberships
+teams
+users
+workflow_jobs
+workflow_jobs_steps
+workflow_runs
+workflow_runs_head_commit
+workflow_runs_head_commit_author
+workflow_runs_head_commit_committer
+workflow_runs_head_repository
+workflow_runs_head_repository_owner
+workflow_runs_repository
+workflow_runs_repository_owner
+workflows`;
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/queries-to-export.ts b/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/queries-to-export.ts
new file mode 100644
index 0000000..3b26679
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/lib/config/queries-to-export.ts
@@ -0,0 +1,224 @@
+import { useMemo } from "react";
+import type { ExportQueryInput } from "../../types";
+
+/**
+ * Return a a list of queries to export from Splitgraph to Seafowl, given the
+ * source repository (where the GitHub data was imported into), and the destination
+ * schema (where the data will be exported to at Seafowl).
+ */
+export const makeQueriesToExport = ({
+ splitgraphSourceRepository,
+ splitgraphSourceNamespace,
+ seafowlDestinationSchema,
+ splitgraphSourceImageHashOrTag = "latest",
+}: {
+ splitgraphSourceNamespace: string;
+ splitgraphSourceRepository: string;
+ seafowlDestinationSchema: string;
+ splitgraphSourceImageHashOrTag?: string;
+}): {
+ sourceQuery: string;
+ destinationSchema: string;
+ destinationTable: string;
+ /**
+ * Optionally provide a DDL query to create the (empty) destination table in
+ * case the export from Splitgraph fails. This is a workaround of a bug where
+ * exports from Splitgraph to Seafowl fail if the destination table does not
+ * contain any rows. See: https://github.com/splitgraph/seafowl/issues/423
+ *
+ * This way, even if a table fails to load, we can at least reference it in subsequent
+ * analytics queries without challenges like conditionally checking if it exists.
+ */
+ fallbackCreateTableQuery?: string;
+}[] => [
+ {
+ destinationSchema: seafowlDestinationSchema,
+ destinationTable: "simple_stargazers_query",
+ sourceQuery: `
+SELECT * FROM "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}".stargazers`,
+ },
+
+ {
+ destinationSchema: seafowlDestinationSchema,
+ destinationTable: "monthly_user_stats",
+ sourceQuery: `
+WITH
+
+commits AS (
+ SELECT
+ date_trunc('month', created_at) AS created_at_month,
+ author->>'login' AS username,
+ count(*) as no_commits
+ FROM "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}".commits
+ GROUP BY 1, 2
+),
+
+comments AS (
+ SELECT
+ date_trunc('month', created_at) AS created_at_month,
+ "user"->>'login' AS username,
+ count(*) filter (where exists(select regexp_matches(issue_url, '.*/pull/.*'))) as no_pull_request_comments,
+ count(*) filter (where exists(select regexp_matches(issue_url, '.*/issue/.*'))) as no_issue_comments,
+ sum(length(body)) as total_comment_length
+ FROM "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}".comments
+ GROUP BY 1, 2
+),
+
+pull_requests AS (
+ WITH pull_request_creator AS (
+ SELECT id, "user"->>'login' AS username
+ FROM "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}".pull_requests
+ )
+
+ SELECT
+ date_trunc('month', updated_at) AS created_at_month,
+ username,
+ count(*) filter (where merged = true) AS merged_pull_requests,
+ count(*) AS total_pull_requests,
+ sum(additions::integer) filter (where merged = true) AS lines_added,
+ sum(deletions::integer) filter (where merged = true) AS lines_deleted
+ FROM "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}".pull_request_stats
+ INNER JOIN pull_request_creator USING (id)
+ GROUP BY 1, 2
+),
+
+all_months_users AS (
+ SELECT DISTINCT created_at_month, username FROM commits
+ UNION SELECT DISTINCT created_at_month, username FROM comments
+ UNION SELECT DISTINCT created_at_month, username FROM pull_requests
+),
+
+user_stats AS (
+ SELECT
+ amu.created_at_month,
+ amu.username,
+ COALESCE(cmt.no_commits, 0) AS no_commits,
+ COALESCE(cmnt.no_pull_request_comments, 0) AS no_pull_request_comments,
+ COALESCE(cmnt.no_issue_comments, 0) AS no_issue_comments,
+ COALESCE(cmnt.total_comment_length, 0) AS total_comment_length,
+ COALESCE(pr.merged_pull_requests, 0) AS merged_pull_requests,
+ COALESCE(pr.total_pull_requests, 0) AS total_pull_requests,
+ COALESCE(pr.lines_added, 0) AS lines_added,
+ COALESCE(pr.lines_deleted, 0) AS lines_deleted
+
+ FROM all_months_users amu
+ LEFT JOIN commits cmt ON amu.created_at_month = cmt.created_at_month AND amu.username = cmt.username
+ LEFT JOIN comments cmnt ON amu.created_at_month = cmnt.created_at_month AND amu.username = cmnt.username
+ LEFT JOIN pull_requests pr ON amu.created_at_month = pr.created_at_month AND amu.username = pr.username
+
+ ORDER BY created_at_month ASC, username ASC
+)
+
+SELECT * FROM user_stats;
+`,
+ fallbackCreateTableQuery: `
+CREATE TABLE "${seafowlDestinationSchema}".monthly_user_stats (
+ created_at_month TIMESTAMP,
+ username VARCHAR,
+ no_commits BIGINT,
+ no_pull_request_comments BIGINT,
+ no_issue_comments BIGINT,
+ total_comment_length BIGINT,
+ merged_pull_requests BIGINT,
+ total_pull_requests BIGINT,
+ lines_added BIGINT,
+ lines_deleted BIGINT
+);
+ `,
+ },
+ {
+ destinationSchema: seafowlDestinationSchema,
+ destinationTable: "monthly_issue_stats",
+ sourceQuery: `
+SELECT
+ issue_number,
+ date_trunc('month', created_at::TIMESTAMP) as created_at_month,
+ COUNT(*) AS total_reacts,
+ COUNT(*) FILTER (WHERE content = '+1') AS no_plus_one,
+ COUNT(*) FILTER (WHERE content = '-1') AS no_minus_one,
+ COUNT(*) FILTER (WHERE content = 'laugh') AS no_laugh,
+ COUNT(*) FILTER (WHERE content = 'confused') AS no_confused,
+ COUNT(*) FILTER (WHERE content = 'heart') AS no_heart,
+ COUNT(*) FILTER (WHERE content = 'hooray') AS no_hooray,
+ COUNT(*) FILTER (WHERE content = 'rocket') AS no_rocket,
+ COUNT(*) FILTER (WHERE content = 'eyes') AS no_eyes
+FROM
+ "${splitgraphSourceNamespace}/${splitgraphSourceRepository}:${splitgraphSourceImageHashOrTag}"."issue_reactions"
+GROUP BY 1, 2 ORDER BY 2, 3 DESC;
+`,
+ fallbackCreateTableQuery: `
+CREATE TABLE "${seafowlDestinationSchema}".monthly_issue_stats (
+ issue_number BIGINT,
+ created_at_month TIMESTAMP,
+ total_reacts BIGINT,
+ no_plus_one BIGINT,
+ no_minus_one BIGINT,
+ no_laugh BIGINT,
+ no_confused BIGINT,
+ no_heart BIGINT,
+ no_hooray BIGINT,
+ no_rocket BIGINT,
+ no_eyes BIGINT
+);
+`,
+ },
+];
+
+export const useQueriesToExport = ({
+ splitgraphNamespace,
+ splitgraphRepository,
+}: {
+ splitgraphNamespace: string;
+ splitgraphRepository: string;
+}) => {
+ return useMemo(
+ () =>
+ makeQueriesToExport({
+ splitgraphSourceRepository: splitgraphRepository,
+ splitgraphSourceNamespace: splitgraphNamespace,
+ seafowlDestinationSchema: `${splitgraphNamespace}/${splitgraphRepository}`,
+ }),
+ [splitgraphRepository, splitgraphNamespace]
+ );
+};
+
+/** A generic demo query that can be used to show off Splitgraph */
+export const genericDemoQuery = `WITH t (
+ c_int16_smallint,
+ c_int32_int,
+ c_int64_bigint,
+ c_utf8_char,
+ c_utf8_varchar,
+ c_utf8_text,
+ c_float32_float,
+ c_float32_real,
+ c_boolean_boolean,
+ c_date32_date,
+ c_timestamp_microseconds_timestamp
+
+ ) AS (
+ VALUES(
+ /* Int16 / SMALLINT */
+ 42::SMALLINT,
+ /* Int32 / INT */
+ 99::INT,
+ /* Int64 / BIGINT */
+ 420420::BIGINT,
+ /* Utf8 / CHAR */
+ 'x'::CHAR,
+ /* Utf8 / VARCHAR */
+ 'abcdefghijklmnopqrstuvwxyz'::VARCHAR,
+ /* Utf8 / TEXT */
+ 'zyxwvutsrqponmlkjihgfedcba'::TEXT,
+ /* Float32 / FLOAT */
+ 4.4::FLOAT,
+ /* Float32 / REAL */
+ 2.0::REAL,
+ /* Boolean / BOOLEAN */
+ 't'::BOOLEAN,
+ /* Date32 / DATE */
+ '1997-06-17'::DATE,
+ /* Timestamp(us) / TIMESTAMP */
+ '2018-11-11T11:11:11.111111111'::TIMESTAMP
+ )
+ ) SELECT * FROM t;`;
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/lib/util.ts b/examples/nextjs-import-airbyte-github-export-seafowl/lib/util.ts
new file mode 100644
index 0000000..e908061
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/lib/util.ts
@@ -0,0 +1,36 @@
+import { useRouter } from "next/router";
+import type { ParsedUrlQuery } from "querystring";
+
+export const useDebug = () => {
+ const { query } = useRouter();
+
+ return query.debug;
+};
+
+export const getQueryParamAsString = (
+ query: ParsedUrlQuery,
+ key: string
+): T | null => {
+ if (Array.isArray(query[key]) && query[key].length > 0) {
+ throw new Error(`expected only one query param but got multiple: ${key}`);
+ }
+
+ if (!(key in query)) {
+ return null;
+ }
+
+ return query[key] as T;
+};
+
+export const requireKeys = >(
+ obj: T,
+ requiredKeys: (keyof T)[]
+) => {
+ const missingKeys = requiredKeys.filter(
+ (requiredKey) => !(requiredKey in obj)
+ );
+
+ if (missingKeys.length > 0) {
+ throw new Error("missing required keys: " + missingKeys.join(", "));
+ }
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/next-env.d.ts b/examples/nextjs-import-airbyte-github-export-seafowl/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/next.config.js b/examples/nextjs-import-airbyte-github-export-seafowl/next.config.js
new file mode 100644
index 0000000..ac7ae64
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/next.config.js
@@ -0,0 +1,33 @@
+const { ProxyAgent, setGlobalDispatcher } = require("undici");
+
+// If running `yarn dev-mitm`, then setup the proxy with MITMPROXY_ADDRESS
+// NOTE(FIXME): not all madatdata requests get sent through here for some reason
+const setupProxy = () => {
+ if (!process.env.MITMPROXY_ADDRESS) {
+ return;
+ }
+
+ const MITM = process.env.MITMPROXY_ADDRESS;
+
+ console.log("MITM SETUP:", MITM);
+
+ if (!process.env.GLOBAL_AGENT_HTTP_PROXY) {
+ process.env["GLOBAL_AGENT_HTTP_PROXY"] = MITM;
+ }
+
+ process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
+
+ const mitmProxyOpts = {
+ uri: MITM,
+ connect: {
+ rejectUnauthorized: false,
+ requestCert: false,
+ },
+ };
+
+ setGlobalDispatcher(new ProxyAgent(mitmProxyOpts));
+};
+
+setupProxy();
+
+module.exports = {};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/package.json b/examples/nextjs-import-airbyte-github-export-seafowl/package.json
new file mode 100644
index 0000000..9add33f
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/package.json
@@ -0,0 +1,22 @@
+{
+ "private": true,
+ "scripts": {
+ "dev": "yarn next",
+ "dev-mitm": "MITMPROXY_ADDRESS=http://localhost:7979 yarn next",
+ "build": "yarn next build",
+ "start": "yarn next start"
+ },
+ "dependencies": {
+ "@madatdata/core": "latest",
+ "@madatdata/react": "latest",
+ "@observablehq/plot": "0.6.7",
+ "next": "latest",
+ "react": "18.2.0",
+ "react-dom": "18.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^18.0.0",
+ "@types/react": "^18.0.14",
+ "typescript": "^4.7.4"
+ }
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/[github_namespace]/[github_repository].tsx b/examples/nextjs-import-airbyte-github-export-seafowl/pages/[github_namespace]/[github_repository].tsx
new file mode 100644
index 0000000..6d8bfa4
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/[github_namespace]/[github_repository].tsx
@@ -0,0 +1,164 @@
+import { BaseLayout } from "../../components/BaseLayout";
+
+import { Sidebar } from "../../components/Sidebar";
+import { Charts } from "../../components/RepositoryAnalytics/Charts";
+import { useRouter } from "next/router";
+
+import type { ImportedRepository } from "../../types";
+
+import { ImportedRepoMetadata } from "../../components/RepositoryAnalytics/ImportedRepoMetadata";
+import { useCallback, useMemo } from "react";
+
+import {
+ EmbeddedQueryPreviews,
+ EmbeddedTablePreviews,
+ EmbeddedQueryPreviewHeadingAndDescription,
+ EmbeddedTablePreviewHeadingAndDescription,
+} from "../../components/EmbeddedQuery/EmbeddedPreviews";
+
+import { useQueriesToExport } from "../../lib/config/queries-to-export";
+import { useTablesToExport } from "../../lib/config/github-tables";
+import { getQueryParamAsString } from "../../lib/util";
+import { TabButton } from "../../components/EmbeddedQuery/TabButton";
+
+type ActiveTab = "charts" | "tables" | "queries";
+
+const useActiveTab = (defaultTab: ActiveTab) => {
+ const router = useRouter();
+
+ const activeTab =
+ getQueryParamAsString(router.query, "activeTab") ?? defaultTab;
+
+ const switchTab = useCallback(
+ (nextTab: ActiveTab) => {
+ if (nextTab === activeTab) {
+ return;
+ }
+
+ return router.push({
+ pathname: router.pathname,
+ query: {
+ ...router.query,
+ activeTab: nextTab,
+ },
+ });
+ },
+ [router.query]
+ );
+
+ return {
+ activeTab,
+ switchTab,
+ };
+};
+
+const useImportedRepoFromURL = () => {
+ const { query } = useRouter();
+
+ const queryParams = useMemo(
+ () =>
+ (
+ [
+ ["github_namespace", "githubNamespace"],
+ ["github_repository", "githubRepository"],
+ ["splitgraphNamespace", "splitgraphNamespace"],
+ ["splitgraphRepository", "splitgraphRepository"],
+ ] as [string, keyof ImportedRepository][]
+ ).reduce((parsedQueryParams, [queryParam, repoKey]) => {
+ if (!query[queryParam] || Array.isArray(query[queryParam])) {
+ // throw new Error(
+ // `Invalid query params: unexpected type of ${queryParam}: ${query[queryParam]}}`
+ // );
+ return parsedQueryParams;
+ }
+
+ return {
+ ...parsedQueryParams,
+ [repoKey]: query[queryParam] as string,
+ };
+ }, {} as ImportedRepository),
+ [query]
+ );
+
+ return queryParams;
+};
+
+const RepositoryAnalyticsPage = () => {
+ const importedRepository = useImportedRepoFromURL();
+
+ const tablesToExport = useTablesToExport(importedRepository);
+ const queriesToExport = useQueriesToExport(importedRepository);
+
+ const { activeTab, switchTab } = useActiveTab("charts");
+
+ return (
+ }>
+
+
+
+
+
+
+
+
+
+ ({ loading: false, completed: true })}
+ tablesToExport={tablesToExport}
+ splitgraphRepository={importedRepository.splitgraphRepository}
+ splitgraphNamespace={importedRepository.splitgraphNamespace}
+ />
+
+
+
+
+ ({ loading: false, completed: true })}
+ queriesToExport={queriesToExport}
+ splitgraphRepository={importedRepository.splitgraphRepository}
+ splitgraphNamespace={importedRepository.splitgraphNamespace}
+ />
+
+
+ );
+};
+
+const TabPane = ({ active, children }: { active: boolean; children: any }) => {
+ return {children}
;
+};
+
+const PageTabs = ({
+ activeTab,
+ switchTab,
+}: ReturnType) => {
+ return (
+
+ switchTab("charts")}
+ style={{ marginRight: "1rem" }}
+ size="1.5rem"
+ >
+ Charts
+
+ switchTab("tables")}
+ style={{ marginRight: "1rem" }}
+ size="1.5rem"
+ >
+ Raw Tables
+
+ switchTab("queries")}
+ style={{ marginRight: "1rem" }}
+ size="1.5rem"
+ >
+ Raw Queries
+
+
+ );
+};
+
+export default RepositoryAnalyticsPage;
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/_app.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/pages/_app.tsx
new file mode 100644
index 0000000..2ae46c6
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/_app.tsx
@@ -0,0 +1,8 @@
+import type { AppProps } from "next/app";
+
+import "../components/global-styles/reset.css";
+import "../components/global-styles/theme.css";
+
+export default function GitHubAnalyticsApp({ Component, pageProps }: AppProps) {
+ return ;
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-export-to-seafowl-task.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-export-to-seafowl-task.ts
new file mode 100644
index 0000000..61a7ae1
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-export-to-seafowl-task.ts
@@ -0,0 +1,76 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { makeAuthenticatedSplitgraphDb } from "../../lib/backend/splitgraph-db";
+import type { DeferredSplitgraphExportTask } from "@madatdata/db-splitgraph/plugins/exporters/splitgraph-base-export-plugin";
+
+type ResponseData =
+ | {
+ completed: boolean;
+ jobStatus: DeferredSplitgraphExportTask["response"];
+ }
+ | { error: string; completed: false };
+
+/**
+ * To manually send a request, example:
+
+```bash
+curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/await-export-to-seafowl-task \
+ -d '{ "taskId": "2923fd6f-2197-495a-9df1-2428a9ca8dee" }'
+```
+ */
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (!req.body["taskId"]) {
+ res.status(400).json({
+ error: "Missing required key: taskId",
+ completed: false,
+ });
+ return;
+ }
+
+ const { taskId } = req.body;
+
+ try {
+ const maybeCompletedTask = await pollImport({
+ splitgraphTaskId: taskId,
+ });
+
+ if (maybeCompletedTask.error) {
+ throw new Error(JSON.stringify(maybeCompletedTask.error));
+ }
+
+ res.status(200).json(maybeCompletedTask);
+ return;
+ } catch (err) {
+ res.status(400).json({
+ error: err.message,
+ completed: false,
+ });
+ return;
+ }
+}
+
+const pollImport = async ({
+ splitgraphTaskId,
+}: {
+ splitgraphTaskId: string;
+}) => {
+ const db = makeAuthenticatedSplitgraphDb();
+
+ // NOTE: We must call this, or else requests will fail silently
+ await db.fetchAccessToken();
+
+ const maybeCompletedTask = (await db.pollDeferredTask("export-to-seafowl", {
+ taskId: splitgraphTaskId,
+ })) as DeferredSplitgraphExportTask;
+
+ // NOTE: We do not include the jobLog, in case it could leak the GitHub PAT
+ // (remember we're using our PAT on behalf of the users of this app)
+ return {
+ completed: maybeCompletedTask?.completed ?? false,
+ jobStatus: maybeCompletedTask?.response,
+ error: maybeCompletedTask?.error ?? undefined,
+ };
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-import-from-github.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-import-from-github.ts
new file mode 100644
index 0000000..4563044
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/await-import-from-github.ts
@@ -0,0 +1,89 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { makeAuthenticatedSplitgraphDb } from "../../lib/backend/splitgraph-db";
+import type { DeferredSplitgraphImportTask } from "@madatdata/db-splitgraph/plugins/importers/splitgraph-base-import-plugin";
+
+type ResponseData =
+ | {
+ completed: boolean;
+ jobStatus: DeferredSplitgraphImportTask["response"]["jobStatus"];
+ }
+ | { error: string; completed: false };
+
+/**
+ * To manually send a request, example:
+
+```bash
+curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/await-import-from-github \
+ -d '{ "taskId": "xxxx", "splitgraphNamespace": "xxx", "splitgraphRepo": "yyy" }'
+```
+ */
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const missing = [
+ "taskId",
+ "splitgraphNamespace",
+ "splitgraphRepository",
+ ].filter((expKey) => !req.body[expKey]);
+ if (missing.length > 0) {
+ res.status(400).json({
+ error: `Missing required keys: ${missing.join(", ")}`,
+ completed: false,
+ });
+ return;
+ }
+
+ const { taskId, splitgraphNamespace, splitgraphRepository } = req.body;
+
+ try {
+ const maybeCompletedTask = await pollImport({
+ splitgraphTaskId: taskId,
+ splitgraphDestinationNamespace: splitgraphNamespace,
+ splitgraphDestinationRepository: splitgraphRepository,
+ });
+
+ if (maybeCompletedTask.error) {
+ throw new Error(JSON.stringify(maybeCompletedTask.error));
+ }
+
+ res.status(200).json(maybeCompletedTask);
+ return;
+ } catch (err) {
+ res.status(400).json({
+ error: err.message,
+ completed: false,
+ });
+ return;
+ }
+}
+
+const pollImport = async ({
+ splitgraphTaskId,
+ splitgraphDestinationNamespace,
+ splitgraphDestinationRepository,
+}: {
+ splitgraphDestinationNamespace: string;
+ splitgraphDestinationRepository: string;
+ splitgraphTaskId: string;
+}) => {
+ const db = makeAuthenticatedSplitgraphDb();
+
+ // NOTE: We must call this, or else requests will fail silently
+ await db.fetchAccessToken();
+
+ const maybeCompletedTask = (await db.pollDeferredTask("csv", {
+ taskId: splitgraphTaskId,
+ namespace: splitgraphDestinationNamespace,
+ repository: splitgraphDestinationRepository,
+ })) as DeferredSplitgraphImportTask;
+
+ // NOTE: We do not include the jobLog, in case it could leak the GitHub PAT
+ // (remember we're using our PAT on behalf of the users of this app)
+ return {
+ completed: maybeCompletedTask?.completed ?? false,
+ jobStatus: maybeCompletedTask?.response.jobStatus,
+ error: maybeCompletedTask?.error ?? undefined,
+ };
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/create-fallback-table-after-failed-export.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/create-fallback-table-after-failed-export.ts
new file mode 100644
index 0000000..9a2340a
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/create-fallback-table-after-failed-export.ts
@@ -0,0 +1,85 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { makeAuthenticatedSeafowlHTTPContext } from "../../lib/backend/seafowl-db";
+import type {
+ CreateFallbackTableForFailedExportRequestShape,
+ CreateFallbackTableForFailedExportResponseData,
+} from "../../types";
+
+/**
+ curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/create-fallback-table-after-failed-export \
+ -d '{ "taskId": "2923fd6f-2197-495a-9df1-2428a9ca8dee" }'
+ */
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const { fallbackCreateTableQuery, destinationSchema, destinationTable } =
+ req.body as CreateFallbackTableForFailedExportRequestShape;
+
+ const errors = [];
+
+ if (!fallbackCreateTableQuery) {
+ errors.push("missing fallbackCreateTableQuery in request body");
+ }
+
+ if (!destinationSchema) {
+ errors.push("missing destinationSchema in request body");
+ }
+
+ if (!destinationTable) {
+ errors.push("missing destinationTable in request body");
+ }
+
+ if (typeof fallbackCreateTableQuery !== "string") {
+ errors.push("invalid fallbackCreateTableQuery in request body");
+ }
+
+ if (typeof destinationSchema !== "string") {
+ errors.push("invalid destinationSchema in request body");
+ }
+
+ if (!fallbackCreateTableQuery.includes(destinationSchema)) {
+ errors.push("fallbackCreateTableQuery must include destinationSchema");
+ }
+
+ if (typeof destinationTable !== "string") {
+ errors.push("invalid destinationTable in request body");
+ }
+
+ if (!fallbackCreateTableQuery.includes(destinationTable)) {
+ errors.push("fallbackCreateTableQuery must include destinationTable");
+ }
+
+ if (errors.length > 0) {
+ res.status(400).json({ error: errors.join(", "), success: false });
+ return;
+ }
+
+ try {
+ await createFallbackTable(
+ req.body as CreateFallbackTableForFailedExportRequestShape
+ );
+ res.status(200).json({ success: true });
+ return;
+ } catch (err) {
+ console.trace(err);
+ res.status(400).json({ error: err.message, success: false });
+ }
+}
+
+const createFallbackTable = async ({
+ fallbackCreateTableQuery,
+ destinationTable,
+ destinationSchema,
+}: CreateFallbackTableForFailedExportRequestShape) => {
+ const { client } = makeAuthenticatedSeafowlHTTPContext();
+
+ // NOTE: client.execute should never throw (on error it returns an object including .error)
+ // i.e. Even if the table doesn't exist, or if the schema already existed, we don't need to try/catch
+ await client.execute(
+ `DROP TABLE IF EXISTS "${destinationSchema}"."${destinationTable}";`
+ );
+ await client.execute(`CREATE SCHEMA "${destinationSchema}";`);
+ await client.execute(fallbackCreateTableQuery);
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/mark-import-export-complete.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/mark-import-export-complete.ts
new file mode 100644
index 0000000..de85325
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/mark-import-export-complete.ts
@@ -0,0 +1,171 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import {
+ makeAuthenticatedSplitgraphHTTPContext,
+ claimsFromJWT,
+} from "../../lib/backend/splitgraph-db";
+
+export type MarkImportExportCompleteRequestShape = {
+ githubSourceNamespace: string;
+ githubSourceRepository: string;
+ splitgraphDestinationRepository: string;
+};
+
+export type MarkImportExportCompleteSuccessResponse = {
+ status: "inserted";
+};
+
+export type MarkImportExportCompleteResponseData =
+ | MarkImportExportCompleteSuccessResponse
+ | { error: string };
+
+const META_NAMESPACE =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE;
+const META_REPOSITORY =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY;
+const META_TABLE =
+ process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_COMPLETED_TABLE;
+
+/**
+ * To manually send a request, example:
+
+```bash
+curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/mark-import-export-complete \
+ -d@- <
+) {
+ if (!META_NAMESPACE) {
+ res.status(400).json({
+ error:
+ "Missing env var: NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE " +
+ "Is it in .env.local or Vercel secrets?",
+ });
+ return;
+ }
+
+ if (!META_REPOSITORY) {
+ res.status(400).json({
+ error:
+ "Missing env var: NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_REPOSITORY " +
+ "Is it in .env or Vercel environment variables?",
+ });
+ return;
+ }
+
+ const missingOrInvalidKeys = [
+ "githubSourceNamespace",
+ "githubSourceRepository",
+ "splitgraphDestinationRepository",
+ ].filter(
+ (requiredKey) =>
+ !(requiredKey in req.body) ||
+ typeof req.body[requiredKey] !== "string" ||
+ !req.body[requiredKey] ||
+ !isSQLSafe(req.body[requiredKey])
+ );
+
+ if (missingOrInvalidKeys.length > 0) {
+ res.status(400).json({
+ error: `missing, non-string, empty or invalid keys: ${missingOrInvalidKeys.join(
+ ", "
+ )}`,
+ });
+ return;
+ }
+
+ try {
+ const { status } = await markImportExportAsComplete({
+ githubSourceNamespace: req.body.githubSourceNamespace,
+ githubSourceRepository: req.body.githubSourceRepository,
+ splitgraphDestinationRepository: req.body.splitgraphDestinationRepository,
+ });
+
+ if (status === "already exists") {
+ res.status(204).end();
+ return;
+ }
+
+ res.status(200).json({ status });
+ return;
+ } catch (err) {
+ res.status(400).json({ error: err });
+ return;
+ }
+}
+
+/**
+ * NOTE: We assume that this table already exists. If it does not exist, you can
+ * create it manually with a query like this in https://www.splitgraph.com/query :
+ *
+ * ```sql
+CREATE TABLE IF NOT EXISTS "miles/github-analytics-metadata".completed_repositories (
+ github_namespace VARCHAR NOT NULL,
+ github_repository VARCHAR NOT NULL,
+ splitgraph_namespace VARCHAR NOT NULL,
+ splitgraph_repository VARCHAR NOT NULL,
+ completed_at TIMESTAMP NOT NULL
+);
+```
+ */
+const markImportExportAsComplete = async ({
+ splitgraphDestinationRepository,
+ githubSourceNamespace,
+ githubSourceRepository,
+}: MarkImportExportCompleteRequestShape): Promise<{
+ status: "already exists" | "inserted";
+}> => {
+ const { db, client } = makeAuthenticatedSplitgraphHTTPContext();
+ const { username } = claimsFromJWT((await db.fetchAccessToken()).token);
+
+ // NOTE: We also assume that META_NAMESPACE is the same as destination namespace
+ if (!username || username !== META_NAMESPACE) {
+ throw new Error(
+ "Authenticated user does not match NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE"
+ );
+ }
+
+ // We don't want to insert the row if it already exists
+ // Note that Splitgraph doesn't support constraints so we can't use INSERT ON CONFLICT
+
+ const existingRows = await client.execute(`
+ SELECT splitgraph_repository FROM "${META_NAMESPACE}/${META_REPOSITORY}"."${META_TABLE}"
+ WHERE github_namespace = '${githubSourceNamespace}'
+ AND github_repository = '${githubSourceRepository}'
+ AND splitgraph_namespace = '${META_NAMESPACE}'
+ AND splitgraph_repository = '${splitgraphDestinationRepository}';
+ `);
+
+ if (existingRows.response && existingRows.response.rows.length > 0) {
+ return { status: "already exists" };
+ }
+
+ await client.execute(`INSERT INTO "${META_NAMESPACE}/${META_REPOSITORY}"."${META_TABLE}" (
+ github_namespace,
+ github_repository,
+ splitgraph_namespace,
+ splitgraph_repository,
+ completed_at
+) VALUES (
+ '${githubSourceNamespace}',
+ '${githubSourceRepository}',
+ '${META_NAMESPACE}',
+ '${splitgraphDestinationRepository}',
+ NOW()
+);`);
+
+ return { status: "inserted" };
+};
+
+/**
+ * Return `false` if the string contains any character other than alphanumeric,
+ * `-`, `_`, or `.`
+ */
+const isSQLSafe = (str: string) => !/[^a-z0-9\-_\.]/.test(str);
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-export-to-seafowl.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-export-to-seafowl.ts
new file mode 100644
index 0000000..5b856d3
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-export-to-seafowl.ts
@@ -0,0 +1,168 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { makeAuthenticatedSplitgraphDb } from "../../lib/backend/splitgraph-db";
+
+import type {
+ ExportTableInput,
+ ExportQueryInput,
+ StartExportToSeafowlRequestShape,
+ StartExportToSeafowlResponseData,
+} from "../../types";
+
+/**
+ * To manually send a request, example:
+
+```bash
+curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/start-export-to-seafowl \
+ -d '{ "tables": [{"namespace": "miles", "repository": "import-via-nextjs", "table": "stargazers"}] }'
+```
+ */
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const db = makeAuthenticatedSplitgraphDb();
+ const { tables = [], queries = [] } = req.body;
+
+ if (tables.length === 0 && queries.length === 0) {
+ res.status(400).json({ error: "no tables or queries provided for export" });
+ return;
+ }
+
+ const errors = [];
+
+ if (
+ tables.length > 0 &&
+ !tables.every(
+ (t: ExportTableInput) =>
+ t.namespace &&
+ t.repository &&
+ t.table &&
+ typeof t.namespace === "string" &&
+ typeof t.repository === "string" &&
+ typeof t.table === "string"
+ )
+ ) {
+ errors.push("invalid tables input in request body");
+ }
+
+ if (
+ queries.length > 0 &&
+ !queries.every(
+ (q: ExportQueryInput) =>
+ q.sourceQuery &&
+ q.destinationSchema &&
+ q.destinationTable &&
+ typeof q.sourceQuery === "string" &&
+ typeof q.destinationSchema === "string" &&
+ typeof q.destinationTable === "string"
+ )
+ ) {
+ errors.push("invalid queries input in request body");
+ }
+
+ if (errors.length > 0) {
+ res.status(400).json({ error: `Invalid request: ${errors.join(", ")}` });
+ return;
+ }
+
+ try {
+ const { tables: exportingTables, queries: exportingQueries } =
+ await startExport({
+ db,
+ tables,
+ queries,
+ });
+ res.status(200).json({
+ tables: exportingTables,
+ queries: exportingQueries,
+ });
+ } catch (err) {
+ res.status(400).json({
+ error: err.message,
+ });
+ }
+}
+
+const startExport = async ({
+ db,
+ tables,
+ queries,
+}: {
+ db: ReturnType;
+ tables: ExportTableInput[];
+ queries: ExportQueryInput[];
+}) => {
+ await db.fetchAccessToken();
+
+ const response = await db.exportData(
+ "export-to-seafowl",
+ {
+ queries: queries.map((query) => ({
+ source: {
+ query: query.sourceQuery,
+ },
+ destination: {
+ schema: query.destinationSchema,
+ table: query.destinationTable,
+ },
+ })),
+ tables: tables.map((splitgraphSource) => ({
+ source: {
+ repository: splitgraphSource.repository,
+ namespace: splitgraphSource.namespace,
+ table: splitgraphSource.table,
+ },
+ })),
+ },
+ {
+ // Empty instance will trigger Splitgraph to export to demo.seafowl.cloud
+ seafowlInstance: {},
+ },
+ { defer: true }
+ );
+
+ if (response.error) {
+ throw new Error(JSON.stringify(response.error));
+ }
+
+ const loadingTableTaskId =
+ response.taskIds.tables.length === 1
+ ? response.taskIds.tables[0].jobId
+ : null;
+
+ // NOTE: We return a list of tables, and duplicate the taskId in each of them,
+ // even though there is only one taskId for all tables. It's up to the frontend
+ // how many requests it wants to send when polling for status updates.
+ const loadingTables = loadingTableTaskId
+ ? response.taskIds.tables[0].tables.map(
+ (t: { sourceTable: string; sourceRepository: string }) => ({
+ taskId: loadingTableTaskId,
+ destinationTable: t.sourceTable,
+ destinationSchema: t.sourceRepository,
+ })
+ )
+ : [];
+
+ const loadingQueries = response.taskIds.queries.map(
+ (
+ queryJob: {
+ jobId: string;
+ destinationSchema: string;
+ destinationTable: string;
+ sourceQuery: string;
+ },
+ i: number
+ ) => ({
+ taskId: queryJob.jobId,
+ destinationSchema: queries[i].destinationSchema,
+ destinationTable: queries[i].destinationTable,
+ sourceQuery: queries[i].sourceQuery,
+ })
+ );
+
+ return {
+ tables: loadingTables,
+ queries: loadingQueries,
+ };
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-import-from-github.ts b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-import-from-github.ts
new file mode 100644
index 0000000..47bbea3
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/api/start-import-from-github.ts
@@ -0,0 +1,116 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import {
+ makeAuthenticatedSplitgraphDb,
+ claimsFromJWT,
+} from "../../lib/backend/splitgraph-db";
+import { relevantGitHubTableNamesForImport } from "../../lib/config/github-tables";
+
+const GITHUB_PAT_SECRET = process.env.GITHUB_PAT_SECRET;
+
+type ResponseData =
+ | {
+ destination: {
+ splitgraphNamespace: string;
+ splitgraphRepository: string;
+ };
+ taskId: string;
+ }
+ | { error: string };
+
+/**
+ * To manually send a request, example:
+
+```bash
+curl -i \
+ -H "Content-Type: application/json" http://localhost:3000/api/start-import-from-github \
+ -d '{ "githubSourceRepository": "splitgraph/seafowl", "splitgraphDestinationRepository": "import-via-nextjs" }'
+```
+ */
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const db = makeAuthenticatedSplitgraphDb();
+ const { username } = claimsFromJWT((await db.fetchAccessToken()).token);
+
+ const { githubSourceRepository } = req.body;
+
+ if (!githubSourceRepository) {
+ res.status(400).json({ error: "githubSourceRepository is required" });
+ return;
+ }
+
+ const splitgraphDestinationRepository =
+ req.body.splitgraphDestinationRepository ??
+ `github-import-${githubSourceRepository.replaceAll("/", "-")}`;
+
+ try {
+ const taskId = await startImport({
+ db,
+ githubSourceRepository,
+ splitgraphDestinationRepository,
+ githubStartDate: req.body.githubStartDate,
+ });
+ res.status(200).json({
+ destination: {
+ splitgraphNamespace: username,
+ splitgraphRepository: splitgraphDestinationRepository,
+ },
+ taskId,
+ });
+ } catch (err) {
+ res.status(400).json({
+ error: err.message,
+ });
+ }
+}
+
+const startImport = async ({
+ db,
+ githubSourceRepository,
+ splitgraphDestinationRepository,
+ githubStartDate,
+}: {
+ db: ReturnType;
+ githubSourceRepository: string;
+ splitgraphDestinationRepository: string;
+ /**
+ * Optional start date for ingestion, must be in format like: 2021-06-01T00:00:00Z
+ * Defaults to 2020-01-01T00:00:00Z
+ * */
+ githubStartDate?: string;
+}) => {
+ const { username: splitgraphNamespace } = claimsFromJWT(
+ (await db.fetchAccessToken()).token
+ );
+
+ const { taskId } = await db.importData(
+ "airbyte-github",
+ {
+ credentials: {
+ credentials: {
+ personal_access_token: GITHUB_PAT_SECRET,
+ },
+ },
+ params: {
+ repository: githubSourceRepository,
+ start_date: githubStartDate ?? "2023-01-01T00:00:00Z",
+ page_size_for_large_streams: 100,
+ },
+ },
+ {
+ namespace: splitgraphNamespace,
+ repository: splitgraphDestinationRepository,
+ tables: [
+ ...relevantGitHubTableNamesForImport.map((t) => ({
+ name: t,
+ options: {},
+ schema: [],
+ })),
+ ],
+ },
+ { defer: true }
+ );
+
+ return taskId;
+};
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/pages/index.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/pages/index.tsx
new file mode 100644
index 0000000..fb52bfe
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/pages/index.tsx
@@ -0,0 +1,14 @@
+import { BaseLayout } from "../components/BaseLayout";
+
+import { Sidebar } from "../components/Sidebar";
+import { Stepper } from "../components/ImportExportStepper/Stepper";
+
+const ImportPage = () => {
+ return (
+ }>
+
+
+ );
+};
+
+export default ImportPage;
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/tsconfig.json b/examples/nextjs-import-airbyte-github-export-seafowl/tsconfig.json
new file mode 100644
index 0000000..6446b1b
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "resolveJsonModule": true,
+ "moduleResolution": "Node",
+ "isolatedModules": true,
+ "jsx": "preserve"
+ },
+ "include": ["next-env.d.ts", "env-vars.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/types.ts b/examples/nextjs-import-airbyte-github-export-seafowl/types.ts
new file mode 100644
index 0000000..8b962b6
--- /dev/null
+++ b/examples/nextjs-import-airbyte-github-export-seafowl/types.ts
@@ -0,0 +1,76 @@
+export interface ImportedRepository {
+ githubNamespace: string;
+ githubRepository: string;
+ splitgraphNamespace: string;
+ splitgraphRepository: string;
+}
+
+export interface TargetSplitgraphRepo {
+ splitgraphNamespace?: string;
+ splitgraphRepository: string;
+}
+
+export type ExportTable = {
+ destinationSchema: string;
+ destinationTable: string;
+ taskId: string;
+ sourceQuery?: string;
+ fallbackCreateTableQuery?: string;
+};
+
+export type ExportTableInput = {
+ namespace: string;
+ repository: string;
+ table: string;
+};
+
+export type ExportQueryInput = {
+ sourceQuery: string;
+ destinationSchema: string;
+ destinationTable: string;
+ fallbackCreateTableQuery?: string;
+};
+
+export type StartExportToSeafowlRequestShape =
+ | {
+ tables: ExportTableInput[];
+ }
+ | { queries: ExportQueryInput[] }
+ | { tables: ExportTableInput[]; queries: ExportQueryInput[] };
+
+export type StartExportToSeafowlResponseData =
+ | {
+ tables: {
+ destinationTable: string;
+ destinationSchema: string;
+ taskId: string;
+ }[];
+ queries: {
+ sourceQuery: string;
+ destinationSchema: string;
+ destinationTable: string;
+ taskId: string;
+ }[];
+ }
+ | { error: string };
+
+export type CreateFallbackTableForFailedExportRequestShape = {
+ /**
+ * The query to execute to create the fallback table. Note that it should
+ * already include `destinationSchema` and `destinationTable` in the query,
+ * but those still need to be passed separately to the endpoint so that it
+ * can `CREATE SCHEMA` and `DROP TABLE` prior to executing the `CREATE TABLE` query.
+ */
+ fallbackCreateTableQuery: string;
+ destinationSchema: string;
+ destinationTable: string;
+};
+
+export type CreateFallbackTableForFailedExportResponseData =
+ | {
+ error: string;
+ success: false;
+ }
+ | {
+ success: true;
+ };
diff --git a/examples/yarn.lock b/examples/yarn.lock
index dfc7d9a..0b1d996 100644
--- a/examples/yarn.lock
+++ b/examples/yarn.lock
@@ -533,7 +533,7 @@ __metadata:
languageName: node
linkType: hard
-"@madatdata/core@npm:0.0.11":
+"@madatdata/core@npm:0.0.11, @madatdata/core@npm:latest":
version: 0.0.11
resolution: "@madatdata/core@npm:0.0.11"
dependencies:
@@ -701,6 +701,17 @@ __metadata:
languageName: node
linkType: hard
+"@observablehq/plot@npm:0.6.7":
+ version: 0.6.7
+ resolution: "@observablehq/plot@npm:0.6.7"
+ dependencies:
+ d3: ^7.8.0
+ interval-tree-1d: ^1.0.0
+ isoformat: ^0.2.0
+ checksum: bd9ce3a6ad2073a0b17d2e71cf3e6f1867057de360ffa14cb46cc85b0b02e3b34c73aa121b8231f78b7ce152c71b127c084f68893105871aac4ea551f0252655
+ languageName: node
+ linkType: hard
+
"@swc/helpers@npm:0.4.14":
version: 0.4.14
resolution: "@swc/helpers@npm:0.4.14"
@@ -877,6 +888,13 @@ __metadata:
languageName: node
linkType: hard
+"binary-search-bounds@npm:^2.0.0":
+ version: 2.0.5
+ resolution: "binary-search-bounds@npm:2.0.5"
+ checksum: e073e265570ad09fe7520835c620f1e95036c7e9696c4f2135c9b20f4b4a44e0306b38977e057b049dab60fea4ab53ed4ad2ee19d9bf44cb6b652aa081788b89
+ languageName: node
+ linkType: hard
+
"brace-expansion@npm:^1.1.7":
version: 1.1.11
resolution: "brace-expansion@npm:1.1.11"
@@ -1050,6 +1068,13 @@ __metadata:
languageName: node
linkType: hard
+"commander@npm:7":
+ version: 7.2.0
+ resolution: "commander@npm:7.2.0"
+ checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc
+ languageName: node
+ linkType: hard
+
"concat-map@npm:0.0.1":
version: 0.0.1
resolution: "concat-map@npm:0.0.1"
@@ -1087,6 +1112,324 @@ __metadata:
languageName: node
linkType: hard
+"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.2.0":
+ version: 3.2.3
+ resolution: "d3-array@npm:3.2.3"
+ dependencies:
+ internmap: 1 - 2
+ checksum: 41d6a4989b73e0d2649a880b2f29a7e7cc059db0eba36cd29a79e0118ebdf6b78922a84cde0733cd54cb4072f3442ec44f3563902e00ea42892442d60e99f961
+ languageName: node
+ linkType: hard
+
+"d3-axis@npm:3":
+ version: 3.0.0
+ resolution: "d3-axis@npm:3.0.0"
+ checksum: 227ddaa6d4bad083539c1ec245e2228b4620cca941997a8a650cb0af239375dc20271993127eedac66f0543f331027aca09385e1e16eed023f93eac937cddf0b
+ languageName: node
+ linkType: hard
+
+"d3-brush@npm:3":
+ version: 3.0.0
+ resolution: "d3-brush@npm:3.0.0"
+ dependencies:
+ d3-dispatch: 1 - 3
+ d3-drag: 2 - 3
+ d3-interpolate: 1 - 3
+ d3-selection: 3
+ d3-transition: 3
+ checksum: 1d042167769a02ac76271c71e90376d7184206e489552b7022a8ec2860209fe269db55e0a3430f3dcbe13b6fec2ff65b1adeaccba3218991b38e022390df72e3
+ languageName: node
+ linkType: hard
+
+"d3-chord@npm:3":
+ version: 3.0.1
+ resolution: "d3-chord@npm:3.0.1"
+ dependencies:
+ d3-path: 1 - 3
+ checksum: ddf35d41675e0f8738600a8a2f05bf0858def413438c12cba357c5802ecc1014c80a658acbbee63cbad2a8c747912efb2358455d93e59906fe37469f1dc6b78b
+ languageName: node
+ linkType: hard
+
+"d3-color@npm:1 - 3, d3-color@npm:3":
+ version: 3.1.0
+ resolution: "d3-color@npm:3.1.0"
+ checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b
+ languageName: node
+ linkType: hard
+
+"d3-contour@npm:4":
+ version: 4.0.2
+ resolution: "d3-contour@npm:4.0.2"
+ dependencies:
+ d3-array: ^3.2.0
+ checksum: 56aa082c1acf62a45b61c8d29fdd307041785aa17d9a07de7d1d848633769887a33fb6823888afa383f31c460d0f21d24756593e84e334ddb92d774214d32f1b
+ languageName: node
+ linkType: hard
+
+"d3-delaunay@npm:6":
+ version: 6.0.4
+ resolution: "d3-delaunay@npm:6.0.4"
+ dependencies:
+ delaunator: 5
+ checksum: ce6d267d5ef21a8aeadfe4606329fc80a22ab6e7748d47bc220bcc396ee8be84b77a5473033954c5ac4aa522d265ddc45d4165d30fe4787dd60a15ea66b9bbb4
+ languageName: node
+ linkType: hard
+
+"d3-dispatch@npm:1 - 3, d3-dispatch@npm:3":
+ version: 3.0.1
+ resolution: "d3-dispatch@npm:3.0.1"
+ checksum: fdfd4a230f46463e28e5b22a45dd76d03be9345b605e1b5dc7d18bd7ebf504e6c00ae123fd6d03e23d9e2711e01f0e14ea89cd0632545b9f0c00b924ba4be223
+ languageName: node
+ linkType: hard
+
+"d3-drag@npm:2 - 3, d3-drag@npm:3":
+ version: 3.0.0
+ resolution: "d3-drag@npm:3.0.0"
+ dependencies:
+ d3-dispatch: 1 - 3
+ d3-selection: 3
+ checksum: d297231e60ecd633b0d076a63b4052b436ddeb48b5a3a11ff68c7e41a6774565473a6b064c5e9256e88eca6439a917ab9cea76032c52d944ddbf4fd289e31111
+ languageName: node
+ linkType: hard
+
+"d3-dsv@npm:1 - 3, d3-dsv@npm:3":
+ version: 3.0.1
+ resolution: "d3-dsv@npm:3.0.1"
+ dependencies:
+ commander: 7
+ iconv-lite: 0.6
+ rw: 1
+ bin:
+ csv2json: bin/dsv2json.js
+ csv2tsv: bin/dsv2dsv.js
+ dsv2dsv: bin/dsv2dsv.js
+ dsv2json: bin/dsv2json.js
+ json2csv: bin/json2dsv.js
+ json2dsv: bin/json2dsv.js
+ json2tsv: bin/json2dsv.js
+ tsv2csv: bin/dsv2dsv.js
+ tsv2json: bin/dsv2json.js
+ checksum: 5fc0723647269d5dccd181d74f2265920ab368a2868b0b4f55ffa2fecdfb7814390ea28622cd61ee5d9594ab262879509059544e9f815c54fe76fbfb4ffa4c8a
+ languageName: node
+ linkType: hard
+
+"d3-ease@npm:1 - 3, d3-ease@npm:3":
+ version: 3.0.1
+ resolution: "d3-ease@npm:3.0.1"
+ checksum: 06e2ee5326d1e3545eab4e2c0f84046a123dcd3b612e68858219aa034da1160333d9ce3da20a1d3486d98cb5c2a06f7d233eee1bc19ce42d1533458bd85dedcd
+ languageName: node
+ linkType: hard
+
+"d3-fetch@npm:3":
+ version: 3.0.1
+ resolution: "d3-fetch@npm:3.0.1"
+ dependencies:
+ d3-dsv: 1 - 3
+ checksum: 382dcea06549ef82c8d0b719e5dc1d96286352579e3b51b20f71437f5800323315b09cf7dcfd4e1f60a41e1204deb01758470cea257d2285a7abd9dcec806984
+ languageName: node
+ linkType: hard
+
+"d3-force@npm:3":
+ version: 3.0.0
+ resolution: "d3-force@npm:3.0.0"
+ dependencies:
+ d3-dispatch: 1 - 3
+ d3-quadtree: 1 - 3
+ d3-timer: 1 - 3
+ checksum: 6c7e96438cab62fa32aeadb0ade3297b62b51f81b1b38b0a60a5ec9fd627d74090c1189654d92df2250775f31b06812342f089f1d5947de9960a635ee3581def
+ languageName: node
+ linkType: hard
+
+"d3-format@npm:1 - 3, d3-format@npm:3":
+ version: 3.1.0
+ resolution: "d3-format@npm:3.1.0"
+ checksum: f345ec3b8ad3cab19bff5dead395bd9f5590628eb97a389b1dd89f0b204c7c4fc1d9520f13231c2c7cf14b7c9a8cf10f8ef15bde2befbab41454a569bd706ca2
+ languageName: node
+ linkType: hard
+
+"d3-geo@npm:3":
+ version: 3.1.0
+ resolution: "d3-geo@npm:3.1.0"
+ dependencies:
+ d3-array: 2.5.0 - 3
+ checksum: adf82b0c105c0c5951ae0a833d4dfc479a563791ad7938579fa14e1cffd623b469d8aa7a37dc413a327fb6ac56880f3da3f6c43d4abe3c923972dd98f34f37d1
+ languageName: node
+ linkType: hard
+
+"d3-hierarchy@npm:3":
+ version: 3.1.2
+ resolution: "d3-hierarchy@npm:3.1.2"
+ checksum: 0fd946a8c5fd4686d43d3e11bbfc2037a145fda29d2261ccd0e36f70b66af6d7638e2c0c7112124d63fc3d3127197a00a6aecf676bd5bd392a94d7235a214263
+ languageName: node
+ linkType: hard
+
+"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3":
+ version: 3.0.1
+ resolution: "d3-interpolate@npm:3.0.1"
+ dependencies:
+ d3-color: 1 - 3
+ checksum: a42ba314e295e95e5365eff0f604834e67e4a3b3c7102458781c477bd67e9b24b6bb9d8e41ff5521050a3f2c7c0c4bbbb6e187fd586daa3980943095b267e78b
+ languageName: node
+ linkType: hard
+
+"d3-path@npm:1 - 3, d3-path@npm:3, d3-path@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "d3-path@npm:3.1.0"
+ checksum: 2306f1bd9191e1eac895ec13e3064f732a85f243d6e627d242a313f9777756838a2215ea11562f0c7630c7c3b16a19ec1fe0948b1c82f3317fac55882f6ee5d8
+ languageName: node
+ linkType: hard
+
+"d3-polygon@npm:3":
+ version: 3.0.1
+ resolution: "d3-polygon@npm:3.0.1"
+ checksum: 0b85c532517895544683849768a2c377cee3801ef8ccf3fa9693c8871dd21a0c1a2a0fc75ff54192f0ba2c562b0da2bc27f5bf959dfafc7fa23573b574865d2c
+ languageName: node
+ linkType: hard
+
+"d3-quadtree@npm:1 - 3, d3-quadtree@npm:3":
+ version: 3.0.1
+ resolution: "d3-quadtree@npm:3.0.1"
+ checksum: 5469d462763811475f34a7294d984f3eb100515b0585ca5b249656f6b1a6e99b20056a2d2e463cc9944b888896d2b1d07859c50f9c0cf23438df9cd2e3146066
+ languageName: node
+ linkType: hard
+
+"d3-random@npm:3":
+ version: 3.0.1
+ resolution: "d3-random@npm:3.0.1"
+ checksum: a70ad8d1cabe399ebeb2e482703121ac8946a3b336830b518da6848b9fdd48a111990fc041dc716f16885a72176ffa2898f2a250ca3d363ecdba5ef92b18e131
+ languageName: node
+ linkType: hard
+
+"d3-scale-chromatic@npm:3":
+ version: 3.0.0
+ resolution: "d3-scale-chromatic@npm:3.0.0"
+ dependencies:
+ d3-color: 1 - 3
+ d3-interpolate: 1 - 3
+ checksum: a8ce4cb0267a17b28ebbb929f5e3071d985908a9c13b6fcaa2a198e1e018f275804d691c5794b970df0049725b7944f32297b31603d235af6414004f0c7f82c0
+ languageName: node
+ linkType: hard
+
+"d3-scale@npm:4":
+ version: 4.0.2
+ resolution: "d3-scale@npm:4.0.2"
+ dependencies:
+ d3-array: 2.10.0 - 3
+ d3-format: 1 - 3
+ d3-interpolate: 1.2.0 - 3
+ d3-time: 2.1.1 - 3
+ d3-time-format: 2 - 4
+ checksum: a9c770d283162c3bd11477c3d9d485d07f8db2071665f1a4ad23eec3e515e2cefbd369059ec677c9ac849877d1a765494e90e92051d4f21111aa56791c98729e
+ languageName: node
+ linkType: hard
+
+"d3-selection@npm:2 - 3, d3-selection@npm:3":
+ version: 3.0.0
+ resolution: "d3-selection@npm:3.0.0"
+ checksum: f4e60e133309115b99f5b36a79ae0a19d71ee6e2d5e3c7216ef3e75ebd2cb1e778c2ed2fa4c01bef35e0dcbd96c5428f5bd6ca2184fe2957ed582fde6841cbc5
+ languageName: node
+ linkType: hard
+
+"d3-shape@npm:3":
+ version: 3.2.0
+ resolution: "d3-shape@npm:3.2.0"
+ dependencies:
+ d3-path: ^3.1.0
+ checksum: de2af5fc9a93036a7b68581ca0bfc4aca2d5a328aa7ba7064c11aedd44d24f310c20c40157cb654359d4c15c3ef369f95ee53d71221017276e34172c7b719cfa
+ languageName: node
+ linkType: hard
+
+"d3-time-format@npm:2 - 4, d3-time-format@npm:4":
+ version: 4.1.0
+ resolution: "d3-time-format@npm:4.1.0"
+ dependencies:
+ d3-time: 1 - 3
+ checksum: 7342bce28355378152bbd4db4e275405439cabba082d9cd01946d40581140481c8328456d91740b0fe513c51ec4a467f4471ffa390c7e0e30ea30e9ec98fcdf4
+ languageName: node
+ linkType: hard
+
+"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3":
+ version: 3.1.0
+ resolution: "d3-time@npm:3.1.0"
+ dependencies:
+ d3-array: 2 - 3
+ checksum: 613b435352a78d9f31b7f68540788186d8c331b63feca60ad21c88e9db1989fe888f97f242322ebd6365e45ec3fb206a4324cd4ca0dfffa1d9b5feb856ba00a7
+ languageName: node
+ linkType: hard
+
+"d3-timer@npm:1 - 3, d3-timer@npm:3":
+ version: 3.0.1
+ resolution: "d3-timer@npm:3.0.1"
+ checksum: 1cfddf86d7bca22f73f2c427f52dfa35c49f50d64e187eb788dcad6e927625c636aa18ae4edd44d084eb9d1f81d8ca4ec305dae7f733c15846a824575b789d73
+ languageName: node
+ linkType: hard
+
+"d3-transition@npm:2 - 3, d3-transition@npm:3":
+ version: 3.0.1
+ resolution: "d3-transition@npm:3.0.1"
+ dependencies:
+ d3-color: 1 - 3
+ d3-dispatch: 1 - 3
+ d3-ease: 1 - 3
+ d3-interpolate: 1 - 3
+ d3-timer: 1 - 3
+ peerDependencies:
+ d3-selection: 2 - 3
+ checksum: cb1e6e018c3abf0502fe9ff7b631ad058efb197b5e14b973a410d3935aead6e3c07c67d726cfab258e4936ef2667c2c3d1cd2037feb0765f0b4e1d3b8788c0ea
+ languageName: node
+ linkType: hard
+
+"d3-zoom@npm:3":
+ version: 3.0.0
+ resolution: "d3-zoom@npm:3.0.0"
+ dependencies:
+ d3-dispatch: 1 - 3
+ d3-drag: 2 - 3
+ d3-interpolate: 1 - 3
+ d3-selection: 2 - 3
+ d3-transition: 2 - 3
+ checksum: 8056e3527281cfd1ccbcbc458408f86973b0583e9dac00e51204026d1d36803ca437f970b5736f02fafed9f2b78f145f72a5dbc66397e02d4d95d4c594b8ff54
+ languageName: node
+ linkType: hard
+
+"d3@npm:^7.8.0":
+ version: 7.8.4
+ resolution: "d3@npm:7.8.4"
+ dependencies:
+ d3-array: 3
+ d3-axis: 3
+ d3-brush: 3
+ d3-chord: 3
+ d3-color: 3
+ d3-contour: 4
+ d3-delaunay: 6
+ d3-dispatch: 3
+ d3-drag: 3
+ d3-dsv: 3
+ d3-ease: 3
+ d3-fetch: 3
+ d3-force: 3
+ d3-format: 3
+ d3-geo: 3
+ d3-hierarchy: 3
+ d3-interpolate: 3
+ d3-path: 3
+ d3-polygon: 3
+ d3-quadtree: 3
+ d3-random: 3
+ d3-scale: 4
+ d3-scale-chromatic: 3
+ d3-selection: 3
+ d3-shape: 3
+ d3-time: 3
+ d3-time-format: 4
+ d3-timer: 3
+ d3-transition: 3
+ d3-zoom: 3
+ checksum: 8dfea4d026e5597ab9c46035df7f0ca4f3891b2b52b21b181bd8660fc61d85f0bbe3cc2b8f1e922978084156cb017cbbfa350a8e42349310642023a9f1517471
+ languageName: node
+ linkType: hard
+
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.3":
version: 4.3.4
resolution: "debug@npm:4.3.4"
@@ -1106,6 +1449,15 @@ __metadata:
languageName: node
linkType: hard
+"delaunator@npm:5":
+ version: 5.0.0
+ resolution: "delaunator@npm:5.0.0"
+ dependencies:
+ robust-predicates: ^3.0.0
+ checksum: d6764188442b7f7c6bcacebd96edc00e35f542a96f1af3ef600e586bfb9849a3682c489c0ab423440c90bc4c7cac77f28761babff76fa29e193e1cf50a95b860
+ languageName: node
+ linkType: hard
+
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
@@ -1477,7 +1829,7 @@ __metadata:
languageName: node
linkType: hard
-"iconv-lite@npm:^0.6.2":
+"iconv-lite@npm:0.6, iconv-lite@npm:^0.6.2":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
dependencies:
@@ -1524,6 +1876,22 @@ __metadata:
languageName: node
linkType: hard
+"internmap@npm:1 - 2":
+ version: 2.0.3
+ resolution: "internmap@npm:2.0.3"
+ checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241
+ languageName: node
+ linkType: hard
+
+"interval-tree-1d@npm:^1.0.0":
+ version: 1.0.4
+ resolution: "interval-tree-1d@npm:1.0.4"
+ dependencies:
+ binary-search-bounds: ^2.0.0
+ checksum: 7cac7a4ea99ba2d03e3ebaeaf73ad35eaeebc63b5dca1e126601474c7e41c09b158acc31423595c384a88ea7c1ce8e0b340cfde41a85fd6dbef552d12f35ba69
+ languageName: node
+ linkType: hard
+
"ip@npm:^2.0.0":
version: 2.0.0
resolution: "ip@npm:2.0.0"
@@ -1561,6 +1929,13 @@ __metadata:
languageName: node
linkType: hard
+"isoformat@npm:^0.2.0":
+ version: 0.2.1
+ resolution: "isoformat@npm:0.2.1"
+ checksum: 28487777526c93360c2f49abbf03d45778ad2c55bfccb3a6bf04905b2ddfafcb9f29a68d6ab5251f2919afb47e0e018fe25f815fd68180f4117161c508878558
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -1894,6 +2269,22 @@ __metadata:
languageName: node
linkType: hard
+"nextjs-import-airbyte-github-export-seafowl-acabed@workspace:nextjs-import-airbyte-github-export-seafowl":
+ version: 0.0.0-use.local
+ resolution: "nextjs-import-airbyte-github-export-seafowl-acabed@workspace:nextjs-import-airbyte-github-export-seafowl"
+ dependencies:
+ "@madatdata/core": latest
+ "@madatdata/react": latest
+ "@observablehq/plot": 0.6.7
+ "@types/node": ^18.0.0
+ "@types/react": ^18.0.14
+ next: latest
+ react: 18.2.0
+ react-dom: 18.2.0
+ typescript: ^4.7.4
+ languageName: unknown
+ linkType: soft
+
"node-fetch@npm:2.6.7":
version: 2.6.7
resolution: "node-fetch@npm:2.6.7"
@@ -2222,6 +2613,13 @@ __metadata:
languageName: node
linkType: hard
+"robust-predicates@npm:^3.0.0":
+ version: 3.0.2
+ resolution: "robust-predicates@npm:3.0.2"
+ checksum: 36854c1321548ceca96d36ad9d6e0a5a512986029ec6929ad6ed3ec1612c22cc8b46cc72d2c5674af42e8074a119d793f6f0ea3a5b51373e3ab926c64b172d7a
+ languageName: node
+ linkType: hard
+
"rollup@npm:^3.18.0":
version: 3.20.2
resolution: "rollup@npm:3.20.2"
@@ -2245,6 +2643,13 @@ __metadata:
languageName: unknown
linkType: soft
+"rw@npm:1":
+ version: 1.3.3
+ resolution: "rw@npm:1.3.3"
+ checksum: c20d82421f5a71c86a13f76121b751553a99cd4a70ea27db86f9b23f33db941f3f06019c30f60d50c356d0bd674c8e74764ac146ea55e217c091bde6fba82aa3
+ languageName: node
+ linkType: hard
+
"safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"