33800 Docs

← Retour

Proposition : QwikPress - Architecture Hybride Supabase + Redis

Date : 25/12/2025 Projet : QwikPress Status : VALIDÉ ✅


1. Contexte

QwikPress fonctionne actuellement avec un système de fichiers JSON :

Limites actuelles

Problème Impact
Pas de cache Relecture fichiers à chaque requête
Multi-sites impossible 1 instance = 1 site
Pas de collaboration Accès filesystem requis
Médias en local Pas de CDN, difficile à scale
Backup manuel Risque de perte

Objectif

Créer une architecture hybride permettant :


2. Architecture cible

┌─────────────────────────────────────────────────────────────────┐
│                         QwikPress                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ┌─────────────────────────────────────────────────────────┐   │
│   │                   DATA SERVICE                           │   │
│   │            (Interface abstraite unique)                  │   │
│   └─────────────────────────────────────────────────────────┘   │
│                              │                                   │
│              ┌───────────────┴───────────────┐                  │
│              │                               │                  │
│              ▼                               ▼                  │
│   ┌──────────────────┐           ┌──────────────────────────┐  │
│   │   FILE ADAPTER   │           │   SUPABASE ADAPTER       │  │
│   │                  │           │                          │  │
│   │  data/           │           │  ┌─────────────────────┐ │  │
│   │  ├─ config.json  │           │  │      REDIS          │ │  │
│   │  ├─ pages/       │           │  │  (Cache Layer)      │ │  │
│   │  └─ medias/      │           │  │  - Config: 5min TTL │ │  │
│   │                  │           │  │  - Pages: 1min TTL  │ │  │
│   │                  │           │  │  - Invalidation     │ │  │
│   │                  │           │  └─────────┬───────────┘ │  │
│   │                  │           │            │             │  │
│   │                  │           │            ▼             │  │
│   │                  │           │  ┌─────────────────────┐ │  │
│   │                  │           │  │   SUPABASE          │ │  │
│   │                  │           │  │                     │ │  │
│   │                  │           │  │  PostgreSQL:        │ │  │
│   │                  │           │  │  - sites            │ │  │
│   │                  │           │  │  - pages            │ │  │
│   │                  │           │  │  - modules          │ │  │
│   │                  │           │  │  - medias           │ │  │
│   │                  │           │  │                     │ │  │
│   │                  │           │  │  Storage (Bucket):  │ │  │
│   │                  │           │  │  - images           │ │  │
│   │                  │           │  │  - assets           │ │  │
│   │                  │           │  └─────────────────────┘ │  │
│   └──────────────────┘           └──────────────────────────┘  │
│                                                                  │
│   ENV: DATA_MODE=file              ENV: DATA_MODE=supabase      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

3. Structure du répertoire supabase/

Tout le nécessaire pour reconstruire l'environnement Supabase :

qwikpress/
└── supabase/
    ├── README.md                    # Guide de setup
    ├── config.toml                  # Config Supabase CLI (optionnel)
    │
    ├── migrations/                  # Migrations SQL versionnées
    │   ├── 00001_init_schema.sql
    │   ├── 00002_create_sites.sql
    │   ├── 00003_create_pages.sql
    │   ├── 00004_create_modules.sql
    │   ├── 00005_create_medias.sql
    │   ├── 00006_create_indexes.sql
    │   ├── 00007_create_functions.sql
    │   └── 00008_create_policies.sql
    │
    ├── seeds/                       # Données initiales
    │   ├── 01_default_site.sql      # Site par défaut
    │   └── 02_demo_pages.sql        # Pages démo (optionnel)
    │
    ├── storage/                     # Config buckets
    │   └── buckets.sql              # Création buckets + policies
    │
    ├── types/                       # Types TypeScript générés
    │   └── database.types.ts        # Auto-généré par Supabase CLI
    │
    └── scripts/                     # Scripts utilitaires
        ├── setup.sh                 # Setup complet (migrations + seeds)
        ├── migrate.sh               # Appliquer migrations
        ├── seed.sh                  # Injecter seeds
        ├── reset.sh                 # Reset complet (dev only)
        ├── generate-types.sh        # Regénérer types TS
        └── migrate-from-files.ts    # Migration fichiers → Supabase

