mirror of
https://github.com/R0m1k3/Apiflow-gateway.git
synced 2026-10-17 20:26:46 +02:00
- Schéma Hostinger recopié automatiquement depuis PG Debian (colonnes, PK, index) : plus d'init.sql maintenu à la main (corrige articles.artcentrale, mvtart.no_id et les 12 tables manquantes) - Stratégies : full transactionnel (DELETE + INSERT, garde-fou de volume) et window (signature mensuelle + 10 derniers jours) pour les grosses tables - Attente de la fin de la sync Debian (_run), pg_try_advisory_lock, cron 07:30 Europe/Paris, force honoré, alertes webhook - Dashboard : basic auth, CSP sans script inline, plus d'innerHTML, port 3002 non publié (accès via Nginx Proxy Manager) - Secrets hors du compose (.env), frps épinglé 0.71.0 avec TLS forcé, allowPorts et additionalScopes - Suppression de sync.sh, tables.js, utils.js, init.sql ; README de déploiement L'ancien mot de passe de la base `post` figure dans l'historique git : il doit être changé sur le serveur (voir README). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.8 KiB
JavaScript
151 lines
5.8 KiB
JavaScript
'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 };
|