diff --git a/.env.example b/.env.example index 5083341f..f432535a 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -SUPABASE_AUTH_GITHUB_CLIENT_ID=github-client-id -SUPABASE_AUTH_GITHUB_SECRET=github-secret +SUPABASE_AUTH_GITHUB_CLIENT_ID="" SUPABASE_AUTH_GITHUB_REDIRECT_URI=http://localhost:54321/auth/v1/callback +SUPABASE_AUTH_GITHUB_SECRET="" diff --git a/.prettierrc b/.prettierrc index 91390711..09f964f4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -15,4 +15,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a5415d1..d64290f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,20 @@ { "deno.enablePaths": ["supabase/functions"], "deno.lint": true, - "deno.unstable": true + "deno.unstable": false, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/README.md b/README.md index c6d70ade..f9eba283 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ How is this possible? [PGlite](https://pglite.dev/), a WASM version of Postgres This is a monorepo split into the following projects: -- [Frontend (Next.js)](./apps/postgres-new/): This contains the primary web app built with Next.js -- [Backend (pg-gateway)](./apps/db-service/): This serves S3-backed PGlite databases over the PG wire protocol using [pg-gateway](https://github.com/supabase-community/pg-gateway) +- [Frontend (Next.js)](./apps/web/): This contains the primary web app built with Next.js +- [Backend (pg-gateway)](./apps/proxy/): This serves S3-backed PGlite databases over the PG wire protocol using [pg-gateway](https://github.com/supabase-community/pg-gateway) ## Video diff --git a/apps/db-service/.dockerignore b/apps/db-service/.dockerignore deleted file mode 100644 index 47719bef..00000000 --- a/apps/db-service/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -fly.toml -Dockerfile -.dockerignore -node_modules -.git diff --git a/apps/db-service/docker-compose.yml b/apps/db-service/docker-compose.yml deleted file mode 100644 index 13e26335..00000000 --- a/apps/db-service/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -services: - db-service: - image: db-service - build: - context: . - environment: - S3FS_ENDPOINT: http://minio:9000 - S3FS_BUCKET: test - S3FS_REGION: us-east-1 # default region for s3-compatible APIs - S3FS_MOUNT: /mnt/s3 - AWS_ACCESS_KEY_ID: minioadmin - AWS_SECRET_ACCESS_KEY: minioadmin - ports: - - 5432:5432 - devices: - - /dev/fuse - cap_add: - - SYS_ADMIN - depends_on: - minio: - condition: service_healthy - tls-init: - image: tls-init - build: - context: . - environment: - S3FS_ENDPOINT: http://minio:9000 - S3FS_BUCKET: test - S3FS_REGION: us-east-1 # default region for s3-compatible APIs - S3FS_MOUNT: /mnt/s3 - AWS_ACCESS_KEY_ID: minioadmin - AWS_SECRET_ACCESS_KEY: minioadmin - devices: - - /dev/fuse - cap_add: - - SYS_ADMIN - command: ./scripts/generate-certs.sh - depends_on: - minio: - condition: service_healthy - minio: - image: minio/minio - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - ports: - - 9000:9000 - command: server /data - healthcheck: - test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1 - interval: 5s - timeout: 5s - retries: 1 - minio-init: - image: minio/mc - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 minioadmin minioadmin; - (mc ls local/test || mc mb local/test); - " - depends_on: - minio: - condition: service_healthy diff --git a/apps/db-service/package.json b/apps/db-service/package.json deleted file mode 100644 index 1003cc7f..00000000 --- a/apps/db-service/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "db-service", - "type": "module", - "scripts": { - "start": "node dist/index.js", - "dev": "tsx src/index.ts", - "build": "tsc -b", - "generate:certs": "scripts/generate-certs.sh", - "psql": "psql 'host=localhost port=5432 user=postgres sslmode=verify-ca sslrootcert=ca-cert.pem'" - }, - "dependencies": { - "@electric-sql/pglite": "0.2.0-alpha.9", - "pg-gateway": "^0.2.5-alpha.2" - }, - "devDependencies": { - "@types/node": "^20.14.11", - "tsx": "^4.16.2", - "typescript": "^5.5.3" - } -} diff --git a/apps/db-service/src/index.ts b/apps/db-service/src/index.ts deleted file mode 100644 index 9e28a20c..00000000 --- a/apps/db-service/src/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { PGlite, PGliteInterface } from '@electric-sql/pglite' -import { mkdir, readFile } from 'node:fs/promises' -import net from 'node:net' -import { hashMd5Password, PostgresConnection, TlsOptions } from 'pg-gateway' - -const s3fsMount = process.env.S3FS_MOUNT ?? '.' -const dbDir = `${s3fsMount}/dbs` -const tlsDir = `${s3fsMount}/tls` - -await mkdir(dbDir, { recursive: true }) -await mkdir(tlsDir, { recursive: true }) - -const tls: TlsOptions = { - key: await readFile(`${tlsDir}/key.pem`), - cert: await readFile(`${tlsDir}/cert.pem`), - ca: await readFile(`${tlsDir}/ca-cert.pem`), -} - -function getIdFromServerName(serverName: string) { - // The left-most subdomain contains the ID - // ie. 12345.db.example.com -> 12345 - const [id] = serverName.split('.') - return id -} - -const server = net.createServer((socket) => { - let db: PGliteInterface - - const connection = new PostgresConnection(socket, { - serverVersion: '16.3 (PGlite 0.2.0)', - authMode: 'md5Password', - tls, - async validateCredentials(credentials) { - if (credentials.authMode === 'md5Password') { - const { hash, salt } = credentials - const expectedHash = await hashMd5Password('postgres', 'postgres', salt) - return hash === expectedHash - } - return false - }, - async onTlsUpgrade({ tlsInfo }) { - if (!tlsInfo) { - connection.sendError({ - severity: 'FATAL', - code: '08000', - message: `ssl connection required`, - }) - connection.socket.end() - return - } - - if (!tlsInfo.sniServerName) { - connection.sendError({ - severity: 'FATAL', - code: '08000', - message: `ssl sni extension required`, - }) - connection.socket.end() - return - } - - const databaseId = getIdFromServerName(tlsInfo.sniServerName) - - console.log(`Serving database '${databaseId}'`) - - db = new PGlite(`${dbDir}/${databaseId}`) - }, - async onStartup() { - if (!db) { - console.log('PGlite instance undefined. Was onTlsUpgrade never called?') - connection.sendError({ - severity: 'FATAL', - code: 'XX000', - message: `error loading database`, - }) - connection.socket.end() - return true - } - - // Wait for PGlite to be ready before further processing - await db.waitReady - return false - }, - async onMessage(data, { isAuthenticated }) { - // Only forward messages to PGlite after authentication - if (!isAuthenticated) { - return false - } - - // Forward raw message to PGlite - try { - const responseData = await db.execProtocolRaw(data) - connection.sendData(responseData) - } catch (err) { - console.error(err) - } - return true - }, - }) - - socket.on('end', async () => { - console.log('Client disconnected') - await db?.close() - }) -}) - -server.listen(5432, async () => { - console.log('Server listening on port 5432') -}) diff --git a/apps/postgres-new/components/sidebar.tsx b/apps/postgres-new/components/sidebar.tsx deleted file mode 100644 index 3cb87929..00000000 --- a/apps/postgres-new/components/sidebar.tsx +++ /dev/null @@ -1,496 +0,0 @@ -'use client' - -import { AnimatePresence, m } from 'framer-motion' -import { - ArrowLeftToLine, - ArrowRightToLine, - Database as DbIcon, - Download, - Loader, - LogOut, - MoreVertical, - PackagePlus, - Pencil, - Trash2, - Upload, -} from 'lucide-react' -import Link from 'next/link' -import { useParams, useRouter } from 'next/navigation' -import { useState } from 'react' -import { Button } from '~/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog' -import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' -import { useDatabaseDeleteMutation } from '~/data/databases/database-delete-mutation' -import { useDatabaseUpdateMutation } from '~/data/databases/database-update-mutation' -import { useDatabasesQuery } from '~/data/databases/databases-query' -import { useDeployWaitlistCreateMutation } from '~/data/deploy-waitlist/deploy-waitlist-create-mutation' -import { useIsOnDeployWaitlistQuery } from '~/data/deploy-waitlist/deploy-waitlist-query' -import { Database } from '~/lib/db' -import { downloadFile, titleToKebabCase } from '~/lib/util' -import { cn } from '~/lib/utils' -import { useApp } from './app-provider' -import { CodeBlock } from './code-block' -import SignInButton from './sign-in-button' -import ThemeDropdown from './theme-dropdown' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './ui/dropdown-menu' - -export default function Sidebar() { - const { user, signOut, focusRef, isSignInDialogOpen, setIsSignInDialogOpen } = useApp() - let { id: currentDatabaseId } = useParams<{ id: string }>() - const router = useRouter() - const { data: databases, isLoading: isLoadingDatabases } = useDatabasesQuery() - const [showSidebar, setShowSidebar] = useState(true) - - return ( - <> - { - setIsSignInDialogOpen(open) - }} - > - - - Sign in to create a database -
- -

Why do I need to sign in?

-

- Even though your Postgres databases run{' '} - - directly in the browser - - , we still need to connect to an API that runs the large language model (required for - all database interactions). -

-

We ask you to sign in to prevent API abuse.

