mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
+103
-27
@@ -3,19 +3,31 @@ set -euo pipefail
|
||||
|
||||
SESSION="sauci-dev"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="$ROOT/infra/backend/compose.yaml"
|
||||
COMPOSE_PROJECT="sauci-local"
|
||||
POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}"
|
||||
API_PORT="${SAUCI_API_PORT:-3003}"
|
||||
LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci"
|
||||
LOCAL_API_URL="http://127.0.0.1:${API_PORT}"
|
||||
MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}"
|
||||
AUTH_URL="${SUPABASE_AUTH_URL:-${EXPO_PUBLIC_SUPABASE_URL:-}}"
|
||||
AUTH_ANON_KEY="${EXPO_PUBLIC_SUPABASE_ANON_KEY:-}"
|
||||
LOCAL_ADMIN_SERVICE_USER_ID="${ADMIN_API_SERVICE_USER_ID:-00000000-0000-4000-8000-000000000002}"
|
||||
LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service-token-123456789}"
|
||||
LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
|
||||
|
||||
SERVERS=(
|
||||
"functions|npx supabase functions serve --workdir apps"
|
||||
"web|scripts/with-local-supabase-env.sh web npm run dev -w @sauci/web -- --port 3000"
|
||||
"admin|scripts/with-local-supabase-env.sh admin npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001"
|
||||
"mcp|scripts/with-local-supabase-env.sh mcp npm run dev -w sauci-mcp-server"
|
||||
"mobile|scripts/with-local-supabase-env.sh mobile npm run dev -w @sauci/mobile -- --port 8081 --localhost"
|
||||
"api|env PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL SUPABASE_AUTH_URL=$AUTH_URL ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
|
||||
"worker|env DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
|
||||
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port 3000"
|
||||
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001"
|
||||
"mcp|env PORT=3002 SAUCI_ADMIN_API_URL=$LOCAL_API_URL SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY npm run dev -w sauci-mcp-server"
|
||||
"mobile|env EXPO_PUBLIC_API_URL=$MOBILE_API_URL EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY npm run dev -w @sauci/mobile -- --port 8081 --localhost"
|
||||
)
|
||||
|
||||
PORTS=(
|
||||
"supabase-api:54321"
|
||||
"postgres:54322"
|
||||
"studio:54323"
|
||||
"postgres:$POSTGRES_PORT"
|
||||
"api:$API_PORT"
|
||||
"web:3000"
|
||||
"admin:3001"
|
||||
"mcp:3002"
|
||||
@@ -29,26 +41,51 @@ warn() { printf "${c_ylw}! %s${c_reset}\n" "$*"; }
|
||||
die() { printf "${c_red}✗ %s${c_reset}\n" "$*" >&2; exit 1; }
|
||||
port_up() { lsof -ti :"$1" -sTCP:LISTEN >/dev/null 2>&1; }
|
||||
|
||||
compose() {
|
||||
docker compose --project-name "$COMPOSE_PROJECT" --file "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
preflight() {
|
||||
command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux"
|
||||
command -v npm >/dev/null 2>&1 || die "npm not found. Install Node $(cat "$ROOT/.nvmrc")."
|
||||
command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found."
|
||||
docker info >/dev/null 2>&1 || die "Container runtime is not running."
|
||||
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required."
|
||||
[ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci"
|
||||
local node_major
|
||||
node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
|
||||
[ "$node_major" = "20" ] || die "Node 20 is required. Activate .nvmrc before starting the stack."
|
||||
}
|
||||
|
||||
assert_local_database() {
|
||||
case "$LOCAL_DATABASE_URL" in
|
||||
postgresql://*@127.0.0.1:"$POSTGRES_PORT"/sauci|postgresql://*@localhost:"$POSTGRES_PORT"/sauci) ;;
|
||||
*) die "Refusing database operation against non-local URL: $LOCAL_DATABASE_URL" ;;
|
||||
esac
|
||||
[ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320."
|
||||
}
|
||||
|
||||
assert_local_auth() {
|
||||
case "$AUTH_URL" in
|
||||
http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) ;;
|
||||
*) die "Refusing local development against non-local Supabase Auth: $AUTH_URL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_infra() {
|
||||
if (cd "$ROOT" && npx supabase status --workdir apps >/dev/null 2>&1); then
|
||||
ok "Local Supabase already running"
|
||||
else
|
||||
info "Starting local Supabase"
|
||||
(cd "$ROOT" && GOOGLE_CLIENT_ID=local APPLE_CLIENT_ID=local \
|
||||
GOOGLE_CLIENT_SECRET=local APPLE_CLIENT_SECRET=local \
|
||||
npx supabase start --workdir apps)
|
||||
fi
|
||||
info "Starting standalone PostgreSQL"
|
||||
compose up --detach --wait postgres
|
||||
ok "PostgreSQL is healthy on 127.0.0.1:$POSTGRES_PORT"
|
||||
}
|
||||
|
||||
api_workspace_exists() {
|
||||
[ -f "$ROOT/apps/api/package.json" ]
|
||||
}
|
||||
|
||||
run_api_script() {
|
||||
local script="$1"
|
||||
api_workspace_exists || die "apps/api is not present yet; cannot run db:$script"
|
||||
(cd "$ROOT" && env DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
|
||||
}
|
||||
|
||||
start_window() {
|
||||
@@ -57,6 +94,10 @@ start_window() {
|
||||
warn "window '$name' already exists"
|
||||
return
|
||||
fi
|
||||
if [ "$name" = "api" ] && ! api_workspace_exists; then
|
||||
warn "apps/api is not present yet; skipping API window"
|
||||
return
|
||||
fi
|
||||
tmux new-window -t "$SESSION" -n "$name" -c "$ROOT"
|
||||
tmux send-keys -t "$SESSION:$name" "$cmd" C-m
|
||||
}
|
||||
@@ -69,18 +110,25 @@ port_check() {
|
||||
if port_up "$port"; then printf " ${c_grn}●${c_reset} %-14s :%s\n" "$name" "$port"
|
||||
else printf " ${c_dim}·${c_reset} %-14s :%s\n" "$name" "$port"; fi
|
||||
done
|
||||
if compose ps --status running postgres >/dev/null 2>&1; then
|
||||
compose ps postgres --format ' container {{.Name}}: {{.Status}}'
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
preflight
|
||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification."
|
||||
assert_local_auth
|
||||
ensure_infra
|
||||
run_api_script migrate
|
||||
run_api_script seed
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT"
|
||||
local service
|
||||
for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done
|
||||
tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
ok "Stack starting in tmux session '$SESSION'"
|
||||
port_check
|
||||
printf "\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n Studio:http://127.0.0.1:54323\n"
|
||||
printf "\n API: %s/health/live\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n" "$LOCAL_API_URL"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
@@ -94,13 +142,23 @@ cmd_status() {
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local service="${1:?usage: logs <service>}"
|
||||
if [ "$service" = "postgres" ]; then
|
||||
compose logs --tail 400 postgres
|
||||
return
|
||||
fi
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||
tmux capture-pane -p -S -400 -t "$SESSION:${1:?usage: logs <service>}"
|
||||
tmux capture-pane -p -S -400 -t "$SESSION:$service"
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||
local name="${1:?usage: restart <service>}" service
|
||||
if [ "$name" = "postgres" ]; then
|
||||
compose restart postgres
|
||||
ok "restarted postgres"
|
||||
return
|
||||
fi
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||
for service in "${SERVERS[@]}"; do
|
||||
if [ "${service%%|*}" = "$name" ]; then
|
||||
@@ -115,24 +173,41 @@ cmd_restart() {
|
||||
cmd_down() {
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null && ok "application services stopped" || warn "no session '$SESSION'"
|
||||
if [ "${1:-}" = "--all" ]; then
|
||||
(cd "$ROOT" && npx supabase stop --workdir apps)
|
||||
ok "local Supabase stopped"
|
||||
compose down
|
||||
ok "standalone backend infrastructure stopped (data retained)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_reset() {
|
||||
preflight
|
||||
assert_local_database
|
||||
warn "Resetting the dedicated local PostgreSQL volume"
|
||||
compose down --volumes --remove-orphans
|
||||
ensure_infra
|
||||
(cd "$ROOT" && npx supabase db reset --workdir apps)
|
||||
(cd "$ROOT" && scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs)
|
||||
ok "Local database reset and deterministic fixtures created"
|
||||
run_api_script migrate
|
||||
run_api_script seed
|
||||
ok "Local PostgreSQL reset, migrations applied, and configured fixtures created"
|
||||
}
|
||||
|
||||
cmd_seed() {
|
||||
preflight
|
||||
assert_local_database
|
||||
ensure_infra
|
||||
run_api_script migrate
|
||||
run_api_script seed
|
||||
ok "Local migrations applied and configured fixtures created"
|
||||
}
|
||||
|
||||
cmd_native() {
|
||||
local platform="$1"
|
||||
preflight
|
||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
||||
[ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
||||
assert_local_auth
|
||||
ensure_infra
|
||||
(cd "$ROOT" && scripts/with-local-supabase-env.sh mobile npm run "$platform" -w @sauci/mobile)
|
||||
(cd "$ROOT" && env EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
|
||||
EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \
|
||||
npm run "$platform" -w @sauci/mobile)
|
||||
}
|
||||
|
||||
case "${1:-up}" in
|
||||
@@ -142,10 +217,11 @@ case "${1:-up}" in
|
||||
logs) cmd_logs "${2:-}" ;;
|
||||
restart) cmd_restart "${2:-}" ;;
|
||||
attach) tmux attach -t "$SESSION" ;;
|
||||
reset|seed) cmd_reset ;;
|
||||
reset) cmd_reset ;;
|
||||
seed) cmd_seed ;;
|
||||
ios|android) cmd_native "$1" ;;
|
||||
help|-h|--help)
|
||||
echo "usage: scripts/dev-local.sh up|down [--all]|status|logs <service>|restart <service>|attach|reset|ios|android"
|
||||
echo "usage: scripts/dev-local.sh up|down [--all]|status|logs <service>|restart <service>|attach|reset|seed|ios|android"
|
||||
;;
|
||||
*) die "unknown command '$1'" ;;
|
||||
esac
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import pg from 'pg';
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
const targetUrl = process.env.TARGET_DATABASE_URL;
|
||||
if (!targetUrl) throw new Error('TARGET_DATABASE_URL is required');
|
||||
|
||||
const parsedTarget = new URL(targetUrl);
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(parsedTarget.hostname)) {
|
||||
throw new Error('Catalogue imports must use a localhost database URL (use an SSH tunnel for remote staging)');
|
||||
}
|
||||
|
||||
const order = [
|
||||
'categories',
|
||||
'topics',
|
||||
'question_packs',
|
||||
'dare_packs',
|
||||
'questions',
|
||||
'dares',
|
||||
'pack_topics',
|
||||
'app_config',
|
||||
'ai_config',
|
||||
];
|
||||
const keys = {
|
||||
categories: ['id'],
|
||||
topics: ['id'],
|
||||
question_packs: ['id'],
|
||||
dare_packs: ['id'],
|
||||
questions: ['id'],
|
||||
dares: ['id'],
|
||||
pack_topics: ['pack_id', 'topic_id'],
|
||||
app_config: ['id'],
|
||||
ai_config: ['id'],
|
||||
};
|
||||
const forbiddenColumns = new Set(['openrouter_api_key', 'updated_by', 'supabase_url']);
|
||||
const rowsByTable = new Map();
|
||||
const encodedChunks = new Map();
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of input) {
|
||||
if (!line.trim()) continue;
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.done === true) break;
|
||||
if (order.includes(entry.table) && typeof entry.chunk === 'string') {
|
||||
encodedChunks.set(entry.table, [...(encodedChunks.get(entry.table) ?? []), entry.chunk]);
|
||||
continue;
|
||||
}
|
||||
if (order.includes(entry.table) && entry.tableDone === true) {
|
||||
const encoded = (encodedChunks.get(entry.table) ?? []).join('');
|
||||
const rows = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
||||
if (!Array.isArray(rows)) throw new Error(`${entry.table} payload is not an array`);
|
||||
rowsByTable.set(entry.table, [...(rowsByTable.get(entry.table) ?? []), ...rows]);
|
||||
encodedChunks.delete(entry.table);
|
||||
continue;
|
||||
}
|
||||
if (!order.includes(entry.table) || !Array.isArray(entry.rows)) {
|
||||
throw new Error('Each input line must contain a supported table and rows array');
|
||||
}
|
||||
rowsByTable.set(entry.table, [...(rowsByTable.get(entry.table) ?? []), ...entry.rows]);
|
||||
}
|
||||
input.close();
|
||||
|
||||
for (const table of order) {
|
||||
if (!rowsByTable.has(table)) throw new Error(`Missing catalogue export for ${table}`);
|
||||
}
|
||||
|
||||
const quote = (value) => `"${value.replaceAll('"', '""')}"`;
|
||||
const client = new Client({ connectionString: targetUrl });
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
await client.query('begin');
|
||||
await client.query("set local sauci.suppress_operations = 'on'");
|
||||
|
||||
for (const table of [...order].reverse()) {
|
||||
await client.query(`delete from ${quote(table)}`);
|
||||
}
|
||||
|
||||
for (const table of order) {
|
||||
const rows = rowsByTable.get(table);
|
||||
const targetColumnRows = (await client.query(
|
||||
`select column_name,data_type from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`,
|
||||
[table],
|
||||
)).rows;
|
||||
const targetColumns = targetColumnRows.map((row) => row.column_name);
|
||||
const targetTypes = new Map(targetColumnRows.map((row) => [row.column_name, row.data_type]));
|
||||
const columns = targetColumns.filter((column) =>
|
||||
!forbiddenColumns.has(column) && rows.some((row) => Object.hasOwn(row, column)),
|
||||
);
|
||||
if (!keys[table].every((key) => columns.includes(key))) {
|
||||
throw new Error(`${table} export is missing a required key`);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const values = columns.map((column) => {
|
||||
if (table === 'questions' && column === 'inverse_of') return null;
|
||||
const value = row[column];
|
||||
return targetTypes.get(column) === 'jsonb' && value !== null ? JSON.stringify(value) : value;
|
||||
});
|
||||
await client.query(
|
||||
`insert into ${quote(table)} (${columns.map(quote).join(',')}) values (${columns.map((_, index) => `$${index + 1}`).join(',')})`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rowsByTable.get('questions')) {
|
||||
if (row.inverse_of) await client.query('update questions set inverse_of=$1 where id=$2', [row.inverse_of, row.id]);
|
||||
}
|
||||
|
||||
await client.query('commit');
|
||||
const counts = {};
|
||||
for (const table of order) {
|
||||
counts[table] = Number((await client.query(`select count(*) count from ${quote(table)}`)).rows[0].count);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(counts)}\n`);
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
@@ -6,6 +6,15 @@ PROFILE="${1:?usage: with-local-supabase-env.sh <web|admin|mobile|mcp|tests> <co
|
||||
shift
|
||||
[ "$#" -gt 0 ] || { echo "command is required" >&2; exit 2; }
|
||||
|
||||
if [ "$PROFILE" = "mcp" ]; then
|
||||
: "${SAUCI_ADMIN_API_URL:?SAUCI_ADMIN_API_URL is required for MCP}"
|
||||
: "${SAUCI_ADMIN_API_TOKEN:?SAUCI_ADMIN_API_TOKEN is required for MCP}"
|
||||
: "${SAUCI_MCP_API_KEY:?SAUCI_MCP_API_KEY is required for MCP}"
|
||||
exec env SAUCI_ADMIN_API_URL="$SAUCI_ADMIN_API_URL" \
|
||||
SAUCI_ADMIN_API_TOKEN="$SAUCI_ADMIN_API_TOKEN" \
|
||||
SAUCI_MCP_API_KEY="$SAUCI_MCP_API_KEY" "$@"
|
||||
fi
|
||||
|
||||
STATUS_ENV="$(cd "$ROOT" && npx supabase status --workdir apps -o env 2>/dev/null)" || {
|
||||
echo "Local Supabase is not running. Run scripts/dev-local.sh up." >&2
|
||||
exit 1
|
||||
@@ -27,11 +36,6 @@ case "$PROFILE" in
|
||||
mobile)
|
||||
exec env EXPO_PUBLIC_SUPABASE_URL="$API_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$ANON_KEY" "$@"
|
||||
;;
|
||||
mcp)
|
||||
exec env PORT=3002 SUPABASE_URL="$API_URL" \
|
||||
SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
|
||||
SAUCI_MCP_API_KEY="local-sauci-mcp-only" "$@"
|
||||
;;
|
||||
tests)
|
||||
exec env SUPABASE_URL="$API_URL" SUPABASE_ANON_KEY="$ANON_KEY" \
|
||||
SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" "$@"
|
||||
|
||||
Reference in new issue
Block a user