Show HN: Self-hosted collaborative SQL editor for
bootstrappedQueryTeam — Spécification Technique MVP
1. Vue d'ensemble
Concept
QueryTeam est un éditeur SQL collaboratif self-hosted (déployé via Docker sur l'infrastructure du client) permettant aux équipes data de partager, versionner et valider leurs requêtes SQL sans que leurs données ne quittent leur réseau. L'application SaaS centrale gère uniquement l'authentification, la facturation et la distribution des licences — le moteur d'exécution SQL tourne exclusivement chez le client.
Valeur ajoutée principale
Zéro compromis entre compliance (données 100% on-premise) et productivité (collaboration temps réel, versioning, workflow de revue). C'est le seul outil de ce segment avec une expérience d'installation < 10 minutes via docker-compose.
Ce qu'on build en MVP (48h)
> Ce qui est hors scope MVP : co-édition simultanée type CRDTs, AI query assist, query scheduling, intégration Git externe, SSO SAML.
2. Stack technique
Core
| Couche | Choix |
|--------|-------|
| Framework | Next.js 15 (App Router) + TypeScript strict |
| Auth + DB | Supabase (PostgreSQL 15) |
| Billing | Stripe (Subscriptions + Webhooks) |
| Emails | Resend + React Email |
| Déploiement SaaS | Vercel |
| Déploiement agent | Docker + docker-compose |
Dépendances NPM — App SaaS centrale
{
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"@supabase/ssr": "^0.5.0",
"stripe": "^16.0.0",
"resend": "^3.5.0",
"@react-email/components": "^0.0.22",
"zod": "^3.23.0",
"zustand": "^4.5.0",
"react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
"nanoid": "^5.0.0",
"date-fns": "^3.6.0",
"lucide-react": "^0.441.0",
"tailwindcss": "^3.4.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-badge": "^1.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0"
}
}
Dépendances NPM — Agent self-hosted (app Express/Next dans Docker)
{
"dependencies": {
"express": "^4.21.0",
"@monaco-editor/react": "^4.6.0",
"monaco-editor": "^0.52.0",
"next": "^15.0.0",
"react": "^18.3.0",
"knex": "^3.1.0",
"pg": "^8.13.0",
"mysql2": "^3.11.0",
"tedious": "^18.6.0",
"better-sqlite3": "^11.3.0",
"ws": "^8.18.0",
"socket.io": "^4.7.0",
"socket.io-client": "^4.7.0",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.3",
"uuid": "^10.0.0",
"dotenv": "^16.4.0",
"winston": "^3.14.0",
"node-cron": "^3.0.3",
"axios": "^1.7.0"
}
}
3. Modèle de données Supabase
> Les tables profiles et subscription_events sont supposées déjà existantes. On les référence mais ne les recrée pas.
Schéma complet
-- ============================================================-- EXTENSIONS
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- TABLE: workspaces
-- Représente une installation client (1 licence = 1 workspace)
-- ============================================================
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, -- subdomain ou identifiant URL
owner_id UUID NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
plan TEXT NOT NULL DEFAULT 'trial' CHECK (plan IN ('trial', 'starter', 'pro', 'enterprise')),
trial_ends_at TIMESTAMPTZ,
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
license_key TEXT UNIQUE NOT NULL, -- clé envoyée à l'agent self-hosted
license_expires_at TIMESTAMPTZ,
max_members INT NOT NULL DEFAULT 5,
max_connections INT NOT NULL DEFAULT 3,
agent_last_ping TIMESTAMPTZ, -- heartbeat de l'agent
agent_version TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workspaces_owner_id ON workspaces(owner_id);
CREATE INDEX idx_workspaces_license_key ON workspaces(license_key);
CREATE INDEX idx_workspaces_stripe_customer ON workspaces(stripe_customer_id);
-- ============================================================
-- TABLE: workspace_members
-- Membres d'un workspace avec leur rôle
-- ============================================================
CREATE TABLE workspace_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'analyst' CHECK (role IN ('owner', 'admin', 'analyst', 'viewer')),
invited_by UUID REFERENCES profiles(id),
invited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accepted_at TIMESTAMPTZ,
UNIQUE(workspace_id, user_id)
);
CREATE INDEX idx_workspace_members_workspace ON workspace_members(workspace_id);
CREATE INDEX idx_workspace_members_user ON workspace_members(user_id);
-- ============================================================
-- TABLE: invitations
-- Invitations en attente par email
-- ============================================================
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'analyst' CHECK (role IN ('admin', 'analyst', 'viewer')),
token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'),
invited_by UUID NOT NULL REFERENCES profiles(id),
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(workspace_id, email)
);
CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_email ON invitations(email);
-- ============================================================
-- TABLE: db_connections
-- Connexions BDD déclarées dans l'agent (métadonnées seulement,
-- les credentials sont stockés UNIQUEMENT dans l'agent on-premise)
-- ============================================================
CREATE TABLE db_connections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
db_type TEXT NOT NULL CHECK (db_type IN ('postgresql', 'mysql', 'mssql', 'sqlite', 'bigquery', 'snowflake')),
environment TEXT NOT NULL DEFAULT 'dev' CHECK (environment IN ('dev', 'staging', 'prod')),
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
require_approval BOOLEAN NOT NULL DEFAULT FALSE, -- queries doivent être approuvées
created_by UUID NOT NULL REFERENCES profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Note: host/port/credentials sont dans l'agent, pas ici
agent_connection_id TEXT NOT NULL -- ID côté agent pour la jointure
);
CREATE INDEX idx_db_connections_workspace ON db_connections(workspace_id);
-- ============================================================
-- TABLE: query_collections
-- Dossiers/collections pour organiser les requêtes
-- ============================================================
CREATE TABLE query_collections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
parent_id UUID REFERENCES query_collections(id) ON DELETE SET NULL,
created_by UUID NOT NULL REFERENCES profiles(id),
is_shared BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_query_collections_workspace ON query_collections(workspace_id);
CREATE INDEX idx_query_collections_parent ON query_collections(parent_id);
-- ============================================================
-- TABLE: saved_queries
-- Requêtes sauvegardées et partagées dans le workspace
-- ============================================================
CREATE TABLE saved_queries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
collection_id UUID REFERENCES query_collections(id) ON DELETE SET NULL,
connection_id UUID REFERENCES db_connections(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
sql_content TEXT NOT NULL,
tags TEXT[] DEFAULT '{}',
is_shared BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID NOT NULL REFERENCES profiles(id),
updated_by UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Stats d'usage (dénormalisées pour perf)
run_count INT NOT NULL DEFAULT 0,
last_run_at TIMESTAMPTZ
);
CREATE INDEX idx_saved_queries_workspace ON saved_queries(workspace_id);
CREATE INDEX idx_saved_queries_collection ON saved_queries(collection_id);
CREATE INDEX idx_saved_queries_created_by ON saved_queries(created_by);
CREATE INDEX idx_saved_queries_tags ON saved_queries USING GIN(tags);
-- Full-text search sur title + description
CREATE INDEX idx_saved_queries_fts ON saved_queries
USING GIN(to_tsvector('french', coalesce(title,'') || ' ' || coalesce(description,'')));
-- ============================================================
-- TABLE: query_versions
-- Historique de versions des requêtes (comme un Git simplifié)
-- ============================================================
CREATE TABLE query_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
query_id UUID NOT NULL REFERENCES saved_queries(id) ON DELETE CASCADE,
version_num INT NOT NULL,
sql_content TEXT NOT NULL,
commit_msg TEXT,
created_by UUID NOT NULL REFERENCES profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(query_id, version_num)
);
CREATE INDEX idx_query_versions_query ON query_versions(query_id);
-- ============================================================
-- TABLE: query_comments
-- Commentaires sur une requête (avec ligne de SQL optionnelle)
-- ============================================================
CREATE TABLE query_comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
query_id UUID NOT NULL REFERENCES saved_queries(id) ON DELETE CASCADE,
parent_id UUID REFERENCES query_comments(id) ON DELETE CASCADE, -- threads
author_id UUID NOT NULL REFERENCES profiles(id),
content TEXT NOT NULL,
line_number INT, -- si commentaire ancré sur une ligne SQL
resolved BOOLEAN NOT NULL DEFAULT FALSE,
resolved_by UUID REFERENCES profiles(id),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_query_comments_query ON query_comments(query_id);
-- ============================================================
-- TABLE: review_requests
-- Workflow de validation avant exécution sur prod
-- ============================================================
CREATE TABLE review_requests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
query_id UUID REFERENCES saved_queries(id) ON DELETE SET NULL,
connection_id UUID REFERENCES db_connections(id) ON DELETE SET NULL,
sql_content TEXT NOT NULL, -- snapshot du SQL au moment de la demande
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled', 'executed')),
requested_by UUID NOT NULL REFERENCES profiles(id),
reviewed_by UUID REFERENCES profiles(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
executed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '48 hours'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_review_requests_workspace ON review_requests(workspace_id);
CREATE INDEX idx_review_requests_status ON review_requests(status);
CREATE INDEX idx_review_requests_requested_by ON review_requests(requested_by);
-- ============================================================
-- TABLE: audit_logs
-- Traçabilité de toutes les actions sensibles (RGPD / SOC2)
-- ============================================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
actor_id UUID REFERENCES profiles(id),
action TEXT NOT NULL, -- ex: 'query.execute', 'connection.create', 'member.invite'
resource_type TEXT, -- ex: 'saved_query', 'review_request'
resource_id UUID,
metadata JSONB DEFAULT '{}',
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_workspace ON audit_logs(workspace_id);
CREATE INDEX idx_audit_logs_actor ON audit_logs(actor_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
-- ============================================================
-- TABLE: license_validations
-- Log des validations de licence par les agents on-premise
-- ============================================================
CREATE TABLE license_validations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
license_key TEXT NOT NULL,
agent_version TEXT,
ip_address INET,
is_valid BOOLEAN NOT NULL,
validated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_license_validations_workspace ON license_validations(workspace_id);
CREATE INDEX idx_license_validations_key ON license_validations(license_key);
-- ============================================================
-- TRIGGERS: updated_at automatique
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_workspaces_updated_at
BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_saved_queries_updated_at
BEFORE UPDATE ON saved_queries FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_query_comments_updated_at
BEFORE UPDATE ON query_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at();
RLS Policies
```sql
-- ============================================================
-- RLS: workspaces
-- ============================================================
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
-- Lecture : être owner OU membre du workspace
CREATE POLICY "workspace_select" ON workspaces FOR SELECT
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspaces.id AND user_id = auth.uid() AND accepted_at IS NOT NULL
)
);
-- Création : tout utilisateur authentifié peut créer un workspace
CREATE POLICY "workspace_insert" ON workspaces FOR INSERT
WITH CHECK (owner_id = auth.uid());
-- Mise à jour : owner ou admin seulement
CREATE POLICY "workspace_update" ON workspaces FOR UPDATE
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspaces.id AND user_id = auth.uid() AND role IN ('owner', 'admin')
)
);
-- ============================================================
-- RLS: workspace_members
-- ============================================================
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "members_select" ON workspace_members FOR SELECT
USING (
EXISTS (
SELECT 1 FROM workspace_members wm
WHERE wm.workspace_id = workspace_members.workspace_id
AND wm.user_id = auth.uid()
AND wm.accepted_at IS NOT NULL
)
);
CREATE POLICY "members_insert" ON workspace_members FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM workspace_members wm
WHERE wm.workspace_id = workspace_members.workspace_id
AND wm.user_id = auth.uid()
AND wm.role IN ('owner', 'admin')
)
OR user_id = auth.uid() -- auto-join via invitation token
);
CREATE POLICY "members_delete" ON workspace_members FOR DELETE
USING (
user_id = auth.uid() -- quitter le workspace
OR EXISTS (
SELECT 1 FROM workspace_members wm
WHERE wm.workspace_id = workspace_members.workspace_id
AND wm.user_id = auth.uid()
AND wm.role IN ('owner', 'admin')
)
);
-- ============================================================
-- RLS: saved_queries
-- ============================================================
ALTER TABLE saved_queries ENABLE ROW LEVEL SECURITY;
CREATE POLICY "queries_select" ON saved_queries FOR SELECT
USING (
is_shared = TRUE
AND EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = saved_queries.workspace_id
AND user_id = auth.uid()
AND accepted_at IS NOT NULL
)
OR (is_shared = FALSE AND created_by = auth.uid())
);
CREATE POLICY "queries_insert" ON saved_queries FOR INSERT
WITH CHECK (
created_by = auth.uid()
AND EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = saved_queries.workspace_id
AND user_id = auth.uid()
AND role NOT IN ('viewer')
AND accepted_at IS NOT NULL
)
);
CREATE POLICY "queries_update" ON saved_queries FOR UPDATE
USING (
created_by = auth.uid()
OR EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = saved_queries.workspace_id
AND user_id = auth.uid()
AND role IN ('owner', 'admin')
AND accepted_at IS NOT NULL
)
);
CREATE POLICY "queries_delete" ON saved_queries FOR DELETE
USING (
created_by = auth.uid()
OR EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = saved_queries.workspace_id
AND user_id = auth.uid()
AND role IN ('owner', 'admin')
AND accepted_at IS NOT NULL
)
);
-- ============================================================
-- RLS: query_comments
-- ============================================================
ALTER TABLE query_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "comments_select" ON query_comments FOR SELECT
USING (
EXISTS (
SELECT 1 FROM saved_queries sq
JOIN workspace_members wm ON wm.workspace_id = sq.workspace_id
WHERE sq.id = query_comments.query_id
AND wm.user_id = auth.uid()
AND wm.accepted_at IS NOT NULL
)
);
CREATE POLICY "comments_insert" ON query_comments FOR INSERT
WITH CHECK (
author_id = auth.uid()
AND EXISTS (
SELECT 1 FROM saved_queries sq
JOIN workspace_members wm ON wm.workspace_id = sq.workspace_id
WHERE sq.id = query_comments.query_id
AND wm.user_id = auth.uid()
AND wm.role NOT IN ('viewer')
AND wm.accepted_at IS NOT NULL
)
);
CREATE POLICY "comments_update" ON query_comments FOR UPDATE
USING (author_id = auth.uid());
-- ============================================================
-- RLS: review_requests
-- ============================================================
ALTER TABLE review_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "reviews_select" ON review_requests FOR SELECT
USING (
EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = review_requests.workspace_id
AND user_id = auth.uid()
AND accepted_at IS NOT NULL
)
);
CREATE POLICY "reviews_insert" ON review_requests FOR INSERT
WITH CHECK (
requested_by = auth.uid()
AND EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = review_requests.workspace_id
AND user_id = auth.uid()
AND accepted_at IS NOT NULL