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

Sync Hostinger

+ PG Debian (frps:15432) → PG Hostinger (miroir automatique du schéma) + Inactif + +
+
+
+ + + + + +
+ + + +
Chargement…
+ +
+
Statut des tables
+
+ + + +
TableStratégieDernière syncLignesDuréeStatutDétail / erreur
+
+
+ +
+
Logs
+
Chargement…
+
+
+ + + diff --git a/sync/schema.js b/sync/schema.js new file mode 100644 index 0000000..918d4f7 --- /dev/null +++ b/sync/schema.js @@ -0,0 +1,167 @@ +'use strict'; +/** + * Miroir automatique du schéma source → destination. + * Aucune liste de tables ni schéma écrit à la main : tout est lu dans pg_catalog + * à chaque exécution. On ne supprime jamais rien côté destination. + */ +const log = require('./logger'); +const { + qt, buildCreateTable, buildAddColumn, buildAddPrimaryKey, + indexDefIfNotExists, needsTrgm, +} = require('./sql'); + +const COLUMNS_SQL = ` +SELECT c.relname AS table_name, a.attname AS name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, + a.attnotnull AS notnull +FROM pg_catalog.pg_attribute a +JOIN pg_catalog.pg_class c ON c.oid = a.attrelid +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p') + AND NOT c.relispartition + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY c.relname, a.attnum`; + +const PK_SQL = ` +SELECT c.relname AS table_name, + array_agg(a.attname::text ORDER BY k.ord) AS pk +FROM pg_catalog.pg_constraint con +JOIN pg_catalog.pg_class c ON c.oid = con.conrelid +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) +JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum +WHERE n.nspname = 'public' AND con.contype = 'p' +GROUP BY c.relname`; + +// Index hors clé primaire (les index des contraintes UNIQUE deviennent des index uniques) +const INDEXES_SQL = ` +SELECT i.tablename AS table_name, i.indexname AS name, i.indexdef AS def +FROM pg_catalog.pg_indexes i +JOIN pg_catalog.pg_namespace n ON n.nspname = i.schemaname +JOIN pg_catalog.pg_class ic ON ic.relname = i.indexname AND ic.relnamespace = n.oid +JOIN pg_catalog.pg_index x ON x.indexrelid = ic.oid +WHERE i.schemaname = 'public' AND NOT x.indisprimary +ORDER BY i.tablename, i.indexname`; + +/** + * Lit le catalogue d'une base : Map table → { name, columns:[{name,type,notnull}], pk:[], indexes:[{name,def}] }. + */ +async function readCatalog(client) { + const tables = new Map(); + const { rows: cols } = await client.query(COLUMNS_SQL); + for (const r of cols) { + if (!tables.has(r.table_name)) tables.set(r.table_name, { name: r.table_name, columns: [], pk: [], indexes: [] }); + tables.get(r.table_name).columns.push({ name: r.name, type: r.type, notnull: r.notnull }); + } + const { rows: pks } = await client.query(PK_SQL); + for (const r of pks) if (tables.has(r.table_name)) tables.get(r.table_name).pk = r.pk; + const { rows: idx } = await client.query(INDEXES_SQL); + for (const r of idx) if (tables.has(r.table_name)) tables.get(r.table_name).indexes.push({ name: r.name, def: r.def }); + return tables; +} + +/** + * Crée la table destination si absente, ajoute les colonnes manquantes, + * signale (sans modifier) les colonnes de type différent. + * Retourne la liste des avertissements. + */ +async function mirrorTable(dst, srcTable, dstName, dstCatalog) { + const warnings = []; + const existing = dstCatalog.get(dstName); + if (!existing) { + await dst.query(buildCreateTable(dstName, srcTable.columns, srcTable.pk)); + log.info(`[${dstName}] table créée (${srcTable.columns.length} colonnes)`); + dstCatalog.set(dstName, { + name: dstName, + columns: srcTable.columns.map(c => ({ ...c })), + pk: [...srcTable.pk], + indexes: [], + }); + return warnings; + } + const dstCols = new Map(existing.columns.map(c => [c.name, c])); + for (const col of srcTable.columns) { + const d = dstCols.get(col.name); + if (!d) { + await dst.query(buildAddColumn(dstName, col)); + existing.columns.push({ name: col.name, type: col.type, notnull: false }); + log.info(`[${dstName}] colonne ajoutée : ${col.name} ${col.type}`); + } else if (d.type !== col.type) { + const w = `type différent pour ${col.name} : source ${col.type} / destination ${d.type} (non modifié)`; + warnings.push(w); + log.warn(`[${dstName}] ${w}`); + } + } + return warnings; +} + +/** Tente CREATE EXTENSION pg_trgm si un index source en a besoin. Retourne true si disponible. */ +async function ensureTrgm(dst, srcCatalog) { + const needed = [...srcCatalog.values()].some(t => t.indexes.some(i => needsTrgm(i.def))); + if (!needed) return true; + try { + await dst.query('CREATE EXTENSION IF NOT EXISTS pg_trgm'); + return true; + } catch (err) { + log.warn(`pg_trgm indisponible sur la destination (${err.message}) — index trigrammes ignorés`); + return false; + } +} + +/** + * Après la copie des données : crée les index manquants (même nom, IF NOT EXISTS) + * et la clé primaire si la destination n'en a pas. Les échecs sont des avertissements. + * Les index ne sont reproduits que si le nom de table est identique (pas pour source_sync_log). + */ +async function mirrorIndexes(dst, srcTable, dstName, dstCatalog, trgmOk) { + const warnings = []; + const existing = dstCatalog.get(dstName); + if (srcTable.pk.length && existing && !existing.pk.length) { + try { + await dst.query(buildAddPrimaryKey(dstName, srcTable.pk)); + existing.pk = [...srcTable.pk]; + log.info(`[${dstName}] clé primaire ajoutée (${srcTable.pk.join(', ')})`); + } catch (err) { + const w = `clé primaire (${srcTable.pk.join(', ')}) non ajoutée : ${err.message}`; + warnings.push(w); + log.warn(`[${dstName}] ${w}`); + } + } + if (dstName !== srcTable.name) return warnings; + const have = new Set((existing?.indexes || []).map(i => i.name)); + for (const idx of srcTable.indexes) { + if (have.has(idx.name)) continue; + if (!trgmOk && needsTrgm(idx.def)) { + warnings.push(`index ${idx.name} ignoré (pg_trgm indisponible)`); + continue; + } + try { + await dst.query(indexDefIfNotExists(idx.def)); + existing?.indexes.push({ name: idx.name, def: idx.def }); + log.info(`[${dstName}] index ${idx.name} OK`); + } catch (err) { + const w = `index ${idx.name} non créé : ${err.message}`; + warnings.push(w); + log.warn(`[${dstName}] ${w}`); + } + } + return warnings; +} + +/** Table de suivi côté destination (mêmes colonnes que la source + colonnes de détail). */ +async function ensureDstSyncLog(dst) { + await dst.query(`CREATE TABLE IF NOT EXISTS ${qt('sync_log')} ( + table_name TEXT PRIMARY KEY, + last_sync TIMESTAMP, + rows_synced INTEGER, + status TEXT, + error_msg TEXT + )`); + for (const [name, type] of [['strategy', 'TEXT'], ['duration_ms', 'INTEGER'], ['detail', 'TEXT']]) { + await dst.query(`ALTER TABLE ${qt('sync_log')} ADD COLUMN IF NOT EXISTS "${name}" ${type}`); + } +} + +module.exports = { readCatalog, mirrorTable, ensureTrgm, mirrorIndexes, ensureDstSyncLog }; diff --git a/sync/server.js b/sync/server.js index 74bdf95..485999b 100644 --- a/sync/server.js +++ b/sync/server.js @@ -1,281 +1,136 @@ 'use strict'; +/** + * Dashboard de la sync Hostinger (port 3002, joint par Nginx Proxy Manager sur nginx_default). + * + * Sécurité : + * - HTTP Basic auth (DASHBOARD_USER / DASHBOARD_PASSWORD), comparaison à temps constant ; + * sans ces variables, tout répond 503 sauf GET /healthz (healthcheck Docker). + * - POST /api/sync/run exige l'en-tête X-Requested-By (bloque les formulaires cross-site / CSRF). + * - Aucune donnée dynamique insérée via innerHTML ; CSP sans script inline. + */ +const crypto = require('node:crypto'); +const path = require('node:path'); const express = require('express'); +const { config } = require('./config'); +const log = require('./logger'); const { getDst } = require('./db'); -const { TABLES } = require('./tables'); +const { state } = require('./sync'); -const app = express(); -const PORT = process.env.SYNC_DASHBOARD_PORT || 3002; +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', 'loopback, uniquelocal'); -const TABLES_ORDER = TABLES.map(t => t.name); -const TABLES_TOTAL = TABLES_ORDER.length; +let runFn = null; +let nextRunFn = () => null; +function registerSyncFn(fn) { runFn = fn; } +function registerNextRunFn(fn) { nextRunFn = fn; } -const state = { - running: false, - progress: null, -}; +app.get('/healthz', (req, res) => res.json({ ok: true })); -const logBuffer = []; -function pushLog(msg, level = 'info') { - logBuffer.push({ ts: new Date().toISOString(), msg, level }); - if (logBuffer.length > 300) logBuffer.shift(); +const sha = s => crypto.createHash('sha256').update(String(s)).digest(); +function safeEqual(a, b) { + return crypto.timingSafeEqual(sha(a), sha(b)); } -const _log = console.log.bind(console); -const _err = console.error.bind(console); -console.log = (...a) => { pushLog(a.join(' '), 'info'); _log(...a); }; -console.error = (...a) => { pushLog(a.join(' '), 'error'); _err(...a); }; - -function setProgress(tableName, index) { - state.progress = { - current: tableName, - index, - total: TABLES_TOTAL, - pct: Math.round((index / TABLES_TOTAL) * 100), - startedAt: state.progress?.startedAt || new Date().toISOString(), - }; +function basicAuth(req, res, next) { + if (!config.dashboardUser || !config.dashboardPass) { + return res.status(503).type('text/plain; charset=utf-8') + .send('dashboard désactivé: définir DASHBOARD_USER/DASHBOARD_PASSWORD'); + } + const header = req.get('authorization') || ''; + const m = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(header); + let user = ''; + let pass = ''; + if (m) { + const decoded = Buffer.from(m[1], 'base64').toString('utf8'); + const i = decoded.indexOf(':'); + if (i >= 0) { user = decoded.slice(0, i); pass = decoded.slice(i + 1); } + } + // Les deux comparaisons sont toujours évaluées (pas de court-circuit) + const okUser = safeEqual(user, config.dashboardUser); + const okPass = safeEqual(pass, config.dashboardPass); + if (m && okUser && okPass) return next(); + if (m) log.warn(`Authentification dashboard refusée (ip ${req.ip})`); + res.set('WWW-Authenticate', 'Basic realm="ff_sync_hostinger", charset="UTF-8"'); + return res.status(401).type('text/plain; charset=utf-8').send('Authentification requise'); } -function clearProgress() { state.progress = null; } -let syncAllFn = null; -function registerSyncFn(fn) { syncAllFn = fn; } -function setSyncRunning(val) { state.running = val; } -function isSyncRunning() { return state.running; } - -app.use(express.json()); +app.use(basicAuth); +app.use((req, res, next) => { + res.set({ + 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }); + next(); +}); +app.use(express.json({ limit: '10kb' })); app.get('/api/status', async (req, res) => { + let tables = []; + let dbError = null; try { - const dst = getDst(); - const { rows } = await dst.query( - `SELECT table_name, last_sync, rows_synced, status, error_msg - FROM sync_log ORDER BY table_name` + const { rows } = await getDst().query( + `SELECT table_name, last_sync, rows_synced, status, error_msg, strategy, duration_ms, detail + FROM public.sync_log ORDER BY table_name`, ); - res.json({ running: state.running, progress: state.progress, tables: rows }); + tables = rows; } catch (err) { - res.status(500).json({ error: err.message }); + // 42P01 = sync_log pas encore créée (aucune exécution) + if (err.code !== '42P01') dbError = 'base destination injoignable'; + if (err.code !== '42P01') log.error(`/api/status : ${err.message}`); } + res.json({ + running: state.running, + force: state.force, + phase: state.phase, + progress: state.progress, + lastRun: state.lastRun, + tables, + dbError, + }); }); app.get('/api/logs', (req, res) => { - const limit = parseInt(req.query.limit) || 150; - res.json(logBuffer.slice(-limit)); + res.json(log.getLogs(parseInt(req.query.limit, 10) || 200)); }); app.get('/api/next', (req, res) => { - const now = new Date(); - const next = new Date(now); - next.setHours(5, 30, 0, 0); - if (next <= now) next.setDate(next.getDate() + 1); - res.json({ next: next.toISOString() }); + const next = nextRunFn(); + res.json({ next: next ? next.toISOString() : null, cron: config.cron, timeZone: config.timeZone }); }); -app.post('/api/sync/run', async (req, res) => { +app.post('/api/sync/run', (req, res) => { + if (req.get('x-requested-by') !== 'ff-dashboard') { + return res.status(400).json({ error: 'en-tête X-Requested-By manquant' }); + } if (state.running) return res.status(409).json({ error: 'Sync déjà en cours' }); - if (!syncAllFn) return res.status(500).json({ error: 'syncAll non enregistré' }); - res.json({ message: 'Sync lancée' }); - syncAllFn(true).catch(err => console.error('Erreur sync manuelle:', err.message)); + if (!runFn) return res.status(500).json({ error: 'sync non initialisée' }); + const force = req.body?.force === true; + log.info(`Sync ${force ? 'FORCÉE ' : ''}demandée depuis le dashboard (ip ${req.ip})`); + res.status(202).json({ message: force ? 'Sync forcée lancée' : 'Sync lancée' }); + runFn({ force, trigger: 'dashboard' }).catch(err => log.error(`Sync manuelle : ${err.message}`)); }); -app.get('/', (req, res) => res.send(HTML)); +const PUBLIC_DIR = path.join(__dirname, 'public'); +app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); +app.get('/app.js', (req, res) => res.type('application/javascript').sendFile(path.join(PUBLIC_DIR, 'app.js'))); + +app.use((req, res) => res.status(404).json({ error: 'not found' })); +// eslint-disable-next-line no-unused-vars +app.use((err, req, res, next) => { + log.error(`HTTP ${req.method} ${req.path} : ${err.message}`); + res.status(500).json({ error: 'erreur interne' }); +}); function startDashboard() { - app.listen(PORT, '0.0.0.0', () => { - console.log(`Dashboard sync Hostinger : http://0.0.0.0:${PORT}`); + if (!config.dashboardUser || !config.dashboardPass) { + log.warn('DASHBOARD_USER / DASHBOARD_PASSWORD non définis : dashboard et API désactivés (503)'); + } + return app.listen(config.port, '0.0.0.0', () => { + log.info(`Dashboard sync Hostinger : port ${config.port}`); }); } -module.exports = { startDashboard, registerSyncFn, setSyncRunning, isSyncRunning, setProgress, clearProgress }; - -const HTML = ` - - - - -Sync Hostinger - - - -
-