-
- -
- -
- - {showSidebar && ( - -
- - - - - - - -

Close sidebar

-
-
- - - -
- {databases && databases.length > 0 ? ( - - {databases.map((database) => ( - - - - ))} - - ) : ( -
- {isLoadingDatabases ? ( - - ) : ( - <> - - No databases - - )} -
- )} - - - - {user && ( - - - - )} -
- )} -
- {!showSidebar && ( -
-
- - - - - - - -

Open sidebar

-
-
- - - - - - - -

New database

-
-
-
-
- - - - - - - -

Toggle theme

-
-
- {user && ( - - - - - - - -

Sign out

-
-
- )} -
-
- )} - - ) -} - -type DatabaseMenuItemProps = { - database: Database - isActive: boolean -} - -function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { - const router = useRouter() - const { user, dbManager } = useApp() - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - const { mutateAsync: deleteDatabase } = useDatabaseDeleteMutation() - const { mutateAsync: updateDatabase } = useDatabaseUpdateMutation() - - const [isRenaming, setIsRenaming] = useState(false) - const [isDeployDialogOpen, setIsDeployDialogOpen] = useState(false) - - const { data: isOnDeployWaitlist } = useIsOnDeployWaitlistQuery() - const { mutateAsync: joinDeployWaitlist } = useDeployWaitlistCreateMutation() - - return ( - <> - { - setIsDeployDialogOpen(open) - }} - > - - - Deployments are in Private Alpha -
- -

What are deployments?

-

- Deploy your database to a serverless PGlite instance so that it can be accessed outside - the browser using any Postgres client: -

- - {`psql "postgres://postgres:@/postgres"`} - -
- - {!isOnDeployWaitlist ? ( - - ) : ( - -

🎉 You're on the waitlist!

-

We'll send you an email when you have access to deploy.

-
- )} -
-
- -
- - - {database.name ?? 'My database'} - { - setIsPopoverOpen(open) - if (!open) { - setIsRenaming(false) - } - }} - open={isPopoverOpen} - > - { - e.preventDefault() - setIsPopoverOpen(true) - }} - > - - - - - {isRenaming ? ( -
{ - e.preventDefault() - - if (e.target instanceof HTMLFormElement) { - const formData = new FormData(e.target) - const name = formData.get('name') - - if (typeof name === 'string') { - await updateDatabase({ ...database, name }) - } - } - - setIsPopoverOpen(false) - setIsRenaming(false) - }} - > - -
- ) : ( -
- { - e.preventDefault() - setIsRenaming(true) - }} - > - - Rename - - { - e.preventDefault() - - if (!dbManager) { - throw new Error('dbManager is not available') - } - - const db = await dbManager.getDbInstance(database.id) - const dumpBlob = await db.dumpDataDir() - - const fileName = `${titleToKebabCase(database.name ?? 'My Database')}-${Date.now()}` - const file = new File([dumpBlob], fileName, { type: dumpBlob.type }) - - downloadFile(file) - }} - > - - - Download - - { - e.preventDefault() - - setIsDeployDialogOpen(true) - setIsPopoverOpen(false) - }} - disabled={user === undefined} - > - - Deploy - - - { - e.preventDefault() - setIsPopoverOpen(false) - await deleteDatabase({ id: database.id }) - - if (isActive) { - router.push('/') - } - }} - > - - Delete - -
- )} -
-
- - - ) -} diff --git a/apps/proxy/.dockerignore b/apps/proxy/.dockerignore new file mode 100644 index 00000000..aeabd0b9 --- /dev/null +++ b/apps/proxy/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.vscode +.github +dist +.next + +apps +!apps/proxy +!packages/supabase/dist \ No newline at end of file diff --git a/apps/proxy/.env.example b/apps/proxy/.env.example new file mode 100644 index 00000000..adf3c16f --- /dev/null +++ b/apps/proxy/.env.example @@ -0,0 +1,17 @@ +AWS_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c +AWS_ENDPOINT_URL_S3=http://host.docker.internal:54321/storage/v1/s3 +AWS_S3_BUCKET=s3fs +AWS_SECRET_ACCESS_KEY=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 +AWS_REGION=local +# Cache disk usage threshold in percentage of the total disk space +CACHE_DISK_USAGE_THRESHOLD=90 +CACHE_PATH=/var/lib/postgres-new/cache +# Cache schedule interval in hours +CACHE_SCHEDULE_INTERVAL=1 +CACHE_TIMESTAMP_FILE=/var/lib/postgres-new/delete_cache_last_run +# Cache time to live in hours +CACHE_TTL=24 +S3FS_MOUNT=/mnt/s3 +SUPABASE_SERVICE_ROLE_KEY="" +SUPABASE_URL="" +WILDCARD_DOMAIN=db.example.com \ No newline at end of file diff --git a/apps/db-service/Dockerfile b/apps/proxy/Dockerfile similarity index 53% rename from apps/db-service/Dockerfile rename to apps/proxy/Dockerfile index 9476c5f9..17527796 100644 --- a/apps/db-service/Dockerfile +++ b/apps/proxy/Dockerfile @@ -1,8 +1,8 @@ # syntax = docker/dockerfile:1 # Adjust NODE_VERSION as desired -ARG NODE_VERSION=20.4.0 -FROM node:${NODE_VERSION}-bookworm as base +ARG NODE_VERSION=20.16.0 +FROM node:${NODE_VERSION}-bookworm AS base LABEL fly_launch_runtime="NodeJS" @@ -13,14 +13,14 @@ WORKDIR /app ENV NODE_ENV=production # Build S3FS -FROM base as build-s3fs +FROM base AS build-s3fs # Install dependencies RUN apt-get update && \ apt-get install -y \ libfuse-dev -RUN git clone https://github.com/s3fs-fuse/s3fs-fuse.git --branch v1.94 && \ +RUN git clone https://github.com/supabase/s3fs-fuse.git --branch s3/support-endpoint-with-uris && \ cd s3fs-fuse && \ ./autogen.sh && \ ./configure && \ @@ -28,7 +28,7 @@ RUN git clone https://github.com/s3fs-fuse/s3fs-fuse.git --branch v1.94 && \ make install # Build app -FROM base as build-app +FROM base AS build-app # Install packages needed to build node modules RUN apt-get update -qq && \ @@ -37,18 +37,17 @@ RUN apt-get update -qq && \ pkg-config \ build-essential -# Install node modules -COPY --link package.json . -RUN npm install --production=false - -# Copy application code +# Copy everything COPY --link . . +# Install dependencies +RUN npm install -w @postgres-new/proxy --production=false + # Build app -RUN npm run build +RUN npm run -w @postgres-new/proxy build # Remove development dependencies -RUN npm prune --production +RUN npm prune -w @postgres-new/proxy --production # Final stage for app image FROM base @@ -60,9 +59,16 @@ RUN apt-get update && \ && rm -rf /var/lib/apt/lists/* COPY --from=build-s3fs /usr/local/bin/s3fs /usr/local/bin/s3fs -COPY --from=build-app /app /app +# We copy the root node_modules +COPY --from=build-app /app/node_modules /app/node_modules +# We copy the package-lock.json for PGlite version detection +COPY --from=build-app /app/package-lock.json /app/package-lock.json +# We copy the proxy folder +COPY --from=build-app /app/apps/proxy /app/proxy + +EXPOSE 5432 -ENTRYPOINT [ "./entrypoint.sh" ] +ENTRYPOINT [ "./proxy/entrypoint.sh" ] # Start the server by default, this can be overwritten at runtime -CMD [ "node", "dist/index.js" ] +CMD [ "node", "proxy/dist/index.js" ] diff --git a/apps/db-service/README.md b/apps/proxy/README.md similarity index 54% rename from apps/db-service/README.md rename to apps/proxy/README.md index b4383a68..8ff43e79 100644 --- a/apps/db-service/README.md +++ b/apps/proxy/README.md @@ -13,7 +13,7 @@ It also requires TLS certs, since we use SNI to reverse proxy DB connections (eg ## Development -### Without `s3fs` +### Without `s3fs` (direct Node.js) If want to develop locally without dealing with containers or underlying storage: @@ -46,19 +46,15 @@ If want to develop locally without dealing with containers or underlying storage psql "host=12345.db.example.com port=5432 user=postgres" ``` -### With `s3fs` +### With `s3fs` and DNS tools (Docker) -To simulate an environment closer to production, you can test the service with DBs backed by `s3fs` using Minio and Docker. +To simulate an environment closer to production, you can test the service with DBs backed by `s3fs` using Minio and Docker. This approach also adds a local DNS server which forwards all wildcard DNS requests to `*.db.example.com` to the `proxy` so that you don't have to keep changing your `/etc/hosts` file. -1. Start Minio as a local s3-compatible server: +1. Start CoreDNS (handles local wildcard DNS) and Minio (local s3-compatible server): ```shell - docker compose up -d minio + docker compose up -d dns minio minio-init ``` -1. Initialize test bucket: - ```shell - docker compose up minio-init - ``` - This will run to completion then exit. + `minio-init` initializes a test bucket. It will run to completion then exit. 1. Initialize local TLS certs: ```shell @@ -69,33 +65,51 @@ To simulate an environment closer to production, you can test the service with D 1. Run the `pg-gateway` server: ```shell - docker compose up --build db-service + docker compose up --build proxy ``` - This will build the container (if it's not cached) then run the Node `db-service`. All DBs will live under `/mnt/s3/dbs`. + This will build the container (if it's not cached) then run the Node `proxy`. All DBs will live under `/mnt/s3/dbs`. 1. Connect to the server via `psql`: ```shell - psql "host=localhost port=5432 user=postgres" + npm run psql -- "host=12345.db.example.com port=5432 user=postgres" ``` + This uses a wrapped version of `psql` that runs in a Docker container under the hood. We do this in order to resolve all `*.db.example.com` addresses to the `proxy`. + > Note the very first time a DB is created will be very slow (`s3fs` writes are slow with that many file handles) so expect this to hang for a while. Subsequent requests will be much quicker. This is temporary anyway - in the future the DB will have to already exist in `/mnt/s3/dbs/` in order to connect. - or to test a real database ID, add a loopback entry to your `/etc/hosts` file: +To stop all Docker containers, run: - ``` - # ... +```shell +docker compose down +``` - 127.0.0.1 12345.db.example.com +## Deploying to fly.io + +1. Create a new app if it doesn't exist + + ```shell + fly apps create postgres-new-proxy ``` - and connect to that host: +2. Set the appropriate environment variables and secrets for the app "postgres-new-proxy" (see `.env.example`) in fly.io UI. + +3. Allocate a dedicated IPv4 address for the app, it's required to resolve wildcard DNS queries correctly. ```shell - psql "host=12345.db.example.com port=5432 user=postgres" + fly ips allocate-v4 ``` -To stop all Docker containers, run: +4. On Cloudflare, add a new CNAME DNS record pointing to the Fly.io app domain: -```shell -docker compose down -``` + | Type | Name | Value | + | --- | --- | --- | + | CNAME | *.db | postgres-new-proxy.fly.dev | + +5. Deploy the app + + Due to a [bug](https://github.com/superfly/flyctl/issues/3870) in `fly` ignoring `.dockerignore` files when setting up a build context, we need to copy the file to the root of the repo and remove it after the deploy. + + ```shell + cp .dockerignore ../.. && fly deploy ../.. --config apps/proxy/fly.toml && rm ../../.dockerignore + ``` \ No newline at end of file diff --git a/apps/proxy/docker-compose.yml b/apps/proxy/docker-compose.yml new file mode 100644 index 00000000..f3c40ef0 --- /dev/null +++ b/apps/proxy/docker-compose.yml @@ -0,0 +1,53 @@ +services: + proxy: + image: proxy + build: + context: ../.. + dockerfile: apps/proxy/Dockerfile + env_file: + - .env + ports: + - 5432:5432 + devices: + - /dev/fuse + cap_add: + - SYS_ADMIN + depends_on: + tls-init: + condition: service_completed_successfully + + tls-init: + image: tls-init + build: + context: ../.. + dockerfile: apps/proxy/Dockerfile + env_file: + - .env + devices: + - /dev/fuse + cap_add: + - SYS_ADMIN + command: ./proxy/scripts/generate-certs.sh + + dns: + build: + context: ./tools/dns + environment: + WILDCARD_DOMAIN: db.example.com + SERVICE_NAME: proxy + networks: + default: + ipv4_address: 173.20.0.10 + psql: + image: postgres:16 + depends_on: + - dns + dns: + - 173.20.0.10 + +networks: + default: + driver: bridge + ipam: + config: + - subnet: 173.20.0.0/24 diff --git a/apps/db-service/entrypoint.sh b/apps/proxy/entrypoint.sh similarity index 75% rename from apps/db-service/entrypoint.sh rename to apps/proxy/entrypoint.sh index be930a28..81d657c0 100755 --- a/apps/db-service/entrypoint.sh +++ b/apps/proxy/entrypoint.sh @@ -5,7 +5,7 @@ set -o pipefail cleanup() { echo "Unmounting s3fs..." - fusermount -u $S3FS_MOUNT + umount $S3FS_MOUNT exit 0 } @@ -17,11 +17,11 @@ trap 'forward_signal SIGINT' SIGINT trap 'forward_signal SIGTERM' SIGTERM trap 'cleanup' EXIT -# Create the mount point directory +# Create the s3 mount point directory mkdir -p $S3FS_MOUNT # Mount the S3 bucket -s3fs $S3FS_BUCKET $S3FS_MOUNT -o use_path_request_style -o url=$S3FS_ENDPOINT -o endpoint=$S3FS_REGION +s3fs $AWS_S3_BUCKET $S3FS_MOUNT -o use_path_request_style -o url=$AWS_ENDPOINT_URL_S3 -o endpoint=$AWS_REGION # Check if the mount was successful if mountpoint -q $S3FS_MOUNT; then diff --git a/apps/proxy/fly.toml b/apps/proxy/fly.toml new file mode 100644 index 00000000..9810d8c4 --- /dev/null +++ b/apps/proxy/fly.toml @@ -0,0 +1,36 @@ +app = 'postgres-new-proxy' + +primary_region = 'iad' + +[build] +dockerfile = "Dockerfile" +ignorefile = ".dockerignore" + +[env] +CACHE_DISK_USAGE_THRESHOLD = "90" +CACHE_SCHEDULE_INTERVAL = "1" +CACHE_TIMESTAMP_FILE = "/var/lib/postgres-new/delete_cache_last_run" +CACHE_TTL = "24" +CACHE_PATH = "/var/lib/postgres-new/cache" +S3FS_MOUNT = "/mnt/s3" +WILDCARD_DOMAIN = "db.postgres.new" + +[[services]] +internal_port = 5432 +protocol = "tcp" +auto_stop_machines = "suspend" +auto_start_machines = true +min_machines_running = 0 + +[[services.ports]] +port = 5432 + +[services.concurrency] +type = "connections" +hard_limit = 25 +soft_limit = 20 + +[[vm]] +memory = '1gb' +cpu_kind = 'shared' +cpus = 1 diff --git a/apps/proxy/package.json b/apps/proxy/package.json new file mode 100644 index 00000000..dd74a2c7 --- /dev/null +++ b/apps/proxy/package.json @@ -0,0 +1,27 @@ +{ + "name": "@postgres-new/proxy", + "type": "module", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "build": "tsc", + "generate:certs": "scripts/generate-certs.sh", + "psql": "docker compose run --rm -i psql psql", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@electric-sql/pglite": "0.2.2", + "@supabase/supabase-js": "^2.45.1", + "find-up": "^7.0.0", + "pg-gateway": "0.3.0-alpha.6", + "tar": "^7.4.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@postgres-new/supabase": "*", + "@types/node": "^20.14.11", + "@types/tar": "^6.1.13", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } +} diff --git a/apps/db-service/scripts/generate-certs.sh b/apps/proxy/scripts/generate-certs.sh similarity index 58% rename from apps/db-service/scripts/generate-certs.sh rename to apps/proxy/scripts/generate-certs.sh index 8e474774..a1b3d73a 100755 --- a/apps/db-service/scripts/generate-certs.sh +++ b/apps/proxy/scripts/generate-certs.sh @@ -4,6 +4,7 @@ set -e set -o pipefail S3FS_MOUNT=${S3FS_MOUNT:=.} +DOMAIN="*.${WILDCARD_DOMAIN:=db.example.com}" CERT_DIR="$S3FS_MOUNT/tls" mkdir -p $CERT_DIR @@ -13,6 +14,12 @@ openssl genpkey -algorithm RSA -out ca-key.pem openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days 365 -subj "/CN=MyCA" openssl genpkey -algorithm RSA -out key.pem -openssl req -new -key key.pem -out csr.pem -subj "/CN=*.db.example.com" +openssl req -new -key key.pem -out csr.pem -subj "/CN=$DOMAIN" openssl x509 -req -in csr.pem -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out cert.pem -days 365 + +# create fullchain by concatenating the server certificate and the CA certificate +cat cert.pem ca-cert.pem > fullchain.pem + +# replace cert with the fullchain +mv fullchain.pem cert.pem \ No newline at end of file diff --git a/apps/proxy/src/delete-cache.ts b/apps/proxy/src/delete-cache.ts new file mode 100644 index 00000000..df430acd --- /dev/null +++ b/apps/proxy/src/delete-cache.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import { env } from './env.js' +const execAsync = promisify(exec) + +async function deleteOldFolders() { + const now = Date.now() + const ttlInMillis = env.CACHE_TTL * 60 * 60 * 1000 + + try { + const folders = await fs.readdir(env.CACHE_PATH) + for (const folder of folders) { + const folderPath = path.join(env.CACHE_PATH, folder) + const stats = await fs.stat(folderPath) + + if (stats.isDirectory() && now - stats.mtimeMs > ttlInMillis) { + await fs.rm(folderPath, { recursive: true, force: true }) + console.log(`Deleted folder: ${folderPath}`) + } + } + } catch (err) { + console.error('Failed to delete old folders:', err) + } +} + +async function scriptAlreadyRan() { + try { + const lastRun = parseInt(await fs.readFile(env.CACHE_TIMESTAMP_FILE, 'utf8')) + const now = Math.floor(Date.now() / 1000) + const diff = now - lastRun + return diff < env.CACHE_SCHEDULE_INTERVAL * 60 * 60 * 1000 + } catch (err) { + // File does not exist + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + return false + } + throw err + } +} + +async function updateTimestampFile() { + const now = Math.floor(Date.now() / 1000).toString() + await fs.writeFile(env.CACHE_TIMESTAMP_FILE, now) +} + +/** + * Get the disk usage of the root directory + */ +async function getDiskUsage() { + // awk 'NR==2 {print $5}' prints the 5th column of the df command which contains the percentage of the total disk space used + // sed 's/%//' removes the % from the output + const command = `df / | awk 'NR==2 {print $5}' | sed 's/%//'` + const { stdout } = await execAsync(command) + return parseInt(stdout.trim(), 10) +} + +async function getFoldersByModificationTime() { + const folders = await fs.readdir(env.CACHE_PATH, { withFileTypes: true }) + const folderStats = await Promise.all( + folders + .filter((dirent) => dirent.isDirectory()) + .map(async (dirent) => { + const fullPath = path.join(env.CACHE_PATH, dirent.name) + const stats = await fs.stat(fullPath) + return { path: fullPath, mtime: stats.mtime.getTime() } + }) + ) + return folderStats.sort((a, b) => a.mtime - b.mtime).map((folder) => folder.path) +} + +export async function deleteCache() { + if (await scriptAlreadyRan()) { + console.log(`Script already ran in the last ${env.CACHE_SCHEDULE_INTERVAL} hours, skipping.`) + return + } + + await updateTimestampFile() + + // Always delete old folders based on TTL + await deleteOldFolders() + + let diskUsage = await getDiskUsage() + + // If disk usage exceeds the threshold, delete additional old folders + if (diskUsage >= env.CACHE_DISK_USAGE_THRESHOLD) { + console.log( + `Disk usage is at ${diskUsage}%, which is above the threshold of ${env.CACHE_DISK_USAGE_THRESHOLD}%.` + ) + + const folders = await getFoldersByModificationTime() + + // Loop through the folders and delete them one by one until disk usage is below the threshold + for (const folder of folders) { + console.log(`Deleting folder: ${folder}`) + await fs.rm(folder, { recursive: true, force: true }) + + diskUsage = await getDiskUsage() + if (diskUsage < env.CACHE_DISK_USAGE_THRESHOLD) { + console.log(`Disk usage is now at ${diskUsage}%, which is below the threshold.`) + break + } + } + } else { + console.log( + `Disk usage is at ${diskUsage}%, which is below the threshold of ${env.CACHE_DISK_USAGE_THRESHOLD}%.` + ) + } +} diff --git a/apps/proxy/src/env.ts b/apps/proxy/src/env.ts new file mode 100644 index 00000000..43f3e374 --- /dev/null +++ b/apps/proxy/src/env.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const env = z + .object({ + AWS_ACCESS_KEY_ID: z.string(), + AWS_ENDPOINT_URL_S3: z.string(), + AWS_S3_BUCKET: z.string(), + AWS_SECRET_ACCESS_KEY: z.string(), + AWS_REGION: z.string(), + CACHE_DISK_USAGE_THRESHOLD: z.string().transform((val) => parseInt(val, 10)), + CACHE_PATH: z.string(), + CACHE_SCHEDULE_INTERVAL: z.string().transform((val) => parseInt(val, 10)), + CACHE_TIMESTAMP_FILE: z.string(), + CACHE_TTL: z.string().transform((val) => parseInt(val, 10)), + S3FS_MOUNT: z.string(), + SUPABASE_SERVICE_ROLE_KEY: z.string(), + SUPABASE_URL: z.string(), + WILDCARD_DOMAIN: z.string(), + }) + .parse(process.env) diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts new file mode 100644 index 00000000..894ff143 --- /dev/null +++ b/apps/proxy/src/index.ts @@ -0,0 +1,255 @@ +import { PGlite, PGliteInterface } from '@electric-sql/pglite' +import { vector } from '@electric-sql/pglite/vector' +import { mkdir, readFile, access, rm } from 'node:fs/promises' +import net from 'node:net' +import { createReadStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { createGunzip } from 'node:zlib' +import { extract } from 'tar' +import { PostgresConnection, ScramSha256Data, TlsOptions } from 'pg-gateway' +import { createClient } from '@supabase/supabase-js' +import type { Database } from '@postgres-new/supabase' +import { findUp } from 'find-up' +import { env } from './env.js' +import { deleteCache } from './delete-cache.js' +import path from 'node:path' + +const supabaseUrl = env.SUPABASE_URL +const supabaseKey = env.SUPABASE_SERVICE_ROLE_KEY +const s3fsMount = env.S3FS_MOUNT +const wildcardDomain = env.WILDCARD_DOMAIN + +const packageLockJsonPath = await findUp('package-lock.json') +if (!packageLockJsonPath) { + throw new Error('package-lock.json not found') +} +const packageLockJson = JSON.parse(await readFile(packageLockJsonPath, 'utf8')) as { + packages: { + 'node_modules/@electric-sql/pglite': { + version: string + } + } +} +const pgliteVersion = `(PGlite ${packageLockJson.packages['node_modules/@electric-sql/pglite'].version})` + +const dumpDir = `${s3fsMount}/dbs` +const tlsDir = `${s3fsMount}/tls` + +await mkdir(dumpDir, { recursive: true }) +await mkdir(env.CACHE_PATH, { recursive: true }) +await mkdir(tlsDir, { recursive: true }) + +const tls: TlsOptions = { + key: await readFile(`${tlsDir}/key.pem`), + cert: await readFile(`${tlsDir}/cert.pem`), +} + +function getIdFromServerName(serverName: string) { + // The left-most subdomain contains the ID + // ie. 12345.db.example.com -> 12345 + const [id] = serverName.split('.') + return id +} + +const PostgresErrorCodes = { + ConnectionException: '08000', +} as const + +function sendFatalError(connection: PostgresConnection, code: string, message: string): Error { + connection.sendError({ + severity: 'FATAL', + code, + message, + }) + connection.socket.end() + return new Error(message) +} + +async function fileExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +const supabase = createClient(supabaseUrl, supabaseKey) + +const server = net.createServer((socket) => { + let db: PGliteInterface + + deleteCache().catch((err) => { + console.error(`Error deleting cache: ${err}`) + }) + + const connection = new PostgresConnection(socket, { + serverVersion: async () => { + const { + rows: [{ version }], + } = await db.query<{ version: string }>( + `select current_setting('server_version') as version;` + ) + const serverVersion = `${version} ${pgliteVersion}` + console.log(serverVersion) + return serverVersion + }, + auth: { + method: 'scram-sha-256', + async getScramSha256Data(_, { tlsInfo }) { + if (!tlsInfo?.sniServerName) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + 'sniServerName required in TLS info' + ) + } + + const databaseId = getIdFromServerName(tlsInfo.sniServerName) + const { data, error } = await supabase + .from('deployed_databases') + .select('auth_method, auth_data') + .eq('database_id', databaseId) + .single() + + if (error) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Error getting auth data for database ${databaseId}` + ) + } + + if (data === null) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Database ${databaseId} not found` + ) + } + + if (data.auth_method !== 'scram-sha-256') { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Unsupported auth method for database ${databaseId}: ${data.auth_method}` + ) + } + + return data.auth_data as ScramSha256Data + }, + }, + tls, + async onTlsUpgrade({ tlsInfo }) { + if (!tlsInfo?.sniServerName) { + connection.sendError({ + severity: 'FATAL', + code: '08000', + message: `ssl sni extension required`, + }) + connection.socket.end() + return + } + + if (!tlsInfo.sniServerName.endsWith(wildcardDomain)) { + connection.sendError({ + severity: 'FATAL', + code: '08000', + message: `unknown server ${tlsInfo.sniServerName}`, + }) + connection.socket.end() + return + } + }, + async onAuthenticated({ tlsInfo }) { + // at this point we know sniServerName is set + const databaseId = getIdFromServerName(tlsInfo!.sniServerName!) + + console.log(`Serving database '${databaseId}'`) + + const dbPath = path.join(env.CACHE_PATH, databaseId) + + if (!(await fileExists(dbPath))) { + console.log(`Database '${databaseId}' is not cached, downloading...`) + + const dumpPath = path.join(dumpDir, `${databaseId}.tar.gz`) + + if (!(await fileExists(dumpPath))) { + connection.sendError({ + severity: 'FATAL', + code: 'XX000', + message: `database ${databaseId} not found`, + }) + connection.socket.end() + return + } + + // Create a directory for the database + await mkdir(dbPath, { recursive: true }) + + try { + // Extract the .tar.gz file + await pipeline(createReadStream(dumpPath), createGunzip(), extract({ cwd: dbPath })) + } catch (error) { + console.error(error) + await rm(dbPath, { recursive: true, force: true }) // Clean up the partially created directory + connection.sendError({ + severity: 'FATAL', + code: 'XX000', + message: `Error extracting database: ${(error as Error).message}`, + }) + connection.socket.end() + return + } + } + + db = new PGlite({ + dataDir: dbPath, + extensions: { + vector, + }, + }) + await db.waitReady + const { rows } = await db.query("SELECT 1 FROM pg_roles WHERE rolname = 'readonly_postgres';") + if (rows.length === 0) { + await db.exec(` + CREATE USER readonly_postgres; + GRANT pg_read_all_data TO readonly_postgres; + `) + } + await db.close() + db = new PGlite({ + dataDir: dbPath, + username: 'readonly_postgres', + extensions: { + vector, + }, + }) + await db.waitReady + }, + async onMessage(data, { isAuthenticated }) { + // Only forward messages to PGlite after authentication + if (!isAuthenticated) { + return false + } + + // Forward raw message to PGlite + try { + const responseData = await db.execProtocolRaw(data) + connection.sendData(responseData) + } catch (err) { + console.error(err) + } + return true + }, + }) + + socket.on('close', async () => { + console.log('Client disconnected') + await db?.close() + }) +}) + +server.listen(5432, async () => { + console.log('Server listening on port 5432') +}) diff --git a/apps/proxy/tools/dns/Corefile b/apps/proxy/tools/dns/Corefile new file mode 100644 index 00000000..29d5a269 --- /dev/null +++ b/apps/proxy/tools/dns/Corefile @@ -0,0 +1,12 @@ +.:53 { + # Resolve all wildcard domain requests to the proxy + template IN ANY {$WILDCARD_DOMAIN} { + answer "{{ .Name }} 60 IN CNAME {$SERVICE_NAME}" + } + + # Forward any other queries to Docker DNS + forward . 127.0.0.11 + + log + errors +} diff --git a/apps/proxy/tools/dns/Dockerfile b/apps/proxy/tools/dns/Dockerfile new file mode 100644 index 00000000..2da82bea --- /dev/null +++ b/apps/proxy/tools/dns/Dockerfile @@ -0,0 +1,7 @@ +FROM coredns/coredns:latest + +COPY Corefile /Corefile + +EXPOSE 53/udp + +CMD ["-conf", "/Corefile"] diff --git a/apps/db-service/tsconfig.json b/apps/proxy/tsconfig.json similarity index 100% rename from apps/db-service/tsconfig.json rename to apps/proxy/tsconfig.json diff --git a/apps/postgres-new/.env.example b/apps/web/.env.example similarity index 61% rename from apps/postgres-new/.env.example rename to apps/web/.env.example index 425deb9d..70a506ea 100644 --- a/apps/postgres-new/.env.example +++ b/apps/web/.env.example @@ -1,13 +1,16 @@ -NEXT_PUBLIC_SUPABASE_ANON_KEY="" -NEXT_PUBLIC_SUPABASE_URL="" -NEXT_PUBLIC_IS_PREVIEW=true - +AWS_ACCESS_KEY_ID="" +AWS_ENDPOINT_URL_S3=http://localhost:54321/storage/v1/s3 +AWS_S3_BUCKET=s3fs +AWS_SECRET_ACCESS_KEY="" +AWS_REGION=local +# Vercel KV (local Docker available) +KV_REST_API_TOKEN="local_token" +KV_REST_API_URL="http://localhost:8080" OPENAI_API_KEY="" - # Optional # OPENAI_API_BASE="" # OPENAI_MODEL="" - -# Vercel KV (local Docker available) -KV_REST_API_URL="http://localhost:8080" -KV_REST_API_TOKEN="local_token" +NEXT_PUBLIC_IS_PREVIEW=true +NEXT_PUBLIC_SUPABASE_ANON_KEY="" +NEXT_PUBLIC_SUPABASE_URL="" +NEXT_PUBLIC_WILDCARD_DOMAIN=db.example.com \ No newline at end of file diff --git a/apps/postgres-new/README.md b/apps/web/README.md similarity index 97% rename from apps/postgres-new/README.md rename to apps/web/README.md index 233348aa..569fbe9a 100644 --- a/apps/postgres-new/README.md +++ b/apps/web/README.md @@ -1,4 +1,4 @@ -# postgres-new +# @postgres-new/web In-browser Postgres sandbox with AI assistance. Built on Next.js. @@ -36,7 +36,7 @@ Authentication and users are managed by a [Supabase](https://supabase.com/) data ## Development -From this directory (`./apps/postgres-new`): +From this directory (`./apps/web`): 1. Install dependencies: ```shell diff --git a/apps/postgres-new/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts similarity index 100% rename from apps/postgres-new/app/api/chat/route.ts rename to apps/web/app/api/chat/route.ts diff --git a/apps/web/app/api/databases/[id]/reset-password/route.ts b/apps/web/app/api/databases/[id]/reset-password/route.ts new file mode 100644 index 00000000..4e7839f1 --- /dev/null +++ b/apps/web/app/api/databases/[id]/reset-password/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '~/utils/supabase/server' +import { createScramSha256Data } from 'pg-gateway' +import { generateDatabasePassword } from '~/utils/generate-database-password' + +export type DatabaseResetPasswordResponse = + | { + success: true + data: { + password: string + } + } + | { + success: false + error: string + } + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const databaseId = params.id + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + if (!existingDeployedDatabase) { + return NextResponse.json( + { + success: false, + error: `Database ${databaseId} was not found`, + }, + { status: 404 } + ) + } + + const password = generateDatabasePassword() + + await supabase + .from('deployed_databases') + .update({ + auth_data: createScramSha256Data(password), + }) + .eq('database_id', databaseId) + + return NextResponse.json({ + success: true, + data: { + password, + }, + }) +} diff --git a/apps/web/app/api/databases/[id]/route.ts b/apps/web/app/api/databases/[id]/route.ts new file mode 100644 index 00000000..fe6c7046 --- /dev/null +++ b/apps/web/app/api/databases/[id]/route.ts @@ -0,0 +1,71 @@ +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '~/utils/supabase/server' + +const s3Client = new S3Client({ forcePathStyle: true }) + +export type DatabaseDeleteResponse = + | { + success: true + } + | { + success: false + error: string + } + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const databaseId = params.id + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + if (!existingDeployedDatabase) { + return NextResponse.json( + { + success: false, + error: `Database ${databaseId} was not found`, + }, + { status: 404 } + ) + } + + await supabase.from('deployed_databases').delete().eq('database_id', databaseId) + + const key = `dbs/${databaseId}.tar.gz` + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET, + Key: key, + }) + ) + } catch (error) { + console.error(`Error deleting S3 object ${key}:`, error) + } + + return NextResponse.json({ + success: true, + }) +} diff --git a/apps/web/app/api/databases/[id]/upload/route.ts b/apps/web/app/api/databases/[id]/upload/route.ts new file mode 100644 index 00000000..99e40628 --- /dev/null +++ b/apps/web/app/api/databases/[id]/upload/route.ts @@ -0,0 +1,142 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { Upload } from '@aws-sdk/lib-storage' +import { NextRequest, NextResponse } from 'next/server' +import { createGzip } from 'zlib' +import { Readable } from 'stream' +import { createClient } from '~/utils/supabase/server' +import { createScramSha256Data } from 'pg-gateway' +import { generateDatabasePassword } from '~/utils/generate-database-password' +import { getUncompressedSizeInMB } from '~/utils/get-uncompressed-size-in-mb' + +const wildcardDomain = process.env.NEXT_PUBLIC_WILDCARD_DOMAIN ?? 'db.example.com' +const s3Client = new S3Client({ forcePathStyle: true }) + +export type DatabaseUploadResponse = + | { + success: true + data: { + username: string + password?: string + host: string + port: number + databaseName: string + } + } + | { + success: false + error: string + } + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const data = await req.formData() + + const dump = data.get('dump') as File | null + const name = data.get('name') as string | null + const createdAt = data.get('created_at') as string | null + + if (!dump || !name || !createdAt) { + return NextResponse.json( + { + success: false, + error: 'Missing fields', + }, + { status: 400 } + ) + } + + if ((await getUncompressedSizeInMB(dump)) > 100) { + return NextResponse.json( + { + success: false, + error: "You can't deploy a database that is bigger than 100MB", + }, + { status: 413 } + ) + } + + const databaseId = params.id + const key = `dbs/${databaseId}.tar.gz` + + const gzip = createGzip() + const body = Readable.from(streamToAsyncIterable(dump.stream())) + + const upload = new Upload({ + client: s3Client, + params: { + Bucket: process.env.AWS_S3_BUCKET, + Key: key, + Body: body.pipe(gzip), + }, + }) + + await upload.done() + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + let password: string | undefined + + if (existingDeployedDatabase) { + await supabase + .from('deployed_databases') + .update({ + deployed_at: 'now()', + }) + .eq('database_id', databaseId) + } else { + password = generateDatabasePassword() + await supabase.from('deployed_databases').insert({ + database_id: databaseId, + name, + created_at: createdAt, + auth_method: 'scram-sha-256', + auth_data: createScramSha256Data(password), + }) + } + + return NextResponse.json({ + success: true, + data: { + username: 'readonly_postgres', + password, + host: `${databaseId}.${wildcardDomain}`, + port: 5432, + databaseName: 'postgres', + }, + }) +} + +async function* streamToAsyncIterable(stream: ReadableStream) { + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + yield value + } + } finally { + reader.releaseLock() + } +} diff --git a/apps/postgres-new/app/apple-icon.png b/apps/web/app/apple-icon.png similarity index 100% rename from apps/postgres-new/app/apple-icon.png rename to apps/web/app/apple-icon.png diff --git a/apps/postgres-new/app/db/[id]/page.tsx b/apps/web/app/db/[id]/page.tsx similarity index 100% rename from apps/postgres-new/app/db/[id]/page.tsx rename to apps/web/app/db/[id]/page.tsx diff --git a/apps/postgres-new/app/favicon.ico b/apps/web/app/favicon.ico similarity index 100% rename from apps/postgres-new/app/favicon.ico rename to apps/web/app/favicon.ico diff --git a/apps/postgres-new/app/globals.css b/apps/web/app/globals.css similarity index 100% rename from apps/postgres-new/app/globals.css rename to apps/web/app/globals.css diff --git a/apps/postgres-new/app/icon.svg b/apps/web/app/icon.svg similarity index 100% rename from apps/postgres-new/app/icon.svg rename to apps/web/app/icon.svg diff --git a/apps/postgres-new/app/layout.tsx b/apps/web/app/layout.tsx similarity index 100% rename from apps/postgres-new/app/layout.tsx rename to apps/web/app/layout.tsx diff --git a/apps/postgres-new/app/opengraph-image.png b/apps/web/app/opengraph-image.png similarity index 100% rename from apps/postgres-new/app/opengraph-image.png rename to apps/web/app/opengraph-image.png diff --git a/apps/postgres-new/app/page.tsx b/apps/web/app/page.tsx similarity index 89% rename from apps/postgres-new/app/page.tsx rename to apps/web/app/page.tsx index c0cc19a9..f76ae1fd 100644 --- a/apps/postgres-new/app/page.tsx +++ b/apps/web/app/page.tsx @@ -5,8 +5,8 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { useApp } from '~/components/app-provider' import Workspace from '~/components/workspace' -import { useDatabaseCreateMutation } from '~/data/databases/database-create-mutation' -import { useDatabaseUpdateMutation } from '~/data/databases/database-update-mutation' +import { useLocalDatabaseCreateMutation } from '~/data/local-databases/local-database-create-mutation' +import { useLocalDatabaseUpdateMutation } from '~/data/local-databases/local-database-update-mutation' // Use a DNS safe alphabet const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) @@ -19,8 +19,8 @@ export default function Page() { const { dbManager } = useApp() const router = useRouter() - const { mutateAsync: createDatabase } = useDatabaseCreateMutation() - const { mutateAsync: updateDatabase } = useDatabaseUpdateMutation() + const { mutateAsync: createDatabase } = useLocalDatabaseCreateMutation() + const { mutateAsync: updateDatabase } = useLocalDatabaseUpdateMutation() /** * Preloads next empty database so that it is ready immediately. diff --git a/apps/postgres-new/assets/github-icon.tsx b/apps/web/assets/github-icon.tsx similarity index 100% rename from apps/postgres-new/assets/github-icon.tsx rename to apps/web/assets/github-icon.tsx diff --git a/apps/postgres-new/components.json b/apps/web/components.json similarity index 100% rename from apps/postgres-new/components.json rename to apps/web/components.json diff --git a/apps/postgres-new/components/ai-icon-animation/ai-icon-animation-style.module.css b/apps/web/components/ai-icon-animation/ai-icon-animation-style.module.css similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/ai-icon-animation-style.module.css rename to apps/web/components/ai-icon-animation/ai-icon-animation-style.module.css diff --git a/apps/postgres-new/components/ai-icon-animation/ai-icon-animation.tsx b/apps/web/components/ai-icon-animation/ai-icon-animation.tsx similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/ai-icon-animation.tsx rename to apps/web/components/ai-icon-animation/ai-icon-animation.tsx diff --git a/apps/postgres-new/components/ai-icon-animation/index.tsx b/apps/web/components/ai-icon-animation/index.tsx similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/index.tsx rename to apps/web/components/ai-icon-animation/index.tsx diff --git a/apps/postgres-new/components/app-provider.tsx b/apps/web/components/app-provider.tsx similarity index 94% rename from apps/postgres-new/components/app-provider.tsx rename to apps/web/components/app-provider.tsx index 49725991..5c3af6bb 100644 --- a/apps/postgres-new/components/app-provider.tsx +++ b/apps/web/components/app-provider.tsx @@ -27,7 +27,6 @@ const dbManager = typeof window !== 'undefined' ? new DbManager() : undefined export default function AppProvider({ children }: AppProps) { const [isLoadingUser, setIsLoadingUser] = useState(true) const [user, setUser] = useState() - const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const [isRateLimited, setIsRateLimited] = useState(false) const focusRef = useRef(null) @@ -112,8 +111,6 @@ export default function AppProvider({ children }: AppProps) { isLoadingUser, signIn, signOut, - isSignInDialogOpen, - setIsSignInDialogOpen, isRateLimited, setIsRateLimited, focusRef, @@ -137,8 +134,6 @@ export type AppContextValues = { isLoadingUser: boolean signIn: () => Promise signOut: () => Promise - isSignInDialogOpen: boolean - setIsSignInDialogOpen: (open: boolean) => void isRateLimited: boolean setIsRateLimited: (limited: boolean) => void focusRef: RefObject diff --git a/apps/postgres-new/components/chat-message.tsx b/apps/web/components/chat-message.tsx similarity index 100% rename from apps/postgres-new/components/chat-message.tsx rename to apps/web/components/chat-message.tsx diff --git a/apps/postgres-new/components/chat.tsx b/apps/web/components/chat.tsx similarity index 96% rename from apps/postgres-new/components/chat.tsx rename to apps/web/components/chat.tsx index 7f1c87b8..e27cdb6a 100644 --- a/apps/postgres-new/components/chat.tsx +++ b/apps/web/components/chat.tsx @@ -25,6 +25,7 @@ import { useApp } from './app-provider' import ChatMessage from './chat-message' import SignInButton from './sign-in-button' import { useWorkspace } from './workspace' +import { SignInDialog } from './sign-in-dialog' export function getInitialMessages(tables: TablesData): Message[] { return [ @@ -48,7 +49,7 @@ export function getInitialMessages(tables: TablesData): Message[] { } export default function Chat() { - const { user, isLoadingUser, focusRef, setIsSignInDialogOpen, isRateLimited } = useApp() + const { user, isLoadingUser, focusRef, isRateLimited } = useApp() const [inputFocusState, setInputFocusState] = useState(false) const { @@ -352,14 +353,11 @@ export default function Chat() {

