diff --git a/.vscode/settings.json b/.vscode/settings.json
index 4a5415d1..a3933350 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": true,
+ "[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/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 (
- <>
-
-
- {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 (
- <>
-
-
-
- {database.name ?? 'My database'}
- {
- setIsPopoverOpen(open)
- if (!open) {
- setIsRenaming(false)
- }
- }}
- open={isPopoverOpen}
- >
- {
- e.preventDefault()
- setIsPopoverOpen(true)
- }}
- >
-
-
-
-
- {isRenaming ? (
-
- ) : (
-
-
{
- 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..c5cc9c83
--- /dev/null
+++ b/apps/proxy/.env.example
@@ -0,0 +1,10 @@
+BUCKET_NAME=test
+AWS_REGION=us-east-1
+S3FS_MOUNT=/mnt/s3
+DATA_MOUNT=/mnt/data
+AWS_ACCESS_KEY_ID=minioadmin
+AWS_SECRET_ACCESS_KEY=minioadmin
+# Only if you need to test against a bucket hosted in AWS S3
+# AWS_SESSION_TOKEN=
+AWS_ENDPOINT_URL_S3=http://minio:9000
+WILDCARD_DOMAIN=db.example.com
\ No newline at end of file
diff --git a/apps/db-service/Dockerfile b/apps/proxy/Dockerfile
similarity index 69%
rename from apps/db-service/Dockerfile
rename to apps/proxy/Dockerfile
index 9476c5f9..8dc94f05 100644
--- a/apps/db-service/Dockerfile
+++ b/apps/proxy/Dockerfile
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
-ARG NODE_VERSION=20.4.0
+ARG NODE_VERSION=20.16.0
FROM node:${NODE_VERSION}-bookworm as base
LABEL fly_launch_runtime="NodeJS"
@@ -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,14 @@ 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 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 67%
rename from apps/db-service/README.md
rename to apps/proxy/README.md
index b4383a68..79995c1f 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,27 @@ 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"
```
- > 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:
+ 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`.
- ```
- # ...
-
- 127.0.0.1 12345.db.example.com
- ```
-
- and connect to that host:
-
- ```shell
- psql "host=12345.db.example.com port=5432 user=postgres"
- ```
+ > 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.
To stop all Docker containers, run:
```shell
docker compose down
```
+
+## Deployment
+
+The proxy is deployed on Fly.io.
+
+A Tigris bucket is used to store the DB tarballs and the TLS certificates.
\ No newline at end of file
diff --git a/apps/db-service/docker-compose.yml b/apps/proxy/docker-compose.yml
similarity index 63%
rename from apps/db-service/docker-compose.yml
rename to apps/proxy/docker-compose.yml
index 13e26335..b5660ea0 100644
--- a/apps/db-service/docker-compose.yml
+++ b/apps/proxy/docker-compose.yml
@@ -1,15 +1,11 @@
services:
- db-service:
- image: db-service
+ proxy:
+ image: proxy
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
+ context: ../..
+ dockerfile: apps/proxy/Dockerfile
+ env_file:
+ - .env
ports:
- 5432:5432
devices:
@@ -23,13 +19,8 @@ services:
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
+ env_file:
+ - .env
devices:
- /dev/fuse
cap_add:
@@ -45,7 +36,8 @@ services:
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
- command: server /data
+ - 9001:9001
+ command: server /data --console-address ":9001"
healthcheck:
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1
interval: 5s
@@ -61,3 +53,25 @@ services:
depends_on:
minio:
condition: service_healthy
+ dns:
+ build:
+ context: ./tools/dns
+ environment:
+ WILDCARD_DOMAIN: db.example.com
+ SERVICE_NAME: proxy
+ networks:
+ default:
+ ipv4_address: 172.20.0.10
+ psql:
+ image: postgres:16
+ depends_on:
+ - dns
+ dns:
+ - 172.20.0.10
+
+networks:
+ default:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.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..44b21866 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 $BUCKET_NAME $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..ff52e855
--- /dev/null
+++ b/apps/proxy/fly.toml
@@ -0,0 +1,23 @@
+app = 'postgres-new-proxy'
+
+primary_region = 'yyz'
+
+[[services]]
+internal_port = 5432
+protocol = "tcp"
+auto_stop_machines = true
+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..701c01d8
--- /dev/null
+++ b/apps/proxy/package.json
@@ -0,0 +1,25 @@
+{
+ "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.0",
+ "@supabase/supabase-js": "^2.45.1",
+ "pg-gateway": "0.3.0-alpha.6",
+ "tar": "^7.4.3"
+ },
+ "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/index.ts b/apps/proxy/src/index.ts
new file mode 100644
index 00000000..9d48d302
--- /dev/null
+++ b/apps/proxy/src/index.ts
@@ -0,0 +1,245 @@
+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 path from 'node:path'
+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'
+
+const supabaseUrl = process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321'
+const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ''
+const dataMount = process.env.DATA_MOUNT ?? './data'
+const s3fsMount = process.env.S3FS_MOUNT ?? './s3'
+const wildcardDomain = process.env.WILDCARD_DOMAIN ?? 'db.example.com'
+const packageJson = JSON.parse(
+ await readFile(path.join(import.meta.dirname, '..', 'package.json'), 'utf8')
+) as {
+ dependencies: {
+ '@electric-sql/pglite': string
+ }
+}
+const pgliteVersion = `(PGlite ${packageJson.dependencies['@electric-sql/pglite']})`
+
+const dumpDir = `${s3fsMount}/dbs`
+const tlsDir = `${s3fsMount}/tls`
+const dbDir = `${dataMount}/dbs`
+
+await mkdir(dumpDir, { recursive: true })
+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`),
+}
+
+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
+
+ 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 = `${dbDir}/${databaseId}`
+
+ if (!(await fileExists(dbPath))) {
+ console.log(`Database '${databaseId}' is not cached, downloading...`)
+
+ const dumpPath = `${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/certbot/.env.example b/apps/proxy/tools/certbot/.env.example
new file mode 100644
index 00000000..d3e932b0
--- /dev/null
+++ b/apps/proxy/tools/certbot/.env.example
@@ -0,0 +1,9 @@
+CERTBOT_DOMAIN=db.postgres.new
+CERTBOT_EMAIL=""
+CLOUDFLARE_API_TOKEN=""
+AWS_ACCESS_KEY_ID=minioadmin
+AWS_ENDPOINT_URL_S3=http://minio:9000
+AWS_REGION=us-east-1
+AWS_SECRET_ACCESS_KEY=minioadmin
+BUCKET_NAME=test
+S3FS_MOUNT=/mnt/s3
\ No newline at end of file
diff --git a/apps/proxy/tools/certbot/Dockerfile b/apps/proxy/tools/certbot/Dockerfile
new file mode 100644
index 00000000..802aeb3e
--- /dev/null
+++ b/apps/proxy/tools/certbot/Dockerfile
@@ -0,0 +1,47 @@
+# syntax = docker/dockerfile:1
+
+# Adjust CERTBOT_VERSION as desired
+ARG CERTBOT_VERSION=2.11.0
+FROM certbot/dns-cloudflare:v${CERTBOT_VERSION} as base
+
+WORKDIR /app
+
+# Build S3FS
+FROM base as build-s3fs
+
+# Install dependencies
+RUN apk add --no-cache \
+ git \
+ build-base \
+ automake \
+ autoconf \
+ libxml2-dev \
+ fuse-dev \
+ curl-dev
+
+RUN git clone https://github.com/s3fs-fuse/s3fs-fuse.git --branch v1.94 && \
+ cd s3fs-fuse && \
+ ./autogen.sh && \
+ ./configure && \
+ make && \
+ make install
+
+# Final stage
+FROM base
+
+# Install dependencies
+RUN apk add --no-cache \
+ bash \
+ curl \
+ fuse \
+ libxml2
+
+COPY --from=build-s3fs /usr/local/bin/s3fs /usr/local/bin/s3fs
+COPY certbot.sh deploy-hook.sh entrypoint.sh /app/
+
+RUN chmod +x certbot.sh
+RUN chmod +x deploy-hook.sh
+
+ENTRYPOINT [ "./entrypoint.sh" ]
+
+CMD [ "./certbot.sh" ]
diff --git a/apps/proxy/tools/certbot/README.md b/apps/proxy/tools/certbot/README.md
new file mode 100644
index 00000000..ece5f011
--- /dev/null
+++ b/apps/proxy/tools/certbot/README.md
@@ -0,0 +1,105 @@
+# Certbot
+
+This service is responsible for managing the certificates for the PGLite instances.
+
+It uses `fly machine run --schedule weekly` to wake up the service every week to renew the certificates if needed. Let's Encrypt certificates are valid for 90 days.
+
+## Testing certbot-service locally
+
+Copy `.env.example` to `.env` and set the missing environment variables.
+
+Start minio to emulate the S3 service:
+
+```shell
+docker compose up -d minio
+```
+
+Initialize the bucket:
+
+```shell
+docker compose up minio-init
+```
+
+Build and run the certbot service:
+
+```shell
+docker compose up --build certbot-service
+```
+
+The certificates will be generated in `/mnt/s3/tls`.
+
+## Deploying to fly.io
+
+1. Create a new app if it doesn't exist
+
+```shell
+flyctl apps create postgres-new-certbot
+```
+
+2. Build and deploy the Docker image to fly.io image registry
+
+```shell
+flyctl deploy --build-only --push -a postgres-new-certbot --image-label
+ latest
+```
+
+1. Set the appropriate environment variables and secrets for the app "postgres-new-certbot" (see `.env.example`) in fly.io UI.
+
+2. Setup [cron-manager](https://github.com/fly-apps/cron-manager?tab=readme-ov-file#getting-started) to run the certbot service every 2 weeks with the following `schedules.json`:
+
+```json
+[
+ {
+ "name": "postgres-new-certbot",
+ "app_name": "postgres-new-certbot",
+ "schedule": "0 0 1,15 * *",
+ "region": "ord",
+ "command": "./certbot.sh",
+ "command_timeout": 120,
+ "enabled": true,
+ "config": {
+ "metadata": {
+ "fly_process_group": "cron"
+ },
+ "auto_destroy": true,
+ "disable_machine_autostart": true,
+ "guest": {
+ "cpu_kind": "shared",
+ "cpus": 1,
+ "memory_mb": 256
+ },
+ "image": "registry.fly.io/postgres-new-certbot:latest",
+ "restart": {
+ "max_retries": 1,
+ "policy": "no"
+ }
+ }
+ }
+]
+```
+
+5. Test running the job by SSHing into cron-manager console
+
+Run this command in the cron-manager root folder:
+
+```shell
+flyctl ssh console
+```
+
+Once in the cron-manager instance:
+
+```shell
+cm jobs trigger 1
+```
+
+If you open the "postgres-new-certbot" live logs in fly.io UI, you should see the job being executed.
+
+6. You can check if the certificates are present in the Tigris bucket
+
+Run this command in the apps/db-instance folder:
+
+```shell
+flyctl storage dashboard
+```
+
+It should open the Tigris dashboard where you can check the bucket's content. The certificates should be created under `/tls`.
\ No newline at end of file
diff --git a/apps/proxy/tools/certbot/certbot.sh b/apps/proxy/tools/certbot/certbot.sh
new file mode 100644
index 00000000..7743ae45
--- /dev/null
+++ b/apps/proxy/tools/certbot/certbot.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+CONFIG_DIR="${S3FS_MOUNT}/tls/letsencrypt"
+CERT_PATH="${CONFIG_DIR}/live/${CERTBOT_DOMAIN}/fullchain.pem"
+CLOUD_FLARE_INI="/app/cloudflare.ini"
+DEPLOY_HOOK="/app/deploy-hook.sh"
+
+renew_certificate() {
+ echo "Certificates exist. Renewing..."
+ certbot renew --non-interactive \
+ --cert-name "${CERTBOT_DOMAIN}" \
+ --dns-cloudflare \
+ --dns-cloudflare-credentials "${CLOUD_FLARE_INI}" \
+ --deploy-hook "${DEPLOY_HOOK}" \
+ --config-dir "${CONFIG_DIR}"
+}
+
+create_certificate() {
+ echo "Certificates do not exist. Creating..."
+ certbot certonly --non-interactive \
+ --agree-tos \
+ --cert-name "${CERTBOT_DOMAIN}" \
+ --email "${CERTBOT_EMAIL}" \
+ --dns-cloudflare \
+ --dns-cloudflare-credentials "${CLOUD_FLARE_INI}" \
+ --dns-cloudflare-propagation-seconds 60 \
+ -d "*.${CERTBOT_DOMAIN}" \
+ --deploy-hook "${DEPLOY_HOOK}" \
+ --config-dir "${CONFIG_DIR}"
+}
+
+main() {
+ if [[ -f "${CERT_PATH}" ]]; then
+ renew_certificate
+ else
+ create_certificate
+ fi
+}
+
+main "$@"
\ No newline at end of file
diff --git a/apps/proxy/tools/certbot/deploy-hook.sh b/apps/proxy/tools/certbot/deploy-hook.sh
new file mode 100644
index 00000000..1e135f3e
--- /dev/null
+++ b/apps/proxy/tools/certbot/deploy-hook.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SOURCE_DIR="$S3FS_MOUNT/tls/letsencrypt/live/$CERTBOT_DOMAIN"
+TARGET_DIR="$S3FS_MOUNT/tls"
+
+# Ensure the target directory exists
+mkdir -p $TARGET_DIR
+
+# Copy the key and cert to the target directory
+cp -f $SOURCE_DIR/privkey.pem $TARGET_DIR/key.pem
+cp -f $SOURCE_DIR/fullchain.pem $TARGET_DIR/cert.pem
\ No newline at end of file
diff --git a/apps/proxy/tools/certbot/docker-compose.yml b/apps/proxy/tools/certbot/docker-compose.yml
new file mode 100644
index 00000000..97d09f80
--- /dev/null
+++ b/apps/proxy/tools/certbot/docker-compose.yml
@@ -0,0 +1,38 @@
+services:
+ certbot-service:
+ image: certbot-service
+ build:
+ context: .
+ env_file:
+ - .env
+ devices:
+ - /dev/fuse
+ cap_add:
+ - SYS_ADMIN
+ depends_on:
+ minio:
+ condition: service_healthy
+ minio:
+ image: minio/minio
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ ports:
+ - 9000:9000
+ - 9001:9001
+ command: server /data --console-address :9001
+ 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/proxy/tools/certbot/entrypoint.sh b/apps/proxy/tools/certbot/entrypoint.sh
new file mode 100755
index 00000000..fc083a79
--- /dev/null
+++ b/apps/proxy/tools/certbot/entrypoint.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# s3fs ################################
+cleanup() {
+ echo "Unmounting s3fs..."
+ fusermount -u $S3FS_MOUNT
+ exit 0
+}
+
+forward_signal() {
+ kill -$1 "$MAIN_PID"
+}
+
+trap 'forward_signal SIGINT' SIGINT
+trap 'forward_signal SIGTERM' SIGTERM
+trap 'cleanup' EXIT
+
+# Create the mount point directory
+mkdir -p $S3FS_MOUNT
+
+# Mount the S3 bucket
+s3fs $BUCKET_NAME $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
+ echo "S3 bucket mounted successfully at $S3FS_MOUNT"
+else
+ echo "Failed to mount S3 bucket"
+ exit 1
+fi
+
+# cloudflare.ini ######################
+echo "dns_cloudflare_api_token = $CLOUDFLARE_API_TOKEN" > /app/cloudflare.ini
+chmod 600 /app/cloudflare.ini
+
+# Execute the original command
+"$@" &
+MAIN_PID=$!
+
+wait $MAIN_PID
diff --git a/apps/proxy/tools/certbot/fly.toml b/apps/proxy/tools/certbot/fly.toml
new file mode 100644
index 00000000..888f61c0
--- /dev/null
+++ b/apps/proxy/tools/certbot/fly.toml
@@ -0,0 +1,3 @@
+app = 'postgres-new-certbot'
+
+primary_region = 'yyz'
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 50%
rename from apps/postgres-new/.env.example
rename to apps/web/.env.example
index f94678ce..535bef03 100644
--- a/apps/postgres-new/.env.example
+++ b/apps/web/.env.example
@@ -1,5 +1,11 @@
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
NEXT_PUBLIC_SUPABASE_URL=""
NEXT_PUBLIC_IS_PREVIEW=true
+NEXT_PUBLIC_WILDCARD_DOMAIN=db.example.com
OPENAI_API_KEY=""
+S3_ENDPOINT=http://localhost:9000
+S3_BUCKET=test
+AWS_ACCESS_KEY_ID=minioadmin
+AWS_SECRET_ACCESS_KEY=minioadmin
+
diff --git a/apps/postgres-new/README.md b/apps/web/README.md
similarity index 96%
rename from apps/postgres-new/README.md
rename to apps/web/README.md
index 414e0744..f4be247d 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.
@@ -25,7 +25,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..7611322b
--- /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({ endpoint: process.env.S3_ENDPOINT, 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.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..e5b04bae
--- /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'
+
+const wildcardDomain = process.env.NEXT_PUBLIC_WILDCARD_DOMAIN ?? 'db.example.com'
+const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, 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 }
+ )
+ }
+
+ const dumpSizeInMB = dump.size / (1024 * 1024)
+ if (dumpSizeInMB > 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.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 93%
rename from apps/postgres-new/components/app-provider.tsx
rename to apps/web/components/app-provider.tsx
index 3d1c79a3..257e87d1 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 focusRef = useRef(null)
@@ -111,8 +110,6 @@ export default function AppProvider({ children }: AppProps) {
isLoadingUser,
signIn,
signOut,
- isSignInDialogOpen,
- setIsSignInDialogOpen,
focusRef,
isPreview,
dbManager,
@@ -134,8 +131,6 @@ export type AppContextValues = {
isLoadingUser: boolean
signIn: () => Promise
signOut: () => Promise
- isSignInDialogOpen: boolean
- setIsSignInDialogOpen: (open: boolean) => void
focusRef: RefObject
isPreview: boolean
dbManager?: DbManager
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 65b40f75..7eec23a4 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 } = useApp()
+ const { user, isLoadingUser, focusRef } = useApp()
const [inputFocusState, setInputFocusState] = useState(false)
const {
@@ -326,14 +327,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?
+
+
)}
@@ -380,14 +378,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 (
+
+
+
+
+
+
+ {isRenaming ? (
+ {
+ setIsDropdownOpen(false)
+ setIsRenaming(false)
+ }}
+ />
+ ) : (
+
+ {
+ e.preventDefault()
+ setIsRenaming(true)
+ }}
+ />
+
+
+
+
+
+ )}
+
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (