diff --git a/.env.example b/.env.example
index f0e3e37..1412d65 100644
--- a/.env.example
+++ b/.env.example
@@ -1,11 +1,42 @@
-# Copier ce fichier en .env et remplir les valeurs
+# Copier ce fichier en .env (exclu du git) et remplir les valeurs.
+# Le token frp n'est pas ici : il est dans frps.toml (voir frps.toml.example).
-# Token partagé entre frps (Hostinger) et frpc (magasin)
-# Doit être identique dans frps.toml ET frpc.toml côté magasin
-FRP_TOKEN=CHANGE_ME_FRP_SECRET_TOKEN
-
-# Credentials PostgreSQL Debian (source du sync)
-# Même valeurs que dans foirfouille-api/.env.docker
+# --- Source : PostgreSQL Debian, joint via le tunnel frps:15432 ---
+# Mêmes valeurs que foirfouille-api/.env.docker (idéalement un rôle en lecture seule)
+SRC_HOST=frps
+SRC_PORT=15432
SRC_DB=CHANGE_ME_DB_NAME
SRC_USER=CHANGE_ME_DB_USER
SRC_PASS=CHANGE_ME_DB_PASSWORD
+
+# --- Destination : PostgreSQL Hostinger (container postgres_db sur nginx_default) ---
+DST_HOST=postgres_db
+DST_PORT=5432
+DST_DB=CHANGE_ME_DST_DB
+DST_USER=CHANGE_ME_DST_USER
+# Mot de passe long et aléatoire (openssl rand -hex 24) — l'ancien a fuité dans l'historique git
+DST_PASS=CHANGE_ME_DST_PASSWORD
+
+# --- Planification (cron 5 champs, fuseau ci-dessous) ---
+SYNC_CRON=30 7 * * *
+SYNC_TZ=Europe/Paris
+
+# --- Tables ---
+# Tables source à ne pas répliquer (liste séparée par des virgules), ex : client,adrclient,cartes
+EXCLUDE_TABLES=
+# Surcharge des tables "fenêtre" (table:colonne_date,…). Vide = liste par défaut du code :
+# mvtart:datmvt,mvtreg:datmvt,mvtart_motif:datmvt,histo_art_gamme:date,demarques:datmvt,meteo:date,fidelite_jour:date
+WINDOW_TABLES=
+# Nombre de jours toujours rechargés pour les tables "fenêtre"
+WINDOW_RECENT_DAYS=10
+
+# --- Attente de la sync Debian (sync_log._run = 'running') ---
+DEBIAN_WAIT_POLL_MIN=2
+DEBIAN_WAIT_MAX_MIN=180
+
+# --- Dashboard (HTTP Basic auth). Sans ces deux variables, le dashboard répond 503 ---
+DASHBOARD_USER=CHANGE_ME_USER
+DASHBOARD_PASSWORD=CHANGE_ME_LONG_RANDOM_PASSWORD
+
+# --- Alerte webhook (Slack, Discord, Mattermost…) : vide = pas d'alerte ---
+ALERT_WEBHOOK_URL=
diff --git a/.gitignore b/.gitignore
index d81bcd4..8bed0b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
+# Secrets — ne jamais committer
.env
-# frps.toml contient le token secret — ne pas committer
frps.toml
+
+node_modules/
caddy_data/
caddy_config/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8a22c38
--- /dev/null
+++ b/README.md
@@ -0,0 +1,201 @@
+# Gateway Hostinger — frps + miroir PostgreSQL
+
+Côté VPS Hostinger. Reçoit le tunnel frp du serveur Debian du magasin et recopie chaque jour
+la base PostgreSQL Debian dans le PostgreSQL Hostinger (`postgres_db`).
+
+## Architecture
+
+```
+Serveur Debian (magasin) VPS Hostinger (réseau Docker externe nginx_default)
+┌──────────────────────────┐ tunnel frp ┌──────────────────────────────────────────────────┐
+│ frpc 0.71.0 ─────────────┼── :7000 (TLS) ──▶│ ff_frps (snowdreamtech/frps:0.71.0-alpine) │
+│ API Debian :3000 ─────┼──────────────────│ → ff_frps:13000 (API Debian) │
+│ PostgreSQL :5432 ─────┼──────────────────│ → ff_frps:15432 (PostgreSQL Debian) │
+└──────────────────────────┘ │ │
+ │ ff_sync_hostinger (Node 22) │
+ │ lit frps:15432 (source, lecture seule) │
+ │ écrit postgres_db:5432 (destination) │
+ │ dashboard :3002 ◀── Nginx Proxy Manager (HTTPS)│
+ └──────────────────────────────────────────────────┘
+```
+
+- Seul le port **7000** est publié sur l'hôte (connexion frpc). Les ports 13000/15432 et le
+ dashboard 3002 ne sont joignables que depuis le réseau `nginx_default`.
+- frps n'accepte que les ports distants 13000 et 15432 (`allowPorts`) et des connexions TLS.
+
+## Fichiers
+
+| Fichier | Rôle |
+|---|---|
+| `docker-compose.yml` | services `frps` et `sync` (aucun secret) |
+| `frps.toml.example` | modèle de config frps → copier en `frps.toml` (ignoré par git) |
+| `.env.example` | modèle des variables → copier en `.env` (ignoré par git) |
+| `sync/` | service Node du miroir PostgreSQL + dashboard |
+| `sync/test/` | tests des fonctions SQL pures (`npm test`) |
+
+L'ancien `init.sql` (schéma écrit à la main) et `sync/tables.js` (liste de tables) ont été
+**supprimés** : ils avaient dérivé de la source (colonnes et tables manquantes). Le schéma de la
+destination est désormais recopié automatiquement depuis la source à chaque exécution.
+`sync/sync.sh` (pg_dump, jamais utilisé) a aussi été supprimé.
+
+## Variables d'environnement (`.env`)
+
+| Variable | Défaut | Description |
+|---|---|---|
+| `SRC_HOST` / `SRC_PORT` | `frps` / `15432` | PostgreSQL Debian via le tunnel |
+| `SRC_DB` / `SRC_USER` / `SRC_PASS` | — (obligatoire) | identifiants source (idéalement rôle lecture seule) |
+| `DST_HOST` / `DST_PORT` | `postgres_db` / `5432` | PostgreSQL Hostinger |
+| `DST_DB` / `DST_USER` / `DST_PASS` | — (obligatoire) | identifiants destination |
+| `SYNC_CRON` | `30 7 * * *` | planification (cron 5 champs) |
+| `SYNC_TZ` | `Europe/Paris` | fuseau du cron |
+| `EXCLUDE_TABLES` | vide | tables source à ne pas répliquer (`a,b,c`) |
+| `WINDOW_TABLES` | liste du code | surcharge des tables « fenêtre » (`table:colonne_date,…`) |
+| `WINDOW_RECENT_DAYS` | `10` | jours toujours rechargés pour les tables « fenêtre » |
+| `DEBIAN_WAIT_POLL_MIN` / `DEBIAN_WAIT_MAX_MIN` | `2` / `180` | attente de la fin de la sync Debian |
+| `DASHBOARD_USER` / `DASHBOARD_PASSWORD` | vide | Basic auth ; **vide = dashboard désactivé (503)** |
+| `ALERT_WEBHOOK_URL` | vide | webhook Slack/Discord/Mattermost (POST `{text, content}`) |
+
+## Fonctionnement du miroir
+
+Une exécution (cron, bouton du dashboard ou CLI) :
+
+1. **Verrou** `pg_try_advisory_lock` sur la destination : deux exécutions ne peuvent jamais se
+ chevaucher, même depuis deux process (service + `docker exec`). La ligne `_run` de `sync_log`
+ (destination) passe à `running`.
+2. **Attente de la sync Debian** : tant que `sync_log` source a `table_name='_run'` et
+ `status='running'`, on attend (contrôle toutes les 2 min, 3 h max). Si la ligne n'existe pas,
+ on continue. Après 3 h : alerte, puis copie quand même.
+3. **Lecture du catalogue source** (`pg_catalog`) : toutes les tables de base du schéma `public`
+ (sauf `EXCLUDE_TABLES`), colonnes dans l'ordre avec leur type exact (`format_type`), NOT NULL,
+ clé primaire, index.
+4. Pour chaque table (chacune dans son propre try/catch, résultat écrit dans `sync_log`) :
+ - **Schéma** : `CREATE TABLE IF NOT EXISTS` (mêmes colonnes et PK), `ADD COLUMN IF NOT EXISTS`
+ pour les colonnes manquantes (ajoutées NULLables). Un type différent est seulement signalé.
+ Rien n'est jamais supprimé côté destination (tables ou colonnes en trop conservées).
+ - **Données** (voir stratégies ci-dessous), en lisant la source par curseur serveur
+ (`FETCH 5000`) et en insérant par `INSERT` multi-lignes paramétrés
+ (`floor(60000 / nb_colonnes)` lignes par requête). Les valeurs transitent en texte brut :
+ aucune perte de précision (timestamps, numeric, bytea…).
+ - **Index** après la copie : index source recréés avec le même nom (`IF NOT EXISTS`), clé
+ primaire ajoutée si la table destination n'en avait pas. Si un index utilise `pg_trgm`, on
+ tente `CREATE EXTENSION IF NOT EXISTS pg_trgm` ; en cas d'échec, ces index sont ignorés
+ (avertissement).
+5. `sync_log` source → table destination **`source_sync_log`** (la table `sync_log` de la
+ destination est celle du miroir et n'est jamais écrasée).
+6. Fin : `_run` = `ok` ou `error`, verrou libéré, **alerte webhook** si au moins une table a échoué.
+
+### Stratégies de copie
+
+**`full`** (toutes les tables sauf les tables « fenêtre ») :
+`BEGIN; DELETE FROM; INSERT de toutes les lignes; COMMIT` dans une seule transaction. Grâce au
+MVCC, les lecteurs continuent de lire l'ancienne version sans être bloqués (pas de verrou ACCESS
+EXCLUSIVE comme avec TRUNCATE) et ne voient jamais une table vide ou partielle ; l'autovacuum
+récupère l'espace des anciennes lignes. `lock_timeout` de 60 s par sécurité.
+**Garde-fou volume** : si la source a moins de 50 % des lignes de la destination (et la
+destination plus de 100 lignes), la table n'est pas copiée et passe en erreur (alerte).
+
+**`window`** (tables de mouvements, configurées dans `sync/config.js` : `mvtart`, `mvtreg`,
+`mvtart_motif`, `histo_art_gamme`, `demarques`, `meteo`, `fidelite_jour` ; une table absente de la
+source est ignorée) :
+
+1. Signature par mois des deux côtés :
+ `date_trunc('month', col), count(*), sum(hashtext(ROW(colonnes source)::text)::bigint)`.
+2. Garde-fou volume sur le total des lignes.
+3. Les **10 derniers jours** sont toujours rechargés (DELETE + INSERT dans une transaction).
+4. Chaque mois dont la signature diffère (ou présent d'un seul côté) est rechargé dans sa propre
+ transaction (`DELETE` du mois + réinsertion depuis la source).
+
+Résultat : coût incrémental chaque nuit, propagation des corrections et suppressions anciennes,
+et **remplissage automatique d'une nouvelle colonne** (tous les mois diffèrent une fois, puis plus).
+Les deux connexions utilisent les mêmes paramètres de session (`TimeZone=UTC`, `DateStyle=ISO`,
+`extra_float_digits=3`…) pour que les signatures soient comparables. Si un type de colonne diffère
+entre source et destination, les mois concernés seront rechargés à chaque exécution : corriger le
+type à la main côté destination.
+
+**Mode forcé** (bouton « Forcer » avec confirmation, ou `--force`) : garde-fou volume ignoré et
+tables « fenêtre » rechargées entièrement (stratégie `full`).
+
+## Dashboard
+
+- Exposé uniquement sur `nginx_default` : dans Nginx Proxy Manager, créer un proxy host vers
+ `ff_sync_hostinger`, port `3002`, **avec certificat SSL et « Force SSL »** (Basic auth en clair
+ sinon).
+- HTTP Basic auth (`DASHBOARD_USER` / `DASHBOARD_PASSWORD`). Sans ces variables : 503.
+- `GET /healthz` sans authentification (healthcheck Docker).
+- Bouton « Lancer la sync » = exécution normale ; « Forcer » = mode forcé (confirmation).
+- Par table : stratégie, dernière sync, lignes, durée, statut, détail ou erreur.
+
+## Déploiement (VPS Hostinger)
+
+```bash
+cd ~/gateway # emplacement du dépôt sur le VPS
+git pull
+
+# 1. Secrets (une seule fois, puis à chaque rotation)
+cp -n .env.example .env # compléter DST_*, DASHBOARD_*, ALERT_WEBHOOK_URL…
+cp -n frps.toml.example frps.toml # si frps.toml n'existe pas encore
+# → dans frps.toml existant, ajouter allowPorts / additionalScopes / tls.force (voir l'exemple)
+chmod 600 .env frps.toml
+
+# 2. Changer le mot de passe PostgreSQL destination (voir ci-dessous) et le reporter dans .env
+
+# 3. Construire et démarrer
+docker compose up -d --build
+docker compose ps # ff_sync_hostinger doit passer "healthy"
+docker logs -f ff_sync_hostinger
+
+# 4. Première exécution manuelle (facultatif, sinon cron 07:30 Paris)
+docker exec ff_sync_hostinger node index.js --once
+```
+
+Le premier passage crée les tables/colonnes/index manquants et recharge tous les mois des tables
+« fenêtre » : il est plus long que les suivants.
+
+Commandes utiles :
+
+```bash
+docker exec ff_sync_hostinger node index.js --once # exécution normale
+docker exec ff_sync_hostinger node index.js --force # exécution forcée
+docker exec -it postgres_db psql -U post -d post -c "SELECT * FROM sync_log ORDER BY last_sync DESC"
+```
+
+## Rotation des secrets
+
+### Token frp
+
+```bash
+openssl rand -hex 32
+```
+
+1. Mettre le nouveau token dans `frps.toml` (Hostinger) **et** dans `frpc.toml` (Debian).
+2. `docker compose restart frps` côté Hostinger, puis redémarrer frpc côté Debian.
+3. Vérifier : `docker logs ff_frps` (connexion du client, proxys 13000 et 15432 acceptés).
+
+frpc doit être en **0.71.0** (`snowdreamtech/frpc:0.71.0-alpine`) avec
+`auth.additionalScopes = ["HeartBeats", "NewWorkConns"]` et `transport.tls.enable = true`
+(valeur par défaut), sinon frps refusera la connexion.
+
+### Mot de passe PostgreSQL `post` (destination)
+
+L'ancien mot de passe de `post` a été commité dans l'historique git de ce dépôt : il doit être
+considéré comme public et **changé sur le serveur**.
+
+```bash
+NEWPASS=$(openssl rand -hex 24)
+docker exec -it postgres_db psql -U post -d post -c "ALTER USER post WITH PASSWORD '$NEWPASS';"
+# Reporter $NEWPASS dans .env (DST_PASS) ET dans la config de tout autre service qui utilise ce
+# compte (API Hostinger, outils…), puis :
+docker compose up -d sync
+```
+
+Réécrire l'historique git ne suffit pas (copies existantes) : seule la rotation protège.
+
+### Dashboard
+
+Changer `DASHBOARD_PASSWORD` dans `.env` puis `docker compose up -d sync`.
+
+## Tests
+
+```bash
+cd sync && npm ci && npm test # fonctions SQL pures, sans base de données
+```
diff --git a/docker-compose.yml b/docker-compose.yml
index 624e8e0..b242458 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,36 +1,71 @@
+# Gateway Hostinger : frps (tunnel depuis le serveur Debian du magasin) + miroir PostgreSQL.
+# Tous les services sont sur le réseau externe nginx_default (Nginx Proxy Manager, postgres_db…).
+# Aucun secret dans ce fichier : tout vient de .env (voir .env.example) et frps.toml (voir frps.toml.example).
+
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
services:
frps:
- image: snowdreamtech/frps:latest
+ # Version épinglée : frpc côté Debian doit utiliser la même (snowdreamtech/frpc:0.71.0-alpine)
+ image: snowdreamtech/frps:0.71.0-alpine
container_name: ff_frps
volumes:
- ./frps.toml:/etc/frp/frps.toml:ro
ports:
+ # Seul port publié : connexion du frpc Debian. Les ports distants 13000 (API) et
+ # 15432 (PostgreSQL) ne sont joignables que depuis le réseau nginx_default.
- "7000:7000"
+ environment:
+ TZ: Europe/Paris
networks:
- nginx_default
+ logging: *logging
restart: unless-stopped
sync:
build: ./sync
+ image: ff_sync_hostinger:local
container_name: ff_sync_hostinger
environment:
- SRC_HOST: frps
- SRC_PORT: 15432
- SRC_DB: ${SRC_DB}
- SRC_USER: ${SRC_USER}
- SRC_PASS: ${SRC_PASS}
- DST_HOST: postgres_db
- DST_PORT: 5432
- DST_DB: post
- DST_USER: post
- DST_PASS: post25
- ports:
- - "3002:3002"
+ TZ: Europe/Paris
+ # Source : PostgreSQL Debian via le tunnel frps
+ SRC_HOST: ${SRC_HOST:-frps}
+ SRC_PORT: ${SRC_PORT:-15432}
+ SRC_DB: ${SRC_DB:?SRC_DB manquant dans .env}
+ SRC_USER: ${SRC_USER:?SRC_USER manquant dans .env}
+ SRC_PASS: ${SRC_PASS:?SRC_PASS manquant dans .env}
+ # Destination : PostgreSQL Hostinger
+ DST_HOST: ${DST_HOST:-postgres_db}
+ DST_PORT: ${DST_PORT:-5432}
+ DST_DB: ${DST_DB:?DST_DB manquant dans .env}
+ DST_USER: ${DST_USER:?DST_USER manquant dans .env}
+ DST_PASS: ${DST_PASS:?DST_PASS manquant dans .env}
+ # Planification et comportement
+ SYNC_CRON: ${SYNC_CRON:-30 7 * * *}
+ SYNC_TZ: ${SYNC_TZ:-Europe/Paris}
+ EXCLUDE_TABLES: ${EXCLUDE_TABLES:-}
+ WINDOW_TABLES: ${WINDOW_TABLES:-}
+ WINDOW_RECENT_DAYS: ${WINDOW_RECENT_DAYS:-10}
+ DEBIAN_WAIT_POLL_MIN: ${DEBIAN_WAIT_POLL_MIN:-2}
+ DEBIAN_WAIT_MAX_MIN: ${DEBIAN_WAIT_MAX_MIN:-180}
+ # Dashboard (sans ces deux variables : dashboard désactivé, 503)
+ DASHBOARD_USER: ${DASHBOARD_USER:-}
+ DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:-}
+ # Alerte (Slack / Discord / Mattermost…) — optionnel
+ ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
+ # Pas de "ports:" : Nginx Proxy Manager joint ff_sync_hostinger:3002 via nginx_default
+ expose:
+ - "3002"
networks:
- nginx_default
depends_on:
- frps
+ logging: *logging
restart: unless-stopped
networks:
diff --git a/frps.toml.example b/frps.toml.example
index e6c2d25..a22a532 100644
--- a/frps.toml.example
+++ b/frps.toml.example
@@ -1,10 +1,29 @@
-# Copier en frps.toml et remplir le token
-# frps.toml est exclu du git (contient le token secret)
+# Serveur frp (Hostinger) — version frp 0.71.0 (image snowdreamtech/frps:0.71.0-alpine).
+# Copier en frps.toml et remplacer le token. frps.toml est exclu du git (contient le secret).
+#
+# Token : long et aléatoire, identique dans frpc.toml côté Debian. Générer avec :
+# openssl rand -hex 32
bindAddr = "0.0.0.0"
bindPort = 7000
auth.method = "token"
-auth.token = "CHANGE_ME_FRP_SECRET_TOKEN"
+auth.token = "CHANGE_ME_openssl_rand_hex_32"
+# Le token authentifie aussi les heartbeats et chaque nouvelle connexion de travail
+# (doit être identique côté frpc : auth.additionalScopes = ["HeartBeats", "NewWorkConns"])
+auth.additionalScopes = ["HeartBeats", "NewWorkConns"]
+
+# Refuse les connexions frpc non chiffrées (frpc a transport.tls.enable = true par défaut)
+transport.tls.force = true
+
+# Seuls ports distants autorisés : API Debian (13000) et PostgreSQL Debian (15432).
+# Toute autre demande de proxy du frpc est refusée.
+allowPorts = [
+ { single = 13000 },
+ { single = 15432 },
+]
+
+# Limite le nombre de proxys par client
+maxPortsPerClient = 2
log.level = "info"
diff --git a/init.sql b/init.sql
deleted file mode 100644
index ef7f994..0000000
--- a/init.sql
+++ /dev/null
@@ -1,389 +0,0 @@
--- ============================================================
--- Schéma PostgreSQL — foirfouille API
--- Tables sélectionnées depuis SQL Server (copie nightly via sync)
--- ============================================================
-
--- Contrôle de synchronisation
-CREATE TABLE IF NOT EXISTS sync_log (
- table_name TEXT PRIMARY KEY,
- last_sync TIMESTAMP,
- rows_synced INTEGER,
- status TEXT,
- error_msg TEXT
-);
-
--- ============================================================
--- Référentiel articles
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS articles (
- no_id BIGINT PRIMARY KEY,
- codein TEXT,
- libelle1 TEXT,
- libelle2 TEXT,
- lib_ticket TEXT,
- tax_code TEXT,
- ach_code TEXT,
- utilisable TEXT,
- actif TEXT,
- suspendu TEXT,
- suividatecreation TIMESTAMP,
- suividatemodif TIMESTAMP,
- nom_no_id BIGINT
-);
-CREATE INDEX IF NOT EXISTS idx_articles_codein ON articles (codein);
-CREATE INDEX IF NOT EXISTS idx_articles_libelle1 ON articles (libelle1);
-CREATE INDEX IF NOT EXISTS idx_articles_nom_no_id ON articles (nom_no_id);
-
-CREATE TABLE IF NOT EXISTS article_infosup (
- artnoid BIGINT PRIMARY KEY,
- prix_vente_mini NUMERIC(12,4),
- prix_vente_maxi NUMERIC(12,4),
- eco_ht NUMERIC(12,4),
- eco_ttc NUMERIC(12,4),
- on_web TEXT,
- interdit_remise TEXT,
- nomphoto TEXT,
- datedebvente TIMESTAMP,
- datefinvente TIMESTAMP,
- datevaliditeachat TIMESTAMP,
- commentaire TEXT,
- pv_conseille NUMERIC(12,4),
- fraislogistic NUMERIC(12,4),
- transit NUMERIC(12,4),
- distribution NUMERIC(12,4)
-);
-
-CREATE TABLE IF NOT EXISTS art_gtin (
- idarticle BIGINT,
- gtin TEXT,
- preferentiel SMALLINT,
- PRIMARY KEY (idarticle, gtin)
-);
-CREATE INDEX IF NOT EXISTS idx_art_gtin_article ON art_gtin (idarticle);
-
--- ============================================================
--- Nomenclature & Gammes
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS nomenclature (
- no_id BIGINT PRIMARY KEY,
- code TEXT,
- libelle TEXT,
- niveau INTEGER,
- chemin_pere TEXT
-);
-CREATE INDEX IF NOT EXISTS idx_nomenclature_niveau ON nomenclature (niveau);
-
-CREATE TABLE IF NOT EXISTS gammes (
- no_id BIGINT PRIMARY KEY,
- code TEXT,
- libelle TEXT
-);
-
-CREATE TABLE IF NOT EXISTS saisons (
- no_id BIGINT PRIMARY KEY,
- code TEXT,
- libelle TEXT
-);
-
-CREATE TABLE IF NOT EXISTS art_gamme_saison (
- artnoid BIGINT,
- idgamme BIGINT,
- idsaison BIGINT,
- PRIMARY KEY (artnoid, idgamme, idsaison)
-);
-CREATE INDEX IF NOT EXISTS idx_ags_artnoid ON art_gamme_saison (artnoid);
-
--- ============================================================
--- Fournisseurs
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS artfou1 (
- no_id BIGINT PRIMARY KEY,
- art_no_id BIGINT,
- code TEXT,
- reference TEXT,
- ean13 TEXT,
- itf TEXT,
- qteua NUMERIC(10,3),
- pcb NUMERIC(10,3),
- spcb NUMERIC(10,3),
- delai INTEGER,
- securite NUMERIC(10,3),
- preference INTEGER,
- suspendu TEXT,
- suividatecreation TIMESTAMP,
- suividatemodif TIMESTAMP
-);
-CREATE INDEX IF NOT EXISTS idx_artfou1_art_no_id ON artfou1 (art_no_id);
-CREATE INDEX IF NOT EXISTS idx_artfou1_code ON artfou1 (code);
-
-CREATE TABLE IF NOT EXISTS artfou2 (
- idartfou1 BIGINT PRIMARY KEY,
- prixachat NUMERIC(12,4),
- remise_promotion NUMERIC(10,4)
-);
-
-CREATE TABLE IF NOT EXISTS fouadr1 (
- code TEXT,
- sit_code TEXT,
- raisonsociale TEXT,
- adrligne1 TEXT,
- telephone TEXT,
- email TEXT,
- PRIMARY KEY (code, sit_code)
-);
-
--- ============================================================
--- Cubes de stock et prix
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS cube_stock (
- artnoid BIGINT,
- site TEXT,
- qte NUMERIC(12,3),
- prmp NUMERIC(12,4),
- valstock NUMERIC(14,4),
- pv NUMERIC(12,4),
- stockdispo NUMERIC(12,3),
- stockmort NUMERIC(12,3),
- stockcolis NUMERIC(12,3),
- dernierevente TIMESTAMP,
- dernierereception TIMESTAMP,
- premierevente TIMESTAMP,
- nbjoursdernierMouvement INTEGER,
- nbjoursdernierevente INTEGER,
- nbjoursdernierrereception INTEGER,
- interditachat TEXT,
- codefou TEXT,
- PRIMARY KEY (artnoid, site)
-);
-CREATE INDEX IF NOT EXISTS idx_cube_stock_site ON cube_stock (site);
-
-CREATE TABLE IF NOT EXISTS cube_pa (
- artnoid BIGINT PRIMARY KEY,
- pa NUMERIC(12,4)
-);
-
-CREATE TABLE IF NOT EXISTS cube_pv (
- artnoid BIGINT,
- site TEXT,
- pv NUMERIC(12,4),
- PRIMARY KEY (artnoid, site)
-);
-CREATE INDEX IF NOT EXISTS idx_cube_pv_site ON cube_pv (site);
-
--- ============================================================
--- Mouvements
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS mvtart (
- artnoid BIGINT,
- datmvt TIMESTAMP,
- site TEXT,
- libmvt TEXT,
- genremvt INTEGER,
- qtemvt NUMERIC(12,3),
- valmvt NUMERIC(14,4),
- mntmvtht NUMERIC(14,4),
- mntmvtttc NUMERIC(14,4),
- margemvt NUMERIC(14,4),
- qtestock NUMERIC(12,3),
- prmp NUMERIC(12,4),
- valstock NUMERIC(14,4),
- codefou TEXT
-);
-CREATE INDEX IF NOT EXISTS idx_mvtart_artnoid ON mvtart (artnoid);
-CREATE INDEX IF NOT EXISTS idx_mvtart_datmvt ON mvtart (datmvt);
-CREATE INDEX IF NOT EXISTS idx_mvtart_site ON mvtart (site);
-CREATE INDEX IF NOT EXISTS idx_mvtart_genremvt ON mvtart (genremvt);
-
-CREATE TABLE IF NOT EXISTS mvtreg (
- datmvt TIMESTAMP,
- codtick TEXT,
- codcartecli TEXT,
- coddev TEXT,
- mntreg NUMERIC(14,4),
- mntregdev NUMERIC(14,4),
- clientnom TEXT,
- echeance TIMESTAMP,
- reference TEXT,
- typereg TEXT,
- suividatecreation TIMESTAMP
-);
-CREATE INDEX IF NOT EXISTS idx_mvtreg_datmvt ON mvtreg (datmvt);
-
--- ============================================================
--- Ranking réseau / magasin
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS ranking (
- gencod TEXT,
- site TEXT,
- libelle TEXT,
- foucentrale TEXT,
- nomfoucentrale TEXT,
- ranking_ca NUMERIC(10,2),
- ranking_qte NUMERIC(10,2),
- ranking_mag_ca NUMERIC(10,2),
- ranking_mag_qte NUMERIC(10,2),
- ranking_mag_marge NUMERIC(10,2),
- pv_calcule NUMERIC(12,4),
- pv_mag NUMERIC(12,4),
- pv_cen NUMERIC(12,4),
- codefamille TEXT,
- libellefamille TEXT,
- fichier TEXT,
- date_maj TIMESTAMP,
- date_integration TIMESTAMP,
- date_calcul_mag TIMESTAMP,
- PRIMARY KEY (gencod, site)
-);
-CREATE INDEX IF NOT EXISTS idx_ranking_gencod ON ranking (gencod);
-CREATE INDEX IF NOT EXISTS idx_ranking_site ON ranking (site);
-
--- ============================================================
--- Commandes fournisseurs
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS cdefou_vivant (
- cdefou_ligne_com_no_id BIGINT,
- artfou1_no_id BIGINT,
- articles_codein TEXT,
- articles_libelle1 TEXT,
- articles_libelle2 TEXT,
- artfou1_reference TEXT,
- artfou1_ean13 TEXT,
- artfou1_itf TEXT,
- cdefou_ligne_qtecde NUMERIC(12,3),
- cdefou_ligne_prixbrut NUMERIC(12,4),
- cdefou_ligne_remise NUMERIC(10,4),
- cdefou_ligne_remise2 NUMERIC(10,4),
- cdefou_ligne_remise3 NUMERIC(10,4),
- cdefou_ligne_prixnet NUMERIC(12,4),
- cdefou_ligne_montant NUMERIC(14,4),
- cdefou_ligne_prixvente NUMERIC(12,4),
- cdefou_ligne_gratuit NUMERIC(12,3),
- cdefou_ligne_qteacc NUMERIC(12,3),
- cdefou_ligne_qteann NUMERIC(12,3),
- cdefou_ligne_qteatt NUMERIC(12,3),
- cdefou_ligne_qterel NUMERIC(12,3),
- cdefou_ligne_cdeligtard TIMESTAMP,
- cdefou_ligne_cdeligtot TIMESTAMP,
- cotisation_logistique NUMERIC(14,4),
- fraislogistic NUMERIC(12,4),
- transit NUMERIC(12,4),
- distribution NUMERIC(12,4),
- taxe NUMERIC(12,4),
- commentaire TEXT,
- suividatecreation TIMESTAMP,
- PRIMARY KEY (cdefou_ligne_com_no_id, artfou1_no_id)
-);
-CREATE INDEX IF NOT EXISTS idx_cdefou_vivant_artfou1 ON cdefou_vivant (artfou1_no_id);
-CREATE INDEX IF NOT EXISTS idx_cdefou_vivant_date ON cdefou_vivant (suividatecreation);
-
-CREATE TABLE IF NOT EXISTS cdefou_reception (
- no_id BIGINT PRIMARY KEY,
- suividatecreation TIMESTAMP,
- suividatemodif TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS cdefou_receplig (
- no_id BIGINT PRIMARY KEY,
- cdefou_reception_no_id BIGINT,
- artfou1_no_id BIGINT,
- qtebl NUMERIC(12,3),
- qterec NUMERIC(12,3),
- qteacc NUMERIC(12,3),
- qteref NUMERIC(12,3),
- qteaff NUMERIC(12,3),
- pribrut NUMERIC(12,4),
- remise NUMERIC(10,4),
- prirec NUMERIC(12,4),
- mntrec NUMERIC(14,4),
- motifrefus TEXT,
- recpb SMALLINT,
- recpbok SMALLINT,
- suividatecreation TIMESTAMP,
- suividatemodif TIMESTAMP
-);
-CREATE INDEX IF NOT EXISTS idx_receplig_artfou1 ON cdefou_receplig (artfou1_no_id);
-CREATE INDEX IF NOT EXISTS idx_receplig_date ON cdefou_receplig (suividatecreation);
-
--- ============================================================
--- Publicités (FBENTPUB + ECOULEMENT + ECOULEMENT_DETAIL SQL Server)
--- ============================================================
-
-CREATE TABLE IF NOT EXISTS pub_entetes (
- ent_npub TEXT PRIMARY KEY,
- ent_datdeb TIMESTAMP,
- ent_datfin TIMESTAMP,
- ent_titre TEXT
-);
-
-CREATE TABLE IF NOT EXISTS pub_ecoulement (
- site TEXT NOT NULL,
- tcr_code TEXT NOT NULL,
- tcr_libelle TEXT,
- tcr_nature SMALLINT,
- tcr_type SMALLINT,
- tcrd_datedeb TIMESTAMP,
- tcrd_datefin TIMESTAMP,
- datecalcul TIMESTAMP,
- stock_datedebut NUMERIC(14,3),
- ca_total_periode_pub NUMERIC(14,4),
- ca_pub_periode_pub NUMERIC(14,4),
- pourc_capub_catotal NUMERIC(8,4),
- qte_vendue_pub NUMERIC(14,3),
- client_total_periode NUMERIC(14,3),
- client_pub_periode NUMERIC(14,3),
- stock_datefin NUMERIC(14,3),
- ca_pub_30_jours NUMERIC(14,4),
- stock_30_jours NUMERIC(14,3),
- ca_pub_60_jours NUMERIC(14,4),
- stock_60_jours NUMERIC(14,3),
- ca_pub_90_jours NUMERIC(14,4),
- stock_90_jours NUMERIC(14,3),
- ca_pub_180_jours NUMERIC(14,4),
- stock_180_jours NUMERIC(14,3),
- ca_depuis_finpub NUMERIC(14,4),
- taux_sortie NUMERIC(8,4),
- marge NUMERIC(14,4),
- taux_marge NUMERIC(8,4),
- PRIMARY KEY (site, tcr_code)
-);
-CREATE INDEX IF NOT EXISTS idx_pub_ecoulement_tcr_code ON pub_ecoulement (tcr_code);
-CREATE INDEX IF NOT EXISTS idx_pub_ecoulement_tcrd_datedeb ON pub_ecoulement (tcrd_datedeb);
-
-CREATE TABLE IF NOT EXISTS pub_ecoulement_detail (
- site TEXT NOT NULL,
- tcr_code TEXT NOT NULL,
- artnoid BIGINT NOT NULL,
- tcr_libelle TEXT,
- tcrd_datedeb TIMESTAMP,
- tcrd_datefin TIMESTAMP,
- datecalcul TIMESTAMP,
- ca_pub_periode_pub NUMERIC(14,4),
- qte_vendue_pub NUMERIC(14,3),
- stock_datedebut NUMERIC(14,3),
- stock_datefin NUMERIC(14,3),
- ca_pub_30_jours NUMERIC(14,4),
- ca_pub_60_jours NUMERIC(14,4),
- ca_pub_90_jours NUMERIC(14,4),
- ca_pub_180_jours NUMERIC(14,4),
- ca_depuis_finpub NUMERIC(14,4),
- taux_sortie NUMERIC(8,4),
- marge NUMERIC(14,4),
- taux_marge NUMERIC(8,4),
- codein TEXT,
- libelle TEXT,
- prmp NUMERIC(12,4),
- pa NUMERIC(12,4),
- pv NUMERIC(12,4),
- prixpub TEXT,
- PRIMARY KEY (site, tcr_code, artnoid)
-);
-CREATE INDEX IF NOT EXISTS idx_pub_detail_tcr_code ON pub_ecoulement_detail (tcr_code);
-CREATE INDEX IF NOT EXISTS idx_pub_detail_artnoid ON pub_ecoulement_detail (artnoid);
-CREATE INDEX IF NOT EXISTS idx_pub_detail_codein ON pub_ecoulement_detail (codein);
diff --git a/sync/.dockerignore b/sync/.dockerignore
new file mode 100644
index 0000000..a9655d0
--- /dev/null
+++ b/sync/.dockerignore
@@ -0,0 +1,5 @@
+node_modules
+npm-debug.log
+test
+.env
+*.md
diff --git a/sync/Dockerfile b/sync/Dockerfile
index d6d24fd..086261e 100644
--- a/sync/Dockerfile
+++ b/sync/Dockerfile
@@ -1,12 +1,21 @@
-FROM node:20-alpine
+# Service ff_sync_hostinger — miroir PG Debian → PG Hostinger
+FROM node:22-alpine
+
+# tzdata : fuseau Europe/Paris pour l'heure locale du process (TZ défini dans docker-compose)
+RUN apk add --no-cache tzdata
WORKDIR /app
+ENV NODE_ENV=production
-COPY package.json ./
-RUN npm install --omit=dev
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev && npm cache clean --force
-COPY . .
+COPY --chown=node:node . .
+USER node
EXPOSE 3002
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.SYNC_DASHBOARD_PORT||3002)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+
CMD ["node", "index.js"]
diff --git a/sync/alert.js b/sync/alert.js
new file mode 100644
index 0000000..ebbe914
--- /dev/null
+++ b/sync/alert.js
@@ -0,0 +1,32 @@
+'use strict';
+/**
+ * Alerte par webhook (Slack, Discord, Mattermost, ntfy… compatibles JSON).
+ * Envoie {text, content} : Slack/Mattermost lisent "text", Discord lit "content".
+ * Ne lève jamais d'exception.
+ */
+const { config } = require('./config');
+const log = require('./logger');
+
+async function sendAlert(message) {
+ if (!config.alertWebhookUrl) return false;
+ const text = String(message).slice(0, 1900); // Discord : 2000 caractères max
+ try {
+ const res = await fetch(config.alertWebhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, content: text }),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!res.ok) {
+ log.warn(`Alerte webhook : réponse HTTP ${res.status}`);
+ return false;
+ }
+ log.info('Alerte envoyée');
+ return true;
+ } catch (err) {
+ log.warn(`Alerte webhook impossible : ${err.message}`);
+ return false;
+ }
+}
+
+module.exports = { sendAlert };
diff --git a/sync/config.js b/sync/config.js
new file mode 100644
index 0000000..0ca5a45
--- /dev/null
+++ b/sync/config.js
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Configuration du service, lue depuis les variables d'environnement
+ * (fournies par docker-compose depuis gateway/.env).
+ */
+const { parseList, parseWindowTables } = require('./sql');
+
+/**
+ * Tables copiées par "fenêtre" : signature par mois + rechargement des mois différents
+ * + rechargement systématique des N derniers jours. Clé = table source, valeur = colonne date.
+ * Une table configurée mais absente de la source est ignorée silencieusement.
+ * Surcharge possible via WINDOW_TABLES="table:colonne,table:colonne".
+ */
+const DEFAULT_WINDOW_TABLES = {
+ mvtart: 'datmvt',
+ mvtreg: 'datmvt',
+ mvtart_motif: 'datmvt',
+ histo_art_gamme: 'date',
+ demarques: 'datmvt',
+ meteo: 'date',
+ fidelite_jour: 'date',
+};
+
+function int(name, def) {
+ const v = parseInt(process.env[name], 10);
+ return Number.isFinite(v) && v >= 0 ? v : def;
+}
+
+const config = {
+ cron: process.env.SYNC_CRON || '30 7 * * *',
+ timeZone: process.env.SYNC_TZ || 'Europe/Paris',
+ port: int('SYNC_DASHBOARD_PORT', 3002),
+
+ // Tables source exclues (en plus de sync_log, traitée à part → source_sync_log)
+ excludeTables: new Set(parseList(process.env.EXCLUDE_TABLES)),
+ windowTables: process.env.WINDOW_TABLES
+ ? parseWindowTables(process.env.WINDOW_TABLES)
+ : DEFAULT_WINDOW_TABLES,
+ recentDays: int('WINDOW_RECENT_DAYS', 10),
+
+ // Attente de la fin de la sync Debian (ligne sync_log '_run' = 'running')
+ waitPollMs: int('DEBIAN_WAIT_POLL_MIN', 2) * 60 * 1000,
+ waitMaxMs: int('DEBIAN_WAIT_MAX_MIN', 180) * 60 * 1000,
+
+ // Garde-fou volume : source < 50 % de la destination (si destination > 100 lignes) → table ignorée
+ volumeMinRatio: 0.5,
+ volumeMinRows: 100,
+
+ fetchSize: 5000,
+
+ dashboardUser: process.env.DASHBOARD_USER || '',
+ dashboardPass: process.env.DASHBOARD_PASSWORD || '',
+ alertWebhookUrl: process.env.ALERT_WEBHOOK_URL || '',
+
+ // Clé du verrou consultatif (pg_try_advisory_lock) côté destination — constante
+ lockKey: 4646535901,
+};
+
+module.exports = { config, DEFAULT_WINDOW_TABLES };
diff --git a/sync/copy.js b/sync/copy.js
new file mode 100644
index 0000000..7b2dd85
--- /dev/null
+++ b/sync/copy.js
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * Stratégies de copie des données source → destination.
+ *
+ * - full : BEGIN; DELETE FROM; INSERT de toutes les lignes; COMMIT (une seule transaction
+ * destination : les lecteurs ne voient jamais une table vide ou partielle).
+ * - window : signature (nb lignes + somme des hash) par mois des deux côtés, puis pour
+ * chaque mois différent : BEGIN; DELETE du mois; INSERT depuis la source; COMMIT.
+ * Les N derniers jours sont toujours rechargés.
+ *
+ * Lecture source par curseur serveur (FETCH par lots) : jamais une table entière en RAM.
+ * Les valeurs transitent en texte brut (pas de conversion JS) : aucune perte de précision
+ * sur les timestamps, numeric, bigint, bytea, tableaux…
+ */
+const { config } = require('./config');
+const log = require('./logger');
+const {
+ qt, buildSelect, buildInsert, insertChunkSize,
+ buildSignatureQuery, signatureMap, signatureTotal, diffMonths,
+ monthCondition, recentCondition, cutoffDate,
+} = require('./sql');
+
+// Désactive tous les parseurs de types de node-postgres : chaque valeur reste la chaîne PG
+const RAW_TYPES = { getTypeParser: () => v => v };
+
+let cursorSeq = 0;
+
+/**
+ * Copie les lignes d'un SELECT source vers une table destination.
+ * src doit être dans une transaction (DECLARE CURSOR l'exige).
+ */
+async function streamRows(src, dst, srcTable, dstTable, cols, where) {
+ const cursor = `ff_cur_${++cursorSeq}`;
+ const chunk = insertChunkSize(cols.length);
+ await src.query(`DECLARE ${cursor} NO SCROLL CURSOR FOR ${buildSelect(srcTable, cols, where)}`);
+ let total = 0;
+ let insertSql = null;
+ let insertRows = 0;
+ try {
+ for (;;) {
+ const res = await src.query({ text: `FETCH ${config.fetchSize} FROM ${cursor}`, rowMode: 'array', types: RAW_TYPES });
+ const rows = res.rows;
+ if (!rows.length) break;
+ for (let i = 0; i < rows.length; i += chunk) {
+ const part = rows.slice(i, i + chunk);
+ if (part.length !== insertRows) {
+ insertSql = buildInsert(dstTable, cols, part.length);
+ insertRows = part.length;
+ }
+ await dst.query(insertSql, part.flat());
+ }
+ total += rows.length;
+ }
+ } finally {
+ await src.query(`CLOSE ${cursor}`).catch(() => {});
+ }
+ return total;
+}
+
+async function count(client, table) {
+ const { rows } = await client.query(`SELECT count(*)::bigint AS n FROM ${qt(table)}`);
+ return Number(rows[0].n);
+}
+
+function volumeGuard(srcCount, dstCount, force) {
+ if (force) return null;
+ if (dstCount > config.volumeMinRows && srcCount < dstCount * config.volumeMinRatio) {
+ return `garde-fou volume : source ${srcCount} lignes < 50 % de la destination (${dstCount}) — table non copiée (relancer en mode forcé si c'est voulu)`;
+ }
+ return null;
+}
+
+/** Exécute fn dans une transaction destination (ROLLBACK en cas d'erreur). */
+async function inDstTx(dst, fn) {
+ await dst.query('BEGIN');
+ try {
+ // Ne pas rester bloqué indéfiniment derrière un lecteur long (et bloquer les suivants)
+ await dst.query("SET LOCAL lock_timeout = '60s'");
+ const r = await fn();
+ await dst.query('COMMIT');
+ return r;
+ } catch (err) {
+ await dst.query('ROLLBACK').catch(() => {});
+ throw err;
+ }
+}
+
+/**
+ * Copie complète transactionnelle.
+ * Retourne { rows, detail } ou lève une erreur (dont le garde-fou volume).
+ */
+async function copyFull(src, dst, srcTable, dstTable, cols, force) {
+ const srcCount = await count(src, srcTable);
+ const dstCount = await count(dst, dstTable);
+ const guard = volumeGuard(srcCount, dstCount, force);
+ if (guard) throw new Error(guard);
+ const rows = await inDstTx(dst, async () => {
+ // DELETE (et non TRUNCATE) : les lecteurs continuent de voir l'ancienne version (MVCC)
+ // au lieu d'être bloqués par un verrou ACCESS EXCLUSIVE ; l'autovacuum récupère l'espace.
+ await dst.query(`DELETE FROM ${qt(dstTable)}`);
+ return streamRows(src, dst, srcTable, dstTable, cols, null);
+ });
+ return { rows, detail: `copie complète (${dstCount} -> ${rows} lignes)` };
+}
+
+/** Recharge les lignes vérifiant `where` (DELETE + INSERT dans une transaction). */
+async function reloadSlice(src, dst, table, cols, where) {
+ return inDstTx(dst, async () => {
+ await dst.query(`DELETE FROM ${qt(table)} WHERE ${where}`);
+ return streamRows(src, dst, table, table, cols, where);
+ });
+}
+
+/**
+ * Copie par fenêtre de dates (tables de mouvements volumineuses).
+ * Retourne { rows, detail }.
+ */
+async function copyWindow(src, dst, table, cols, dateCol, force) {
+ if (force) {
+ const r = await copyFull(src, dst, table, table, cols, true);
+ return { rows: r.rows, detail: `mode forcé : ${r.detail}` };
+ }
+ const sigSql = buildSignatureQuery(table, cols, dateCol);
+ const srcSig = signatureMap((await src.query(sigSql)).rows);
+ let dstSig = signatureMap((await dst.query(sigSql)).rows);
+
+ const guard = volumeGuard(signatureTotal(srcSig), signatureTotal(dstSig), false);
+ if (guard) throw new Error(guard);
+
+ // 1. Fenêtre glissante : les N derniers jours sont toujours rechargés
+ const cutoff = cutoffDate(config.recentDays, config.timeZone);
+ let rows = await reloadSlice(src, dst, table, cols, recentCondition(dateCol, cutoff));
+ const recentRows = rows;
+
+ // 2. Mois dont la signature diffère (nouvelles colonnes, corrections, suppressions…)
+ dstSig = signatureMap((await dst.query(sigSql)).rows);
+ const months = diffMonths(srcSig, dstSig);
+ for (const month of months) {
+ const n = await reloadSlice(src, dst, table, cols, monthCondition(dateCol, month));
+ log.info(`[${table}] mois ${month} rechargé (${n} lignes)`);
+ rows += n;
+ }
+ return {
+ rows,
+ detail: `${recentRows} lignes depuis le ${cutoff} + ${months.length} mois rechargé(s)` +
+ (months.length ? ` (${months.slice(0, 6).join(', ')}${months.length > 6 ? '…' : ''})` : ''),
+ };
+}
+
+module.exports = { copyFull, copyWindow, streamRows, volumeGuard, RAW_TYPES };
diff --git a/sync/db.js b/sync/db.js
index 1d6f13c..24755cd 100644
--- a/sync/db.js
+++ b/sync/db.js
@@ -1,41 +1,61 @@
'use strict';
const { Pool } = require('pg');
+/**
+ * Paramètres de session identiques des deux côtés : indispensables pour que
+ * ROW(...)::text (signatures par mois) produise le même texte sur la source et la
+ * destination (fuseau, format des dates/intervalles, précision des flottants).
+ */
+const SESSION_OPTIONS = [
+ '-c TimeZone=UTC',
+ '-c DateStyle=ISO',
+ '-c IntervalStyle=postgres',
+ '-c extra_float_digits=3',
+ '-c bytea_output=hex',
+].join(' ');
+
let srcPool = null;
let dstPool = null;
+function makePool(prefix, max) {
+ const required = ['HOST', 'DB', 'USER', 'PASS'];
+ const missing = required.filter(k => !process.env[`${prefix}_${k}`]);
+ if (missing.length) throw new Error(`Variables manquantes : ${missing.map(k => `${prefix}_${k}`).join(', ')}`);
+ const pool = new Pool({
+ host: process.env[`${prefix}_HOST`],
+ port: parseInt(process.env[`${prefix}_PORT`], 10) || 5432,
+ database: process.env[`${prefix}_DB`],
+ user: process.env[`${prefix}_USER`],
+ password: process.env[`${prefix}_PASS`],
+ max,
+ connectionTimeoutMillis: 15000,
+ idleTimeoutMillis: 60000,
+ keepAlive: true,
+ application_name: 'ff_sync_hostinger',
+ options: SESSION_OPTIONS,
+ });
+ // Une connexion inactive qui tombe (tunnel frp coupé…) ne doit pas tuer le process
+ pool.on('error', err => console.error(`[pool ${prefix}] ${err.message}`));
+ return pool;
+}
+
+/** Source : PostgreSQL Debian via le tunnel frps:15432 (lecture seule). */
function getSrc() {
- if (!srcPool) {
- srcPool = new Pool({
- host: process.env.SRC_HOST,
- port: parseInt(process.env.SRC_PORT) || 5432,
- database: process.env.SRC_DB,
- user: process.env.SRC_USER,
- password: process.env.SRC_PASS,
- max: 3,
- connectionTimeoutMillis: 10000,
- });
- }
+ if (!srcPool) srcPool = makePool('SRC', 3);
return srcPool;
}
+/** Destination : PostgreSQL Hostinger (container postgres_db). */
function getDst() {
- if (!dstPool) {
- dstPool = new Pool({
- host: process.env.DST_HOST,
- port: parseInt(process.env.DST_PORT) || 5432,
- database: process.env.DST_DB,
- user: process.env.DST_USER,
- password: process.env.DST_PASS,
- max: 5,
- });
- }
+ if (!dstPool) dstPool = makePool('DST', 6);
return dstPool;
}
async function closeAll() {
- if (srcPool) { await srcPool.end(); srcPool = null; }
- if (dstPool) { await dstPool.end(); dstPool = null; }
+ const p = [];
+ if (srcPool) { p.push(srcPool.end()); srcPool = null; }
+ if (dstPool) { p.push(dstPool.end()); dstPool = null; }
+ await Promise.allSettled(p);
}
-module.exports = { getSrc, getDst, closeAll };
+module.exports = { getSrc, getDst, closeAll, SESSION_OPTIONS };
diff --git a/sync/index.js b/sync/index.js
index 88940d7..cd8a501 100644
--- a/sync/index.js
+++ b/sync/index.js
@@ -1,84 +1,59 @@
'use strict';
+/**
+ * Point d'entrée du service ff_sync_hostinger.
+ *
+ * node index.js → dashboard + cron (SYNC_CRON, fuseau SYNC_TZ)
+ * node index.js --once → une exécution normale puis sortie
+ * node index.js --force → une exécution forcée (garde-fou volume ignoré,
+ * tables "fenêtre" rechargées entièrement) puis sortie
+ *
+ * Le verrou consultatif PostgreSQL empêche toute exécution concurrente,
+ * y compris entre le service et une commande `docker exec … node index.js --once`.
+ */
const cron = require('node-cron');
-const { getSrc, getDst, closeAll } = require('./db');
-const { batchUpsert, fullRefresh, logSync } = require('./utils');
-const { TABLES } = require('./tables');
-const {
- startDashboard, registerSyncFn,
- setSyncRunning, isSyncRunning,
- setProgress, clearProgress,
-} = require('./server');
+const { config } = require('./config');
+const log = require('./logger');
+const { closeAll } = require('./db');
+const { runSync } = require('./sync');
+const { startDashboard, registerSyncFn, registerNextRunFn } = require('./server');
-const forceMode = process.argv.includes('--force');
+const args = process.argv.slice(2);
+const force = args.includes('--force');
+const once = force || args.includes('--once');
-async function syncTable(src, dst, table, pk) {
- const { rows } = await src.query(`SELECT * FROM ${table}`);
- if (!rows.length) {
- await logSync(dst, table, 0, 'ok');
- console.log(`[${table}] 0 lignes (table vide)`);
- return 0;
- }
-
- let count;
- if (pk.length === 0) {
- count = await fullRefresh(dst, table, rows);
- } else {
- count = await batchUpsert(dst, table, rows, pk);
- }
- await logSync(dst, table, count, 'ok');
- console.log(`[${table}] ${count} lignes`);
- return count;
-}
-
-async function syncAll(force = false) {
- if (isSyncRunning()) {
- console.log('Sync déjà en cours — ignorée.');
- return;
- }
- setSyncRunning(true);
- clearProgress();
- const start = Date.now();
- console.log(`\n=== SYNC START ${new Date().toISOString()} ===`);
-
- const src = getSrc();
- const dst = getDst();
-
- try {
- for (let i = 0; i < TABLES.length; i++) {
- const { name, pk } = TABLES[i];
- setProgress(name, i + 1);
- console.log(`[${i + 1}/${TABLES.length}] Sync ${name}…`);
- try {
- await syncTable(src, dst, name, pk);
- } catch (err) {
- await logSync(dst, name, 0, 'error', err.message).catch(() => {});
- console.error(`[${name}] ERREUR: ${err.message}`);
- }
- }
- } finally {
- setSyncRunning(false);
- clearProgress();
- }
-
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
- console.log(`=== SYNC DONE en ${elapsed}s ===\n`);
-}
-
-registerSyncFn(syncAll);
-
-if (forceMode) {
- console.log('Mode force : sync complète en cours...');
- syncAll(true)
- .then(() => closeAll())
- .then(() => process.exit(0))
- .catch(err => {
- console.error('Erreur fatale sync:', err.message);
+if (once) {
+ runSync({ force, trigger: 'cli' })
+ .then(async r => {
+ await closeAll();
+ process.exit(r.skipped || r.status === 'ok' ? 0 : 1);
+ })
+ .catch(async err => {
+ log.error(`Erreur fatale : ${err.message}`);
+ await closeAll();
process.exit(1);
});
} else {
- startDashboard();
- console.log('Service sync Hostinger démarré — cron: 0 7 * * * (7h00 chaque nuit)');
- cron.schedule('0 7 * * *', () => {
- syncAll(false).catch(err => console.error('Erreur cron sync:', err.message));
- });
+ if (!cron.validate(config.cron)) {
+ log.error(`SYNC_CRON invalide : "${config.cron}"`);
+ process.exit(1);
+ }
+ registerSyncFn(runSync);
+ const task = cron.schedule(
+ config.cron,
+ () => runSync({ force: false, trigger: 'cron' }).catch(err => log.error(`Sync cron : ${err.message}`)),
+ { timezone: config.timeZone, name: 'ff-sync', noOverlap: true },
+ );
+ registerNextRunFn(() => task.getNextRun());
+ const server = startDashboard();
+ log.info(`Service sync Hostinger démarré — cron "${config.cron}" (${config.timeZone}), prochaine exécution ${task.getNextRun()?.toISOString() ?? '?'}`);
+
+ const shutdown = sig => {
+ log.info(`${sig} reçu — arrêt (une transaction en cours est annulée par PostgreSQL)`);
+ task.stop();
+ server.close();
+ closeAll().finally(() => process.exit(0));
+ setTimeout(() => process.exit(0), 5000).unref();
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
}
diff --git a/sync/logger.js b/sync/logger.js
new file mode 100644
index 0000000..05e4e10
--- /dev/null
+++ b/sync/logger.js
@@ -0,0 +1,26 @@
+'use strict';
+/**
+ * Journal applicatif : sortie console + tampon circulaire affiché par le dashboard.
+ * Les messages sont du texte brut ; le dashboard les insère via textContent (jamais innerHTML).
+ */
+const MAX_LINES = 500;
+const MAX_LEN = 4000;
+const buffer = [];
+
+function push(level, args) {
+ const msg = args
+ .map(a => (a instanceof Error ? a.message : typeof a === 'string' ? a : JSON.stringify(a)))
+ .join(' ')
+ .slice(0, MAX_LEN);
+ buffer.push({ ts: new Date().toISOString(), level, msg });
+ if (buffer.length > MAX_LINES) buffer.shift();
+ const out = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
+ out(`[${level}] ${msg}`);
+}
+
+module.exports = {
+ info: (...a) => push('info', a),
+ warn: (...a) => push('warn', a),
+ error: (...a) => push('error', a),
+ getLogs: (limit = 200) => buffer.slice(-Math.min(Math.max(1, limit | 0), MAX_LINES)),
+};
diff --git a/sync/package-lock.json b/sync/package-lock.json
new file mode 100644
index 0000000..b51c51a
--- /dev/null
+++ b/sync/package-lock.json
@@ -0,0 +1,1015 @@
+{
+ "name": "ff-sync-hostinger",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ff-sync-hostinger",
+ "version": "2.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "express": "^5.1.0",
+ "node-cron": "^4.6.0",
+ "pg": "^8.16.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
+ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
+ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.3.0",
+ "content-disposition": "^2.0.1",
+ "content-type": "^2.0.0",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.8",
+ "qs": "^6.16.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.1.0",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/node-cron": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
+ "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
+ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/sync/package.json b/sync/package.json
index 4f7957b..e997525 100644
--- a/sync/package.json
+++ b/sync/package.json
@@ -1,13 +1,23 @@
{
"name": "ff-sync-hostinger",
- "version": "1.0.0",
- "description": "Sync nightly PG Debian → PG Hostinger",
+ "version": "2.0.0",
+ "private": true,
+ "description": "Miroir PG Debian → PG Hostinger (schéma automatique, copie transactionnelle/incrémentale)",
"main": "index.js",
"license": "ISC",
"type": "commonjs",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "start": "node index.js",
+ "sync": "node index.js --once",
+ "sync:force": "node index.js --force",
+ "test": "node --test test/*.test.js"
+ },
"dependencies": {
"express": "^5.1.0",
- "node-cron": "^3.0.3",
- "pg": "^8.13.3"
+ "node-cron": "^4.6.0",
+ "pg": "^8.16.0"
}
}
diff --git a/sync/public/app.js b/sync/public/app.js
new file mode 100644
index 0000000..3e9417f
--- /dev/null
+++ b/sync/public/app.js
@@ -0,0 +1,170 @@
+'use strict';
+// Dashboard sync Hostinger — toutes les données dynamiques passent par textContent (pas d'innerHTML).
+
+const $ = id => document.getElementById(id);
+
+function el(tag, opts = {}, children = []) {
+ const e = document.createElement(tag);
+ if (opts.className) e.className = opts.className;
+ if (opts.text != null) e.textContent = String(opts.text);
+ if (opts.title) e.title = String(opts.title);
+ if (opts.colSpan) e.colSpan = opts.colSpan;
+ for (const c of children) e.appendChild(c);
+ return e;
+}
+
+function fmtDate(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ return isNaN(d) ? '—' : d.toLocaleString('fr-FR');
+}
+function fmtDuration(ms) {
+ if (ms == null) return '—';
+ const s = Math.round(ms / 1000);
+ return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s';
+}
+function fmtInt(n) {
+ return n == null ? '—' : Number(n).toLocaleString('fr-FR');
+}
+
+function renderProgress(data) {
+ const box = $('progressBox');
+ if (!data.running) { box.classList.add('hidden'); return; }
+ box.classList.remove('hidden');
+ const p = data.progress;
+ const pct = p ? Math.round((p.index / p.total) * 100) : 0;
+ $('progressBar').style.width = pct + '%';
+ $('progressPct').textContent = pct + '%';
+ $('progressLabel').textContent = 'Sync ' + (data.force ? 'FORCÉE ' : '') + 'en cours — ' +
+ (data.phase || '') + (p ? ' — table ' + p.index + '/' + p.total : '');
+ $('progressDetail').textContent = p
+ ? 'En cours : ' + p.current + ' | Durée : ' + fmtDuration(Date.now() - new Date(p.startedAt))
+ : '';
+}
+
+function renderRunInfo(data) {
+ const run = data.tables.find(t => t.table_name === '_run');
+ const box = $('runInfo');
+ box.textContent = '';
+ if (data.dbError) {
+ box.appendChild(el('span', { className: 'st-error', text: 'Erreur : ' + data.dbError }));
+ return;
+ }
+ if (!run) { box.textContent = 'Aucune exécution enregistrée.'; return; }
+ box.appendChild(el('span', { text: 'Dernière exécution : ' }));
+ box.appendChild(el('strong', { className: 'st-' + run.status, text: run.status }));
+ box.appendChild(el('span', { text: ' — ' + fmtDate(run.last_sync) + ' — ' + fmtDuration(run.duration_ms) + ' — ' + (run.detail || '') }));
+ if (run.error_msg) box.appendChild(el('div', { className: 'st-error', text: 'Tables en erreur : ' + run.error_msg }));
+}
+
+function renderTables(data) {
+ const body = $('tableBody');
+ body.textContent = '';
+ const rows = data.tables.filter(t => t.table_name !== '_run');
+ if (!rows.length) {
+ body.appendChild(el('tr', {}, [el('td', { colSpan: 7, text: 'Aucune table synchronisée pour le moment.' })]));
+ return;
+ }
+ for (const t of rows) {
+ const status = t.status || '—';
+ body.appendChild(el('tr', {}, [
+ el('td', {}, [el('strong', { text: t.table_name })]),
+ el('td', {}, [el('span', { className: 'strat ' + (t.strategy || ''), text: t.strategy || '—' })]),
+ el('td', { text: fmtDate(t.last_sync) }),
+ el('td', { className: 'num', text: fmtInt(t.rows_synced) }),
+ el('td', { className: 'num', text: fmtDuration(t.duration_ms) }),
+ el('td', { className: 'st-' + status, text: status }),
+ el('td', { className: 'detail', text: t.error_msg || t.detail || '' }),
+ ]));
+ }
+}
+
+function renderStatus(data) {
+ const badge = $('statusBadge');
+ badge.textContent = data.running ? 'En cours…' : 'Inactif';
+ badge.className = 'badge ' + (data.running ? 'running' : 'idle');
+ $('btnSync').disabled = data.running;
+ $('btnForce').disabled = data.running;
+ renderProgress(data);
+ renderRunInfo(data);
+ renderTables(data);
+}
+
+function renderLogs(logs) {
+ const box = $('logBox');
+ const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 10;
+ box.textContent = '';
+ if (!logs.length) { box.textContent = 'Aucun log.'; return; }
+ for (const l of logs) {
+ const line = el('div', { className: 'log-line ' + (['info', 'warn', 'error'].includes(l.level) ? l.level : '') });
+ line.appendChild(el('span', { className: 'ts', text: new Date(l.ts).toLocaleTimeString('fr-FR') }));
+ line.appendChild(document.createTextNode(l.msg));
+ box.appendChild(line);
+ }
+ if (atBottom) box.scrollTop = box.scrollHeight;
+}
+
+async function getJson(url) {
+ const r = await fetch(url, { cache: 'no-store' });
+ if (!r.ok) throw new Error(url + ' : HTTP ' + r.status);
+ return r.json();
+}
+
+let lastStatus = null;
+async function refresh() {
+ try {
+ const [s, logs, next] = await Promise.all([getJson('/api/status'), getJson('/api/logs?limit=200'), getJson('/api/next')]);
+ lastStatus = s;
+ renderStatus(s);
+ renderLogs(logs);
+ $('nextSync').textContent = 'Prochaine sync auto : ' + fmtDate(next.next) + ' (' + next.cron + ', ' + next.timeZone + ')';
+ $('lastRefresh').textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR');
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+function showMsg(ok, text) {
+ const msg = $('msg');
+ msg.textContent = text;
+ msg.className = ok ? 'ok' : 'err';
+ setTimeout(() => msg.classList.add('hidden'), 6000);
+}
+
+async function runSync(force) {
+ if (force && !confirm('Forcer la sync ?\n\nLe garde-fou de volume sera ignoré et les tables de mouvements (mvtart, mvtreg…) seront entièrement rechargées. Cela peut prendre longtemps.')) return;
+ try {
+ const r = await fetch('/api/sync/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Requested-By': 'ff-dashboard' },
+ body: JSON.stringify({ force: !!force }),
+ });
+ const data = await r.json().catch(() => ({}));
+ showMsg(r.ok, r.ok ? data.message : (data.error || 'HTTP ' + r.status));
+ } catch (e) {
+ showMsg(false, e.message);
+ }
+ setTimeout(refresh, 800);
+}
+
+function copyLogs() {
+ const text = Array.from($('logBox').querySelectorAll('.log-line')).map(l => l.textContent).join('\n');
+ const btn = $('btnCopyLogs');
+ const done = () => { btn.textContent = 'Copié !'; setTimeout(() => { btn.textContent = 'Copier les logs'; }, 2000); };
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(done).catch(() => alert('Copie impossible : sélectionnez les logs puis Ctrl+C'));
+ } else {
+ alert('Copie impossible hors HTTPS : sélectionnez les logs puis Ctrl+C');
+ }
+}
+
+$('btnSync').addEventListener('click', () => runSync(false));
+$('btnForce').addEventListener('click', () => runSync(true));
+$('btnRefresh').addEventListener('click', refresh);
+$('btnCopyLogs').addEventListener('click', copyLogs);
+
+async function loop() {
+ await refresh();
+ setTimeout(loop, lastStatus && lastStatus.running ? 3000 : 15000);
+}
+loop();
diff --git a/sync/public/index.html b/sync/public/index.html
new file mode 100644
index 0000000..26d8ee1
--- /dev/null
+++ b/sync/public/index.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+Sync Hostinger
+
+
+
+
+ Sync Hostinger
+ PG Debian (frps:15432) → PG Hostinger (miroir automatique du schéma)
+ Inactif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Table | Stratégie | Dernière sync | Lignes | Durée | Statut | Détail / erreur |
+
+
+
+
+
+
+
+
+
+
diff --git a/sync/schema.js b/sync/schema.js
new file mode 100644
index 0000000..918d4f7
--- /dev/null
+++ b/sync/schema.js
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Miroir automatique du schéma source → destination.
+ * Aucune liste de tables ni schéma écrit à la main : tout est lu dans pg_catalog
+ * à chaque exécution. On ne supprime jamais rien côté destination.
+ */
+const log = require('./logger');
+const {
+ qt, buildCreateTable, buildAddColumn, buildAddPrimaryKey,
+ indexDefIfNotExists, needsTrgm,
+} = require('./sql');
+
+const COLUMNS_SQL = `
+SELECT c.relname AS table_name, a.attname AS name,
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
+ a.attnotnull AS notnull
+FROM pg_catalog.pg_attribute a
+JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
+JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p')
+ AND NOT c.relispartition
+ AND a.attnum > 0
+ AND NOT a.attisdropped
+ORDER BY c.relname, a.attnum`;
+
+const PK_SQL = `
+SELECT c.relname AS table_name,
+ array_agg(a.attname::text ORDER BY k.ord) AS pk
+FROM pg_catalog.pg_constraint con
+JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
+JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
+JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
+WHERE n.nspname = 'public' AND con.contype = 'p'
+GROUP BY c.relname`;
+
+// Index hors clé primaire (les index des contraintes UNIQUE deviennent des index uniques)
+const INDEXES_SQL = `
+SELECT i.tablename AS table_name, i.indexname AS name, i.indexdef AS def
+FROM pg_catalog.pg_indexes i
+JOIN pg_catalog.pg_namespace n ON n.nspname = i.schemaname
+JOIN pg_catalog.pg_class ic ON ic.relname = i.indexname AND ic.relnamespace = n.oid
+JOIN pg_catalog.pg_index x ON x.indexrelid = ic.oid
+WHERE i.schemaname = 'public' AND NOT x.indisprimary
+ORDER BY i.tablename, i.indexname`;
+
+/**
+ * Lit le catalogue d'une base : Map table → { name, columns:[{name,type,notnull}], pk:[], indexes:[{name,def}] }.
+ */
+async function readCatalog(client) {
+ const tables = new Map();
+ const { rows: cols } = await client.query(COLUMNS_SQL);
+ for (const r of cols) {
+ if (!tables.has(r.table_name)) tables.set(r.table_name, { name: r.table_name, columns: [], pk: [], indexes: [] });
+ tables.get(r.table_name).columns.push({ name: r.name, type: r.type, notnull: r.notnull });
+ }
+ const { rows: pks } = await client.query(PK_SQL);
+ for (const r of pks) if (tables.has(r.table_name)) tables.get(r.table_name).pk = r.pk;
+ const { rows: idx } = await client.query(INDEXES_SQL);
+ for (const r of idx) if (tables.has(r.table_name)) tables.get(r.table_name).indexes.push({ name: r.name, def: r.def });
+ return tables;
+}
+
+/**
+ * Crée la table destination si absente, ajoute les colonnes manquantes,
+ * signale (sans modifier) les colonnes de type différent.
+ * Retourne la liste des avertissements.
+ */
+async function mirrorTable(dst, srcTable, dstName, dstCatalog) {
+ const warnings = [];
+ const existing = dstCatalog.get(dstName);
+ if (!existing) {
+ await dst.query(buildCreateTable(dstName, srcTable.columns, srcTable.pk));
+ log.info(`[${dstName}] table créée (${srcTable.columns.length} colonnes)`);
+ dstCatalog.set(dstName, {
+ name: dstName,
+ columns: srcTable.columns.map(c => ({ ...c })),
+ pk: [...srcTable.pk],
+ indexes: [],
+ });
+ return warnings;
+ }
+ const dstCols = new Map(existing.columns.map(c => [c.name, c]));
+ for (const col of srcTable.columns) {
+ const d = dstCols.get(col.name);
+ if (!d) {
+ await dst.query(buildAddColumn(dstName, col));
+ existing.columns.push({ name: col.name, type: col.type, notnull: false });
+ log.info(`[${dstName}] colonne ajoutée : ${col.name} ${col.type}`);
+ } else if (d.type !== col.type) {
+ const w = `type différent pour ${col.name} : source ${col.type} / destination ${d.type} (non modifié)`;
+ warnings.push(w);
+ log.warn(`[${dstName}] ${w}`);
+ }
+ }
+ return warnings;
+}
+
+/** Tente CREATE EXTENSION pg_trgm si un index source en a besoin. Retourne true si disponible. */
+async function ensureTrgm(dst, srcCatalog) {
+ const needed = [...srcCatalog.values()].some(t => t.indexes.some(i => needsTrgm(i.def)));
+ if (!needed) return true;
+ try {
+ await dst.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
+ return true;
+ } catch (err) {
+ log.warn(`pg_trgm indisponible sur la destination (${err.message}) — index trigrammes ignorés`);
+ return false;
+ }
+}
+
+/**
+ * Après la copie des données : crée les index manquants (même nom, IF NOT EXISTS)
+ * et la clé primaire si la destination n'en a pas. Les échecs sont des avertissements.
+ * Les index ne sont reproduits que si le nom de table est identique (pas pour source_sync_log).
+ */
+async function mirrorIndexes(dst, srcTable, dstName, dstCatalog, trgmOk) {
+ const warnings = [];
+ const existing = dstCatalog.get(dstName);
+ if (srcTable.pk.length && existing && !existing.pk.length) {
+ try {
+ await dst.query(buildAddPrimaryKey(dstName, srcTable.pk));
+ existing.pk = [...srcTable.pk];
+ log.info(`[${dstName}] clé primaire ajoutée (${srcTable.pk.join(', ')})`);
+ } catch (err) {
+ const w = `clé primaire (${srcTable.pk.join(', ')}) non ajoutée : ${err.message}`;
+ warnings.push(w);
+ log.warn(`[${dstName}] ${w}`);
+ }
+ }
+ if (dstName !== srcTable.name) return warnings;
+ const have = new Set((existing?.indexes || []).map(i => i.name));
+ for (const idx of srcTable.indexes) {
+ if (have.has(idx.name)) continue;
+ if (!trgmOk && needsTrgm(idx.def)) {
+ warnings.push(`index ${idx.name} ignoré (pg_trgm indisponible)`);
+ continue;
+ }
+ try {
+ await dst.query(indexDefIfNotExists(idx.def));
+ existing?.indexes.push({ name: idx.name, def: idx.def });
+ log.info(`[${dstName}] index ${idx.name} OK`);
+ } catch (err) {
+ const w = `index ${idx.name} non créé : ${err.message}`;
+ warnings.push(w);
+ log.warn(`[${dstName}] ${w}`);
+ }
+ }
+ return warnings;
+}
+
+/** Table de suivi côté destination (mêmes colonnes que la source + colonnes de détail). */
+async function ensureDstSyncLog(dst) {
+ await dst.query(`CREATE TABLE IF NOT EXISTS ${qt('sync_log')} (
+ table_name TEXT PRIMARY KEY,
+ last_sync TIMESTAMP,
+ rows_synced INTEGER,
+ status TEXT,
+ error_msg TEXT
+ )`);
+ for (const [name, type] of [['strategy', 'TEXT'], ['duration_ms', 'INTEGER'], ['detail', 'TEXT']]) {
+ await dst.query(`ALTER TABLE ${qt('sync_log')} ADD COLUMN IF NOT EXISTS "${name}" ${type}`);
+ }
+}
+
+module.exports = { readCatalog, mirrorTable, ensureTrgm, mirrorIndexes, ensureDstSyncLog };
diff --git a/sync/server.js b/sync/server.js
index 74bdf95..485999b 100644
--- a/sync/server.js
+++ b/sync/server.js
@@ -1,281 +1,136 @@
'use strict';
+/**
+ * Dashboard de la sync Hostinger (port 3002, joint par Nginx Proxy Manager sur nginx_default).
+ *
+ * Sécurité :
+ * - HTTP Basic auth (DASHBOARD_USER / DASHBOARD_PASSWORD), comparaison à temps constant ;
+ * sans ces variables, tout répond 503 sauf GET /healthz (healthcheck Docker).
+ * - POST /api/sync/run exige l'en-tête X-Requested-By (bloque les formulaires cross-site / CSRF).
+ * - Aucune donnée dynamique insérée via innerHTML ; CSP sans script inline.
+ */
+const crypto = require('node:crypto');
+const path = require('node:path');
const express = require('express');
+const { config } = require('./config');
+const log = require('./logger');
const { getDst } = require('./db');
-const { TABLES } = require('./tables');
+const { state } = require('./sync');
-const app = express();
-const PORT = process.env.SYNC_DASHBOARD_PORT || 3002;
+const app = express();
+app.disable('x-powered-by');
+app.set('trust proxy', 'loopback, uniquelocal');
-const TABLES_ORDER = TABLES.map(t => t.name);
-const TABLES_TOTAL = TABLES_ORDER.length;
+let runFn = null;
+let nextRunFn = () => null;
+function registerSyncFn(fn) { runFn = fn; }
+function registerNextRunFn(fn) { nextRunFn = fn; }
-const state = {
- running: false,
- progress: null,
-};
+app.get('/healthz', (req, res) => res.json({ ok: true }));
-const logBuffer = [];
-function pushLog(msg, level = 'info') {
- logBuffer.push({ ts: new Date().toISOString(), msg, level });
- if (logBuffer.length > 300) logBuffer.shift();
+const sha = s => crypto.createHash('sha256').update(String(s)).digest();
+function safeEqual(a, b) {
+ return crypto.timingSafeEqual(sha(a), sha(b));
}
-const _log = console.log.bind(console);
-const _err = console.error.bind(console);
-console.log = (...a) => { pushLog(a.join(' '), 'info'); _log(...a); };
-console.error = (...a) => { pushLog(a.join(' '), 'error'); _err(...a); };
-
-function setProgress(tableName, index) {
- state.progress = {
- current: tableName,
- index,
- total: TABLES_TOTAL,
- pct: Math.round((index / TABLES_TOTAL) * 100),
- startedAt: state.progress?.startedAt || new Date().toISOString(),
- };
+function basicAuth(req, res, next) {
+ if (!config.dashboardUser || !config.dashboardPass) {
+ return res.status(503).type('text/plain; charset=utf-8')
+ .send('dashboard désactivé: définir DASHBOARD_USER/DASHBOARD_PASSWORD');
+ }
+ const header = req.get('authorization') || '';
+ const m = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(header);
+ let user = '';
+ let pass = '';
+ if (m) {
+ const decoded = Buffer.from(m[1], 'base64').toString('utf8');
+ const i = decoded.indexOf(':');
+ if (i >= 0) { user = decoded.slice(0, i); pass = decoded.slice(i + 1); }
+ }
+ // Les deux comparaisons sont toujours évaluées (pas de court-circuit)
+ const okUser = safeEqual(user, config.dashboardUser);
+ const okPass = safeEqual(pass, config.dashboardPass);
+ if (m && okUser && okPass) return next();
+ if (m) log.warn(`Authentification dashboard refusée (ip ${req.ip})`);
+ res.set('WWW-Authenticate', 'Basic realm="ff_sync_hostinger", charset="UTF-8"');
+ return res.status(401).type('text/plain; charset=utf-8').send('Authentification requise');
}
-function clearProgress() { state.progress = null; }
-let syncAllFn = null;
-function registerSyncFn(fn) { syncAllFn = fn; }
-function setSyncRunning(val) { state.running = val; }
-function isSyncRunning() { return state.running; }
-
-app.use(express.json());
+app.use(basicAuth);
+app.use((req, res, next) => {
+ res.set({
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
+ 'X-Content-Type-Options': 'nosniff',
+ 'Referrer-Policy': 'no-referrer',
+ 'Cache-Control': 'no-store',
+ });
+ next();
+});
+app.use(express.json({ limit: '10kb' }));
app.get('/api/status', async (req, res) => {
+ let tables = [];
+ let dbError = null;
try {
- const dst = getDst();
- const { rows } = await dst.query(
- `SELECT table_name, last_sync, rows_synced, status, error_msg
- FROM sync_log ORDER BY table_name`
+ const { rows } = await getDst().query(
+ `SELECT table_name, last_sync, rows_synced, status, error_msg, strategy, duration_ms, detail
+ FROM public.sync_log ORDER BY table_name`,
);
- res.json({ running: state.running, progress: state.progress, tables: rows });
+ tables = rows;
} catch (err) {
- res.status(500).json({ error: err.message });
+ // 42P01 = sync_log pas encore créée (aucune exécution)
+ if (err.code !== '42P01') dbError = 'base destination injoignable';
+ if (err.code !== '42P01') log.error(`/api/status : ${err.message}`);
}
+ res.json({
+ running: state.running,
+ force: state.force,
+ phase: state.phase,
+ progress: state.progress,
+ lastRun: state.lastRun,
+ tables,
+ dbError,
+ });
});
app.get('/api/logs', (req, res) => {
- const limit = parseInt(req.query.limit) || 150;
- res.json(logBuffer.slice(-limit));
+ res.json(log.getLogs(parseInt(req.query.limit, 10) || 200));
});
app.get('/api/next', (req, res) => {
- const now = new Date();
- const next = new Date(now);
- next.setHours(5, 30, 0, 0);
- if (next <= now) next.setDate(next.getDate() + 1);
- res.json({ next: next.toISOString() });
+ const next = nextRunFn();
+ res.json({ next: next ? next.toISOString() : null, cron: config.cron, timeZone: config.timeZone });
});
-app.post('/api/sync/run', async (req, res) => {
+app.post('/api/sync/run', (req, res) => {
+ if (req.get('x-requested-by') !== 'ff-dashboard') {
+ return res.status(400).json({ error: 'en-tête X-Requested-By manquant' });
+ }
if (state.running) return res.status(409).json({ error: 'Sync déjà en cours' });
- if (!syncAllFn) return res.status(500).json({ error: 'syncAll non enregistré' });
- res.json({ message: 'Sync lancée' });
- syncAllFn(true).catch(err => console.error('Erreur sync manuelle:', err.message));
+ if (!runFn) return res.status(500).json({ error: 'sync non initialisée' });
+ const force = req.body?.force === true;
+ log.info(`Sync ${force ? 'FORCÉE ' : ''}demandée depuis le dashboard (ip ${req.ip})`);
+ res.status(202).json({ message: force ? 'Sync forcée lancée' : 'Sync lancée' });
+ runFn({ force, trigger: 'dashboard' }).catch(err => log.error(`Sync manuelle : ${err.message}`));
});
-app.get('/', (req, res) => res.send(HTML));
+const PUBLIC_DIR = path.join(__dirname, 'public');
+app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
+app.get('/app.js', (req, res) => res.type('application/javascript').sendFile(path.join(PUBLIC_DIR, 'app.js')));
+
+app.use((req, res) => res.status(404).json({ error: 'not found' }));
+// eslint-disable-next-line no-unused-vars
+app.use((err, req, res, next) => {
+ log.error(`HTTP ${req.method} ${req.path} : ${err.message}`);
+ res.status(500).json({ error: 'erreur interne' });
+});
function startDashboard() {
- app.listen(PORT, '0.0.0.0', () => {
- console.log(`Dashboard sync Hostinger : http://0.0.0.0:${PORT}`);
+ if (!config.dashboardUser || !config.dashboardPass) {
+ log.warn('DASHBOARD_USER / DASHBOARD_PASSWORD non définis : dashboard et API désactivés (503)');
+ }
+ return app.listen(config.port, '0.0.0.0', () => {
+ log.info(`Dashboard sync Hostinger : port ${config.port}`);
});
}
-module.exports = { startDashboard, registerSyncFn, setSyncRunning, isSyncRunning, setProgress, clearProgress };
-
-const HTML = `
-
-
-
-
-Sync Hostinger
-
-
-
-
- ⚡ Sync Hostinger
- PG Debian → PG Hostinger
- Idle
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Table | Dernière sync | Lignes | Statut |
- | Chargement… |
-
-
-
-
-
-
-
-`;
+module.exports = { app, startDashboard, registerSyncFn, registerNextRunFn };
diff --git a/sync/sql.js b/sync/sql.js
new file mode 100644
index 0000000..a8c9f8b
--- /dev/null
+++ b/sync/sql.js
@@ -0,0 +1,191 @@
+'use strict';
+/**
+ * Fonctions pures de génération SQL (aucun accès base) — testées par test/sql.test.js.
+ * Toutes les tables sont dans le schéma public, côté source comme côté destination.
+ */
+
+/** Quote un identifiant PostgreSQL ("nom" avec doublement des guillemets). */
+function qi(name) {
+ return '"' + String(name).replace(/"/g, '""') + '"';
+}
+
+/** Nom qualifié public."table". */
+function qt(table) {
+ return 'public.' + qi(table);
+}
+
+/** Littéral SQL texte (échappement des apostrophes). */
+function lit(value) {
+ return "'" + String(value).replace(/'/g, "''") + "'";
+}
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** Littéral date 'YYYY-MM-DD' strictement validé (utilisé dans les DECLARE CURSOR). */
+function dateLit(value) {
+ if (!DATE_RE.test(String(value))) throw new Error(`Date invalide : ${value}`);
+ return lit(value);
+}
+
+/** Liste de colonnes quotées, dans l'ordre fourni. */
+function colList(cols) {
+ return cols.map(c => qi(c.name ?? c)).join(', ');
+}
+
+/**
+ * CREATE TABLE IF NOT EXISTS reproduisant colonnes (ordre, type exact, NOT NULL) et clé primaire.
+ * Pas de DEFAULT (les séquences ne sont pas répliquées) ni de clé étrangère.
+ * cols : [{ name, type, notnull }], pk : ['col', ...]
+ */
+function buildCreateTable(table, cols, pk = []) {
+ const defs = cols.map(c => ` ${qi(c.name)} ${c.type}${c.notnull ? ' NOT NULL' : ''}`);
+ if (pk.length) defs.push(` PRIMARY KEY (${pk.map(qi).join(', ')})`);
+ return `CREATE TABLE IF NOT EXISTS ${qt(table)} (\n${defs.join(',\n')}\n)`;
+}
+
+/**
+ * Ajout d'une colonne manquante. Toujours NULLable : un NOT NULL sans DEFAULT
+ * échouerait sur une table déjà remplie ; la colonne est remplie par la copie suivante.
+ */
+function buildAddColumn(table, col) {
+ return `ALTER TABLE ${qt(table)} ADD COLUMN IF NOT EXISTS ${qi(col.name)} ${col.type}`;
+}
+
+function buildAddPrimaryKey(table, pk) {
+ return `ALTER TABLE ${qt(table)} ADD PRIMARY KEY (${pk.map(qi).join(', ')})`;
+}
+
+/**
+ * Transforme un pg_indexes.indexdef ("CREATE [UNIQUE] INDEX nom ON public.t USING ...")
+ * en version idempotente avec IF NOT EXISTS (même nom d'index).
+ */
+function indexDefIfNotExists(indexdef) {
+ const out = String(indexdef).replace(/^CREATE (UNIQUE )?INDEX (?!IF NOT EXISTS )/i, (m, u) => `CREATE ${u || ''}INDEX IF NOT EXISTS `);
+ if (out === indexdef && !/^CREATE (UNIQUE )?INDEX IF NOT EXISTS /i.test(indexdef)) {
+ throw new Error(`Définition d'index inattendue : ${indexdef}`);
+ }
+ return out;
+}
+
+/** L'index nécessite-t-il l'extension pg_trgm ? */
+function needsTrgm(indexdef) {
+ return /\b(gin|gist)_trgm_ops\b/i.test(String(indexdef));
+}
+
+/**
+ * Requête de signature par mois : nb de lignes + somme des hash de chaque ligne.
+ * La liste de colonnes est explicite (ordre source) pour que source et destination
+ * hachent exactement les mêmes colonnes, même si la destination a des colonnes en plus.
+ * Résultat : m ('YYYY-MM-DD' ou NULL), n (texte), h (texte).
+ */
+function buildSignatureQuery(table, cols, dateCol, where = null) {
+ const d = qi(dateCol);
+ return `SELECT to_char(date_trunc('month', ${d}), 'YYYY-MM-DD') AS m,
+ count(*)::text AS n,
+ coalesce(sum(hashtext(ROW(${colList(cols)})::text)::bigint), 0)::text AS h
+FROM ${qt(table)}${where ? `\nWHERE ${where}` : ''}
+GROUP BY 1`;
+}
+
+const NULL_MONTH = 'NULL';
+
+/** Convertit le résultat d'une requête de signature en Map mois → "n:h". */
+function signatureMap(rows) {
+ const map = new Map();
+ for (const r of rows) map.set(r.m ?? NULL_MONTH, `${r.n}:${r.h}`);
+ return map;
+}
+
+/** Total des lignes d'une Map de signatures. */
+function signatureTotal(map) {
+ let total = 0;
+ for (const v of map.values()) total += Number(v.split(':')[0]);
+ return total;
+}
+
+/** Mois dont la signature diffère, ou présents d'un seul côté (triés, NULL en premier). */
+function diffMonths(srcMap, dstMap) {
+ const keys = new Set([...srcMap.keys(), ...dstMap.keys()]);
+ return [...keys].filter(k => srcMap.get(k) !== dstMap.get(k)).sort();
+}
+
+/** 1er jour du mois suivant ('YYYY-MM-01' → 'YYYY-MM-01'). */
+function nextMonth(month) {
+ if (!DATE_RE.test(month)) throw new Error(`Mois invalide : ${month}`);
+ const [y, m] = month.split('-').map(Number);
+ const ny = m === 12 ? y + 1 : y;
+ const nm = m === 12 ? 1 : m + 1;
+ return `${ny}-${String(nm).padStart(2, '0')}-01`;
+}
+
+/** Condition WHERE sélectionnant un mois (ou les dates NULL). */
+function monthCondition(dateCol, month) {
+ const d = qi(dateCol);
+ if (month === NULL_MONTH) return `${d} IS NULL`;
+ return `${d} >= ${dateLit(month)} AND ${d} < ${dateLit(nextMonth(month))}`;
+}
+
+/** Condition WHERE de la fenêtre glissante (date >= cutoff). */
+function recentCondition(dateCol, cutoff) {
+ return `${qi(dateCol)} >= ${dateLit(cutoff)}`;
+}
+
+/** Taille de lot d'INSERT : limite de 65535 paramètres par requête. */
+function insertChunkSize(ncols) {
+ return Math.max(1, Math.floor(60000 / ncols));
+}
+
+/** INSERT multi-lignes paramétré ($1..$n). */
+function buildInsert(table, cols, nrows) {
+ const n = cols.length;
+ const tuples = new Array(nrows);
+ for (let r = 0; r < nrows; r++) {
+ const ph = new Array(n);
+ for (let c = 0; c < n; c++) ph[c] = '$' + (r * n + c + 1);
+ tuples[r] = '(' + ph.join(',') + ')';
+ }
+ return `INSERT INTO ${qt(table)} (${colList(cols)}) VALUES ${tuples.join(',')}`;
+}
+
+/** SELECT des colonnes source (ordre source) avec filtre optionnel. */
+function buildSelect(table, cols, where = null) {
+ return `SELECT ${colList(cols)} FROM ${qt(table)}${where ? ` WHERE ${where}` : ''}`;
+}
+
+/** "a,b , c" → ['a','b','c'] (espaces retirés, éléments vides ignorés). */
+function parseList(value) {
+ return String(value || '')
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean);
+}
+
+/** "mvtart:datmvt,meteo:date" → { mvtart: 'datmvt', meteo: 'date' }. */
+function parseWindowTables(value) {
+ const out = {};
+ for (const item of parseList(value)) {
+ const [table, col] = item.split(':').map(s => (s || '').trim());
+ if (!table || !col) throw new Error(`WINDOW_TABLES invalide : "${item}" (attendu table:colonne)`);
+ out[table] = col;
+ }
+ return out;
+}
+
+/** Date 'YYYY-MM-DD' (fuseau donné) de "aujourd'hui - days". */
+function cutoffDate(days, timeZone = 'Europe/Paris', now = new Date()) {
+ const fmt = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
+ const today = fmt.format(now); // en-CA → YYYY-MM-DD
+ const [y, m, d] = today.split('-').map(Number);
+ const t = new Date(Date.UTC(y, m - 1, d - days));
+ return t.toISOString().slice(0, 10);
+}
+
+module.exports = {
+ qi, qt, lit, dateLit, colList,
+ buildCreateTable, buildAddColumn, buildAddPrimaryKey,
+ indexDefIfNotExists, needsTrgm,
+ buildSignatureQuery, signatureMap, signatureTotal, diffMonths, NULL_MONTH,
+ nextMonth, monthCondition, recentCondition,
+ insertChunkSize, buildInsert, buildSelect,
+ parseList, parseWindowTables, cutoffDate,
+};
diff --git a/sync/sync.js b/sync/sync.js
new file mode 100644
index 0000000..6780239
--- /dev/null
+++ b/sync/sync.js
@@ -0,0 +1,216 @@
+'use strict';
+/**
+ * Orchestration d'une exécution : verrou, attente de la sync Debian, miroir du schéma,
+ * copie table par table, journalisation dans sync_log (destination), alerte.
+ */
+const { config } = require('./config');
+const log = require('./logger');
+const { getSrc, getDst } = require('./db');
+const { readCatalog, mirrorTable, ensureTrgm, mirrorIndexes, ensureDstSyncLog } = require('./schema');
+const { copyFull, copyWindow } = require('./copy');
+const { sendAlert } = require('./alert');
+const { qt } = require('./sql');
+
+const SOURCE_SYNC_LOG = 'sync_log'; // table source traitée à part
+const DST_SOURCE_SYNC_LOG = 'source_sync_log';
+
+// État partagé avec le dashboard
+const state = {
+ running: false,
+ force: false,
+ phase: null, // 'verrou' | 'attente Debian' | 'schéma' | 'copie' | null
+ progress: null, // { current, index, total, startedAt }
+ lastRun: null, // { startedAt, finishedAt, force, status, tables: [...] }
+};
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function writeLog(dst, entry) {
+ await dst.query(
+ `INSERT INTO ${qt('sync_log')} (table_name, last_sync, rows_synced, status, error_msg, strategy, duration_ms, detail)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (table_name) DO UPDATE SET
+ last_sync = EXCLUDED.last_sync, rows_synced = EXCLUDED.rows_synced, status = EXCLUDED.status,
+ error_msg = EXCLUDED.error_msg, strategy = EXCLUDED.strategy,
+ duration_ms = EXCLUDED.duration_ms, detail = EXCLUDED.detail`,
+ // Date JS : stockée correctement en TIMESTAMP (heure locale du conteneur, TZ=Europe/Paris) comme en TIMESTAMPTZ
+ [entry.table, new Date(), Math.min(entry.rows ?? 0, 2147483647), entry.status, entry.error ?? null,
+ entry.strategy ?? null, entry.durationMs ?? null, entry.detail ?? null],
+ );
+}
+
+/**
+ * Attend que la sync Debian soit terminée (ligne sync_log '_run' ≠ 'running').
+ * Retourne 'ok' | 'absent' | 'timeout'.
+ */
+async function waitForDebian() {
+ const src = getSrc();
+ const deadline = Date.now() + config.waitMaxMs;
+ let first = true;
+ for (;;) {
+ let status;
+ try {
+ const { rows } = await src.query(`SELECT status FROM ${qt(SOURCE_SYNC_LOG)} WHERE table_name = '_run'`);
+ if (!rows.length) return 'absent';
+ status = rows[0].status;
+ } catch (err) {
+ if (err.code === '42P01') return 'absent'; // sync_log inexistante côté source
+ throw err;
+ }
+ if (status !== 'running') return 'ok';
+ if (Date.now() >= deadline) return 'timeout';
+ if (first) log.info(`Sync Debian en cours — attente (contrôle toutes les ${config.waitPollMs / 60000} min, max ${config.waitMaxMs / 60000} min)`);
+ first = false;
+ await sleep(Math.min(config.waitPollMs, Math.max(1000, deadline - Date.now())));
+ }
+}
+
+/** Liste ordonnée des copies à effectuer : [{ src, dstName, strategy, dateCol }]. */
+function planTables(srcCatalog) {
+ const plan = [];
+ for (const name of [...srcCatalog.keys()].sort()) {
+ if (name === SOURCE_SYNC_LOG || config.excludeTables.has(name)) continue;
+ const t = srcCatalog.get(name);
+ const dateCol = config.windowTables[name];
+ if (dateCol && t.columns.some(c => c.name === dateCol)) {
+ plan.push({ src: t, dstName: name, strategy: 'window', dateCol });
+ } else {
+ if (dateCol) log.warn(`[${name}] colonne ${dateCol} absente de la source — copie complète`);
+ plan.push({ src: t, dstName: name, strategy: 'full' });
+ }
+ }
+ if (srcCatalog.has(SOURCE_SYNC_LOG) && !config.excludeTables.has(SOURCE_SYNC_LOG)) {
+ plan.push({ src: srcCatalog.get(SOURCE_SYNC_LOG), dstName: DST_SOURCE_SYNC_LOG, strategy: 'full' });
+ }
+ return plan;
+}
+
+async function syncOne(item, ctx) {
+ const { src: srcTable, dstName, strategy, dateCol } = item;
+ const started = Date.now();
+ const entry = { table: dstName, strategy, rows: 0, status: 'ok', error: null, detail: null };
+ const srcClient = await getSrc().connect();
+ const dstClient = await getDst().connect();
+ try {
+ const warnings = await mirrorTable(dstClient, srcTable, dstName, ctx.dstCatalog);
+ // Instantané cohérent de la source pour toute la table (comptage, signatures, curseurs)
+ await srcClient.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ try {
+ const r = strategy === 'window'
+ ? await copyWindow(srcClient, dstClient, srcTable.name, srcTable.columns, dateCol, ctx.force)
+ : await copyFull(srcClient, dstClient, srcTable.name, dstName, srcTable.columns, ctx.force);
+ entry.rows = r.rows;
+ entry.detail = r.detail;
+ } finally {
+ await srcClient.query('COMMIT').catch(() => {});
+ }
+ warnings.push(...await mirrorIndexes(dstClient, srcTable, dstName, ctx.dstCatalog, ctx.trgmOk));
+ if (warnings.length) entry.detail = `${entry.detail} | ${warnings.length} avertissement(s) : ${warnings.join(' ; ')}`;
+ log.info(`[${dstName}] OK — ${entry.detail}`);
+ } catch (err) {
+ entry.status = 'error';
+ entry.error = err.message;
+ log.error(`[${dstName}] ERREUR : ${err.message}`);
+ } finally {
+ srcClient.release();
+ dstClient.release();
+ }
+ entry.durationMs = Date.now() - started;
+ await writeLog(getDst(), entry).catch(e => log.error(`[${dstName}] écriture sync_log impossible : ${e.message}`));
+ return entry;
+}
+
+/**
+ * Lance une synchronisation complète.
+ * force = ignorer le garde-fou volume + rechargement complet des tables "fenêtre".
+ * Retourne { skipped } | { status, tables }.
+ */
+async function runSync({ force = false, trigger = 'manuel' } = {}) {
+ if (state.running) {
+ log.warn('Sync déjà en cours dans ce process — demande ignorée');
+ return { skipped: 'running' };
+ }
+ state.running = true;
+ state.force = force;
+ state.phase = 'verrou';
+ state.progress = null;
+ const startedAt = new Date();
+ const results = [];
+ let lockClient = null;
+ let locked = false;
+ let fatal = null;
+
+ try {
+ lockClient = await getDst().connect();
+ const { rows } = await lockClient.query('SELECT pg_try_advisory_lock($1::bigint) AS ok', [config.lockKey]);
+ locked = rows[0].ok;
+ if (!locked) {
+ log.warn('Une autre synchronisation détient le verrou (autre process) — exécution ignorée');
+ return { skipped: 'locked' };
+ }
+ log.info(`=== SYNC START (${trigger}${force ? ', FORCÉE' : ''}) ===`);
+ await ensureDstSyncLog(lockClient);
+ await writeLog(lockClient, { table: '_run', status: 'running', strategy: force ? 'force' : trigger, detail: `démarrée ${startedAt.toISOString()}` });
+
+ state.phase = 'attente Debian';
+ const wait = await waitForDebian();
+ if (wait === 'timeout') {
+ const msg = `Sync Debian toujours "running" après ${config.waitMaxMs / 60000} min — copie lancée quand même`;
+ log.error(msg);
+ await sendAlert(`[ff_sync_hostinger] ${msg}`);
+ }
+
+ state.phase = 'schéma';
+ const srcCatalog = await readCatalog(getSrc());
+ const dstCatalog = await readCatalog(lockClient);
+ const trgmOk = await ensureTrgm(lockClient, srcCatalog);
+ const plan = planTables(srcCatalog);
+ log.info(`${plan.length} tables à synchroniser (${plan.filter(p => p.strategy === 'window').length} par fenêtre)`);
+
+ state.phase = 'copie';
+ const ctx = { force, dstCatalog, trgmOk };
+ for (let i = 0; i < plan.length; i++) {
+ state.progress = { current: plan[i].dstName, index: i + 1, total: plan.length, startedAt: startedAt.toISOString() };
+ results.push(await syncOne(plan[i], ctx));
+ }
+ } catch (err) {
+ fatal = err;
+ log.error(`ERREUR FATALE : ${err.message}`);
+ } finally {
+ const failed = results.filter(r => r.status !== 'ok');
+ if (locked) {
+ const status = fatal || failed.length ? 'error' : 'ok';
+ const detail = fatal
+ ? `erreur fatale : ${fatal.message}`
+ : `${results.length} tables, ${failed.length} en erreur`;
+ await writeLog(lockClient, {
+ table: '_run', status, strategy: force ? 'force' : trigger,
+ rows: results.reduce((s, r) => s + (r.rows || 0), 0),
+ error: fatal ? fatal.message : failed.length ? failed.map(f => f.table).join(', ') : null,
+ durationMs: Date.now() - startedAt.getTime(), detail,
+ }).catch(e => log.error(`écriture _run impossible : ${e.message}`));
+ await lockClient.query('SELECT pg_advisory_unlock($1::bigint)', [config.lockKey]).catch(() => {});
+ if (fatal || failed.length) {
+ const lines = failed.slice(0, 15).map(f => `• ${f.table} : ${f.error}`);
+ await sendAlert(
+ `[ff_sync_hostinger] Sync ${fatal ? 'en échec' : 'terminée avec erreurs'} — ${detail}` +
+ (lines.length ? `\n${lines.join('\n')}` : ''),
+ );
+ }
+ log.info(`=== SYNC FIN en ${((Date.now() - startedAt.getTime()) / 1000).toFixed(1)} s — ${detail} ===`);
+ state.lastRun = {
+ startedAt: startedAt.toISOString(), finishedAt: new Date().toISOString(),
+ force, trigger, status: fatal || failed.length ? 'error' : 'ok', tables: results,
+ };
+ } else if (fatal) {
+ await sendAlert(`[ff_sync_hostinger] Sync impossible : ${fatal.message}`);
+ }
+ if (lockClient) lockClient.release();
+ state.running = false;
+ state.phase = null;
+ state.progress = null;
+ }
+ return { status: fatal || results.some(r => r.status !== 'ok') ? 'error' : 'ok', tables: results };
+}
+
+module.exports = { runSync, state, planTables, waitForDebian };
diff --git a/sync/sync.sh b/sync/sync.sh
deleted file mode 100644
index 6eecfea..0000000
--- a/sync/sync.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/bin/sh
-# Sync PostgreSQL Debian → PostgreSQL Hostinger
-# Source : frps:15432 (tunnel frp vers postgres Debian)
-# Destination : postgres_db (container existant sur Hostinger)
-
-set -e
-
-echo "=== SYNC START $(date) ==="
-
-PGPASSWORD=$SRC_PASS pg_dump \
- -h "$SRC_HOST" -p "$SRC_PORT" \
- -U "$SRC_USER" -d "$SRC_DB" \
- --data-only --if-exists --clean \
- --no-acl --no-owner \
-| PGPASSWORD=$DST_PASS psql \
- -h "$DST_HOST" -p "$DST_PORT" \
- -U "$DST_USER" -d "$DST_DB" \
- --set ON_ERROR_STOP=0
-
-echo "=== SYNC DONE $(date) ==="
diff --git a/sync/tables.js b/sync/tables.js
deleted file mode 100644
index 275126c..0000000
--- a/sync/tables.js
+++ /dev/null
@@ -1,44 +0,0 @@
-'use strict';
-// Définition des tables à synchroniser PG Debian → PG Hostinger
-// pk: [] = full refresh (TRUNCATE + INSERT), sinon ON CONFLICT DO UPDATE
-
-const TABLES = [
- // Référentiel
- { name: 'nomenclature', pk: ['no_id'] },
- { name: 'gammes', pk: ['no_id'] },
- { name: 'saisons', pk: ['no_id'] },
- // Articles
- { name: 'articles', pk: ['no_id'] },
- { name: 'article_infosup', pk: ['artnoid'] },
- { name: 'art_gtin', pk: ['idarticle', 'gtin'] },
- { name: 'art_gamme_saison', pk: ['artnoid', 'idgamme', 'idsaison'] },
- // Fournisseurs
- { name: 'fouadr1', pk: ['code', 'sit_code'] },
- { name: 'artfou1', pk: ['no_id'] },
- { name: 'artfou2', pk: ['idartfou1'] },
- // Stock & prix
- { name: 'cube_pa', pk: ['artnoid'] },
- { name: 'cube_pv', pk: ['artnoid', 'site'] },
- { name: 'cube_stock', pk: ['artnoid', 'site'] },
- // Mouvements (sans PK → full refresh)
- { name: 'mvtart', pk: [] },
- { name: 'mvtreg', pk: [] },
- // Ranking
- { name: 'ranking', pk: ['gencod', 'site'] },
- // CA journalier + trafic
- { name: 'statopca', pk: ['site', 'datmvt'] },
- // Gamme conseillée par site
- { name: 'stat_dispoperm', pk: ['codesite', 'code_art'] },
- // Journal de commande conseillé
- { name: 'phenix_quantite_conseille', pk: ['ident'] },
- // Commandes
- { name: 'cdefou_reception', pk: ['no_id'] },
- { name: 'cdefou_vivant', pk: ['cdefou_ligne_com_no_id', 'artfou1_no_id'] },
- { name: 'cdefou_receplig', pk: ['no_id'] },
- // Publicités
- { name: 'pub_entetes', pk: ['ent_npub'] },
- { name: 'pub_ecoulement', pk: ['site', 'tcr_code'] },
- { name: 'pub_ecoulement_detail', pk: ['site', 'tcr_code', 'artnoid'] },
-];
-
-module.exports = { TABLES };
diff --git a/sync/test/sql.test.js b/sync/test/sql.test.js
new file mode 100644
index 0000000..0d2e214
--- /dev/null
+++ b/sync/test/sql.test.js
@@ -0,0 +1,97 @@
+'use strict';
+// Tests des fonctions pures de génération SQL : `npm test` (node --test, sans base de données).
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const s = require('../sql');
+
+test('qi échappe les guillemets', () => {
+ assert.equal(s.qi('mvtart'), '"mvtart"');
+ assert.equal(s.qi('a"b'), '"a""b"');
+});
+
+test('buildCreateTable : colonnes ordonnées, NOT NULL, PK', () => {
+ const sql = s.buildCreateTable('mvtart', [
+ { name: 'no_id', type: 'bigint', notnull: true },
+ { name: 'datmvt', type: 'timestamp without time zone', notnull: false },
+ { name: 'qte', type: 'numeric(12,4)', notnull: false },
+ ], ['no_id']);
+ assert.equal(sql,
+ 'CREATE TABLE IF NOT EXISTS public."mvtart" (\n' +
+ ' "no_id" bigint NOT NULL,\n' +
+ ' "datmvt" timestamp without time zone,\n' +
+ ' "qte" numeric(12,4),\n' +
+ ' PRIMARY KEY ("no_id")\n)');
+ assert.ok(!s.buildCreateTable('t', [{ name: 'a', type: 'text' }], []).includes('PRIMARY KEY'));
+});
+
+test('buildAddColumn est idempotent et nullable', () => {
+ assert.equal(s.buildAddColumn('articles', { name: 'artcentrale', type: 'text', notnull: true }),
+ 'ALTER TABLE public."articles" ADD COLUMN IF NOT EXISTS "artcentrale" text');
+});
+
+test('indexDefIfNotExists', () => {
+ assert.equal(s.indexDefIfNotExists('CREATE INDEX idx_a ON public.articles USING btree (codein)'),
+ 'CREATE INDEX IF NOT EXISTS idx_a ON public.articles USING btree (codein)');
+ assert.equal(s.indexDefIfNotExists('CREATE UNIQUE INDEX u ON public.t USING btree (a, b)'),
+ 'CREATE UNIQUE INDEX IF NOT EXISTS u ON public.t USING btree (a, b)');
+ assert.equal(s.indexDefIfNotExists('CREATE INDEX IF NOT EXISTS x ON public.t (a)'),
+ 'CREATE INDEX IF NOT EXISTS x ON public.t (a)');
+ assert.throws(() => s.indexDefIfNotExists('DROP TABLE x'));
+});
+
+test('needsTrgm', () => {
+ assert.ok(s.needsTrgm('CREATE INDEX i ON public.a USING gin (libelle1 gin_trgm_ops)'));
+ assert.ok(!s.needsTrgm('CREATE INDEX i ON public.a USING btree (libelle1)'));
+});
+
+test('buildSignatureQuery : colonnes explicites dans l\'ordre source', () => {
+ const sql = s.buildSignatureQuery('mvtart', [{ name: 'no_id' }, { name: 'datmvt' }], 'datmvt');
+ assert.match(sql, /date_trunc\('month', "datmvt"\)/);
+ assert.match(sql, /hashtext\(ROW\("no_id", "datmvt"\)::text\)::bigint/);
+ assert.match(sql, /FROM public\."mvtart"\nGROUP BY 1$/);
+});
+
+test('signatures : diff, total, mois NULL', () => {
+ const src = s.signatureMap([
+ { m: '2026-08-01', n: '10', h: '123' },
+ { m: '2026-09-01', n: '5', h: '7' },
+ { m: null, n: '1', h: '9' },
+ ]);
+ const dst = s.signatureMap([
+ { m: '2026-08-01', n: '10', h: '123' },
+ { m: '2026-09-01', n: '5', h: '8' },
+ { m: '2026-07-01', n: '3', h: '1' },
+ ]);
+ assert.deepEqual(s.diffMonths(src, dst), ['2026-07-01', '2026-09-01', s.NULL_MONTH]);
+ assert.equal(s.signatureTotal(src), 16);
+});
+
+test('monthCondition / nextMonth / recentCondition', () => {
+ assert.equal(s.nextMonth('2026-12-01'), '2027-01-01');
+ assert.equal(s.nextMonth('2026-02-01'), '2026-03-01');
+ assert.equal(s.monthCondition('datmvt', '2026-12-01'), `"datmvt" >= '2026-12-01' AND "datmvt" < '2027-01-01'`);
+ assert.equal(s.monthCondition('datmvt', s.NULL_MONTH), '"datmvt" IS NULL');
+ assert.equal(s.recentCondition('date', '2026-10-07'), `"date" >= '2026-10-07'`);
+ assert.throws(() => s.monthCondition('d', "2026-01-01'; DROP TABLE x; --"));
+});
+
+test('buildInsert / insertChunkSize', () => {
+ assert.equal(s.buildInsert('t', [{ name: 'a' }, { name: 'b' }], 2),
+ 'INSERT INTO public."t" ("a", "b") VALUES ($1,$2),($3,$4)');
+ assert.equal(s.insertChunkSize(30), 2000);
+ assert.equal(s.insertChunkSize(100000), 1);
+ assert.ok(s.insertChunkSize(7) * 7 <= 65535);
+});
+
+test('parseWindowTables / parseList', () => {
+ assert.deepEqual(s.parseWindowTables('mvtart:datmvt, meteo:date'), { mvtart: 'datmvt', meteo: 'date' });
+ assert.throws(() => s.parseWindowTables('mvtart'));
+ assert.deepEqual(s.parseList(' a, ,b '), ['a', 'b']);
+ assert.deepEqual(s.parseList(undefined), []);
+});
+
+test('cutoffDate en Europe/Paris', () => {
+ // 17/10/2026 23:30 UTC = 18/10/2026 01:30 à Paris
+ assert.equal(s.cutoffDate(10, 'Europe/Paris', new Date('2026-10-17T23:30:00Z')), '2026-10-08');
+ assert.equal(s.cutoffDate(0, 'Europe/Paris', new Date('2026-03-01T12:00:00Z')), '2026-03-01');
+});
diff --git a/sync/utils.js b/sync/utils.js
deleted file mode 100644
index 8f457c9..0000000
--- a/sync/utils.js
+++ /dev/null
@@ -1,89 +0,0 @@
-'use strict';
-
-/**
- * Upsert en masse dans PG destination (chunks de 500 lignes).
- * Les colonnes sont déduites dynamiquement des lignes source.
- */
-async function batchUpsert(pg, table, rows, pk) {
- if (!rows.length) return 0;
- const cols = Object.keys(rows[0]);
- const CHUNK = 500;
- let total = 0;
-
- for (let i = 0; i < rows.length; i += CHUNK) {
- const chunk = rows.slice(i, i + CHUNK);
- const values = [];
- const placeholders = chunk.map((row, ri) => {
- const ph = cols.map((col, ci) => {
- values.push(row[col] ?? null);
- return `$${ri * cols.length + ci + 1}`;
- });
- return `(${ph.join(', ')})`;
- });
- const updateCols = cols.filter(c => !pk.includes(c));
- const updateSet = updateCols.length
- ? updateCols.map(c => `${c} = EXCLUDED.${c}`).join(', ')
- : `${pk[0]} = EXCLUDED.${pk[0]}`; // fallback si toutes les cols sont PK
-
- const sql = `
- INSERT INTO ${table} (${cols.join(', ')})
- VALUES ${placeholders.join(', ')}
- ON CONFLICT (${pk.join(', ')}) DO UPDATE SET ${updateSet}
- `;
- await pg.query(sql, values);
- total += chunk.length;
- }
- return total;
-}
-
-/**
- * Full refresh : TRUNCATE puis INSERT en masse.
- */
-async function fullRefresh(pg, table, rows) {
- await pg.query(`TRUNCATE TABLE ${table} CASCADE`);
- if (!rows.length) return 0;
-
- const cols = Object.keys(rows[0]);
- const CHUNK = 500;
- let total = 0;
-
- for (let i = 0; i < rows.length; i += CHUNK) {
- const chunk = rows.slice(i, i + CHUNK);
- const values = [];
- const placeholders = chunk.map((row, ri) => {
- const ph = cols.map((col, ci) => {
- values.push(row[col] ?? null);
- return `$${ri * cols.length + ci + 1}`;
- });
- return `(${ph.join(', ')})`;
- });
- await pg.query(
- `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`,
- values
- );
- total += chunk.length;
- }
- return total;
-}
-
-async function logSync(pg, tableName, rowsSynced, status, errorMsg = null) {
- await pg.query(`
- INSERT INTO sync_log (table_name, last_sync, rows_synced, status, error_msg)
- VALUES ($1, NOW(), $2, $3, $4)
- ON CONFLICT (table_name) DO UPDATE SET
- last_sync = NOW(),
- rows_synced = $2,
- status = $3,
- error_msg = $4
- `, [tableName, rowsSynced, status, errorMsg]);
-}
-
-async function getLastSync(pg, tableName) {
- const res = await pg.query(
- `SELECT last_sync FROM sync_log WHERE table_name = $1 AND status = 'ok'`,
- [tableName]
- );
- return res.rows[0]?.last_sync || null;
-}
-
-module.exports = { batchUpsert, fullRefresh, logSync, getLastSync };