4. Schéma PostgreSQL complet

4.1 Migration initiale : Schema

-- supabase/migrations/00001_init_schema.sql

-- Extension UUID
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Schema dédié QwikPress
CREATE SCHEMA IF NOT EXISTS qwikpress;

-- Fonction updated_at automatique
CREATE OR REPLACE FUNCTION qwikpress.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

4.2 Table Sites

-- supabase/migrations/00002_create_sites.sql

CREATE TABLE qwikpress.sites (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

    -- Identification
    slug VARCHAR(100) UNIQUE NOT NULL,      -- "rhinov", "monsite"
    name VARCHAR(200) NOT NULL,
    domain VARCHAR(200),                     -- "monsite.com" (optionnel)

    -- Configuration globale (reprend config.json)
    config JSONB NOT NULL DEFAULT '{}'::jsonb,
    /*
    Structure config:
    {
      "site": { "name": "...", "logo": "...", "favicon": "..." },
      "navigation": { "main": [...], "footer": [...] },
      "theme": { "colors": {...}, "fonts": {...} },
      "seo": { "defaultTitle": "...", "defaultDescription": "..." },
      "footer": { "tagline": "...", "social": [...] }
    }
    */

    -- Status
    is_active BOOLEAN DEFAULT true,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index
CREATE INDEX idx_sites_slug ON qwikpress.sites(slug);
CREATE INDEX idx_sites_domain ON qwikpress.sites(domain);
CREATE INDEX idx_sites_active ON qwikpress.sites(is_active) WHERE is_active = true;

-- Trigger updated_at
CREATE TRIGGER sites_updated_at
    BEFORE UPDATE ON qwikpress.sites
    FOR EACH ROW EXECUTE FUNCTION qwikpress.update_updated_at();

4.3 Table Pages

-- supabase/migrations/00003_create_pages.sql

CREATE TABLE qwikpress.pages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    site_id UUID NOT NULL REFERENCES qwikpress.sites(id) ON DELETE CASCADE,

    -- Identification
    slug VARCHAR(200) NOT NULL,              -- "home", "about", "services/web"

    -- Métadonnées (reprend meta.json)
    meta JSONB NOT NULL DEFAULT '{}'::jsonb,
    /*
    Structure meta:
    {
      "title": "Page Title",
      "description": "SEO description",
      "template": "default",
      "showInNav": true,
      "navOrder": 1,
      "ogImage": "/images/og.jpg"
    }
    */

    -- Contenu (reprend content.json)
    content JSONB NOT NULL DEFAULT '{"modules": []}'::jsonb,
    /*
    Structure content:
    {
      "modules": [
        { "id": "uuid", "type": "hero", "data": {...} },
        { "id": "uuid", "type": "features", "data": {...} }
      ]
    }
    */

    -- Publication
    is_published BOOLEAN DEFAULT false,
    published_at TIMESTAMPTZ,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    -- Contraintes
    UNIQUE(site_id, slug)
);

-- Index
CREATE INDEX idx_pages_site ON qwikpress.pages(site_id);
CREATE INDEX idx_pages_slug ON qwikpress.pages(slug);
CREATE INDEX idx_pages_published ON qwikpress.pages(site_id, is_published)
    WHERE is_published = true;

-- Index GIN pour recherche JSONB
CREATE INDEX idx_pages_meta ON qwikpress.pages USING GIN (meta);
CREATE INDEX idx_pages_content ON qwikpress.pages USING GIN (content);

-- Trigger updated_at
CREATE TRIGGER pages_updated_at
    BEFORE UPDATE ON qwikpress.pages
    FOR EACH ROW EXECUTE FUNCTION qwikpress.update_updated_at();

4.4 Table Modules partagés

-- supabase/migrations/00004_create_modules.sql

CREATE TABLE qwikpress.shared_modules (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    site_id UUID NOT NULL REFERENCES qwikpress.sites(id) ON DELETE CASCADE,

    -- Identification
    name VARCHAR(100) NOT NULL,              -- "header-principal", "footer-contact"
    type VARCHAR(50) NOT NULL,               -- "hero", "cta", "features"

    -- Contenu
    data JSONB NOT NULL DEFAULT '{}'::jsonb,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    -- Contraintes
    UNIQUE(site_id, name)
);

-- Index
CREATE INDEX idx_shared_modules_site ON qwikpress.shared_modules(site_id);
CREATE INDEX idx_shared_modules_type ON qwikpress.shared_modules(type);

-- Trigger updated_at
CREATE TRIGGER shared_modules_updated_at
    BEFORE UPDATE ON qwikpress.shared_modules
    FOR EACH ROW EXECUTE FUNCTION qwikpress.update_updated_at();

4.5 Table Médias

-- supabase/migrations/00005_create_medias.sql

CREATE TABLE qwikpress.medias (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    site_id UUID NOT NULL REFERENCES qwikpress.sites(id) ON DELETE CASCADE,

    -- Fichier
    filename VARCHAR(200) NOT NULL,
    original_name VARCHAR(200),
    storage_path TEXT NOT NULL,              -- Chemin dans bucket Supabase
    public_url TEXT,                         -- URL publique CDN

    -- Métadonnées
    mime_type VARCHAR(100),
    size_bytes INTEGER,
    width INTEGER,                           -- Pour images
    height INTEGER,                          -- Pour images
    alt_text VARCHAR(500),

    -- Organisation
    folder VARCHAR(200) DEFAULT '/',         -- "/images", "/documents"
    tags TEXT[],

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index
CREATE INDEX idx_medias_site ON qwikpress.medias(site_id);
CREATE INDEX idx_medias_folder ON qwikpress.medias(site_id, folder);
CREATE INDEX idx_medias_mime ON qwikpress.medias(mime_type);
CREATE INDEX idx_medias_tags ON qwikpress.medias USING GIN (tags);

-- Trigger updated_at
CREATE TRIGGER medias_updated_at
    BEFORE UPDATE ON qwikpress.medias
    FOR EACH ROW EXECUTE FUNCTION qwikpress.update_updated_at();

4.6 Fonctions utilitaires

-- supabase/migrations/00007_create_functions.sql

-- Récupérer config + pages d'un site (optimisé)
CREATE OR REPLACE FUNCTION qwikpress.get_site_data(p_slug VARCHAR)
RETURNS JSONB AS $$
DECLARE
    result JSONB;
BEGIN
    SELECT jsonb_build_object(
        'site', jsonb_build_object(
            'id', s.id,
            'slug', s.slug,
            'name', s.name,
            'config', s.config
        ),
        'pages', COALESCE(
            (SELECT jsonb_agg(
                jsonb_build_object(
                    'id', p.id,
                    'slug', p.slug,
                    'meta', p.meta,
                    'content', p.content
                )
            )
            FROM qwikpress.pages p
            WHERE p.site_id = s.id AND p.is_published = true),
            '[]'::jsonb
        )
    ) INTO result
    FROM qwikpress.sites s
    WHERE s.slug = p_slug AND s.is_active = true;

    RETURN result;
END;
$$ LANGUAGE plpgsql;

-- Récupérer une page spécifique
CREATE OR REPLACE FUNCTION qwikpress.get_page(p_site_slug VARCHAR, p_page_slug VARCHAR)
RETURNS JSONB AS $$
DECLARE
    result JSONB;
BEGIN
    SELECT jsonb_build_object(
        'id', p.id,
        'slug', p.slug,
        'meta', p.meta,
        'content', p.content,
        'site', jsonb_build_object(
            'id', s.id,
            'slug', s.slug,
            'name', s.name,
            'config', s.config
        )
    ) INTO result
    FROM qwikpress.pages p
    JOIN qwikpress.sites s ON s.id = p.site_id
    WHERE s.slug = p_site_slug
      AND p.slug = p_page_slug
      AND s.is_active = true
      AND p.is_published = true;

    RETURN result;
END;
$$ LANGUAGE plpgsql;

-- Générer slug unique pour page
CREATE OR REPLACE FUNCTION qwikpress.generate_page_slug(
    p_site_id UUID,
    p_base_slug VARCHAR
)
RETURNS VARCHAR AS $$
DECLARE
    v_slug VARCHAR;
    v_count INTEGER := 0;
BEGIN
    v_slug := p_base_slug;

    WHILE EXISTS (
        SELECT 1 FROM qwikpress.pages
        WHERE site_id = p_site_id AND slug = v_slug
    ) LOOP
        v_count := v_count + 1;
        v_slug := p_base_slug || '-' || v_count;
    END LOOP;

    RETURN v_slug;
END;
$$ LANGUAGE plpgsql;

4.7 Row Level Security (RLS)

-- supabase/migrations/00008_create_policies.sql

-- Activer RLS
ALTER TABLE qwikpress.sites ENABLE ROW LEVEL SECURITY;
ALTER TABLE qwikpress.pages ENABLE ROW LEVEL SECURITY;
ALTER TABLE qwikpress.shared_modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE qwikpress.medias ENABLE ROW LEVEL SECURITY;

-- Policies pour lecture publique (sites actifs, pages publiées)
CREATE POLICY "Sites publics lisibles" ON qwikpress.sites
    FOR SELECT USING (is_active = true);

CREATE POLICY "Pages publiées lisibles" ON qwikpress.pages
    FOR SELECT USING (is_published = true);

CREATE POLICY "Modules partagés lisibles" ON qwikpress.shared_modules
    FOR SELECT USING (true);

CREATE POLICY "Médias lisibles" ON qwikpress.medias
    FOR SELECT USING (true);

-- Note: Les policies d'écriture seront ajoutées
-- quand le système d'auth admin sera implémenté

5. Configuration Storage (Buckets)

-- supabase/storage/buckets.sql

-- Bucket pour les médias QwikPress
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
    'qwikpress-medias',
    'qwikpress-medias',
    true,  -- Public pour CDN
    52428800,  -- 50MB max
    ARRAY[
        'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
        'video/mp4', 'video/webm',
        'application/pdf',
        'font/woff', 'font/woff2'
    ]
);

-- Bucket pour les certificats/exports (privé)
INSERT INTO storage.buckets (id, name, public)
VALUES ('qwikpress-exports', 'qwikpress-exports', false);

-- Policies Storage
CREATE POLICY "Médias publics lisibles"
ON storage.objects FOR SELECT
USING (bucket_id = 'qwikpress-medias');

CREATE POLICY "Upload médias authentifié"
ON storage.objects FOR INSERT
WITH CHECK (
    bucket_id = 'qwikpress-medias'
    AND auth.role() = 'authenticated'
);

6. Stratégie Redis

6.1 Structure des clés

NAMESPACE: qwikpress

CLÉS:
  qwikpress:site:{slug}              → Config site complète (JSONB)
  qwikpress:site:{slug}:nav          → Navigation (extrait de config)
  qwikpress:site:{slug}:page:{slug}  → Page complète (meta + content)
  qwikpress:site:{slug}:pages        → Liste des pages (meta only)

TTL:
  Config site:  300s (5 min)
  Navigation:   300s (5 min)
  Page:         60s  (1 min)
  Liste pages:  120s (2 min)

6.2 Service Redis

// src/services/redis-cache.service.ts

import Redis from 'ioredis';

export class RedisCacheService {
    private redis: Redis;
    private prefix = 'qwikpress';

    constructor() {
        this.redis = new Redis({
            host: process.env.REDIS_HOST,
            port: parseInt(process.env.REDIS_PORT || '6379'),
            password: process.env.REDIS_PASSWORD,
        });
    }

    // Clés
    private siteKey(slug: string) { return `${this.prefix}:site:${slug}`; }
    private pageKey(siteSlug: string, pageSlug: string) {
        return `${this.prefix}:site:${siteSlug}:page:${pageSlug}`;
    }
    private navKey(slug: string) { return `${this.prefix}:site:${slug}:nav`; }

    // GET avec fallback
    async getSite(slug: string): Promise<SiteConfig | null> {
        const cached = await this.redis.get(this.siteKey(slug));
        return cached ? JSON.parse(cached) : null;
    }

    async getPage(siteSlug: string, pageSlug: string): Promise<Page | null> {
        const cached = await this.redis.get(this.pageKey(siteSlug, pageSlug));
        return cached ? JSON.parse(cached) : null;
    }

    // SET avec TTL
    async setSite(slug: string, data: SiteConfig): Promise<void> {
        await this.redis.setex(this.siteKey(slug), 300, JSON.stringify(data));
    }

    async setPage(siteSlug: string, pageSlug: string, data: Page): Promise<void> {
        await this.redis.setex(this.pageKey(siteSlug, pageSlug), 60, JSON.stringify(data));
    }

    // Invalidation
    async invalidateSite(slug: string): Promise<void> {
        const pattern = `${this.prefix}:site:${slug}*`;
        const keys = await this.redis.keys(pattern);
        if (keys.length > 0) {
            await this.redis.del(...keys);
        }
    }

    async invalidatePage(siteSlug: string, pageSlug: string): Promise<void> {
        await this.redis.del(this.pageKey(siteSlug, pageSlug));
    }
}

6.3 Invalidation côté serveur Qwik

Plutôt que des Edge Functions Supabase, l'invalidation est gérée directement dans le serveur Qwik :

Option A : Invalidation automatique dans le DataService

// src/services/data/supabase-data.service.ts

// L'invalidation se fait automatiquement lors des opérations CRUD
async updatePage(siteSlug: string, pageSlug: string, data: UpdatePageInput): Promise<Page> {
    // 1. Update Supabase
    const { data: page, error } = await this.supabase
        .schema('qwikpress')
        .from('pages')
        .update({ content: data.content, meta: data.meta, updated_at: new Date() })
        .eq('slug', pageSlug)
        .select()
        .single();

    if (error) throw error;

    // 2. Invalider cache Redis immédiatement
    await this.cache.invalidatePage(siteSlug, pageSlug);

    return page;
}

async deletePage(siteSlug: string, pageSlug: string): Promise<void> {
    await this.supabase
        .schema('qwikpress')
        .from('pages')
        .delete()
        .eq('slug', pageSlug);

    // Invalider cache
    await this.cache.invalidatePage(siteSlug, pageSlug);
    await this.cache.invalidateSiteNav(siteSlug); // Navigation aussi
}

Option B : Route API pour webhook Supabase (si besoin sync externe)

// src/routes/api/webhooks/supabase/index.ts

import type { RequestHandler } from '@builder.io/qwik-city';
import { cacheService } from '~/services/redis-cache.service';

export const onPost: RequestHandler = async ({ request, json }) => {
    // Vérifier secret webhook
    const secret = request.headers.get('x-supabase-webhook-secret');
    if (secret !== process.env.SUPABASE_WEBHOOK_SECRET) {
        return json(401, { error: 'Unauthorized' });
    }

    const payload = await request.json();
    const { type, table, record, old_record } = payload;

    // Invalider selon la table modifiée
    switch (table) {
        case 'sites':
            const siteSlug = record?.slug || old_record?.slug;
            if (siteSlug) await cacheService.invalidateSite(siteSlug);
            break;

        case 'pages':
            // Récupérer site_id → slug puis invalider
            const siteId = record?.site_id || old_record?.site_id;
            if (siteId) {
                const site = await getSiteById(siteId);
                if (site) {
                    await cacheService.invalidatePage(site.slug, record?.slug);
                }
            }
            break;
    }

    return json(200, { success: true });
};

Option C : Invalidation manuelle via Admin UI

// src/routes/admin/cache/index.ts

import type { RequestHandler } from '@builder.io/qwik-city';
import { cacheService } from '~/services/redis-cache.service';

// POST /admin/cache/invalidate
export const onPost: RequestHandler = async ({ request, json }) => {
    const { siteSlug, pageSlug, type } = await request.json();

    switch (type) {
        case 'page':
            await cacheService.invalidatePage(siteSlug, pageSlug);
            break;
        case 'site':
            await cacheService.invalidateSite(siteSlug);
            break;
        case 'all':
            await cacheService.invalidateAll();
            break;
    }

    return json(200, { success: true, invalidated: type });
};

Avantage : Tout le code reste dans le projet Qwik, pas de fragmentation avec Supabase Functions.


7. Data Service abstrait

7.1 Interface

// src/services/data/data-service.interface.ts

export interface IDataService {
    // Sites
    getSiteBySlug(slug: string): Promise<SiteConfig | null>;
    getSiteByDomain(domain: string): Promise<SiteConfig | null>;

    // Pages
    getPage(siteSlug: string, pageSlug: string): Promise<Page | null>;
    getPages(siteSlug: string, options?: { published?: boolean }): Promise<PageMeta[]>;

    // Navigation
    getNavigation(siteSlug: string): Promise<Navigation>;

    // Modules partagés
    getSharedModule(siteSlug: string, moduleName: string): Promise<Module | null>;

    // Médias
    getMediaUrl(siteSlug: string, path: string): string;

    // Admin (CRUD)
    createPage(siteSlug: string, data: CreatePageInput): Promise<Page>;
    updatePage(siteSlug: string, pageSlug: string, data: UpdatePageInput): Promise<Page>;
    deletePage(siteSlug: string, pageSlug: string): Promise<void>;

    // Mode info
    getMode(): 'file' | 'supabase';
}

7.2 Factory

// src/services/data/data-service.factory.ts

import { FileDataService } from './file-data.service';
import { SupabaseDataService } from './supabase-data.service';
import type { IDataService } from './data-service.interface';

export function createDataService(): IDataService {
    const mode = process.env.DATA_MODE || 'file';

    if (mode === 'supabase') {
        return new SupabaseDataService();
    }

    return new FileDataService();
}

// Singleton export
export const dataService = createDataService();

7.3 Implémentation Supabase

// src/services/data/supabase-data.service.ts

import { createClient } from '@supabase/supabase-js';
import { RedisCacheService } from '../redis-cache.service';
import type { IDataService } from './data-service.interface';

export class SupabaseDataService implements IDataService {
    private supabase;
    private cache: RedisCacheService;

    constructor() {
        this.supabase = createClient(
            process.env.SUPABASE_URL!,
            process.env.SUPABASE_SERVICE_ROLE_KEY!
        );
        this.cache = new RedisCacheService();
    }

    async getSiteBySlug(slug: string): Promise<SiteConfig | null> {
        // 1. Check cache
        const cached = await this.cache.getSite(slug);
        if (cached) return cached;

        // 2. Query Supabase
        const { data, error } = await this.supabase
            .schema('qwikpress')
            .from('sites')
            .select('*')
            .eq('slug', slug)
            .eq('is_active', true)
            .single();

        if (error || !data) return null;

        // 3. Cache result
        await this.cache.setSite(slug, data);

        return data;
    }

    async getPage(siteSlug: string, pageSlug: string): Promise<Page | null> {
        // 1. Check cache
        const cached = await this.cache.getPage(siteSlug, pageSlug);
        if (cached) return cached;

        // 2. Query via fonction PostgreSQL (optimisé)
        const { data, error } = await this.supabase
            .rpc('get_page', {
                p_site_slug: siteSlug,
                p_page_slug: pageSlug
            });

        if (error || !data) return null;

        // 3. Cache result
        await this.cache.setPage(siteSlug, pageSlug, data);

        return data;
    }

    getMediaUrl(siteSlug: string, path: string): string {
        // URL publique du bucket Supabase Storage
        return `${process.env.SUPABASE_URL}/storage/v1/object/public/qwikpress-medias/${siteSlug}/${path}`;
    }

    getMode(): 'file' | 'supabase' {
        return 'supabase';
    }

    // ... autres méthodes
}

8. Scripts utilitaires

8.1 Setup complet

#!/bin/bash
# supabase/scripts/setup.sh

set -e

echo "=== QwikPress Supabase Setup ==="

# Variables
SUPABASE_URL="${SUPABASE_URL:-}"
SUPABASE_SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY:-}"

if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_SERVICE_KEY" ]; then
    echo "Error: SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY requis"
    exit 1
fi

