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

168 lines
6.5 KiB
JavaScript

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