fix: .gitignore игнорировал src/core/history (паттерн history/ без якоря) — CI падал с MODULE_NOT_FOUND
All checks were successful
test / test (push) Successful in 11s

Паттерн 'history/' в .gitignore матчил любой каталог с таким именем, включая служебный модуль src/core/history/. Привязаны /history/ и /data/ к корню репозитория; модуль истории добавлен в индекс.
This commit is contained in:
2026-08-27 13:04:46 +07:00
parent 401969cfdb
commit 6d5b8d5f05
3 changed files with 392 additions and 2 deletions

6
.gitignore vendored
View File

@@ -4,8 +4,10 @@ node_modules/
.npmrc
# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
history/
data/
# Паттерны привязаны к корню репозитория: иначе "history/" игнорирует
# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы).
/history/
/data/
.codex-jobs/
# Временные и тестовые файлы

View File

@@ -0,0 +1,225 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { getImageDimensions } = require("../images/dimensions");
const { isHeic, convertHeicToJpeg } = require("../images/heic");
const { sniffImageType } = require("../images/sniff");
const { AppError, NotFoundError } = require("../../errors");
function contentTypeForExt(ext) {
const map = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
};
return map[String(ext || "").toLowerCase()] || "image/jpeg";
}
function extForMimetype(mimetype) {
const map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "jpg",
"image/heif": "jpg",
};
return map[String(mimetype || "").toLowerCase()] || "jpg";
}
class HistoryService {
/**
* @param {Object} options
* @param {import('./history-store').HistoryStore} options.store
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ store, logger = null }) {
this.store = store;
this.logger = logger;
}
getHistory() {
return this.store.load();
}
getVersion(id) {
return this.store.get(id);
}
async getVersionFile(id) {
const version = this.store.get(id);
if (!version) {
throw new NotFoundError("Версия не найдена в истории.");
}
const filePath = this.store.filePath(version);
if (!fs.existsSync(filePath)) {
throw new NotFoundError("Файл изображения не найден на диске.");
}
const buffer = fs.readFileSync(filePath);
const contentType = contentTypeForExt(version.ext);
return { version, filePath, buffer, contentType };
}
async createVersion({
kind,
label,
prompt = null,
parentId = null,
referenceId = null,
buffer,
ext = "jpg",
width = null,
height = null,
}) {
if (!Buffer.isBuffer(buffer)) {
throw new AppError(400, "Буфер изображения обязателен для создания версии.");
}
let finalWidth = width;
let finalHeight = height;
if (!finalWidth || !finalHeight) {
const dims = getImageDimensions(buffer);
if (dims) {
finalWidth = dims.width;
finalHeight = dims.height;
}
}
const id = crypto.randomUUID();
const fileExt = ext ? ext.toLowerCase().replace(/^\./, "") : "jpg";
const version = {
id,
kind: kind || "original",
label: String(label || "Версия").trim(),
prompt: prompt ? String(prompt).trim() : null,
parentId: parentId || null,
referenceId: referenceId || null,
createdAt: new Date().toISOString(),
url: `/api/history/${id}/image.${fileExt}`,
width: finalWidth || null,
height: finalHeight || null,
ext: fileExt,
};
await this.store.append(version, buffer);
if (this.logger) {
this.logger.info("Создана версия истории", {
versionId: version.id,
kind: version.kind,
ext: version.ext,
width: version.width,
height: version.height,
});
}
return version;
}
async importPhoto({ buffer, mimetype, originalname }) {
if (!Buffer.isBuffer(buffer)) {
throw new AppError(400, "Файл не передан или повреждён.");
}
let processedBuffer = buffer;
let ext = extForMimetype(mimetype);
// Handle HEIC
const isHeicFile = isHeic({ mimetype, originalname }) || sniffImageType(buffer) === "heic";
if (isHeicFile) {
processedBuffer = await convertHeicToJpeg(buffer);
ext = "jpg";
} else {
const sniffed = sniffImageType(buffer);
if (sniffed) {
ext = sniffed === "jpeg" ? "jpg" : sniffed;
}
}
const dims = getImageDimensions(processedBuffer);
const label = originalname ? path.basename(originalname, path.extname(originalname)) : "Оригинал";
return this.createVersion({
kind: "original",
label: label || "Оригинал",
prompt: null,
parentId: null,
referenceId: null,
buffer: processedBuffer,
ext,
width: dims?.width || null,
height: dims?.height || null,
});
}
async removeCascade(id) {
return this.store.removeCascade(id);
}
}
/**
* Миграция истории из старого каталога ./history в data/history при первом старте v2.
* @param {string} legacyDir - e.g. './history'
* @param {string} targetDir - e.g. './data/history'
* @param {import('../../logger').Logger} [logger]
*/
function migrateLegacyHistory(legacyDir, targetDir, logger = null) {
const legacyManifest = path.join(legacyDir, "history.json");
const targetManifest = path.join(targetDir, "history.json");
// If source does not exist, nothing to migrate
if (!fs.existsSync(legacyManifest)) {
return;
}
// If target already has manifest, migration was already done
if (fs.existsSync(targetManifest)) {
return;
}
try {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const files = fs.readdirSync(legacyDir);
let copied = 0;
for (const file of files) {
const srcFile = path.join(legacyDir, file);
const dstFile = path.join(targetDir, file);
const stat = fs.statSync(srcFile);
if (stat.isFile()) {
fs.copyFileSync(srcFile, dstFile);
copied++;
}
}
if (logger) {
logger.info("Миграция истории из ./history в data/history завершена", {
filesCount: copied,
from: legacyDir,
to: targetDir,
});
}
} catch (err) {
if (logger) {
logger.error("Ошибка при миграции истории версий", { error: err.message });
}
}
}
module.exports = {
HistoryService,
migrateLegacyHistory,
contentTypeForExt,
extForMimetype,
};

View File

@@ -0,0 +1,163 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
class HistoryStore {
/**
* @param {Object} options
* @param {string} options.dir - Путь к каталогу с историей (data/history)
* @param {number} [options.limit=50] - Максимальное число хранимых версий
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ dir, limit = 50, logger = null }) {
this.dir = dir;
this.limit = limit;
this.logger = logger;
this.manifestPath = path.join(this.dir, "history.json");
this._writeChain = Promise.resolve();
// Ensure dir exists
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
}
}
load() {
try {
if (!fs.existsSync(this.manifestPath)) {
return [];
}
const raw = fs.readFileSync(this.manifestPath, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
if (this.logger) {
this.logger.warn("Не удалось прочитать history.json, возврат пустого списка", {
error: err.message,
});
}
return [];
}
}
_saveManifestAtomic(versions) {
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
}
const tmpFile = path.join(
this.dir,
`history.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
);
const fd = fs.openSync(tmpFile, "w");
try {
fs.writeFileSync(fd, JSON.stringify(versions, null, 2), "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpFile, this.manifestPath);
}
filePath(version) {
const ext = version.ext || "jpg";
return path.join(this.dir, `${version.id}.${ext}`);
}
get(id) {
if (!id) return null;
const list = this.load();
return list.find((v) => v.id === id) || null;
}
async append(version, buffer = null) {
return new Promise((resolve, reject) => {
this._writeChain = this._writeChain
.then(() => {
if (buffer) {
const fPath = this.filePath(version);
fs.writeFileSync(fPath, buffer);
}
const history = this.load();
history.push(version);
const trimmed = history.slice(-this.limit);
const keptIds = new Set(trimmed.map((entry) => entry.id));
for (const old of history) {
if (!keptIds.has(old.id)) {
try {
fs.rmSync(this.filePath(old), { force: true });
} catch {
// File might not exist
}
}
}
this._saveManifestAtomic(trimmed);
resolve(version);
})
.catch(reject);
});
}
async removeCascade(id) {
return new Promise((resolve, reject) => {
this._writeChain = this._writeChain
.then(() => {
const history = this.load();
const target = history.find((entry) => entry.id === id);
if (!target) {
resolve({ removed: [], remaining: history });
return;
}
const toDelete = new Set([id]);
let grew = true;
while (grew) {
grew = false;
for (const entry of history) {
if (entry.parentId && toDelete.has(entry.parentId) && !toDelete.has(entry.id)) {
toDelete.add(entry.id);
grew = true;
}
}
}
const removed = history.filter((entry) => toDelete.has(entry.id));
const remaining = history.filter((entry) => !toDelete.has(entry.id));
this._saveManifestAtomic(remaining);
for (const entry of removed) {
try {
fs.rmSync(this.filePath(entry), { force: true });
} catch {
// Ignore
}
}
if (this.logger) {
this.logger.info("Удаление версии из истории", {
versionId: id,
removedCount: removed.length,
});
}
resolve({ removed, remaining });
})
.catch(reject);
});
}
}
module.exports = {
HistoryStore,
};