⚡ Sync Hostinger

- PG Debian → PG Hostinger - Idle - -
-
-
- - - - -
- -
-
- Synchronisation en cours… - 0% -
-
-
-
-
-
- -
-
-
Statut des tables
- - - -
TableDernière syncLignesStatut
Chargement…
-
-
-
- Logs - -
-
Chargement…
-
-
-
- - -`; +module.exports = { app, startDashboard, registerSyncFn, registerNextRunFn }; diff --git a/sync/sql.js b/sync/sql.js new file mode 100644 index 0000000..a8c9f8b --- /dev/null +++ b/sync/sql.js @@ -0,0 +1,191 @@ +'use strict'; +/** + * Fonctions pures de génération SQL (aucun accès base) — testées par test/sql.test.js. + * Toutes les tables sont dans le schéma public, côté source comme côté destination. + */ + +/** Quote un identifiant PostgreSQL ("nom" avec doublement des guillemets). */ +function qi(name) { + return '"' + String(name).replace(/"/g, '""') + '"'; +} + +/** Nom qualifié public."table". */ +function qt(table) { + return 'public.' + qi(table); +} + +/** Littéral SQL texte (échappement des apostrophes). */ +function lit(value) { + return "'" + String(value).replace(/'/g, "''") + "'"; +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Littéral date 'YYYY-MM-DD' strictement validé (utilisé dans les DECLARE CURSOR). */ +function dateLit(value) { + if (!DATE_RE.test(String(value))) throw new Error(`Date invalide : ${value}`); + return lit(value); +} + +/** Liste de colonnes quotées, dans l'ordre fourni. */ +function colList(cols) { + return cols.map(c => qi(c.name ?? c)).join(', '); +} + +/** + * CREATE TABLE IF NOT EXISTS reproduisant colonnes (ordre, type exact, NOT NULL) et clé primaire. + * Pas de DEFAULT (les séquences ne sont pas répliquées) ni de clé étrangère. + * cols : [{ name, type, notnull }], pk : ['col', ...] + */ +function buildCreateTable(table, cols, pk = []) { + const defs = cols.map(c => ` ${qi(c.name)} ${c.type}${c.notnull ? ' NOT NULL' : ''}`); + if (pk.length) defs.push(` PRIMARY KEY (${pk.map(qi).join(', ')})`); + return `CREATE TABLE IF NOT EXISTS ${qt(table)} (\n${defs.join(',\n')}\n)`; +} + +/** + * Ajout d'une colonne manquante. Toujours NULLable : un NOT NULL sans DEFAULT + * échouerait sur une table déjà remplie ; la colonne est remplie par la copie suivante. + */ +function buildAddColumn(table, col) { + return `ALTER TABLE ${qt(table)} ADD COLUMN IF NOT EXISTS ${qi(col.name)} ${col.type}`; +} + +function buildAddPrimaryKey(table, pk) { + return `ALTER TABLE ${qt(table)} ADD PRIMARY KEY (${pk.map(qi).join(', ')})`; +} + +/** + * Transforme un pg_indexes.indexdef ("CREATE [UNIQUE] INDEX nom ON public.t USING ...") + * en version idempotente avec IF NOT EXISTS (même nom d'index). + */ +function indexDefIfNotExists(indexdef) { + const out = String(indexdef).replace(/^CREATE (UNIQUE )?INDEX (?!IF NOT EXISTS )/i, (m, u) => `CREATE ${u || ''}INDEX IF NOT EXISTS `); + if (out === indexdef && !/^CREATE (UNIQUE )?INDEX IF NOT EXISTS /i.test(indexdef)) { + throw new Error(`Définition d'index inattendue : ${indexdef}`); + } + return out; +} + +/** L'index nécessite-t-il l'extension pg_trgm ? */ +function needsTrgm(indexdef) { + return /\b(gin|gist)_trgm_ops\b/i.test(String(indexdef)); +} + +/** + * Requête de signature par mois : nb de lignes + somme des hash de chaque ligne. + * La liste de colonnes est explicite (ordre source) pour que source et destination + * hachent exactement les mêmes colonnes, même si la destination a des colonnes en plus. + * Résultat : m ('YYYY-MM-DD' ou NULL), n (texte), h (texte). + */ +function buildSignatureQuery(table, cols, dateCol, where = null) { + const d = qi(dateCol); + return `SELECT to_char(date_trunc('month', ${d}), 'YYYY-MM-DD') AS m, + count(*)::text AS n, + coalesce(sum(hashtext(ROW(${colList(cols)})::text)::bigint), 0)::text AS h +FROM ${qt(table)}${where ? `\nWHERE ${where}` : ''} +GROUP BY 1`; +} + +const NULL_MONTH = 'NULL'; + +/** Convertit le résultat d'une requête de signature en Map mois → "n:h". */ +function signatureMap(rows) { + const map = new Map(); + for (const r of rows) map.set(r.m ?? NULL_MONTH, `${r.n}:${r.h}`); + return map; +} + +/** Total des lignes d'une Map de signatures. */ +function signatureTotal(map) { + let total = 0; + for (const v of map.values()) total += Number(v.split(':')[0]); + return total; +} + +/** Mois dont la signature diffère, ou présents d'un seul côté (triés, NULL en premier). */ +function diffMonths(srcMap, dstMap) { + const keys = new Set([...srcMap.keys(), ...dstMap.keys()]); + return [...keys].filter(k => srcMap.get(k) !== dstMap.get(k)).sort(); +} + +/** 1er jour du mois suivant ('YYYY-MM-01' → 'YYYY-MM-01'). */ +function nextMonth(month) { + if (!DATE_RE.test(month)) throw new Error(`Mois invalide : ${month}`); + const [y, m] = month.split('-').map(Number); + const ny = m === 12 ? y + 1 : y; + const nm = m === 12 ? 1 : m + 1; + return `${ny}-${String(nm).padStart(2, '0')}-01`; +} + +/** Condition WHERE sélectionnant un mois (ou les dates NULL). */ +function monthCondition(dateCol, month) { + const d = qi(dateCol); + if (month === NULL_MONTH) return `${d} IS NULL`; + return `${d} >= ${dateLit(month)} AND ${d} < ${dateLit(nextMonth(month))}`; +} + +/** Condition WHERE de la fenêtre glissante (date >= cutoff). */ +function recentCondition(dateCol, cutoff) { + return `${qi(dateCol)} >= ${dateLit(cutoff)}`; +} + +/** Taille de lot d'INSERT : limite de 65535 paramètres par requête. */ +function insertChunkSize(ncols) { + return Math.max(1, Math.floor(60000 / ncols)); +} + +/** INSERT multi-lignes paramétré ($1..$n). */ +function buildInsert(table, cols, nrows) { + const n = cols.length; + const tuples = new Array(nrows); + for (let r = 0; r < nrows; r++) { + const ph = new Array(n); + for (let c = 0; c < n; c++) ph[c] = '$' + (r * n + c + 1); + tuples[r] = '(' + ph.join(',') + ')'; + } + return `INSERT INTO ${qt(table)} (${colList(cols)}) VALUES ${tuples.join(',')}`; +} + +/** SELECT des colonnes source (ordre source) avec filtre optionnel. */ +function buildSelect(table, cols, where = null) { + return `SELECT ${colList(cols)} FROM ${qt(table)}${where ? ` WHERE ${where}` : ''}`; +} + +/** "a,b , c" → ['a','b','c'] (espaces retirés, éléments vides ignorés). */ +function parseList(value) { + return String(value || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} + +/** "mvtart:datmvt,meteo:date" → { mvtart: 'datmvt', meteo: 'date' }. */ +function parseWindowTables(value) { + const out = {}; + for (const item of parseList(value)) { + const [table, col] = item.split(':').map(s => (s || '').trim()); + if (!table || !col) throw new Error(`WINDOW_TABLES invalide : "${item}" (attendu table:colonne)`); + out[table] = col; + } + return out; +} + +/** Date 'YYYY-MM-DD' (fuseau donné) de "aujourd'hui - days". */ +function cutoffDate(days, timeZone = 'Europe/Paris', now = new Date()) { + const fmt = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }); + const today = fmt.format(now); // en-CA → YYYY-MM-DD + const [y, m, d] = today.split('-').map(Number); + const t = new Date(Date.UTC(y, m - 1, d - days)); + return t.toISOString().slice(0, 10); +} + +module.exports = { + qi, qt, lit, dateLit, colList, + buildCreateTable, buildAddColumn, buildAddPrimaryKey, + indexDefIfNotExists, needsTrgm, + buildSignatureQuery, signatureMap, signatureTotal, diffMonths, NULL_MONTH, + nextMonth, monthCondition, recentCondition, + insertChunkSize, buildInsert, buildSelect, + parseList, parseWindowTables, cutoffDate, +}; diff --git a/sync/sync.js b/sync/sync.js new file mode 100644 index 0000000..6780239 --- /dev/null +++ b/sync/sync.js @@ -0,0 +1,216 @@ +'use strict'; +/** + * Orchestration d'une exécution : verrou, attente de la sync Debian, miroir du schéma, + * copie table par table, journalisation dans sync_log (destination), alerte. + */ +const { config } = require('./config'); +const log = require('./logger'); +const { getSrc, getDst } = require('./db'); +const { readCatalog, mirrorTable, ensureTrgm, mirrorIndexes, ensureDstSyncLog } = require('./schema'); +const { copyFull, copyWindow } = require('./copy'); +const { sendAlert } = require('./alert'); +const { qt } = require('./sql'); + +const SOURCE_SYNC_LOG = 'sync_log'; // table source traitée à part +const DST_SOURCE_SYNC_LOG = 'source_sync_log'; + +// État partagé avec le dashboard +const state = { + running: false, + force: false, + phase: null, // 'verrou' | 'attente Debian' | 'schéma' | 'copie' | null + progress: null, // { current, index, total, startedAt } + lastRun: null, // { startedAt, finishedAt, force, status, tables: [...] } +}; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function writeLog(dst, entry) { + await dst.query( + `INSERT INTO ${qt('sync_log')} (table_name, last_sync, rows_synced, status, error_msg, strategy, duration_ms, detail) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (table_name) DO UPDATE SET + last_sync = EXCLUDED.last_sync, rows_synced = EXCLUDED.rows_synced, status = EXCLUDED.status, + error_msg = EXCLUDED.error_msg, strategy = EXCLUDED.strategy, + duration_ms = EXCLUDED.duration_ms, detail = EXCLUDED.detail`, + // Date JS : stockée correctement en TIMESTAMP (heure locale du conteneur, TZ=Europe/Paris) comme en TIMESTAMPTZ + [entry.table, new Date(), Math.min(entry.rows ?? 0, 2147483647), entry.status, entry.error ?? null, + entry.strategy ?? null, entry.durationMs ?? null, entry.detail ?? null], + ); +} + +/** + * Attend que la sync Debian soit terminée (ligne sync_log '_run' ≠ 'running'). + * Retourne 'ok' | 'absent' | 'timeout'. + */ +async function waitForDebian() { + const src = getSrc(); + const deadline = Date.now() + config.waitMaxMs; + let first = true; + for (;;) { + let status; + try { + const { rows } = await src.query(`SELECT status FROM ${qt(SOURCE_SYNC_LOG)} WHERE table_name = '_run'`); + if (!rows.length) return 'absent'; + status = rows[0].status; + } catch (err) { + if (err.code === '42P01') return 'absent'; // sync_log inexistante côté source + throw err; + } + if (status !== 'running') return 'ok'; + if (Date.now() >= deadline) return 'timeout'; + if (first) log.info(`Sync Debian en cours — attente (contrôle toutes les ${config.waitPollMs / 60000} min, max ${config.waitMaxMs / 60000} min)`); + first = false; + await sleep(Math.min(config.waitPollMs, Math.max(1000, deadline - Date.now()))); + } +} + +/** Liste ordonnée des copies à effectuer : [{ src, dstName, strategy, dateCol }]. */ +function planTables(srcCatalog) { + const plan = []; + for (const name of [...srcCatalog.keys()].sort()) { + if (name === SOURCE_SYNC_LOG || config.excludeTables.has(name)) continue; + const t = srcCatalog.get(name); + const dateCol = config.windowTables[name]; + if (dateCol && t.columns.some(c => c.name === dateCol)) { + plan.push({ src: t, dstName: name, strategy: 'window', dateCol }); + } else { + if (dateCol) log.warn(`[${name}] colonne ${dateCol} absente de la source — copie complète`); + plan.push({ src: t, dstName: name, strategy: 'full' }); + } + } + if (srcCatalog.has(SOURCE_SYNC_LOG) && !config.excludeTables.has(SOURCE_SYNC_LOG)) { + plan.push({ src: srcCatalog.get(SOURCE_SYNC_LOG), dstName: DST_SOURCE_SYNC_LOG, strategy: 'full' }); + } + return plan; +} + +async function syncOne(item, ctx) { + const { src: srcTable, dstName, strategy, dateCol } = item; + const started = Date.now(); + const entry = { table: dstName, strategy, rows: 0, status: 'ok', error: null, detail: null }; + const srcClient = await getSrc().connect(); + const dstClient = await getDst().connect(); + try { + const warnings = await mirrorTable(dstClient, srcTable, dstName, ctx.dstCatalog); + // Instantané cohérent de la source pour toute la table (comptage, signatures, curseurs) + await srcClient.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); + try { + const r = strategy === 'window' + ? await copyWindow(srcClient, dstClient, srcTable.name, srcTable.columns, dateCol, ctx.force) + : await copyFull(srcClient, dstClient, srcTable.name, dstName, srcTable.columns, ctx.force); + entry.rows = r.rows; + entry.detail = r.detail; + } finally { + await srcClient.query('COMMIT').catch(() => {}); + } + warnings.push(...await mirrorIndexes(dstClient, srcTable, dstName, ctx.dstCatalog, ctx.trgmOk)); + if (warnings.length) entry.detail = `${entry.detail} | ${warnings.length} avertissement(s) : ${warnings.join(' ; ')}`; + log.info(`[${dstName}] OK — ${entry.detail}`); + } catch (err) { + entry.status = 'error'; + entry.error = err.message; + log.error(`[${dstName}] ERREUR : ${err.message}`); + } finally { + srcClient.release(); + dstClient.release(); + } + entry.durationMs = Date.now() - started; + await writeLog(getDst(), entry).catch(e => log.error(`[${dstName}] écriture sync_log impossible : ${e.message}`)); + return entry; +} + +/** + * Lance une synchronisation complète. + * force = ignorer le garde-fou volume + rechargement complet des tables "fenêtre". + * Retourne { skipped } | { status, tables }. + */ +async function runSync({ force = false, trigger = 'manuel' } = {}) { + if (state.running) { + log.warn('Sync déjà en cours dans ce process — demande ignorée'); + return { skipped: 'running' }; + } + state.running = true; + state.force = force; + state.phase = 'verrou'; + state.progress = null; + const startedAt = new Date(); + const results = []; + let lockClient = null; + let locked = false; + let fatal = null; + + try { + lockClient = await getDst().connect(); + const { rows } = await lockClient.query('SELECT pg_try_advisory_lock($1::bigint) AS ok', [config.lockKey]); + locked = rows[0].ok; + if (!locked) { + log.warn('Une autre synchronisation détient le verrou (autre process) — exécution ignorée'); + return { skipped: 'locked' }; + } + log.info(`=== SYNC START (${trigger}${force ? ', FORCÉE' : ''}) ===`); + await ensureDstSyncLog(lockClient); + await writeLog(lockClient, { table: '_run', status: 'running', strategy: force ? 'force' : trigger, detail: `démarrée ${startedAt.toISOString()}` }); + + state.phase = 'attente Debian'; + const wait = await waitForDebian(); + if (wait === 'timeout') { + const msg = `Sync Debian toujours "running" après ${config.waitMaxMs / 60000} min — copie lancée quand même`; + log.error(msg); + await sendAlert(`[ff_sync_hostinger] ${msg}`); + } + + state.phase = 'schéma'; + const srcCatalog = await readCatalog(getSrc()); + const dstCatalog = await readCatalog(lockClient); + const trgmOk = await ensureTrgm(lockClient, srcCatalog); + const plan = planTables(srcCatalog); + log.info(`${plan.length} tables à synchroniser (${plan.filter(p => p.strategy === 'window').length} par fenêtre)`); + + state.phase = 'copie'; + const ctx = { force, dstCatalog, trgmOk }; + for (let i = 0; i < plan.length; i++) { + state.progress = { current: plan[i].dstName, index: i + 1, total: plan.length, startedAt: startedAt.toISOString() }; + results.push(await syncOne(plan[i], ctx)); + } + } catch (err) { + fatal = err; + log.error(`ERREUR FATALE : ${err.message}`); + } finally { + const failed = results.filter(r => r.status !== 'ok'); + if (locked) { + const status = fatal || failed.length ? 'error' : 'ok'; + const detail = fatal + ? `erreur fatale : ${fatal.message}` + : `${results.length} tables, ${failed.length} en erreur`; + await writeLog(lockClient, { + table: '_run', status, strategy: force ? 'force' : trigger, + rows: results.reduce((s, r) => s + (r.rows || 0), 0), + error: fatal ? fatal.message : failed.length ? failed.map(f => f.table).join(', ') : null, + durationMs: Date.now() - startedAt.getTime(), detail, + }).catch(e => log.error(`écriture _run impossible : ${e.message}`)); + await lockClient.query('SELECT pg_advisory_unlock($1::bigint)', [config.lockKey]).catch(() => {}); + if (fatal || failed.length) { + const lines = failed.slice(0, 15).map(f => `• ${f.table} : ${f.error}`); + await sendAlert( + `[ff_sync_hostinger] Sync ${fatal ? 'en échec' : 'terminée avec erreurs'} — ${detail}` + + (lines.length ? `\n${lines.join('\n')}` : ''), + ); + } + log.info(`=== SYNC FIN en ${((Date.now() - startedAt.getTime()) / 1000).toFixed(1)} s — ${detail} ===`); + state.lastRun = { + startedAt: startedAt.toISOString(), finishedAt: new Date().toISOString(), + force, trigger, status: fatal || failed.length ? 'error' : 'ok', tables: results, + }; + } else if (fatal) { + await sendAlert(`[ff_sync_hostinger] Sync impossible : ${fatal.message}`); + } + if (lockClient) lockClient.release(); + state.running = false; + state.phase = null; + state.progress = null; + } + return { status: fatal || results.some(r => r.status !== 'ok') ? 'error' : 'ok', tables: results }; +} + +module.exports = { runSync, state, planTables, waitForDebian }; diff --git a/sync/sync.sh b/sync/sync.sh deleted file mode 100644 index 6eecfea..0000000 --- a/sync/sync.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh -# Sync PostgreSQL Debian → PostgreSQL Hostinger -# Source : frps:15432 (tunnel frp vers postgres Debian) -# Destination : postgres_db (container existant sur Hostinger) - -set -e - -echo "=== SYNC START $(date) ===" - -PGPASSWORD=$SRC_PASS pg_dump \ - -h "$SRC_HOST" -p "$SRC_PORT" \ - -U "$SRC_USER" -d "$SRC_DB" \ - --data-only --if-exists --clean \ - --no-acl --no-owner \ -| PGPASSWORD=$DST_PASS psql \ - -h "$DST_HOST" -p "$DST_PORT" \ - -U "$DST_USER" -d "$DST_DB" \ - --set ON_ERROR_STOP=0 - -echo "=== SYNC DONE $(date) ===" diff --git a/sync/tables.js b/sync/tables.js deleted file mode 100644 index 275126c..0000000 --- a/sync/tables.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; -// Définition des tables à synchroniser PG Debian → PG Hostinger -// pk: [] = full refresh (TRUNCATE + INSERT), sinon ON CONFLICT DO UPDATE - -const TABLES = [ - // Référentiel - { name: 'nomenclature', pk: ['no_id'] }, - { name: 'gammes', pk: ['no_id'] }, - { name: 'saisons', pk: ['no_id'] }, - // Articles - { name: 'articles', pk: ['no_id'] }, - { name: 'article_infosup', pk: ['artnoid'] }, - { name: 'art_gtin', pk: ['idarticle', 'gtin'] }, - { name: 'art_gamme_saison', pk: ['artnoid', 'idgamme', 'idsaison'] }, - // Fournisseurs - { name: 'fouadr1', pk: ['code', 'sit_code'] }, - { name: 'artfou1', pk: ['no_id'] }, - { name: 'artfou2', pk: ['idartfou1'] }, - // Stock & prix - { name: 'cube_pa', pk: ['artnoid'] }, - { name: 'cube_pv', pk: ['artnoid', 'site'] }, - { name: 'cube_stock', pk: ['artnoid', 'site'] }, - // Mouvements (sans PK → full refresh) - { name: 'mvtart', pk: [] }, - { name: 'mvtreg', pk: [] }, - // Ranking - { name: 'ranking', pk: ['gencod', 'site'] }, - // CA journalier + trafic - { name: 'statopca', pk: ['site', 'datmvt'] }, - // Gamme conseillée par site - { name: 'stat_dispoperm', pk: ['codesite', 'code_art'] }, - // Journal de commande conseillé - { name: 'phenix_quantite_conseille', pk: ['ident'] }, - // Commandes - { name: 'cdefou_reception', pk: ['no_id'] }, - { name: 'cdefou_vivant', pk: ['cdefou_ligne_com_no_id', 'artfou1_no_id'] }, - { name: 'cdefou_receplig', pk: ['no_id'] }, - // Publicités - { name: 'pub_entetes', pk: ['ent_npub'] }, - { name: 'pub_ecoulement', pk: ['site', 'tcr_code'] }, - { name: 'pub_ecoulement_detail', pk: ['site', 'tcr_code', 'artnoid'] }, -]; - -module.exports = { TABLES }; diff --git a/sync/test/sql.test.js b/sync/test/sql.test.js new file mode 100644 index 0000000..0d2e214 --- /dev/null +++ b/sync/test/sql.test.js @@ -0,0 +1,97 @@ +'use strict'; +// Tests des fonctions pures de génération SQL : `npm test` (node --test, sans base de données). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const s = require('../sql'); + +test('qi échappe les guillemets', () => { + assert.equal(s.qi('mvtart'), '"mvtart"'); + assert.equal(s.qi('a"b'), '"a""b"'); +}); + +test('buildCreateTable : colonnes ordonnées, NOT NULL, PK', () => { + const sql = s.buildCreateTable('mvtart', [ + { name: 'no_id', type: 'bigint', notnull: true }, + { name: 'datmvt', type: 'timestamp without time zone', notnull: false }, + { name: 'qte', type: 'numeric(12,4)', notnull: false }, + ], ['no_id']); + assert.equal(sql, + 'CREATE TABLE IF NOT EXISTS public."mvtart" (\n' + + ' "no_id" bigint NOT NULL,\n' + + ' "datmvt" timestamp without time zone,\n' + + ' "qte" numeric(12,4),\n' + + ' PRIMARY KEY ("no_id")\n)'); + assert.ok(!s.buildCreateTable('t', [{ name: 'a', type: 'text' }], []).includes('PRIMARY KEY')); +}); + +test('buildAddColumn est idempotent et nullable', () => { + assert.equal(s.buildAddColumn('articles', { name: 'artcentrale', type: 'text', notnull: true }), + 'ALTER TABLE public."articles" ADD COLUMN IF NOT EXISTS "artcentrale" text'); +}); + +test('indexDefIfNotExists', () => { + assert.equal(s.indexDefIfNotExists('CREATE INDEX idx_a ON public.articles USING btree (codein)'), + 'CREATE INDEX IF NOT EXISTS idx_a ON public.articles USING btree (codein)'); + assert.equal(s.indexDefIfNotExists('CREATE UNIQUE INDEX u ON public.t USING btree (a, b)'), + 'CREATE UNIQUE INDEX IF NOT EXISTS u ON public.t USING btree (a, b)'); + assert.equal(s.indexDefIfNotExists('CREATE INDEX IF NOT EXISTS x ON public.t (a)'), + 'CREATE INDEX IF NOT EXISTS x ON public.t (a)'); + assert.throws(() => s.indexDefIfNotExists('DROP TABLE x')); +}); + +test('needsTrgm', () => { + assert.ok(s.needsTrgm('CREATE INDEX i ON public.a USING gin (libelle1 gin_trgm_ops)')); + assert.ok(!s.needsTrgm('CREATE INDEX i ON public.a USING btree (libelle1)')); +}); + +test('buildSignatureQuery : colonnes explicites dans l\'ordre source', () => { + const sql = s.buildSignatureQuery('mvtart', [{ name: 'no_id' }, { name: 'datmvt' }], 'datmvt'); + assert.match(sql, /date_trunc\('month', "datmvt"\)/); + assert.match(sql, /hashtext\(ROW\("no_id", "datmvt"\)::text\)::bigint/); + assert.match(sql, /FROM public\."mvtart"\nGROUP BY 1$/); +}); + +test('signatures : diff, total, mois NULL', () => { + const src = s.signatureMap([ + { m: '2026-08-01', n: '10', h: '123' }, + { m: '2026-09-01', n: '5', h: '7' }, + { m: null, n: '1', h: '9' }, + ]); + const dst = s.signatureMap([ + { m: '2026-08-01', n: '10', h: '123' }, + { m: '2026-09-01', n: '5', h: '8' }, + { m: '2026-07-01', n: '3', h: '1' }, + ]); + assert.deepEqual(s.diffMonths(src, dst), ['2026-07-01', '2026-09-01', s.NULL_MONTH]); + assert.equal(s.signatureTotal(src), 16); +}); + +test('monthCondition / nextMonth / recentCondition', () => { + assert.equal(s.nextMonth('2026-12-01'), '2027-01-01'); + assert.equal(s.nextMonth('2026-02-01'), '2026-03-01'); + assert.equal(s.monthCondition('datmvt', '2026-12-01'), `"datmvt" >= '2026-12-01' AND "datmvt" < '2027-01-01'`); + assert.equal(s.monthCondition('datmvt', s.NULL_MONTH), '"datmvt" IS NULL'); + assert.equal(s.recentCondition('date', '2026-10-07'), `"date" >= '2026-10-07'`); + assert.throws(() => s.monthCondition('d', "2026-01-01'; DROP TABLE x; --")); +}); + +test('buildInsert / insertChunkSize', () => { + assert.equal(s.buildInsert('t', [{ name: 'a' }, { name: 'b' }], 2), + 'INSERT INTO public."t" ("a", "b") VALUES ($1,$2),($3,$4)'); + assert.equal(s.insertChunkSize(30), 2000); + assert.equal(s.insertChunkSize(100000), 1); + assert.ok(s.insertChunkSize(7) * 7 <= 65535); +}); + +test('parseWindowTables / parseList', () => { + assert.deepEqual(s.parseWindowTables('mvtart:datmvt, meteo:date'), { mvtart: 'datmvt', meteo: 'date' }); + assert.throws(() => s.parseWindowTables('mvtart')); + assert.deepEqual(s.parseList(' a, ,b '), ['a', 'b']); + assert.deepEqual(s.parseList(undefined), []); +}); + +test('cutoffDate en Europe/Paris', () => { + // 17/10/2026 23:30 UTC = 18/10/2026 01:30 à Paris + assert.equal(s.cutoffDate(10, 'Europe/Paris', new Date('2026-10-17T23:30:00Z')), '2026-10-08'); + assert.equal(s.cutoffDate(0, 'Europe/Paris', new Date('2026-03-01T12:00:00Z')), '2026-03-01'); +}); diff --git a/sync/utils.js b/sync/utils.js deleted file mode 100644 index 8f457c9..0000000 --- a/sync/utils.js +++ /dev/null @@ -1,89 +0,0 @@ -'use strict'; - -/** - * Upsert en masse dans PG destination (chunks de 500 lignes). - * Les colonnes sont déduites dynamiquement des lignes source. - */ -async function batchUpsert(pg, table, rows, pk) { - if (!rows.length) return 0; - const cols = Object.keys(rows[0]); - const CHUNK = 500; - let total = 0; - - for (let i = 0; i < rows.length; i += CHUNK) { - const chunk = rows.slice(i, i + CHUNK); - const values = []; - const placeholders = chunk.map((row, ri) => { - const ph = cols.map((col, ci) => { - values.push(row[col] ?? null); - return `$${ri * cols.length + ci + 1}`; - }); - return `(${ph.join(', ')})`; - }); - const updateCols = cols.filter(c => !pk.includes(c)); - const updateSet = updateCols.length - ? updateCols.map(c => `${c} = EXCLUDED.${c}`).join(', ') - : `${pk[0]} = EXCLUDED.${pk[0]}`; // fallback si toutes les cols sont PK - - const sql = ` - INSERT INTO ${table} (${cols.join(', ')}) - VALUES ${placeholders.join(', ')} - ON CONFLICT (${pk.join(', ')}) DO UPDATE SET ${updateSet} - `; - await pg.query(sql, values); - total += chunk.length; - } - return total; -} - -/** - * Full refresh : TRUNCATE puis INSERT en masse. - */ -async function fullRefresh(pg, table, rows) { - await pg.query(`TRUNCATE TABLE ${table} CASCADE`); - if (!rows.length) return 0; - - const cols = Object.keys(rows[0]); - const CHUNK = 500; - let total = 0; - - for (let i = 0; i < rows.length; i += CHUNK) { - const chunk = rows.slice(i, i + CHUNK); - const values = []; - const placeholders = chunk.map((row, ri) => { - const ph = cols.map((col, ci) => { - values.push(row[col] ?? null); - return `$${ri * cols.length + ci + 1}`; - }); - return `(${ph.join(', ')})`; - }); - await pg.query( - `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`, - values - ); - total += chunk.length; - } - return total; -} - -async function logSync(pg, tableName, rowsSynced, status, errorMsg = null) { - await pg.query(` - INSERT INTO sync_log (table_name, last_sync, rows_synced, status, error_msg) - VALUES ($1, NOW(), $2, $3, $4) - ON CONFLICT (table_name) DO UPDATE SET - last_sync = NOW(), - rows_synced = $2, - status = $3, - error_msg = $4 - `, [tableName, rowsSynced, status, errorMsg]); -} - -async function getLastSync(pg, tableName) { - const res = await pg.query( - `SELECT last_sync FROM sync_log WHERE table_name = $1 AND status = 'ok'`, - [tableName] - ); - return res.rows[0]?.last_sync || null; -} - -module.exports = { batchUpsert, fullRefresh, logSync, getLastSync };