From 717f4754d4ec228f350b7744d9e01f91d36fa9d7 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 8 Oct 2026 03:43:28 +0200 Subject: [PATCH] c --- package.json | 1 + scripts/wipeCompany.js | 374 +++++++++++++++++++++++++++++++++++ src/api/index.js | 15 +- src/api/middleware/logger.js | 23 ++- src/config/env.js | 1 + src/db/store.js | 14 +- src/index.js | 7 +- src/util/errorLog.js | 7 +- src/util/logger.js | 369 ++++++++++++++++++++++++++++++++++ 9 files changed, 787 insertions(+), 24 deletions(-) create mode 100644 scripts/wipeCompany.js create mode 100644 src/util/logger.js diff --git a/package.json b/package.json index 6b31ba2..519165f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "node src/index.js", "dev": "node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src src/index.js", "manage-users": "node scripts/manageUsers.js", + "wipe-company": "node scripts/wipeCompany.js", "docs:api": "node scripts/generateApiDocs.js", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/scripts/wipeCompany.js b/scripts/wipeCompany.js new file mode 100644 index 0000000..94a760a --- /dev/null +++ b/scripts/wipeCompany.js @@ -0,0 +1,374 @@ +import readline from 'readline'; +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { getProvider, closeStore } from '../src/db/store.js'; +import env from '../src/config/env.js'; + +function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function getAllDescendantCategoryIds(provider, rootCategoryId) { + if (!rootCategoryId) return []; + const catIds = [rootCategoryId]; + let queue = [rootCategoryId]; + + while (queue.length > 0) { + const placeholders = queue.map((_, i) => `@p${i}`).join(','); + const params = Object.fromEntries(queue.map((id, i) => [`p${i}`, id])); + const res = await provider.query( + `SELECT id FROM category WHERE parent_id IN (${placeholders})`, + params + ); + queue = res.rows.map((r) => r.id); + catIds.push(...queue); + } + return catIds; +} + +/** + * Sweeps the storage directory for image files on disk not linked to any pic record. + */ +async function cleanStorageDiskFiles(provider) { + const activePics = await provider.query('SELECT hash FROM pic'); + const validHashes = new Set(activePics.rows.map((r) => r.hash)); + + const storageDir = path.resolve(process.cwd(), 'data/storage'); + const subdirs = ['fullbinary', 'thumbbinary', 'thumbavif']; + let removedFiles = 0; + + for (const sub of subdirs) { + const dirPath = path.join(storageDir, sub); + if (!fs.existsSync(dirPath)) continue; + + const files = fs.readdirSync(dirPath); + for (const file of files) { + const hash = file.replace(/\.[^.]+$/, ''); + if (!validHashes.has(hash)) { + try { + fs.unlinkSync(path.join(dirPath, file)); + removedFiles++; + } catch {} + } + } + } + return removedFiles; +} + +async function wipeCompany(companyIdentifier, autoConfirm = false) { + const provider = await getProvider(); + const isSqlite = env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3'; + + // 1. Locate company + let compRes; + const numId = Number(companyIdentifier); + if (Number.isInteger(numId) && numId > 0) { + compRes = await provider.query('SELECT id, name, root_category_id FROM company WHERE id = @id', { id: numId }); + } else { + compRes = await provider.query('SELECT id, name, root_category_id FROM company WHERE name = @name', { name: companyIdentifier }); + } + + if (!compRes.rows[0]) { + console.error(chalk.red(`\nCompany "${companyIdentifier}" not found.\n`)); + const allCompanies = await provider.query('SELECT id, name FROM company ORDER BY id'); + console.log(chalk.yellow('Existing companies:')); + for (const c of allCompanies.rows) { + console.log(` - [ID: ${c.id}] ${c.name}`); + } + process.exit(1); + } + + const company = compRes.rows[0]; + const companyId = company.id; + const companyName = company.name; + const rootCatId = company.root_category_id; + + console.log(chalk.bold.yellow(`\n⚠️ WARNING: You are about to completely wipe company:`)); + console.log(` ID: ${chalk.bold.white(companyId)}`); + console.log(` Name: ${chalk.bold.white(companyName)}\n`); + + if (!autoConfirm) { + const confirmation = await prompt( + chalk.red(`To proceed, type the exact company name "${companyName}": `) + ); + if (confirmation !== companyName) { + console.log(chalk.gray('Aborted. Nothing was deleted.')); + await closeStore(); + process.exit(0); + } + } + + console.log(chalk.cyan(`\nStarting wipe for "${companyName}" (ID: ${companyId})...`)); + + // 2. Collect candidate category IDs + const categoryIds = await getAllDescendantCategoryIds(provider, rootCatId); + console.log(` Found ${categoryIds.length} categories in company tree.`); + + // 3. Begin deletion in dependency order + await provider.transaction(async (tx) => { + if (isSqlite) { + await tx.query('PRAGMA defer_foreign_keys = ON'); + } + + // Wipe google_auth users who had this company selected or belonged to it + const authRes = await tx.query(` + DELETE FROM google_auth + WHERE selected_company = @companyId + OR id IN (SELECT google_auth_id FROM companyuser WHERE company_id = @companyId) + `, { companyId }); + console.log(` Deleted ${authRes.rowsAffected || 0} associated google_auth user(s).`); + + // Manifest, Attachments, Warehouse & Ledger + await tx.query(`DELETE FROM manifestattachment WHERE manifest_id IN (SELECT id FROM manifest WHERE company_id = @companyId)`, { companyId }); + await tx.query(`DELETE FROM ledger WHERE warehouse_id IN (SELECT id FROM warehouse WHERE company_id = @companyId)`, { companyId }); + await tx.query(`DELETE FROM ledger WHERE manifestarticle_id IN (SELECT ma.id FROM manifestarticle ma JOIN manifest m ON m.id = ma.manifest_id WHERE m.company_id = @companyId)`, { companyId }); + await tx.query(`DELETE FROM manifestarticle WHERE manifest_id IN (SELECT id FROM manifest WHERE company_id = @companyId)`, { companyId }); + await tx.query('DELETE FROM warehouse WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM manifest WHERE company_id = @companyId', { companyId }); + + // Hardware / Settings / History + await tx.query(`DELETE FROM posterminalcategory WHERE posterminal_id IN (SELECT id FROM posterminal WHERE company_id = @companyId)`, { companyId }); + await tx.query('DELETE FROM posterminal WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM printer WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM labeltemplate WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM companysetting WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM companyhistory WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM numbers WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM articletombstone WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM modelvfs WHERE company_id = @companyId', { companyId }); + await tx.query(`DELETE FROM modelconversation WHERE companyuser_id IN (SELECT id FROM companyuser WHERE company_id = @companyId)`, { companyId }); + + // Articles & Sub-tables + const artCount = (await tx.query('SELECT COUNT(*) as cnt FROM article WHERE company_id = @companyId', { companyId })).rows[0].cnt; + await tx.query('DELETE FROM articleean WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articletext WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articlepic WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articlehistory WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM price WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articlesupplier WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articleproperty WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articleattributevalue WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM articlelistarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM optionslistarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM categoryarticle WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM article WHERE company_id = @companyId', { companyId }); + console.log(` Deleted ${artCount} articles and all child rows.`); + + // Clean orphaned optionslist and articlelist + const optRes = await tx.query(` + DELETE FROM optionslist + WHERE id NOT IN (SELECT optionslist_id FROM article WHERE optionslist_id IS NOT NULL) + `); + const artListRes = await tx.query(` + DELETE FROM articlelist + WHERE id NOT IN (SELECT articlelist_id FROM article WHERE articlelist_id IS NOT NULL) + `); + console.log(` Cleaned ${optRes.rowsAffected || 0} optionslist and ${artListRes.rowsAffected || 0} articlelist entries.`); + + // Brand & Unit + await tx.query('DELETE FROM brandpic WHERE brand_id IN (SELECT id FROM brand WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM brand WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM unittext WHERE unit_id IN (SELECT id FROM unit WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM unit WHERE company_id = @companyId', { companyId }); + + // Properties & Attributes defined by company + await tx.query('DELETE FROM propertyname WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM attributename WHERE company_id = @companyId', { companyId }); + + // Category tree + if (categoryIds.length > 0) { + const catIn = categoryIds.join(','); + await tx.query(`DELETE FROM categoryarticle WHERE category_id IN (${catIn})`); + await tx.query(`DELETE FROM categorypic WHERE category_id IN (${catIn})`); + await tx.query(`DELETE FROM categorytext WHERE category_id IN (${catIn})`); + await tx.query(`DELETE FROM categoryproperty WHERE category_id IN (${catIn})`); + await tx.query(`DELETE FROM categoryattributevalue WHERE category_id IN (${catIn})`); + + // Detach root_category_id from company first + await tx.query('UPDATE company SET root_category_id = NULL WHERE id = @companyId', { companyId }); + + // Delete categories from leaf to root + const sortedDescCats = [...categoryIds].reverse(); + for (const catId of sortedDescCats) { + await tx.query('DELETE FROM category WHERE id = @catId', { catId }); + } + console.log(` Deleted ${categoryIds.length} categories.`); + } + + // Entities & Customers + await tx.query('DELETE FROM entityaddress WHERE entity_id IN (SELECT id FROM entity WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM entitycontact WHERE entity_id IN (SELECT id FROM entity WHERE company_id = @companyId)', { companyId }); + await tx.query('DELETE FROM entity WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM customergroup WHERE company_id = @companyId', { companyId }); + + // Company User & Company itself + await tx.query('DELETE FROM companyuser WHERE company_id = @companyId', { companyId }); + await tx.query('DELETE FROM company WHERE id = @companyId', { companyId }); + console.log(` Deleted company record.`); + }); + + // 4. Clean up ALL orphaned pictures across the database + const orphanedPics = await provider.query(` + SELECT id, hash FROM pic + WHERE id NOT IN (SELECT pic_id FROM articlepic) + AND id NOT IN (SELECT pic_id FROM categorypic) + AND id NOT IN (SELECT pic_id FROM brandpic) + `); + + if (orphanedPics.rows.length > 0) { + const orphanIds = orphanedPics.rows.map((r) => r.id).join(','); + await provider.query(`DELETE FROM pic WHERE id IN (${orphanIds})`); + console.log(` Deleted ${orphanedPics.rows.length} orphaned rows from pic table.`); + } + + // 5. Clean up files on disk + const removedFiles = await cleanStorageDiskFiles(provider); + if (removedFiles > 0) { + console.log(` Removed ${removedFiles} orphaned image files from data/storage/`); + } + + // 6. Remove data/vfs/ and data/storage/vfs/ + const vfsDir = path.resolve(process.cwd(), 'data/vfs', String(companyId)); + if (fs.existsSync(vfsDir)) { + fs.rmSync(vfsDir, { recursive: true, force: true }); + console.log(` Removed VFS directory at data/vfs/${companyId}`); + } + + const storageVfsDir = path.resolve(process.cwd(), 'data/storage/vfs', String(companyId)); + if (fs.existsSync(storageVfsDir)) { + fs.rmSync(storageVfsDir, { recursive: true, force: true }); + console.log(` Removed VFS storage directory at data/storage/vfs/${companyId}`); + } + + // 7. Remove import directory from disk if exists + const importDir = path.resolve(process.cwd(), 'data/import', String(companyId)); + if (fs.existsSync(importDir)) { + fs.rmSync(importDir, { recursive: true, force: true }); + console.log(` Removed import files at data/import/${companyId}`); + } + + console.log(chalk.green(`\n✔ Company "${companyName}" (ID: ${companyId}) completely wiped.\n`)); + await closeStore(); +} + +/** + * Clean all orphaned storage, vfs, and pic records across the entire database. + */ +async function cleanAllOrphans() { + const provider = await getProvider(); + console.log(chalk.cyan('\nCleaning all orphaned pics, optionslists, articlelists, and storage files...')); + + const remainingCompanies = await provider.query('SELECT COUNT(*) as cnt FROM company'); + const compCount = remainingCompanies.rows[0].cnt; + + // Delete orphaned pics + const orphanedPics = await provider.query(` + SELECT id, hash FROM pic + WHERE id NOT IN (SELECT pic_id FROM articlepic) + AND id NOT IN (SELECT pic_id FROM categorypic) + AND id NOT IN (SELECT pic_id FROM brandpic) + `); + + if (orphanedPics.rows.length > 0) { + const orphanIds = orphanedPics.rows.map((r) => r.id).join(','); + await provider.query(`DELETE FROM pic WHERE id IN (${orphanIds})`); + console.log(` Deleted ${orphanedPics.rows.length} orphaned rows from pic table.`); + } + + // Delete orphaned optionslist and articlelist + const optRes = await provider.query(` + DELETE FROM optionslist + WHERE id NOT IN (SELECT optionslist_id FROM article WHERE optionslist_id IS NOT NULL) + `); + const artListRes = await provider.query(` + DELETE FROM articlelist + WHERE id NOT IN (SELECT articlelist_id FROM article WHERE articlelist_id IS NOT NULL) + `); + console.log(` Cleaned ${optRes.rowsAffected || 0} optionslist and ${artListRes.rowsAffected || 0} articlelist rows.`); + + // Wipe google_auth users who have no selected_company or point to nonexistent company + const authRes = await provider.query(` + DELETE FROM google_auth + WHERE selected_company IS NULL + OR selected_company NOT IN (SELECT id FROM company) + `); + console.log(` Cleaned ${authRes.rowsAffected || 0} google_auth user(s) with no valid company.`); + + // Storage files on disk + const removedFiles = await cleanStorageDiskFiles(provider); + console.log(` Removed ${removedFiles} orphaned image files from data/storage/`); + + // If 0 companies remain, clear any leftover folders in data/vfs and data/import + if (compCount === 0) { + const vfsDir = path.resolve(process.cwd(), 'data/vfs'); + if (fs.existsSync(vfsDir)) { + for (const f of fs.readdirSync(vfsDir)) { + fs.rmSync(path.join(vfsDir, f), { recursive: true, force: true }); + } + console.log(` Cleaned all entries in data/vfs/`); + } + const storageVfs = path.resolve(process.cwd(), 'data/storage/vfs'); + if (fs.existsSync(storageVfs)) { + for (const f of fs.readdirSync(storageVfs)) { + fs.rmSync(path.join(storageVfs, f), { recursive: true, force: true }); + } + console.log(` Cleaned all entries in data/storage/vfs/`); + } + const importDir = path.resolve(process.cwd(), 'data/import'); + if (fs.existsSync(importDir)) { + for (const f of fs.readdirSync(importDir)) { + fs.rmSync(path.join(importDir, f), { recursive: true, force: true }); + } + console.log(` Cleaned all entries in data/import/`); + } + } + + console.log(chalk.green('\n✔ Cleanup complete.\n')); + await closeStore(); +} + +async function listCompanies() { + const provider = await getProvider(); + const res = await provider.query(` + SELECT c.id, c.name, + (SELECT COUNT(*) FROM article a WHERE a.company_id = c.id) AS articles, + (SELECT COUNT(*) FROM companyuser cu WHERE cu.company_id = c.id) AS users + FROM company c + ORDER BY c.id + `); + + console.log(chalk.bold('\nAvailable companies:')); + console.table(res.rows.map((r) => ({ + ID: r.id, + Name: r.name, + Articles: r.articles, + Users: r.users, + }))); + console.log(`Usage:`); + console.log(` npm run wipe-company [--yes]`); + console.log(` npm run wipe-company -- --clean-orphans\n`); + await closeStore(); +} + +const args = process.argv.slice(2); +const autoConfirm = args.includes('--yes') || args.includes('-y'); +const shouldCleanOrphans = args.includes('--clean-orphans'); +const target = args.find((a) => !a.startsWith('-')); + +if (shouldCleanOrphans) { + await cleanAllOrphans(); +} else if (!target) { + await listCompanies(); +} else { + await wipeCompany(target, autoConfirm); +} diff --git a/src/api/index.js b/src/api/index.js index 7dbbca5..81d7e29 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -14,8 +14,11 @@ import { closeAll as closeAllSse } from './sse.js'; import { closeAllManagers } from '../services/conversationManager.js'; import { shutdownImageCropWorkers } from '../services/imageCrop.js'; import { getOrCreateCert } from '../util/cert.js'; +import { createLogger } from '../util/logger.js'; import v1Router from '../v1/index.js'; +const apiLog = createLogger('api'); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -155,7 +158,7 @@ async function createApi() { if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { return res.status(400).json({ error: 'Invalid JSON body' }); } - console.error('Unhandled API error:', err); + apiLog.error('Unhandled API error:', err); const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500; res.status(status).json({ error: err.message || 'Internal server error' }); }); @@ -178,9 +181,9 @@ function listenServer(instance, port, protocol) { } catch { // lsof unavailable or no match } - console.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`); + apiLog.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`); } else { - console.error(`${protocol} server error:`, err.message); + apiLog.error(`${protocol} server error:`, err.message); } reject(err); }); @@ -206,7 +209,7 @@ export async function startApi() { const httpInstance = http.createServer(app); await listenServer(httpInstance, env.port, 'HTTP'); server = httpInstance; - console.log(`API listening on port ${env.port} (HTTP)`); + apiLog.info(`API listening on port ${env.port} (HTTP)`); if (env.httpsPort) { try { @@ -214,9 +217,9 @@ export async function startApi() { const httpsInstance = https.createServer(tls, app); await listenServer(httpsInstance, env.httpsPort, 'HTTPS'); httpsServer = httpsInstance; - console.log(`API listening on port ${env.httpsPort} (HTTPS self-signed)`); + apiLog.info(`API listening on port ${env.httpsPort} (HTTPS self-signed)`); } catch (err) { - console.error('Failed to start HTTPS server:', err.message); + apiLog.error('Failed to start HTTPS server:', err.message); } } diff --git a/src/api/middleware/logger.js b/src/api/middleware/logger.js index 180806f..5147280 100644 --- a/src/api/middleware/logger.js +++ b/src/api/middleware/logger.js @@ -1,17 +1,18 @@ -import chalk from 'chalk'; import { isEmpty, prettyJson } from '../../util/prettyJson.js'; import { writeErrorLog } from '../../util/errorLog.js'; +import { createLogger, chalk } from '../../util/logger.js'; + +const httpLog = createLogger('http'); function logField(label, value, { skipEmpty = false } = {}) { if (skipEmpty && isEmpty(value)) return; const rendered = prettyJson(value); const prefix = chalk.gray(` ${label}: `); if (!rendered.includes('\n')) { - console.log(prefix + rendered); + httpLog.debug(prefix + rendered); return; } - console.log(prefix); - console.log(rendered.replace(/^/gm, ' ')); + httpLog.debug(prefix + '\n' + rendered.replace(/^/gm, ' ')); } function captureResponseBody(res) { @@ -53,14 +54,22 @@ export const loggerMiddleware = (req, res, next) => { else if (status >= 300) status = chalk.cyan(status); else status = chalk.green(status); - console.log(`${method} ${url} ${status} ${chalk.gray(`(${duration}ms)`)}`); + const summary = `${method} ${url} ${status} ${chalk.gray(`(${duration}ms)`)}`; + if (res.statusCode >= 500) { + httpLog.error(summary); + } else if (res.statusCode >= 400) { + httpLog.warn(summary); + } else { + httpLog.info(summary); + } + logField('params', req.params ?? {}, { skipEmpty: true }); logField('query', req.query ?? {}, { skipEmpty: true }); if (req.headers['content-type']) { - console.log(chalk.gray(' content-type:'), chalk.cyan(req.headers['content-type'])); + httpLog.debug(chalk.gray(' content-type: ') + chalk.cyan(req.headers['content-type'])); } if (req.headers.authorization) { - console.log(chalk.gray(' authorization:'), chalk.cyan(req.headers.authorization)); + httpLog.debug(chalk.gray(' authorization: ') + chalk.cyan(req.headers.authorization)); } logField('body', req.body ?? {}, { skipEmpty: true }); logField('response', getBody()); diff --git a/src/config/env.js b/src/config/env.js index 9c6f3aa..53ef396 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -17,6 +17,7 @@ export default { httpsPort: parseHttpsPort(process.env.HTTPS_PORT), httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'), httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'), + logLevel: process.env.LOG_LEVEL || 'debug', bodyLimit: process.env.BODY_LIMIT || '100mb', corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173,https://quixpos.com,https://app.quixpos.com') .split(',') diff --git a/src/db/store.js b/src/db/store.js index 40c4d90..56e81b2 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -1,8 +1,10 @@ -import chalk from 'chalk'; import LibDashStore from 'libdashstore'; import { MssqlProvider } from 'libdashstore/providers/mssql'; import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3'; import env from '../config/env.js'; +import { createLogger, chalk } from '../util/logger.js'; + +const storeLog = createLogger('libstore'); let provider; let store; @@ -65,13 +67,11 @@ function wrapStoreWithTiming(rawStore) { const start = Date.now(); try { const result = await fn(...args); - console.log( - `${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}` - ); + storeLog.debug(`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`); return result; } catch (err) { - console.error( - `${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.red('FAILED')} ` + + storeLog.error( + `${namespace}.${method} ${chalk.red('FAILED')} ` + `${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}` ); throw err; @@ -90,7 +90,7 @@ function wrapStoreWithTiming(rawStore) { return await fn(...args); } finally { const duration = Date.now() - start; - console.log(`${chalk.magenta('libstore')} ${method} ${chalk.gray(`(${duration}ms)`)}`); + storeLog.info(`${method} ${chalk.gray(`(${duration}ms)`)}`); } }; } diff --git a/src/index.js b/src/index.js index f5b5185..4b1d9e1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,16 @@ import './config/env.js'; import { startApi, stopApi } from './api/index.js'; import { closeStore } from './db/store.js'; +import { createLogger } from './util/logger.js'; + +const sysLog = createLogger('system'); async function shutdown() { await stopApi(); try { await closeStore(); } catch (error) { - console.error('Failed to close database:', error); + sysLog.error('Failed to close database:', error); process.exitCode = 1; } } @@ -20,6 +23,6 @@ process.on('SIGTERM', () => { }); startApi().catch((err) => { - console.error('Failed to start API:', err.message); + sysLog.error('Failed to start API:', err.message); process.exit(1); }); diff --git a/src/util/errorLog.js b/src/util/errorLog.js index e48573c..f4e6991 100644 --- a/src/util/errorLog.js +++ b/src/util/errorLog.js @@ -2,6 +2,9 @@ import { appendFileSync, mkdirSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { prettyJson } from '../util/prettyJson.js'; +import { createLogger } from '../util/logger.js'; + +const log = createLogger('error-log'); const __dirname = dirname(fileURLToPath(import.meta.url)); const LOG_DIR = process.env.ERROR_LOG_DIR || resolve(__dirname, '../../logs/errors'); @@ -46,10 +49,10 @@ export function writeErrorLog(label, sections) { } appendFileSync(filePath, `${parts.join('\n')}\n`); - console.error(`[error-log] Wrote ${filePath}`); + log.info(`Wrote ${filePath}`); return filePath; } catch (err) { - console.error('[error-log] Failed to write error log:', err.message); + log.error('Failed to write error log:', err.message); return null; } } diff --git a/src/util/logger.js b/src/util/logger.js new file mode 100644 index 0000000..285373e --- /dev/null +++ b/src/util/logger.js @@ -0,0 +1,369 @@ +import util from 'node:util'; +import defaultChalk, { Chalk } from 'chalk'; + +// Ensure color is preserved under PM2 / non-TTY environments unless NO_COLOR is set +if (!process.env.NO_COLOR && (process.env.FORCE_COLOR === undefined || process.env.FORCE_COLOR === '')) { + process.env.FORCE_COLOR = '3'; +} + +export const colorLevel = process.env.NO_COLOR ? 0 : (Number(process.env.FORCE_COLOR) || 3); +export const chalk = new Chalk({ level: colorLevel }); + +/** + * Log severity levels and their numeric weights. + */ +export const LogLevel = Object.freeze({ + TRACE: 10, + DEBUG: 20, + INFO: 30, + WARN: 40, + ERROR: 50, + FATAL: 60, + SILENT: 100, +}); + +/** + * Map of string names to numeric levels. + */ +const LEVEL_VALUES = { + trace: LogLevel.TRACE, + debug: LogLevel.DEBUG, + info: LogLevel.INFO, + warn: LogLevel.WARN, + warning: LogLevel.WARN, + error: LogLevel.ERROR, + fatal: LogLevel.FATAL, + silent: LogLevel.SILENT, +}; + +/** + * Pre-styled severity tags padded to 5 characters. + */ +const SEVERITY_TAGS = { + [LogLevel.TRACE]: chalk.gray('TRACE'), + [LogLevel.DEBUG]: chalk.blue('DEBUG'), + [LogLevel.INFO]: chalk.green('INFO '), + [LogLevel.WARN]: chalk.yellow('WARN '), + [LogLevel.ERROR]: chalk.red.bold('ERROR'), + [LogLevel.FATAL]: chalk.bgRed.white.bold('FATAL'), +}; + +/** + * Predefined colors for common division names. + */ +const DIVISION_COLORS = { + api: chalk.cyan, + http: chalk.hex('#10b981'), // Emerald + store: chalk.magenta, + libstore: chalk.magenta, + db: chalk.blueBright, + auth: chalk.hex('#f59e0b'), // Amber + sse: chalk.hex('#8b5cf6'), // Purple + system: chalk.hex('#94a3b8'), // Slate + import: chalk.hex('#ec4899'), // Pink + conversation: chalk.hex('#06b6d4'), // Light cyan + worker: chalk.hex('#f97316'), // Orange + cron: chalk.hex('#eab308'), // Yellow + vfs: chalk.hex('#14b8a6'), // Teal + event: chalk.hex('#a855f7'), // Violet +}; + +/** + * Palette of vibrant chalk colors for dynamic divisions. + */ +const PALETTE = [ + chalk.cyan, + chalk.magenta, + chalk.blueBright, + chalk.yellow, + chalk.greenBright, + chalk.hex('#f59e0b'), + chalk.hex('#8b5cf6'), + chalk.hex('#06b6d4'), + chalk.hex('#ec4899'), + chalk.hex('#10b981'), + chalk.hex('#3b82f6'), + chalk.hex('#f97316'), + chalk.hex('#14b8a6'), + chalk.hex('#a855f7'), + chalk.hex('#e879f9'), + chalk.hex('#22d3ee'), +]; + +/** + * Returns a cached or deterministically picked chalk styling function for a division name. + * @param {string} division + * @returns {(text: string) => string} + */ +const divisionColorCache = new Map(); +function getDivisionStyle(division) { + const cached = divisionColorCache.get(division); + if (cached) return cached; + + const lower = division.toLowerCase(); + let style = DIVISION_COLORS[lower]; + + if (!style) { + let hash = 0; + for (let i = 0; i < division.length; i++) { + hash = (hash << 5) - hash + division.charCodeAt(i); + hash |= 0; + } + const index = Math.abs(hash) % PALETTE.length; + style = PALETTE[index]; + } + + const wrapped = (txt) => style.bold(txt); + divisionColorCache.set(division, wrapped); + return wrapped; +} + +/** + * Formats a Date object into local timestamp: YYYY-MM-DD HH:mm:ss.SSS + * @param {Date} [date] + * @returns {string} + */ +export function formatTimestamp(date = new Date()) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + const ms = String(date.getMilliseconds()).padStart(3, '0'); + return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`; +} + +/** + * Parse string or number to numeric LogLevel. + * @param {string | number} val + * @param {number} [fallback=LogLevel.DEBUG] + * @returns {number} + */ +function parseLogLevel(val, fallback = LogLevel.DEBUG) { + if (typeof val === 'number') return val; + if (typeof val === 'string') { + const lower = val.trim().toLowerCase(); + if (lower in LEVEL_VALUES) return LEVEL_VALUES[lower]; + } + return fallback; +} + +/** + * Global log level configuration. Defaults to LOG_LEVEL env var, or DEBUG in dev/test. + */ +let currentLogLevel = parseLogLevel(process.env.LOG_LEVEL, LogLevel.DEBUG); + +export function setLogLevel(level) { + currentLogLevel = parseLogLevel(level, currentLogLevel); +} + +export function getLogLevel() { + return currentLogLevel; +} + +/** + * Formats a log line adhering to: + * time - severity - division - text + * + * @param {number} levelNumeric + * @param {string} division + * @param {any[]} args + * @returns {string} + */ +export function formatLogEntry(levelNumeric, division, args) { + const timeStr = chalk.gray(formatTimestamp()); + const sep = chalk.gray(' - '); + const severityStr = SEVERITY_TAGS[levelNumeric] || chalk.white(String(levelNumeric).padEnd(5)); + const divisionStr = getDivisionStyle(division)(division); + + const prefix = `${timeStr}${sep}${severityStr}${sep}${divisionStr}${sep}`; + + // Format remaining arguments (handles %s specifiers, Error objects, JSON structures, etc.) + const formattedText = util.formatWithOptions( + { colors: colorLevel > 0 }, + ...args + ); + + if (!formattedText.includes('\n')) { + return prefix + formattedText; + } + + const lines = formattedText.split('\n'); + const indent = ' '; + return prefix + lines[0] + '\n' + lines.slice(1).map((l) => indent + l).join('\n'); +} + +/** + * Modern Logger instance. Supports scoped divisions, child loggers, and timers. + */ +export class Logger { + /** + * @param {string} [division='app'] + * @param {{ level?: string | number, stream?: (msg: string) => void }} [options={}] + */ + constructor(division = 'app', options = {}) { + this.divisionName = division; + this.customLevel = options.level ? parseLogLevel(options.level) : null; + this._write = options.stream || null; + this._timers = new Map(); + } + + /** + * Effective log level for this instance. + */ + get level() { + return this.customLevel ?? currentLogLevel; + } + + set level(val) { + this.customLevel = parseLogLevel(val); + } + + /** + * Checks if a level is enabled. + * @param {number} levelNumeric + * @returns {boolean} + */ + isLevelEnabled(levelNumeric) { + return this.level <= levelNumeric; + } + + /** + * Core log method. + * @param {number} levelNumeric + * @param {string | any} firstArg + * @param {...any} rest + */ + log(levelNumeric, firstArg, ...rest) { + if (!this.isLevelEnabled(levelNumeric)) return; + + let division = this.divisionName; + let args; + + // If this is the root/unscoped logger ('app') and the first arg looks like an explicit division: + if ( + this.divisionName === 'app' && + typeof firstArg === 'string' && + firstArg.length > 0 && + !firstArg.includes(' ') && + rest.length > 0 + ) { + division = firstArg; + args = rest; + } else { + args = [firstArg, ...rest]; + } + + const output = formatLogEntry(levelNumeric, division, args); + + if (this._write) { + this._write(output); + return; + } + + if (levelNumeric >= LogLevel.ERROR) { + console.error(output); + } else if (levelNumeric >= LogLevel.WARN) { + console.warn(output); + } else { + console.log(output); + } + } + + trace(...args) { + this.log(LogLevel.TRACE, ...args); + } + + debug(...args) { + this.log(LogLevel.DEBUG, ...args); + } + + info(...args) { + this.log(LogLevel.INFO, ...args); + } + + warn(...args) { + this.log(LogLevel.WARN, ...args); + } + + error(...args) { + this.log(LogLevel.ERROR, ...args); + } + + fatal(...args) { + this.log(LogLevel.FATAL, ...args); + } + + /** + * Starts a timer for a given label. + * @param {string} label + */ + time(label) { + this._timers.set(label, Date.now()); + } + + /** + * Ends a timer and logs duration at DEBUG or INFO level. + * @param {string} label + * @param {string} [msg] + * @param {'debug' | 'info'} [level='debug'] + */ + timeEnd(label, msg, level = 'debug') { + const start = this._timers.get(label); + this._timers.delete(label); + const duration = start ? Date.now() - start : 0; + const text = msg ? `${msg} (${duration}ms)` : `${label} completed in ${duration}ms`; + if (level === 'info') { + this.info(text); + } else { + this.debug(text); + } + } + + /** + * Creates a scoped child logger for a subdivision or new division. + * @param {string} subdivision + * @param {object} [options] + * @returns {Logger} + */ + child(subdivision, options = {}) { + const newDivision = + this.divisionName && this.divisionName !== 'app' + ? `${this.divisionName}:${subdivision}` + : subdivision; + return new Logger(newDivision, { + level: options.level ?? this.customLevel, + stream: options.stream ?? this._write, + }); + } + + /** + * Alias for child / scoped logger. + * @param {string} division + * @returns {Logger} + */ + for(division) { + return new Logger(division, { + level: this.customLevel, + stream: this._write, + }); + } +} + +/** + * Root logger instance. + */ +export const logger = new Logger('app'); + +/** + * Creates a scoped logger instance for a given division. + * @param {string} division + * @param {object} [options] + * @returns {Logger} + */ +export function createLogger(division, options) { + return new Logger(division, options); +} + +export default logger;