# 1. Appliquer migrations
echo ">> Applying migrations..."
for file in ./migrations/*.sql; do
    echo "   - $file"
    psql "$DATABASE_URL" -f "$file"
done

# 2. Setup storage
echo ">> Setting up storage buckets..."
psql "$DATABASE_URL" -f ./storage/buckets.sql

# 3. Seeds (optionnel)
if [ "$1" = "--with-seeds" ]; then
    echo ">> Applying seeds..."
    for file in ./seeds/*.sql; do
        echo "   - $file"
        psql "$DATABASE_URL" -f "$file"
    done
fi

# 4. Générer types
echo ">> Generating TypeScript types..."
./scripts/generate-types.sh

echo "=== Setup complete ==="

8.2 Migration fichiers → Supabase

// supabase/scripts/migrate-from-files.ts

import { createClient } from '@supabase/supabase-js';
import * as fs from 'fs';
import * as path from 'path';

const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
);

async function migrateFromFiles(dataDir: string, siteSlug: string) {
    console.log(`Migrating ${dataDir} to site: ${siteSlug}`);

    // 1. Lire config.json
    const configPath = path.join(dataDir, 'config.json');
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));

    // 2. Créer le site
    const { data: site, error: siteError } = await supabase
        .schema('qwikpress')
        .from('sites')
        .insert({
            slug: siteSlug,
            name: config.site?.name || siteSlug,
            config: config
        })
        .select()
        .single();

    if (siteError) throw siteError;
    console.log(`Site created: ${site.id}`);

    // 3. Migrer les pages
    const pagesDir = path.join(dataDir, 'pages');
    const pageFolders = fs.readdirSync(pagesDir);

    for (const folder of pageFolders) {
        const pagePath = path.join(pagesDir, folder);
        if (!fs.statSync(pagePath).isDirectory()) continue;

        const metaPath = path.join(pagePath, 'meta.json');
        const contentPath = path.join(pagePath, 'content.json');

        if (!fs.existsSync(metaPath) || !fs.existsSync(contentPath)) {
            console.warn(`Skipping ${folder}: missing meta or content`);
            continue;
        }

        const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
        const content = JSON.parse(fs.readFileSync(contentPath, 'utf-8'));

        const { error: pageError } = await supabase
            .schema('qwikpress')
            .from('pages')
            .insert({
                site_id: site.id,
                slug: meta.slug || folder,
                meta: meta,
                content: content,
                is_published: true
            });

        if (pageError) {
            console.error(`Error migrating page ${folder}:`, pageError);
        } else {
            console.log(`Page migrated: ${folder}`);
        }
    }

    // 4. Migrer les médias (TODO: upload vers bucket)
    console.log('Media migration: TODO');

    console.log('Migration complete!');
}

// CLI
const [,, dataDir, siteSlug] = process.argv;
if (!dataDir || !siteSlug) {
    console.log('Usage: bun migrate-from-files.ts <data-dir> <site-slug>');
    process.exit(1);
}

migrateFromFiles(dataDir, siteSlug);

9. Variables d'environnement

# .env.example

# Mode de données
DATA_MODE=file          # "file" ou "supabase"

# Supabase (requis si DATA_MODE=supabase)
SUPABASE_URL=https://supabase-api-prod.33800.nowhere84.com
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...

# Redis (requis si DATA_MODE=supabase)
REDIS_HOST=prod-redis.local
REDIS_PORT=6379
REDIS_PASSWORD=xxx

# File mode
DATA_DIR=./data

10. Plan d'implémentation

Phase 1 : Préparation (1-2 jours)

Phase 2 : Refactoring DataService (2-3 jours)

Phase 3 : Intégration Redis (1 jour)

Phase 4 : Migration & Tests (1-2 jours)

Phase 5 : Admin Supabase (futur)


11. Résumé des fichiers à créer

Chemin Description
supabase/README.md Guide setup
supabase/migrations/*.sql 8 fichiers migration
supabase/seeds/*.sql 2 fichiers seeds
supabase/storage/buckets.sql Config buckets
supabase/scripts/*.sh 5 scripts bash
supabase/scripts/migrate-from-files.ts Script migration
src/services/data/data-service.interface.ts Interface
src/services/data/file-data.service.ts Adapter fichiers
src/services/data/supabase-data.service.ts Adapter Supabase
src/services/data/data-service.factory.ts Factory
src/services/redis-cache.service.ts Service Redis
src/routes/api/webhooks/supabase/index.ts Webhook invalidation (optionnel)
src/routes/admin/cache/index.ts API invalidation manuelle

En attente de validation pour commencer l'implémentation.