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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:41:29 +02:00

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 };