Files
Apiflow-gateway/sync/sync.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

217 lines
9.2 KiB
JavaScript

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