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:
Danila KhodjaefandCursor committed 2026-07-15 11:02:17 +03:00
1 parent 81ebad2120
commit f247b418d8
21 files changed
+1336 -4

No files matched your search

+151
View File
@@ -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,
});
});
+304
View File
@@ -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,
});
});
+196
View File
@@ -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,
});
});
+54
View File
@@ -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,
});
});
+78
View File
@@ -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,
};
+17
View File
@@ -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);
});