c
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src 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",
|
"manage-users": "node scripts/manageUsers.js",
|
||||||
|
"wipe-company": "node scripts/wipeCompany.js",
|
||||||
"docs:api": "node scripts/generateApiDocs.js",
|
"docs:api": "node scripts/generateApiDocs.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
|
|||||||
374
scripts/wipeCompany.js
Normal file
374
scripts/wipeCompany.js
Normal file
@@ -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/<companyId> and data/storage/vfs/<companyId>
|
||||||
|
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 <companyId-or-name> [--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);
|
||||||
|
}
|
||||||
@@ -14,8 +14,11 @@ import { closeAll as closeAllSse } from './sse.js';
|
|||||||
import { closeAllManagers } from '../services/conversationManager.js';
|
import { closeAllManagers } from '../services/conversationManager.js';
|
||||||
import { shutdownImageCropWorkers } from '../services/imageCrop.js';
|
import { shutdownImageCropWorkers } from '../services/imageCrop.js';
|
||||||
import { getOrCreateCert } from '../util/cert.js';
|
import { getOrCreateCert } from '../util/cert.js';
|
||||||
|
import { createLogger } from '../util/logger.js';
|
||||||
import v1Router from '../v1/index.js';
|
import v1Router from '../v1/index.js';
|
||||||
|
|
||||||
|
const apiLog = createLogger('api');
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -155,7 +158,7 @@ async function createApi() {
|
|||||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||||
return res.status(400).json({ error: 'Invalid JSON body' });
|
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;
|
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' });
|
res.status(status).json({ error: err.message || 'Internal server error' });
|
||||||
});
|
});
|
||||||
@@ -178,9 +181,9 @@ function listenServer(instance, port, protocol) {
|
|||||||
} catch {
|
} catch {
|
||||||
// lsof unavailable or no match
|
// 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 {
|
} else {
|
||||||
console.error(`${protocol} server error:`, err.message);
|
apiLog.error(`${protocol} server error:`, err.message);
|
||||||
}
|
}
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
@@ -206,7 +209,7 @@ export async function startApi() {
|
|||||||
const httpInstance = http.createServer(app);
|
const httpInstance = http.createServer(app);
|
||||||
await listenServer(httpInstance, env.port, 'HTTP');
|
await listenServer(httpInstance, env.port, 'HTTP');
|
||||||
server = httpInstance;
|
server = httpInstance;
|
||||||
console.log(`API listening on port ${env.port} (HTTP)`);
|
apiLog.info(`API listening on port ${env.port} (HTTP)`);
|
||||||
|
|
||||||
if (env.httpsPort) {
|
if (env.httpsPort) {
|
||||||
try {
|
try {
|
||||||
@@ -214,9 +217,9 @@ export async function startApi() {
|
|||||||
const httpsInstance = https.createServer(tls, app);
|
const httpsInstance = https.createServer(tls, app);
|
||||||
await listenServer(httpsInstance, env.httpsPort, 'HTTPS');
|
await listenServer(httpsInstance, env.httpsPort, 'HTTPS');
|
||||||
httpsServer = httpsInstance;
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to start HTTPS server:', err.message);
|
apiLog.error('Failed to start HTTPS server:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import chalk from 'chalk';
|
|
||||||
import { isEmpty, prettyJson } from '../../util/prettyJson.js';
|
import { isEmpty, prettyJson } from '../../util/prettyJson.js';
|
||||||
import { writeErrorLog } from '../../util/errorLog.js';
|
import { writeErrorLog } from '../../util/errorLog.js';
|
||||||
|
import { createLogger, chalk } from '../../util/logger.js';
|
||||||
|
|
||||||
|
const httpLog = createLogger('http');
|
||||||
|
|
||||||
function logField(label, value, { skipEmpty = false } = {}) {
|
function logField(label, value, { skipEmpty = false } = {}) {
|
||||||
if (skipEmpty && isEmpty(value)) return;
|
if (skipEmpty && isEmpty(value)) return;
|
||||||
const rendered = prettyJson(value);
|
const rendered = prettyJson(value);
|
||||||
const prefix = chalk.gray(` ${label}: `);
|
const prefix = chalk.gray(` ${label}: `);
|
||||||
if (!rendered.includes('\n')) {
|
if (!rendered.includes('\n')) {
|
||||||
console.log(prefix + rendered);
|
httpLog.debug(prefix + rendered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(prefix);
|
httpLog.debug(prefix + '\n' + rendered.replace(/^/gm, ' '));
|
||||||
console.log(rendered.replace(/^/gm, ' '));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureResponseBody(res) {
|
function captureResponseBody(res) {
|
||||||
@@ -53,14 +54,22 @@ export const loggerMiddleware = (req, res, next) => {
|
|||||||
else if (status >= 300) status = chalk.cyan(status);
|
else if (status >= 300) status = chalk.cyan(status);
|
||||||
else status = chalk.green(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('params', req.params ?? {}, { skipEmpty: true });
|
||||||
logField('query', req.query ?? {}, { skipEmpty: true });
|
logField('query', req.query ?? {}, { skipEmpty: true });
|
||||||
if (req.headers['content-type']) {
|
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) {
|
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('body', req.body ?? {}, { skipEmpty: true });
|
||||||
logField('response', getBody());
|
logField('response', getBody());
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default {
|
|||||||
httpsPort: parseHttpsPort(process.env.HTTPS_PORT),
|
httpsPort: parseHttpsPort(process.env.HTTPS_PORT),
|
||||||
httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'),
|
httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'),
|
||||||
httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'),
|
httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'),
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'debug',
|
||||||
bodyLimit: process.env.BODY_LIMIT || '100mb',
|
bodyLimit: process.env.BODY_LIMIT || '100mb',
|
||||||
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173,https://quixpos.com,https://app.quixpos.com')
|
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173,https://quixpos.com,https://app.quixpos.com')
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import chalk from 'chalk';
|
|
||||||
import LibDashStore from 'libdashstore';
|
import LibDashStore from 'libdashstore';
|
||||||
import { MssqlProvider } from 'libdashstore/providers/mssql';
|
import { MssqlProvider } from 'libdashstore/providers/mssql';
|
||||||
import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3';
|
import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3';
|
||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
|
import { createLogger, chalk } from '../util/logger.js';
|
||||||
|
|
||||||
|
const storeLog = createLogger('libstore');
|
||||||
|
|
||||||
let provider;
|
let provider;
|
||||||
let store;
|
let store;
|
||||||
@@ -65,13 +67,11 @@ function wrapStoreWithTiming(rawStore) {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const result = await fn(...args);
|
const result = await fn(...args);
|
||||||
console.log(
|
storeLog.debug(`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`);
|
||||||
`${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
storeLog.error(
|
||||||
`${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.red('FAILED')} ` +
|
`${namespace}.${method} ${chalk.red('FAILED')} ` +
|
||||||
`${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}`
|
`${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}`
|
||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -90,7 +90,7 @@ function wrapStoreWithTiming(rawStore) {
|
|||||||
return await fn(...args);
|
return await fn(...args);
|
||||||
} finally {
|
} finally {
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
console.log(`${chalk.magenta('libstore')} ${method} ${chalk.gray(`(${duration}ms)`)}`);
|
storeLog.info(`${method} ${chalk.gray(`(${duration}ms)`)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import './config/env.js';
|
import './config/env.js';
|
||||||
import { startApi, stopApi } from './api/index.js';
|
import { startApi, stopApi } from './api/index.js';
|
||||||
import { closeStore } from './db/store.js';
|
import { closeStore } from './db/store.js';
|
||||||
|
import { createLogger } from './util/logger.js';
|
||||||
|
|
||||||
|
const sysLog = createLogger('system');
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
await stopApi();
|
await stopApi();
|
||||||
try {
|
try {
|
||||||
await closeStore();
|
await closeStore();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to close database:', error);
|
sysLog.error('Failed to close database:', error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +23,6 @@ process.on('SIGTERM', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
startApi().catch((err) => {
|
startApi().catch((err) => {
|
||||||
console.error('Failed to start API:', err.message);
|
sysLog.error('Failed to start API:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { appendFileSync, mkdirSync } from 'fs';
|
|||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { prettyJson } from '../util/prettyJson.js';
|
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 __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const LOG_DIR = process.env.ERROR_LOG_DIR || resolve(__dirname, '../../logs/errors');
|
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`);
|
appendFileSync(filePath, `${parts.join('\n')}\n`);
|
||||||
console.error(`[error-log] Wrote ${filePath}`);
|
log.info(`Wrote ${filePath}`);
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[error-log] Failed to write error log:', err.message);
|
log.error('Failed to write error log:', err.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
369
src/util/logger.js
Normal file
369
src/util/logger.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user