feat: gateway sécurisé, miroir de schéma automatique et sync incrémentale

- Schéma Hostinger recopié automatiquement depuis PG Debian (colonnes, PK,
  index) : plus d'init.sql maintenu à la main (corrige articles.artcentrale,
  mvtart.no_id et les 12 tables manquantes)
- Stratégies : full transactionnel (DELETE + INSERT, garde-fou de volume) et
  window (signature mensuelle + 10 derniers jours) pour les grosses tables
- Attente de la fin de la sync Debian (_run), pg_try_advisory_lock,
  cron 07:30 Europe/Paris, force honoré, alertes webhook
- Dashboard : basic auth, CSP sans script inline, plus d'innerHTML,
  port 3002 non publié (accès via Nginx Proxy Manager)
- Secrets hors du compose (.env), frps épinglé 0.71.0 avec TLS forcé,
  allowPorts et additionalScopes
- Suppression de sync.sh, tables.js, utils.js, init.sql ; README de déploiement

L'ancien mot de passe de la base `post` figure dans l'historique git :
il doit être changé sur le serveur (voir README).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-17 11:41:29 +02:00
1 parent f8575b9ff2
commit ea084034b9
26 files changed
+2767 -922

No files matched your search

+38 -7
View File
@@ -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=
+3 -1
View File
@@ -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/
+201
View File
@@ -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
```
+48 -13
View File
@@ -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:
+22 -3
View File
@@ -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"
-389
View File
@@ -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);
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-debug.log
test
.env
*.md
+13 -4
View File
@@ -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"]
+32
View File
@@ -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 };
+59
View File
@@ -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 };
+150
View File
@@ -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 };
+44 -24
View File
@@ -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 };
+51 -76
View File
@@ -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'));
}
+26
View File
@@ -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)),
};
+1015
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -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"
}
}
+170
View File
@@ -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();
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sync Hostinger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
header{background:#1e293b;padding:1rem 2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #334155}
header h1{font-size:1.2rem;font-weight:700;color:#f8fafc}
.subtitle{font-size:.75rem;color:#64748b}
.badge{font-size:.75rem;padding:.25rem .6rem;border-radius:9999px;font-weight:600}
.badge.idle{background:#1e3a5f;color:#93c5fd}
.badge.running{background:#713f12;color:#fde68a;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
main{padding:1.5rem 2rem;max-width:1300px;margin:0 auto}
.actions{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem}
button{padding:.55rem 1.3rem;border:none;border-radius:.5rem;cursor:pointer;font-weight:600;font-size:.85rem;transition:.15s}
button:disabled{opacity:.4;cursor:not-allowed}
#btnSync{background:#2563eb;color:#fff}
#btnSync:hover:not(:disabled){background:#1d4ed8}
#btnForce{background:#7c2d12;color:#fed7aa}
#btnForce:hover:not(:disabled){background:#9a3412}
#btnRefresh,#btnCopyLogs{background:#334155;color:#cbd5e1}
#btnRefresh:hover,#btnCopyLogs:hover{background:#475569}
#btnCopyLogs{font-size:.75rem;padding:.35rem .8rem}
#nextSync{font-size:.8rem;color:#64748b;margin-left:auto}
#msg{font-size:.82rem;padding:.35rem .75rem;border-radius:.4rem}
#msg.ok{background:#14532d;color:#86efac}
#msg.err{background:#450a0a;color:#fca5a5}
.hidden{display:none !important}
.box{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1rem 1.25rem;margin-bottom:1.5rem}
.progress-header{display:flex;justify-content:space-between;margin-bottom:.6rem;font-size:.85rem}
.progress-bar-bg{background:#0f172a;border-radius:9999px;height:12px;overflow:hidden}
.progress-bar-fill{height:100%;background:linear-gradient(90deg,#059669,#2563eb);border-radius:9999px;transition:width .5s ease;width:0}
.progress-detail{margin-top:.5rem;font-size:.78rem;color:#64748b}
#runInfo{font-size:.85rem}
.card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;overflow:hidden;margin-bottom:1.5rem}
.card-header{padding:.65rem 1rem;background:#0f172a;border-bottom:1px solid #334155;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;display:flex;justify-content:space-between;align-items:center}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{padding:.5rem .8rem;text-align:left;color:#64748b;font-weight:500;border-bottom:1px solid #334155;white-space:nowrap}
td{padding:.5rem .8rem;border-bottom:1px solid #263449;vertical-align:top}
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
td.detail{color:#94a3b8;max-width:520px;word-break:break-word}
.st-ok{color:#4ade80}.st-error{color:#f87171}.st-running{color:#fde68a}
.strat{font-size:.72rem;padding:.1rem .45rem;border-radius:.3rem;background:#334155;color:#cbd5e1}
.strat.window{background:#1e3a5f;color:#93c5fd}
.log-box{height:340px;overflow-y:auto;font-family:monospace;font-size:.76rem;padding:.75rem 1rem;background:#0a0f1e;color:#a5f3fc;line-height:1.7}
.log-line{white-space:pre-wrap;word-break:break-all}
.log-line.error{color:#f87171}
.log-line.warn{color:#fbbf24}
.ts{color:#475569;margin-right:.5rem}
#lastRefresh{font-size:.75rem;color:#475569;margin-left:auto}
</style>
</head>
<body>
<header>
<h1>Sync Hostinger</h1>
<span class="subtitle">PG Debian (frps:15432) → PG Hostinger (miroir automatique du schéma)</span>
<span id="statusBadge" class="badge idle">Inactif</span>
<span id="lastRefresh"></span>
</header>
<main>
<div class="actions">
<button id="btnSync" type="button">Lancer la sync</button>
<button id="btnForce" type="button" title="Ignore le garde-fou volume et recharge entièrement les tables de mouvements">Forcer (rechargement complet)</button>
<button id="btnRefresh" type="button">Rafraîchir</button>
<span id="msg" class="hidden"></span>
<span id="nextSync"></span>
</div>
<div id="progressBox" class="box hidden">
<div class="progress-header">
<span id="progressLabel">Synchronisation en cours…</span>
<span id="progressPct">0%</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" id="progressBar"></div></div>
<div class="progress-detail" id="progressDetail"></div>
</div>
<div class="box"><div id="runInfo">Chargement…</div></div>
<div class="card">
<div class="card-header"><span>Statut des tables</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>Table</th><th>Stratégie</th><th>Dernière sync</th><th>Lignes</th><th>Durée</th><th>Statut</th><th>Détail / erreur</th></tr></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><span>Logs</span><button id="btnCopyLogs" type="button">Copier les logs</button></div>
<div class="log-box" id="logBox">Chargement…</div>
</div>
</main>
<script src="/app.js"></script>
</body>
</html>
+167
View File
@@ -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 };
+103 -248
View File
@@ -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 = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sync Hostinger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
header{background:#1e293b;padding:1rem 2rem;display:flex;align-items:center;gap:1rem;border-bottom:1px solid #334155}
header h1{font-size:1.2rem;font-weight:700;color:#f8fafc}
.subtitle{font-size:.75rem;color:#64748b}
.badge{font-size:.75rem;padding:.25rem .6rem;border-radius:9999px;font-weight:600}
.badge.idle{background:#1e3a5f;color:#93c5fd}
.badge.running{background:#713f12;color:#fde68a;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
main{padding:1.5rem 2rem;max-width:1100px;margin:0 auto}
.actions{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem}
button{padding:.55rem 1.3rem;border:none;border-radius:.5rem;cursor:pointer;font-weight:600;font-size:.85rem;transition:.15s}
#btnSync{background:#2563eb;color:#fff}
#btnSync:hover:not(:disabled){background:#1d4ed8}
#btnSync:disabled{opacity:.4;cursor:not-allowed}
#btnRefresh{background:#334155;color:#cbd5e1}
#btnRefresh:hover{background:#475569}
#nextSync{font-size:.8rem;color:#64748b;margin-left:auto}
#msg{font-size:.82rem;padding:.35rem .75rem;border-radius:.4rem;display:none}
#msg.ok{background:#14532d;color:#86efac;display:inline-block}
#msg.err{background:#450a0a;color:#fca5a5;display:inline-block}
#progressBox{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1rem 1.25rem;margin-bottom:1.5rem;display:none}
#progressBox.visible{display:block}
.progress-header{display:flex;justify-content:space-between;margin-bottom:.6rem;font-size:.85rem}
.progress-bar-bg{background:#0f172a;border-radius:9999px;height:12px;overflow:hidden}
.progress-bar-fill{height:100%;background:linear-gradient(90deg,#059669,#2563eb);border-radius:9999px;transition:width .5s ease}
.progress-detail{margin-top:.5rem;font-size:.78rem;color:#64748b}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}
@media(max-width:700px){.grid{grid-template-columns:1fr}}
.card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;overflow:hidden}
.card-header{padding:.65rem 1rem;background:#0f172a;border-bottom:1px solid #334155;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8}
table{width:100%;border-collapse:collapse;font-size:.85rem}
th{padding:.55rem 1rem;text-align:left;color:#64748b;font-weight:500;border-bottom:1px solid #334155}
td{padding:.55rem 1rem;border-bottom:1px solid #1e293b}
tr:last-child td{border-bottom:none}
.ok{color:#4ade80}.err{color:#f87171}
.log-box{height:320px;overflow-y:auto;font-family:monospace;font-size:.76rem;padding:.75rem 1rem;background:#0a0f1e;color:#a5f3fc;line-height:1.7;user-select:text;cursor:text}
.log-line{white-space:pre-wrap;word-break:break-all}
.log-line.error{color:#f87171}
.ts{color:#475569;margin-right:.5rem}
#btnCopyLogs{background:#334155;color:#cbd5e1;font-size:.75rem;padding:.35rem .8rem}
#btnCopyLogs:hover{background:#475569}
#lastRefresh{font-size:.75rem;color:#475569}
</style>
</head>
<body>
<header>
<h1>⚡ Sync Hostinger</h1>
<span class="subtitle">PG Debian → PG Hostinger</span>
<span id="statusBadge" class="badge idle">Idle</span>
<span id="lastRefresh" style="margin-left:auto"></span>
</header>
<main>
<div class="actions">
<button id="btnSync" onclick="runSync()">▶ Lancer sync maintenant</button>
<button id="btnRefresh" onclick="refresh()">↺ Rafraîchir</button>
<span id="msg"></span>
<span id="nextSync"></span>
</div>
<div id="progressBox">
<div class="progress-header">
<span id="progressLabel">Synchronisation en cours…</span>
<span id="progressPct" style="font-weight:700;color:#93c5fd">0%</span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressBar" style="width:0%"></div>
</div>
<div class="progress-detail" id="progressDetail"></div>
</div>
<div class="grid">
<div class="card" style="grid-column:1/-1">
<div class="card-header">Statut des tables</div>
<table>
<thead><tr><th>Table</th><th>Dernière sync</th><th>Lignes</th><th>Statut</th></tr></thead>
<tbody id="tableBody"><tr><td colspan="4" style="color:#475569;padding:1rem">Chargement…</td></tr></tbody>
</table>
</div>
<div class="card" style="grid-column:1/-1">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<span>Logs</span>
<button id="btnCopyLogs" onclick="copyLogs()">Copier les logs</button>
</div>
<div class="log-box" id="logBox">Chargement…</div>
</div>
</div>
</main>
<script>
function fmtDate(iso){
if(!iso) return '—';
return new Date(iso).toLocaleString('fr-FR');
}
function renderProgress(progress, running){
const box = document.getElementById('progressBox');
if(!running || !progress){ box.classList.remove('visible'); return; }
box.classList.add('visible');
document.getElementById('progressBar').style.width = progress.pct + '%';
document.getElementById('progressPct').textContent = progress.pct + '%';
document.getElementById('progressLabel').textContent =
'Sync en cours — table ' + progress.index + '/' + progress.total;
const elapsed = Math.round((Date.now() - new Date(progress.startedAt)) / 1000);
document.getElementById('progressDetail').textContent =
'En cours : ' + progress.current + ' | Durée : ' + elapsed + 's';
}
function renderStatus(data){
const badge = document.getElementById('statusBadge');
badge.textContent = data.running ? 'En cours…' : 'Idle';
badge.className = 'badge ' + (data.running ? 'running' : 'idle');
document.getElementById('btnSync').disabled = data.running;
renderProgress(data.progress, data.running);
if(!data.tables.length){
document.getElementById('tableBody').innerHTML =
'<tr><td colspan="4" style="color:#475569;padding:1rem">Aucune sync — cliquez sur Lancer sync maintenant</td></tr>';
return;
}
document.getElementById('tableBody').innerHTML = data.tables.map(t => \`
<tr>
<td><strong>\${t.table_name}</strong></td>
<td>\${fmtDate(t.last_sync)}</td>
<td>\${t.rows_synced ?? '—'}</td>
<td class="\${t.status==='ok'?'ok':'err'}">\${t.status ?? '—'}\${t.error_msg?' — '+t.error_msg:''}</td>
</tr>
\`).join('');
}
function renderLogs(logs){
const box = document.getElementById('logBox');
if(!logs.length){ box.textContent = 'Aucun log.'; return; }
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 10;
box.innerHTML = logs.map(l => \`
<div class="log-line \${l.level}">
<span class="ts">\${new Date(l.ts).toLocaleTimeString('fr-FR')}</span>\${l.msg}
</div>
\`).join('');
if(atBottom) box.scrollTop = box.scrollHeight;
}
async function refresh(){
try{
const [s, logs, next] = await Promise.all([
fetch('/api/status').then(r=>r.json()),
fetch('/api/logs?limit=150').then(r=>r.json()),
fetch('/api/next').then(r=>r.json()),
]);
renderStatus(s);
renderLogs(logs);
document.getElementById('nextSync').textContent = 'Prochaine sync auto : ' + fmtDate(next.next);
document.getElementById('lastRefresh').textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR');
} catch(e){ console.error(e); }
}
function copyLogs(){
const box = document.getElementById('logBox');
const text = Array.from(box.querySelectorAll('.log-line')).map(l => l.textContent).join('\\n');
const btn = document.getElementById('btnCopyLogs');
function onSuccess(){ btn.textContent = 'Copié !'; setTimeout(() => { btn.textContent = 'Copier les logs'; }, 2000); }
if(navigator.clipboard && window.isSecureContext){
navigator.clipboard.writeText(text).then(onSuccess).catch(fallback);
} else { fallback(); }
function fallback(){
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
document.body.appendChild(ta); ta.focus(); ta.select();
try{ document.execCommand('copy'); onSuccess(); } catch(e){ alert('Sélectionnez les logs et Ctrl+C'); }
document.body.removeChild(ta);
}
}
async function runSync(){
const msg = document.getElementById('msg');
msg.style.display = 'none';
const r = await fetch('/api/sync/run',{method:'POST'});
const data = await r.json();
msg.textContent = r.ok ? '✓ ' + data.message : '✗ ' + data.error;
msg.className = r.ok ? 'ok' : 'err';
setTimeout(()=>{ msg.style.display='none'; }, 5000);
setTimeout(refresh, 800);
}
let timer;
async function loop(){
await refresh();
const s = await fetch('/api/status').then(r=>r.json()).catch(()=>({}));
timer = setTimeout(loop, s.running ? 3000 : 15000);
}
loop();
</script>
</body>
</html>`;
module.exports = { app, startDashboard, registerSyncFn, registerNextRunFn };
+191
View File
@@ -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,
};
+216
View File
@@ -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 };
-20
View File
@@ -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) ==="
-44
View File
@@ -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 };
+97
View File
@@ -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');
});
-89
View File
@@ -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 };