To prevent abuse we ask you to sign in before chatting with AI.

-

{ - setIsSignInDialogOpen(true) - }} - > - Why do I need to sign in? -

+ +

+ Why do I need to sign in? +

+
)} @@ -406,14 +404,11 @@ export default function Chat() {

To prevent abuse we ask you to sign in before chatting with AI.

-

{ - setIsSignInDialogOpen(true) - }} - > - Why do I need to sign in? -

+ +

+ Why do I need to sign in? +

+
)} diff --git a/apps/postgres-new/components/code-accordion.tsx b/apps/web/components/code-accordion.tsx similarity index 100% rename from apps/postgres-new/components/code-accordion.tsx rename to apps/web/components/code-accordion.tsx diff --git a/apps/postgres-new/components/code-block.tsx b/apps/web/components/code-block.tsx similarity index 88% rename from apps/postgres-new/components/code-block.tsx rename to apps/web/components/code-block.tsx index f4c88d1f..acd04303 100644 --- a/apps/postgres-new/components/code-block.tsx +++ b/apps/web/components/code-block.tsx @@ -6,9 +6,10 @@ * TODO: Redesign this component */ +import { Copy } from 'lucide-react' import { useTheme } from 'next-themes' -import { Children, ReactNode, useState } from 'react' -import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' +import { Children, type ReactNode, useState } from 'react' +import { Light as SyntaxHighlighter, type SyntaxHighlighterProps } from 'react-syntax-highlighter' import curl from 'highlightjs-curl' import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' @@ -23,6 +24,7 @@ import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql' import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' import { cn } from '~/lib/utils' +import { Button } from './ui/button' export interface CodeBlockProps { title?: ReactNode @@ -44,7 +46,7 @@ export interface CodeBlockProps { hideLineNumbers?: boolean className?: string value?: string - children?: string + children?: string | string[] renderer?: SyntaxHighlighterProps['renderer'] theme?: 'auto' | 'light' | 'dark' } @@ -69,9 +71,10 @@ export const CodeBlock = ({ const handleCopy = () => { setCopied(true) + navigator.clipboard.writeText(value ?? '') setTimeout(() => { setCopied(false) - }, 1000) + }, 2000) } // Extract string when `children` has a single string node @@ -79,7 +82,13 @@ export const CodeBlock = ({ const [singleChild] = childrenArray.length === 1 ? childrenArray : [] const singleString = typeof singleChild === 'string' ? singleChild : undefined - let codeValue = value ?? singleString ?? children + let codeValue = + value ?? + (typeof children === 'string' + ? children + : Array.isArray(children) + ? children.join('') + : undefined) codeValue = codeValue?.trimEnd?.() ?? codeValue // check the length of the string inside the tag @@ -162,11 +171,24 @@ export const CodeBlock = ({ {!hideCopy && (value || children) && className ? (
+ > + + ) : null} ) : ( diff --git a/apps/web/components/deployed-database-fields.tsx b/apps/web/components/deployed-database-fields.tsx new file mode 100644 index 00000000..657a442e --- /dev/null +++ b/apps/web/components/deployed-database-fields.tsx @@ -0,0 +1,65 @@ +import { CopyIcon } from 'lucide-react' +import { useState } from 'react' +import { Button } from '~/components/ui/button' +import { Input } from '~/components/ui/input' +import { Label } from '~/components/ui/label' +import { DeployedDatabaseCreateResult } from '~/data/deployed-databases/deployed-database-create-mutation' + +export type DeployedDatabaseFieldsProps = DeployedDatabaseCreateResult + +export function DeployedDatabaseFields(props: DeployedDatabaseFieldsProps) { + const port = '5432' + const password = props.password ?? '[The password for your database]' + const connectionStringPassword = props.password + ? encodeURIComponent(props.password) + : '[YOUR-PASSWORD]' + const connectionString = `postgresql://${props.username}:${connectionStringPassword}@${props.host}/${props.databaseName}` + + return ( +
+ + + + + + +
+ ) +} + +function CopyableField(props: { label: string; value: string; disableCopy?: boolean }) { + return ( +
+ + +
+ ) +} + +function CopyableInput(props: { value: string; disableCopy?: boolean }) { + const [isCopying, setIsCopying] = useState(false) + + function handleCopy(value: string) { + setIsCopying(true) + navigator.clipboard.writeText(value) + setTimeout(() => { + setIsCopying(false) + }, 2000) + } + + return ( +
+ + {!props.disableCopy && ( + + )} +
+ ) +} diff --git a/apps/postgres-new/components/framer-features.ts b/apps/web/components/framer-features.ts similarity index 100% rename from apps/postgres-new/components/framer-features.ts rename to apps/web/components/framer-features.ts diff --git a/apps/postgres-new/components/ide.tsx b/apps/web/components/ide.tsx similarity index 100% rename from apps/postgres-new/components/ide.tsx rename to apps/web/components/ide.tsx diff --git a/apps/postgres-new/components/layout.tsx b/apps/web/components/layout.tsx similarity index 92% rename from apps/postgres-new/components/layout.tsx rename to apps/web/components/layout.tsx index e3ad7b0d..50d634b6 100644 --- a/apps/postgres-new/components/layout.tsx +++ b/apps/web/components/layout.tsx @@ -8,7 +8,8 @@ import { PropsWithChildren } from 'react' import { TooltipProvider } from '~/components/ui/tooltip' import { useBreakpoint } from '~/lib/use-breakpoint' import { useApp } from './app-provider' -import Sidebar from './sidebar' +import Sidebar from './sidebar/sidebar' +import { Toaster } from '~/components/ui/toaster' const loadFramerFeatures = () => import('./framer-features').then((res) => res.default) @@ -36,6 +37,7 @@ export default function Layout({ children }: LayoutProps) { + ) diff --git a/apps/postgres-new/components/markdown-accordion.tsx b/apps/web/components/markdown-accordion.tsx similarity index 100% rename from apps/postgres-new/components/markdown-accordion.tsx rename to apps/web/components/markdown-accordion.tsx diff --git a/apps/postgres-new/components/providers.tsx b/apps/web/components/providers.tsx similarity index 100% rename from apps/postgres-new/components/providers.tsx rename to apps/web/components/providers.tsx diff --git a/apps/postgres-new/components/schema/graph.tsx b/apps/web/components/schema/graph.tsx similarity index 100% rename from apps/postgres-new/components/schema/graph.tsx rename to apps/web/components/schema/graph.tsx diff --git a/apps/postgres-new/components/schema/legend.tsx b/apps/web/components/schema/legend.tsx similarity index 100% rename from apps/postgres-new/components/schema/legend.tsx rename to apps/web/components/schema/legend.tsx diff --git a/apps/postgres-new/components/schema/table-graph.tsx b/apps/web/components/schema/table-graph.tsx similarity index 100% rename from apps/postgres-new/components/schema/table-graph.tsx rename to apps/web/components/schema/table-graph.tsx diff --git a/apps/postgres-new/components/schema/table-node.tsx b/apps/web/components/schema/table-node.tsx similarity index 100% rename from apps/postgres-new/components/schema/table-node.tsx rename to apps/web/components/schema/table-node.tsx diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx new file mode 100644 index 00000000..60126aba --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx @@ -0,0 +1,85 @@ +import { MoreVertical } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu' +import { cn } from '~/lib/utils' +import { DatabaseItemRenameAction, RenameDatabaseForm } from './database-item-rename-action' +import { useState } from 'react' +import { DatabaseItemDownloadAction } from './database-item-download-action' +import { DatabaseItemDeployAction } from './database-item-deploy-action/database-item-deploy-action' +import { DatabaseItemDeleteAction } from './database-item-delete-action/database-item-delete-action' +import { Button } from '~/components/ui/button' +import type { Database } from '~/data/databases/database-type' + +export type DatabaseItemActionsProps = { + database: Database + isActive: boolean +} + +export function DatabaseItemActions(props: DatabaseItemActionsProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [hasOpenDialog, setHasOpenDialog] = useState(false) + const [isRenaming, setIsRenaming] = useState(false) + + function handleDialogOpenChange(open: boolean) { + setHasOpenDialog(open) + if (open === false) { + setIsDropdownOpen(false) + } + } + + return ( + + + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx new file mode 100644 index 00000000..b13bdd35 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx @@ -0,0 +1,78 @@ +import { AlertDialogPortal } from '@radix-ui/react-alert-dialog' +import { Loader } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { MouseEvent, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '~/components/ui/alert-dialog' +import { useDatabasesDeleteMutation } from '~/data/databases/database-delete-mutation' +import type { Database } from '~/data/databases/database-type' + +type ConfirmDatabaseDeleteAlertProps = { + children: React.ReactNode + database: Database + isActive: boolean + onOpenChange: (open: boolean) => void +} + +export function ConfirmDatabaseDeleteAlert(props: ConfirmDatabaseDeleteAlertProps) { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const { deleteDatabase, isLoading: isDeleting } = useDatabasesDeleteMutation() + + function handleOpenChange(open: boolean) { + setIsOpen(open) + props.onOpenChange(open) + } + + async function handleDelete(e: MouseEvent) { + e.preventDefault() + await deleteDatabase(props.database) + setIsOpen(false) + if (props.isActive) { + router.push('/') + } + } + + return ( + + {props.children} + + + + Delete database? + + This will permanently delete "{props.database.name}". + {props.database.deployment && ' All connected applications will lose access.'} + + + + Cancel + + {isDeleting ? ( + + {' '} + Deleting + + ) : ( + 'Delete' + )} + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx new file mode 100644 index 00000000..cfe5caa6 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx @@ -0,0 +1,30 @@ +import { Trash2 } from 'lucide-react' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { ConfirmDatabaseDeleteAlert } from './confirm-database-delete-alert' + +export type DatabaseItemDeleteActionProps = { + database: Database + isActive: boolean + onDialogOpenChange: (isOpen: boolean) => void +} + +export function DatabaseItemDeleteAction(props: DatabaseItemDeleteActionProps) { + return ( + + { + e.preventDefault() + }} + > + + Delete + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx new file mode 100644 index 00000000..9f50396b --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx @@ -0,0 +1,91 @@ +import { AlertDialogPortal } from '@radix-ui/react-alert-dialog' +import { Loader } from 'lucide-react' +import { MouseEvent, useEffect, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '~/components/ui/alert-dialog' +import { useToast } from '~/components/ui/use-toast' +import type { Database } from '~/data/databases/database-type' +import { + DeployedDatabaseCreateResult, + useDeployedDatabaseCreateMutation, +} from '~/data/deployed-databases/deployed-database-create-mutation' + +type ConfirmDatabaseRedeployAlertProps = { + children: React.ReactNode + database: Database + onSuccess: (data: DeployedDatabaseCreateResult) => void + onOpenChange: (open: boolean) => void +} + +export function ConfirmDatabaseRedeployAlert(props: ConfirmDatabaseRedeployAlertProps) { + const [isOpen, setIsOpen] = useState(false) + const { mutateAsync: deployDatabase, isPending: isDeploying } = + useDeployedDatabaseCreateMutation() + const { toast } = useToast() + + function handleOpenChange(open: boolean) { + setIsOpen(open) + props.onOpenChange(open) + } + + async function handleDeploy(e: MouseEvent) { + e.preventDefault() + try { + const data = await deployDatabase({ + createdAt: props.database.createdAt, + databaseId: props.database.id, + name: props.database.name, + }) + props.onSuccess(data) + } catch (error) { + toast({ + title: 'Database deployment failed', + description: (error as Error).message, + }) + } finally { + setIsOpen(false) + } + } + + return ( + + {props.children} + + + + Redeploy database? + + This will replace the existing "{props.database.name}" with its current version. + + + + Cancel + + {isDeploying ? ( + + {' '} + Redeploying + + ) : ( + 'Redeploy' + )} + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx new file mode 100644 index 00000000..0f06b5a9 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx @@ -0,0 +1,53 @@ +import { DeployedDatabaseFields } from '~/components/deployed-database-fields' +import { + Dialog, + DialogContent, + DialogHeader, + DialogPortal, + DialogTitle, +} from '~/components/ui/dialog' +import type { Database } from '~/data/databases/database-type' +import { DeployedDatabaseCreateResult } from '~/data/deployed-databases/deployed-database-create-mutation' + +export type DatabaseDeployedDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + database: Database +} & DeployedDatabaseCreateResult + +export function DatabaseDeployedDialog(props: DatabaseDeployedDialogProps) { + return ( + + + + + Database {props.database.name} deployed +
+ +

+ Your database has been deployed to a serverless{' '} + + PGlite + {' '} + instance so that it can be accessed outside the browser using any Postgres client. +

+ + {props.password && ( +
+

+ Please{' '} + save your password, + it will not be shown again! +

+
+ )} + + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx new file mode 100644 index 00000000..bc5014d0 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx @@ -0,0 +1,124 @@ +import { Loader, Upload } from 'lucide-react' +import { useState } from 'react' +import { useApp } from '~/components/app-provider' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { + DeployedDatabaseCreateResult, + useDeployedDatabaseCreateMutation, +} from '~/data/deployed-databases/deployed-database-create-mutation' +import { DatabaseDeployedDialog } from './database-deployed-dialog' +import { ConfirmDatabaseRedeployAlert } from './confirm-redeploy-database-alert' + +export type DatabaseItemDeployActionProps = { + database: Database + onDialogOpenChange: (isOpen: boolean) => void +} + +export function DatabaseItemDeployAction(props: DatabaseItemDeployActionProps) { + const [deployResult, setDeployResult] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + + function handleDeploySuccess(data: DeployedDatabaseCreateResult) { + setDeployResult(data) + setIsDialogOpen(true) + props.onDialogOpenChange(true) + } + + return ( + <> + {props.database.deployment ? ( + + ) : ( + + )} + {deployResult && ( + + )} + + ) +} + +type DatabaseItemDeployActionMenuItemProps = { + database: Database + onDeploySuccess: (data: DeployedDatabaseCreateResult) => void +} + +function DatabaseItemDeployActionMenuItem(props: DatabaseItemDeployActionMenuItemProps) { + const { user } = useApp() + const { mutateAsync: deployDatabase, isPending: isDeploying } = + useDeployedDatabaseCreateMutation() + + async function handleMenuItemSelect(e: Event) { + e.preventDefault() + + const deploymentResult = await deployDatabase({ + databaseId: props.database.id, + createdAt: props.database.createdAt, + name: props.database.name, + }) + + props.onDeploySuccess(deploymentResult) + } + + return ( + + {isDeploying ? ( + + ) : ( + + )} + + Deploy + + ) +} + +type DatabaseItemRedeployActionMenuItemProps = { + database: Database + onDeploySuccess: (data: DeployedDatabaseCreateResult) => void + onDialogOpenChange: (open: boolean) => void +} + +function DatabaseItemRedeployActionMenuItem(props: DatabaseItemRedeployActionMenuItemProps) { + const { user } = useApp() + + return ( + + { + e.preventDefault() + }} + > + + Redeploy + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx new file mode 100644 index 00000000..fd85918f --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx @@ -0,0 +1,32 @@ +import { Download } from 'lucide-react' +import { useApp } from '~/components/app-provider' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { downloadFile, titleToKebabCase } from '~/lib/util' + +export type DatabaseItemDownloadActionProps = { database: Database } + +export function DatabaseItemDownloadAction(props: DatabaseItemDownloadActionProps) { + const { dbManager } = useApp() + + async function handleMenuItemSelect(e: Event) { + if (!dbManager) { + throw new Error('dbManager is not available') + } + + const db = await dbManager.getDbInstance(props.database.id) + const dumpBlob = await db.dumpDataDir() + + const fileName = `${titleToKebabCase(props.database.name ?? 'My Database')}-${Date.now()}` + const file = new File([dumpBlob], fileName, { type: dumpBlob.type }) + + downloadFile(file) + } + + return ( + + + Download + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx new file mode 100644 index 00000000..1e0af6bf --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx @@ -0,0 +1,47 @@ +import { Pencil } from 'lucide-react' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { useLocalDatabaseUpdateMutation } from '~/data/local-databases/local-database-update-mutation' + +export function DatabaseItemRenameAction(props: { + database: Database + onSelect: (e: Event) => void +}) { + return ( + + + Rename + + ) +} + +export function RenameDatabaseForm(props: { database: Database; onSuccess: () => void }) { + const { mutateAsync: updateDatabase } = useLocalDatabaseUpdateMutation() + + return ( +
{ + e.preventDefault() + if (e.target instanceof HTMLFormElement) { + const formData = new FormData(e.target) + const name = formData.get('name') + + if (typeof name === 'string') { + await updateDatabase({ ...props.database, name }) + } + } + props.onSuccess() + }} + > + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item.tsx b/apps/web/components/sidebar/database-list/database-item/database-item.tsx new file mode 100644 index 00000000..e3ccd9ec --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item.tsx @@ -0,0 +1,52 @@ +import type { Database } from '~/data/databases/database-type' +import Link from 'next/link' +import { cn } from '~/lib/utils' +import { DatabaseItemActions } from './database-item-actions/database-item-actions' +import { CloudIcon } from 'lucide-react' +import { DeployedDatabaseDialog } from './deployed-database-dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' +import { Button } from '~/components/ui/button' + +type DatabaseItemProps = { + database: Database + isActive: boolean +} + +export function DatabaseItem(props: DatabaseItemProps) { + const databaseName = props.database.name ?? 'My database' + + return ( +
+ {props.database.deployment ? ( + + + + + + + Database deployed + + + + ) : ( +
+ )} + + {databaseName} + +
+ +
+
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx b/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx new file mode 100644 index 00000000..1a404ec1 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx @@ -0,0 +1,99 @@ +import { Loader, LoaderIcon, RefreshCwIcon } from 'lucide-react' +import { useState } from 'react' +import { + DeployedDatabaseFields, + DeployedDatabaseFieldsProps, +} from '~/components/deployed-database-fields' +import { Button } from '~/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogPortal, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog' +import type { Database } from '~/data/databases/database-type' +import { useDeployedDatabaseResetPasswordMutation } from '~/data/deployed-databases/deployed-database-reset-password-mutation' + +type DeployedDatabaseDialogProps = { + database: Database + children: React.ReactNode +} + +export function DeployedDatabaseDialog(props: DeployedDatabaseDialogProps) { + const [password, setPassword] = useState() + const { mutateAsync: resetDatabasePassword, isPending: isResettingDatabasePassword } = + useDeployedDatabaseResetPasswordMutation() + + // TODO: maybe store these infos as part of the Database type + const fields: DeployedDatabaseFieldsProps = { + username: 'readonly_postgres', + databaseName: 'postgres', + host: `${props.database.id}.${process.env.NEXT_PUBLIC_WILDCARD_DOMAIN}`, + port: 5432, + password, + } + + async function handleResetPassword() { + const result = await resetDatabasePassword({ databaseId: props.database.id }) + setPassword(result.password) + } + + return ( + + {props.children} + + + + Database {props.database.name} +
+ +

+ Your database is deployed to a serverless{' '} + + PGlite + {' '} + instance so that it can be accessed outside the browser using any Postgres client. +

+ +
+ {password ? ( +

+ Please{' '} + save your password, + it will not be shown again! +

+ ) : ( +

+ Forgot your database password? + +

+ )} +
+ + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-list.tsx b/apps/web/components/sidebar/database-list/database-list.tsx new file mode 100644 index 00000000..26e8b500 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-list.tsx @@ -0,0 +1,52 @@ +import { m } from 'framer-motion' +import { useParams } from 'next/navigation' +import { useDatabasesQuery } from '~/data/databases/databases-query' +import { DatabaseItem } from './database-item/database-item' +import { Database as DatabaseIcon, Loader } from 'lucide-react' + +export type DatabaseListProps = {} + +export function DatabaseList(props: DatabaseListProps) { + const { id: currentDatabaseId } = useParams<{ id: string }>() + const { databases, isLoading: isLoadingDatabases } = useDatabasesQuery() + + if (isLoadingDatabases) { + return ( +
+ +
+ ) + } + + if (databases.length === 0) { + return ( +
+ + No databases +
+ ) + } + + return ( + + {databases.map((database) => ( + + + + ))} + + ) +} diff --git a/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx b/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx new file mode 100644 index 00000000..b0e18955 --- /dev/null +++ b/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx @@ -0,0 +1,71 @@ +import { m } from 'framer-motion' +import ThemeDropdown from './theme-dropdown' +import { useApp } from '~/components/app-provider' +import { Button } from '~/components/ui/button' +import { LogOut } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' + +export type SidebarFooterProps = {} + +export function SidebarFooter(props: SidebarFooterProps) { + const { user, signOut } = useApp() + + async function handleSignOut() { + await signOut() + } + + return ( + <> + + + + {user && ( + + + + )} + + ) +} + +export type CollapsedSidebarFooterProps = {} + +export function CollapsedSidebparFooter(props: CollapsedSidebarFooterProps) { + const { user, signOut } = useApp() + + async function handleSignOut() { + await signOut() + } + + return ( +
+ + + + + + + +

Toggle theme

+
+
+ {user && ( + + + + + + + +

Sign out

+
+
+ )} +
+ ) +} diff --git a/apps/postgres-new/components/theme-dropdown.tsx b/apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx similarity index 93% rename from apps/postgres-new/components/theme-dropdown.tsx rename to apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx index 9f002382..73123740 100644 --- a/apps/postgres-new/components/theme-dropdown.tsx +++ b/apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx @@ -1,13 +1,13 @@ 'use client' import { Moon, Sun } from 'lucide-react' -import { Button } from './ui/button' +import { Button } from '~/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from './ui/dropdown-menu' +} from '~/components/ui/dropdown-menu' import { useTheme } from 'next-themes' import { cn } from '~/lib/utils' diff --git a/apps/web/components/sidebar/sidebar-header.tsx b/apps/web/components/sidebar/sidebar-header.tsx new file mode 100644 index 00000000..d7d89435 --- /dev/null +++ b/apps/web/components/sidebar/sidebar-header.tsx @@ -0,0 +1,111 @@ +import { m } from 'framer-motion' +import { ArrowLeftToLine, ArrowRightToLine, PackagePlus } from 'lucide-react' +import { Button } from '~/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' +import { useApp } from '~/components/app-provider' +import { useRouter } from 'next/navigation' +import { SignInDialog } from '~/components/sign-in-dialog' + +export type SidebarHeaderProps = { + onCollapse: () => void +} + +export function SidebarHeader(props: SidebarHeaderProps) { + const { focusRef, user } = useApp() + const router = useRouter() + + return ( +
+ + + + + + + +

Close sidebar

+
+
+ + + + + +
+ ) +} + +export type CollapsedSidebarHeaderProps = { + onExpand: () => void +} + +export function CollapsedSidebarHeader(props: CollapsedSidebarHeaderProps) { + const { focusRef, user } = useApp() + const router = useRouter() + + return ( +
+ + + + + + + +

Open sidebar

+
+
+ + + + + + + + + +

New database

+
+
+
+ ) +} diff --git a/apps/web/components/sidebar/sidebar.tsx b/apps/web/components/sidebar/sidebar.tsx new file mode 100644 index 00000000..3a4eabaf --- /dev/null +++ b/apps/web/components/sidebar/sidebar.tsx @@ -0,0 +1,41 @@ +'use client' + +import { AnimatePresence, m } from 'framer-motion' +import { useState } from 'react' +import React from 'react' +import { DatabaseList } from './database-list/database-list' +import { CollapsedSidebarHeader, SidebarHeader } from './sidebar-header' +import { CollapsedSidebparFooter, SidebarFooter } from './sidebar-footer/sidebar-footer' + +export default function Sidebar() { + const [isCollapsed, setIsCollapsed] = useState(false) + + if (isCollapsed) { + return ( +
+ setIsCollapsed(false)} /> + +
+ ) + } + + return ( + + + setIsCollapsed(true)} /> + + + + + ) +} diff --git a/apps/postgres-new/components/sign-in-button.tsx b/apps/web/components/sign-in-button.tsx similarity index 88% rename from apps/postgres-new/components/sign-in-button.tsx rename to apps/web/components/sign-in-button.tsx index a61f748b..3b68b603 100644 --- a/apps/postgres-new/components/sign-in-button.tsx +++ b/apps/web/components/sign-in-button.tsx @@ -1,8 +1,9 @@ import GitHubIcon from '~/assets/github-icon' -import { useApp } from './app-provider' +import { useApp } from '~/components/app-provider' export default function SignInButton() { const { signIn } = useApp() + return (