SaaS / Show HN: Self-hosted collaborative SQL editor for

Show HN: Self-hosted collaborative SQL editor for

bootstrapped
L
👥 Voir les clients
MRR
0€/m
Clients payants
0
Trials
0
Churn
0%
Fiche produit
ICP
Équipes data & engineering dans des entreprises à contraintes réglementaires
Problème résolu
Les équipes data perdent du temps à partager des requêtes SQL par Slack ou email, sans historique, sans revue de code, et sans pouvoir garder leurs do
Pricing
QueryTeam
49
Pro
129
Business
349
Trial : 14 jours gratuits
Mettre à jour les métriques
Docs générés automatiquement
SPEC.mdDESIGN.mdMARKETING.md

QueryTeam — 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)

  • App SaaS centrale (Next.js sur queryteam.fr) : landing page, auth, onboarding, dashboard d'admin de workspace, gestion billing Stripe, distribution de licence (clé d'activation)
  • Agent self-hosted (image Docker) : éditeur SQL Monaco, connexions multi-BDD, historique des requêtes, partage intra-équipe basique
  • Collaboration "lite" : liste des requêtes partagées, commentaires, pas de co-édition temps réel (scope P2)
  • Workflow de validation : requêtes marquées "à valider" avant exécution en prod
  • > 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