Add dev-prod harmonize tool for bidirectional catalog and image sync.
Schema migrates dev to prod only; catalog rows merge by updated_at and images by mtime with union merge and conflict reporting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
81ebad2120
commit
f247b418d8
21 files changed
+1336
-4
No files matched your search
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Backfill catalog updated_at from linked image file mtimes where possible.
|
||||
*
|
||||
* Dev: npm run dev:backfill-updated-at
|
||||
* Prod: npm run harmonize:backfill-updated-at
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pg = require('pg');
|
||||
const {
|
||||
assertDevDatabase,
|
||||
assertProdDatabase,
|
||||
loadDevPgConfig,
|
||||
loadProdPgConfig,
|
||||
rootDir,
|
||||
} = require('./db-env');
|
||||
const { printCliResult } = require('./lib/cli-result');
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
function parseArgs(argv) {
|
||||
return { prod: argv.includes('--prod') };
|
||||
}
|
||||
|
||||
function resolveImageDir(prod) {
|
||||
if (prod) {
|
||||
const envPath = path.join(rootDir, 'infra', 'docker', '.env.prod');
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('IMAGE_DIR=')) {
|
||||
const val = trimmed.slice('IMAGE_DIR='.length).trim().replace(/^["']|["']$/g, '');
|
||||
if (val) return path.resolve(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return path.resolve('\\\\192.168.10.122\\Gallery\\data\\images');
|
||||
}
|
||||
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
||||
return path.resolve(process.env.IMAGE_DIR || path.join(rootDir, 'data', 'images'));
|
||||
}
|
||||
|
||||
function fileMtimeMs(imageDir, relPath) {
|
||||
if (!relPath || typeof relPath !== 'string') return null;
|
||||
const abs = path.join(imageDir, relPath.replace(/^\//, ''));
|
||||
try {
|
||||
if (!fs.existsSync(abs)) return null;
|
||||
return fs.statSync(abs).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function maxMtime(imageDir, paths) {
|
||||
let max = null;
|
||||
for (const rel of paths) {
|
||||
const ms = fileMtimeMs(imageDir, rel);
|
||||
if (ms != null && (max == null || ms > max)) max = ms;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { prod } = parseArgs(process.argv.slice(2));
|
||||
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
|
||||
if (prod) assertProdDatabase(config.database);
|
||||
else assertDevDatabase(config.database);
|
||||
|
||||
const imageDir = resolveImageDir(prod);
|
||||
const client = new Client(config);
|
||||
await client.connect();
|
||||
|
||||
const fallback = new Date();
|
||||
let updated = 0;
|
||||
|
||||
const { rows: artists } = await client.query(
|
||||
'SELECT id, portrait_path, portrait_thumb_path FROM artists',
|
||||
);
|
||||
for (const row of artists) {
|
||||
const ms = maxMtime(imageDir, [row.portrait_path, row.portrait_thumb_path]);
|
||||
const ts = ms != null ? new Date(ms) : fallback;
|
||||
await client.query('UPDATE artists SET updated_at = $1 WHERE id = $2', [ts, row.id]);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
const { rows: paintings } = await client.query(
|
||||
'SELECT id, image_path, thumbnail_path FROM paintings',
|
||||
);
|
||||
for (const row of paintings) {
|
||||
const ms = maxMtime(imageDir, [row.image_path, row.thumbnail_path]);
|
||||
const ts = ms != null ? new Date(ms) : fallback;
|
||||
await client.query('UPDATE paintings SET updated_at = $1 WHERE id = $2', [ts, row.id]);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
const childUpdates = [
|
||||
['artist_periods', 'artist_id', 'artists'],
|
||||
['painting_influences', 'painting_id', 'paintings'],
|
||||
['painting_influence_sources', 'painting_id', 'paintings'],
|
||||
['painting_annotations', 'painting_id', 'paintings'],
|
||||
];
|
||||
|
||||
for (const [child, fk, parent] of childUpdates) {
|
||||
const res = await client.query(
|
||||
`UPDATE ${child} c
|
||||
SET updated_at = GREATEST(c.updated_at, p.updated_at)
|
||||
FROM ${parent} p
|
||||
WHERE c.${fk} = p.id`,
|
||||
);
|
||||
updated += res.rowCount || 0;
|
||||
}
|
||||
|
||||
for (const table of ['historical_eras', 'art_movements']) {
|
||||
const res = await client.query(
|
||||
`UPDATE ${table} SET updated_at = $1 WHERE updated_at IS NOT NULL`,
|
||||
[fallback],
|
||||
);
|
||||
updated += res.rowCount || 0;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE art_movements m
|
||||
SET updated_at = GREATEST(m.updated_at, e.updated_at)
|
||||
FROM historical_eras e
|
||||
WHERE m.era_id = e.id`,
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE artists a
|
||||
SET updated_at = GREATEST(a.updated_at, m.updated_at)
|
||||
FROM art_movements m
|
||||
WHERE a.movement_id = m.id`,
|
||||
);
|
||||
|
||||
await client.end();
|
||||
|
||||
printCliResult({
|
||||
script: 'backfill-updated-at',
|
||||
ok: true,
|
||||
summary: `Backfilled updated_at on ${config.database} (${updated} row touches).`,
|
||||
details: [`Image dir: ${imageDir}`],
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
printCliResult({
|
||||
script: 'backfill-updated-at',
|
||||
ok: false,
|
||||
summary: err.message,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Bidirectional catalog DB harmonize (dev <-> prod) by updated_at.
|
||||
*
|
||||
* Usage:
|
||||
* npm run harmonize:db
|
||||
* npm run harmonize:db -- --dry-run
|
||||
* npm run harmonize:db -- --prefer=dev
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const pg = require('pg');
|
||||
const {
|
||||
loadDevPgConfig,
|
||||
loadProdPgConfig,
|
||||
confirmProdAction,
|
||||
rootDir,
|
||||
} = require('./db-env');
|
||||
const { printCliResult } = require('./lib/cli-result');
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
const CATALOG_TABLES = [
|
||||
'historical_eras',
|
||||
'art_movements',
|
||||
'artists',
|
||||
'artist_periods',
|
||||
'paintings',
|
||||
'painting_influences',
|
||||
'painting_influence_sources',
|
||||
'painting_annotations',
|
||||
];
|
||||
|
||||
const NATURAL_KEY_FN = {
|
||||
historical_eras: (r) => String(r.name || '').trim().toLowerCase(),
|
||||
art_movements: (r) => String(r.name || '').trim().toLowerCase(),
|
||||
artists: (r) => String(r.name || '').trim().toLowerCase(),
|
||||
artist_periods: (r) => `${r.artist_id}:${String(r.name || '').trim().toLowerCase()}`,
|
||||
paintings: (r) => `${r.artist_id}:${String(r.title || '').trim().toLowerCase()}`,
|
||||
painting_influences: (r) => `${r.painting_id}:${r.influenced_by_painting_id}`,
|
||||
painting_influence_sources: (r) => `${r.painting_id}:${r.source_type}:${r.source_painting_id || 0}:${r.source_artist_id || 0}:${r.source_movement_id || 0}`,
|
||||
painting_annotations: (r) => `${r.painting_id}:${String(r.label || '').trim().toLowerCase()}:${r.sort_order}`,
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const tablesArg = argv.find((a) => a.startsWith('--tables='));
|
||||
const preferArg = argv.find((a) => a.startsWith('--prefer='));
|
||||
return {
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
verbose: argv.includes('--verbose'),
|
||||
tables: tablesArg ? tablesArg.slice('--tables='.length).split(',').map((t) => t.trim()).filter(Boolean) : null,
|
||||
prefer: preferArg ? preferArg.slice('--prefer='.length).trim().toLowerCase() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function stableRowHash(row, columns) {
|
||||
const payload = {};
|
||||
for (const col of columns) {
|
||||
const val = row[col];
|
||||
if (val instanceof Date) payload[col] = val.toISOString();
|
||||
else if (val != null && typeof val === 'object') payload[col] = JSON.stringify(val);
|
||||
else payload[col] = val;
|
||||
}
|
||||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
function compareUpdatedAt(a, b) {
|
||||
const ta = a?.updated_at ? new Date(a.updated_at).getTime() : 0;
|
||||
const tb = b?.updated_at ? new Date(b.updated_at).getTime() : 0;
|
||||
if (ta === tb) return 0;
|
||||
return ta > tb ? 1 : -1;
|
||||
}
|
||||
|
||||
async function getTableColumns(client, tableName) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName],
|
||||
);
|
||||
return rows.map((r) => r.column_name);
|
||||
}
|
||||
|
||||
async function fetchTableRows(client, tableName) {
|
||||
const { rows } = await client.query(`SELECT * FROM ${tableName} ORDER BY id`);
|
||||
const map = new Map();
|
||||
for (const row of rows) map.set(row.id, row);
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildInsertSql(tableName, columns) {
|
||||
const cols = columns.map((c) => `"${c}"`).join(', ');
|
||||
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
||||
return `INSERT INTO ${tableName} (${cols}) OVERRIDING SYSTEM VALUE VALUES (${placeholders})`;
|
||||
}
|
||||
|
||||
function buildUpsertSql(tableName, columns) {
|
||||
const insertCols = columns.map((c) => `"${c}"`).join(', ');
|
||||
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
||||
const updates = columns
|
||||
.filter((c) => c !== 'id')
|
||||
.map((c) => `"${c}" = EXCLUDED."${c}"`)
|
||||
.join(', ');
|
||||
return `INSERT INTO ${tableName} (${insertCols}) OVERRIDING SYSTEM VALUE VALUES (${placeholders})
|
||||
ON CONFLICT (id) DO UPDATE SET ${updates}
|
||||
WHERE ${tableName}.updated_at < EXCLUDED.updated_at`;
|
||||
}
|
||||
|
||||
function rowValues(row, columns) {
|
||||
return columns.map((col) => row[col]);
|
||||
}
|
||||
|
||||
async function insertRow(client, tableName, columns, row, dryRun) {
|
||||
if (dryRun) return;
|
||||
const sql = buildInsertSql(tableName, columns);
|
||||
await client.query(sql, rowValues(row, columns));
|
||||
}
|
||||
|
||||
async function upsertRow(client, tableName, columns, row, dryRun) {
|
||||
if (dryRun) return;
|
||||
const sql = buildUpsertSql(tableName, columns);
|
||||
await client.query(sql, rowValues(row, columns));
|
||||
}
|
||||
|
||||
async function fixSequence(client, tableName) {
|
||||
await client.query(
|
||||
`SELECT setval(pg_get_serial_sequence($1, 'id'), COALESCE((SELECT MAX(id) FROM ${tableName}), 1), true)`,
|
||||
[tableName],
|
||||
);
|
||||
}
|
||||
|
||||
async function harmonizeTable(tableName, devClient, prodClient, options, stats) {
|
||||
const naturalKeyFn = NATURAL_KEY_FN[tableName];
|
||||
const columns = await getTableColumns(devClient, tableName);
|
||||
if (!columns.includes('updated_at')) {
|
||||
throw new Error(`Table ${tableName} missing updated_at — run migrate-sync-timestamps first`);
|
||||
}
|
||||
|
||||
const devRows = await fetchTableRows(devClient, tableName);
|
||||
const prodRows = await fetchTableRows(prodClient, tableName);
|
||||
const allIds = new Set([...devRows.keys(), ...prodRows.keys()]);
|
||||
|
||||
for (const id of allIds) {
|
||||
const devRow = devRows.get(id);
|
||||
const prodRow = prodRows.get(id);
|
||||
|
||||
if (devRow && !prodRow) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'dev→prod insert' });
|
||||
await insertRow(prodClient, tableName, columns, devRow, options.dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prodRow && !devRow) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'prod→dev insert' });
|
||||
await insertRow(devClient, tableName, columns, prodRow, options.dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
const devKey = naturalKeyFn(devRow);
|
||||
const prodKey = naturalKeyFn(prodRow);
|
||||
if (devKey !== prodKey) {
|
||||
stats.conflicts += 1;
|
||||
stats.conflictDetails.push({
|
||||
table: tableName,
|
||||
id,
|
||||
reason: 'id_collision',
|
||||
devKey,
|
||||
prodKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const devHash = stableRowHash(devRow, columns);
|
||||
const prodHash = stableRowHash(prodRow, columns);
|
||||
if (devHash === prodHash) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmp = compareUpdatedAt(devRow, prodRow);
|
||||
if (cmp > 0) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'dev→prod update' });
|
||||
await upsertRow(prodClient, tableName, columns, devRow, options.dryRun);
|
||||
} else if (cmp < 0) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'prod→dev update' });
|
||||
await upsertRow(devClient, tableName, columns, prodRow, options.dryRun);
|
||||
} else if (options.prefer === 'dev') {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'dev→prod update (prefer tie)' });
|
||||
await upsertRow(prodClient, tableName, columns, devRow, options.dryRun);
|
||||
} else if (options.prefer === 'prod') {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ table: tableName, id, action: 'prod→dev update (prefer tie)' });
|
||||
await upsertRow(devClient, tableName, columns, prodRow, options.dryRun);
|
||||
} else {
|
||||
stats.conflicts += 1;
|
||||
stats.conflictDetails.push({
|
||||
table: tableName,
|
||||
id,
|
||||
reason: 'equal_updated_at',
|
||||
devUpdatedAt: devRow.updated_at,
|
||||
prodUpdatedAt: prodRow.updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.dryRun) {
|
||||
await fixSequence(devClient, tableName);
|
||||
await fixSequence(prodClient, tableName);
|
||||
}
|
||||
}
|
||||
|
||||
function timestampSlug(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'_',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const tables = options.tables || CATALOG_TABLES;
|
||||
|
||||
for (const table of tables) {
|
||||
if (!CATALOG_TABLES.includes(table)) {
|
||||
throw new Error(`Unknown catalog table: ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||
await confirmProdAction(
|
||||
'Harmonize will merge catalog rows between gallery_dev and gallery_prod (prod may be modified).',
|
||||
);
|
||||
}
|
||||
|
||||
const devClient = new Client(loadDevPgConfig());
|
||||
const prodClient = new Client(loadProdPgConfig());
|
||||
await devClient.connect();
|
||||
await prodClient.connect();
|
||||
|
||||
const stats = {
|
||||
devToProd: 0,
|
||||
prodToDev: 0,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
actions: [],
|
||||
conflictDetails: [],
|
||||
};
|
||||
|
||||
try {
|
||||
for (const table of tables) {
|
||||
if (options.verbose) console.log(`Harmonizing ${table}…`);
|
||||
await harmonizeTable(table, devClient, prodClient, options, stats);
|
||||
}
|
||||
} finally {
|
||||
await devClient.end();
|
||||
await prodClient.end();
|
||||
}
|
||||
|
||||
const reportDir = path.join(rootDir, 'db', 'SyncReports');
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const reportPath = path.join(reportDir, `harmonize_db_${timestampSlug()}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
dryRun: options.dryRun,
|
||||
prefer: options.prefer,
|
||||
tables,
|
||||
stats: {
|
||||
devToProd: stats.devToProd,
|
||||
prodToDev: stats.prodToDev,
|
||||
skipped: stats.skipped,
|
||||
conflicts: stats.conflicts,
|
||||
},
|
||||
conflicts: stats.conflictDetails,
|
||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 200),
|
||||
}, null, 2));
|
||||
|
||||
const mode = options.dryRun ? ' (dry-run)' : '';
|
||||
printCliResult({
|
||||
script: 'harmonize-db',
|
||||
ok: true,
|
||||
summary: `DB harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}, conflicts ${stats.conflicts}.`,
|
||||
details: [`Report: ${reportPath}`],
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
printCliResult({
|
||||
script: 'harmonize-db',
|
||||
ok: false,
|
||||
summary: err.message,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Bidirectional image harmonize (dev <-> prod) by file mtime.
|
||||
*
|
||||
* Usage:
|
||||
* npm run harmonize:images
|
||||
* npm run harmonize:images -- --dry-run
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { rootDir, confirmProdAction } = require('./db-env');
|
||||
const { loadHarmonizeConfig, prodImagesPath } = require('./lib/harmonize-config');
|
||||
const { printCliResult } = require('./lib/cli-result');
|
||||
|
||||
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
||||
const MTIME_TOLERANCE_MS = 1000;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const configArg = argv.find((a) => a.startsWith('--config='));
|
||||
return {
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
verbose: argv.includes('--verbose'),
|
||||
configPath: configArg ? configArg.slice('--config='.length) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDevImageDir() {
|
||||
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
||||
return path.resolve(process.env.IMAGE_DIR || path.join(rootDir, 'data', 'images'));
|
||||
}
|
||||
|
||||
function walkImageFiles(rootDir, baseRel = '') {
|
||||
const files = [];
|
||||
const abs = path.join(rootDir, baseRel);
|
||||
if (!fs.existsSync(abs)) return files;
|
||||
|
||||
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
const rel = baseRel ? path.join(baseRel, entry.name).replace(/\\/g, '/') : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkImageFiles(rootDir, rel));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectRelativePaths(imageRoot) {
|
||||
const relPaths = new Set();
|
||||
for (const sub of IMAGE_SUBDIRS) {
|
||||
const subRoot = path.join(imageRoot, sub);
|
||||
if (!fs.existsSync(subRoot)) continue;
|
||||
for (const rel of walkImageFiles(subRoot, sub)) {
|
||||
relPaths.add(rel.replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
return relPaths;
|
||||
}
|
||||
|
||||
function statFile(imageRoot, relPath) {
|
||||
const abs = path.join(imageRoot, relPath);
|
||||
try {
|
||||
if (!fs.existsSync(abs)) return null;
|
||||
const st = fs.statSync(abs);
|
||||
if (!st.isFile()) return null;
|
||||
return { size: st.size, mtimeMs: st.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirForFile(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function copyFile(src, dest, dryRun) {
|
||||
if (dryRun) return;
|
||||
ensureDirForFile(dest);
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
function timestampSlug(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'_',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const config = loadHarmonizeConfig(options.configPath);
|
||||
const dryRun = options.dryRun || config.dryRun;
|
||||
|
||||
const devDir = resolveDevImageDir();
|
||||
const prodDir = prodImagesPath(config);
|
||||
|
||||
if (!fs.existsSync(prodDir)) {
|
||||
throw new Error(
|
||||
`Prod image path not reachable: ${prodDir}. Map SMB share or set paths.prodImages in harmonize config.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||
await confirmProdAction(
|
||||
'Harmonize will copy image files between dev and prod (prod files may be overwritten).',
|
||||
);
|
||||
}
|
||||
|
||||
const devPaths = collectRelativePaths(devDir);
|
||||
const prodPaths = collectRelativePaths(prodDir);
|
||||
const allPaths = new Set([...devPaths, ...prodPaths]);
|
||||
|
||||
const stats = {
|
||||
devToProd: 0,
|
||||
prodToDev: 0,
|
||||
skipped: 0,
|
||||
actions: [],
|
||||
};
|
||||
|
||||
for (const rel of allPaths) {
|
||||
const devStat = statFile(devDir, rel);
|
||||
const prodStat = statFile(prodDir, rel);
|
||||
|
||||
if (devStat && !prodStat) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ path: rel, action: 'dev→prod copy' });
|
||||
copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prodStat && !devStat) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ path: rel, action: 'prod→dev copy' });
|
||||
copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!devStat || !prodStat) continue;
|
||||
|
||||
const sameSize = devStat.size === prodStat.size;
|
||||
const mtimeClose = Math.abs(devStat.mtimeMs - prodStat.mtimeMs) <= MTIME_TOLERANCE_MS;
|
||||
if (sameSize && mtimeClose) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (devStat.mtimeMs > prodStat.mtimeMs) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ path: rel, action: 'dev→prod update' });
|
||||
copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun);
|
||||
} else if (prodStat.mtimeMs > devStat.mtimeMs) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ path: rel, action: 'prod→dev update' });
|
||||
copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun);
|
||||
} else {
|
||||
stats.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const reportDir = path.join(rootDir, 'db', 'SyncReports');
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
dryRun,
|
||||
devDir,
|
||||
prodDir,
|
||||
stats: {
|
||||
devToProd: stats.devToProd,
|
||||
prodToDev: stats.prodToDev,
|
||||
skipped: stats.skipped,
|
||||
},
|
||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
||||
}, null, 2));
|
||||
|
||||
const mode = dryRun ? ' (dry-run)' : '';
|
||||
printCliResult({
|
||||
script: 'harmonize-images',
|
||||
ok: true,
|
||||
summary: `Image harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}.`,
|
||||
details: [`Dev: ${devDir}`, `Prod: ${prodDir}`, `Report: ${reportPath}`],
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
printCliResult({
|
||||
script: 'harmonize-images',
|
||||
ok: false,
|
||||
summary: err.message,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Migrate prod schema from dev migrations (schema dev → prod only).
|
||||
*
|
||||
* Usage: npm run harmonize:schema
|
||||
*/
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const { rootDir, loadProdPgConfig, confirmProdAction } = require('./db-env');
|
||||
const { printCliResult } = require('./lib/cli-result');
|
||||
|
||||
async function main() {
|
||||
if (process.env.CONFIRM_PROD !== '1') {
|
||||
await confirmProdAction('Apply dev migrations to gallery_prod schema?');
|
||||
}
|
||||
|
||||
const prodConfig = loadProdPgConfig();
|
||||
const env = {
|
||||
...process.env,
|
||||
DB_HOST: prodConfig.host,
|
||||
DB_PORT: String(prodConfig.port),
|
||||
DB_USER: prodConfig.user,
|
||||
DB_PASSWORD: prodConfig.password,
|
||||
DB_NAME: prodConfig.database,
|
||||
};
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(rootDir, 'server', 'migrate.js')], {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
printCliResult({
|
||||
script: 'harmonize-schema',
|
||||
ok: false,
|
||||
summary: 'Prod schema migration failed.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
printCliResult({
|
||||
script: 'harmonize-schema',
|
||||
ok: true,
|
||||
summary: 'Prod schema migrated from dev migration files (gallery_prod).',
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
printCliResult({
|
||||
script: 'harmonize-schema',
|
||||
ok: false,
|
||||
summary: err.message,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { rootDir } = require('./db-env');
|
||||
|
||||
const DEFAULT_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'harmonize.config.json');
|
||||
const FALLBACK_CONFIG_PATH = path.join(rootDir, 'infra', 'deploy', 'devtoprod.config.json');
|
||||
|
||||
function loadHarmonizeConfig(configPath) {
|
||||
const resolved = configPath
|
||||
? path.resolve(configPath)
|
||||
: (fs.existsSync(DEFAULT_CONFIG_PATH) ? DEFAULT_CONFIG_PATH : FALLBACK_CONFIG_PATH);
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return {
|
||||
configPath: resolved,
|
||||
dryRun: false,
|
||||
prefer: null,
|
||||
schemaChanged: false,
|
||||
steps: {
|
||||
backupDev: true,
|
||||
backupProd: true,
|
||||
schema: false,
|
||||
db: true,
|
||||
images: true,
|
||||
verify: false,
|
||||
},
|
||||
smb: {
|
||||
host: '192.168.10.122',
|
||||
share: 'Gallery',
|
||||
user: '',
|
||||
password: '',
|
||||
},
|
||||
paths: {
|
||||
prodImages: '\\\\192.168.10.122\\Gallery\\data\\images',
|
||||
},
|
||||
verify: {},
|
||||
};
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
const harmonize = raw.harmonize || raw;
|
||||
return {
|
||||
configPath: resolved,
|
||||
dryRun: Boolean(harmonize.dryRun),
|
||||
prefer: harmonize.prefer || null,
|
||||
schemaChanged: Boolean(harmonize.schemaChanged ?? raw.schemaChanged),
|
||||
steps: {
|
||||
backupDev: harmonize.steps?.backupDev ?? true,
|
||||
backupProd: harmonize.steps?.backupProd ?? true,
|
||||
schema: harmonize.steps?.schema ?? Boolean(harmonize.schemaChanged ?? raw.schemaChanged),
|
||||
db: harmonize.steps?.db ?? true,
|
||||
images: harmonize.steps?.images ?? true,
|
||||
verify: harmonize.steps?.verify ?? false,
|
||||
},
|
||||
smb: {
|
||||
host: harmonize.smb?.host ?? raw.smb?.host ?? '192.168.10.122',
|
||||
share: harmonize.smb?.share ?? raw.smb?.share ?? 'Gallery',
|
||||
user: harmonize.smb?.user ?? raw.smb?.user ?? '',
|
||||
password: harmonize.smb?.password ?? raw.smb?.password ?? '',
|
||||
},
|
||||
paths: {
|
||||
prodImages: harmonize.paths?.prodImages
|
||||
?? `\\\\${harmonize.smb?.host ?? raw.smb?.host ?? '192.168.10.122'}\\${harmonize.smb?.share ?? raw.smb?.share ?? 'Gallery'}\\data\\images`,
|
||||
},
|
||||
verify: harmonize.verify ?? raw.verify ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function prodImagesPath(config) {
|
||||
return config.paths?.prodImages
|
||||
|| `\\\\${config.smb.host}\\${config.smb.share}\\data\\images`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG_PATH,
|
||||
loadHarmonizeConfig,
|
||||
prodImagesPath,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('../server/db');
|
||||
|
||||
async function main() {
|
||||
const sqlPath = path.join(__dirname, '../db/migrate-sync-timestamps.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await pool.query(sql);
|
||||
console.log('Catalog sync timestamps ready (updated_at + triggers)');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user