Files

145 lines
11 KiB
JavaScript

'use strict';
// 사이트 메타데이터/명령 이력/토큰 사용량을 data/sites/<id>.json 에 저장하는 단순 JSON 저장소.
const fs = require('fs');
const path = require('path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data', 'sites');
fs.mkdirSync(DATA_DIR, { recursive: true });
const ID_RE = /^[a-f0-9]{32}$/;
function fileOf(id) { return path.join(DATA_DIR, `${id}.json`); }
function emptyTotals() {
return { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0, costUsd: 0, chargeKrw: 0, commands: 0 };
}
function recomputeTotals(site) {
const t = emptyTotals();
for (const c of site.commands) {
if (!c.usage) continue;
t.input += c.usage.input; t.output += c.usage.output;
t.cacheCreate += c.usage.cacheCreate; t.cacheRead += c.usage.cacheRead;
t.thinking += c.usage.thinking || 0; t.total += c.usage.total;
t.costUsd += c.costUsd || 0; t.chargeKrw += c.chargeKrw || 0; t.commands += 1;
}
site.totals = t;
return site;
}
// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음)
const js = require('./jsonstore');
const crypto = require('crypto');
/** 저장이 관리하는 항목 — 변경 감지에서 제외한다(rev 는 디스크 값을 기준으로 올린다) */
const META_KEYS = new Set(['updatedAt', 'saveId', 'rev']);
/**
* 읽었을 때의 상태(항목별 JSON·저장 표식)를 객체에 숨겨 둔다(열거되지 않아 JSON.stringify·spread·publicView 에 나오지 않는다). save 가 "이 객체가 바꾼 항목" 을 알아내는 기준.
* v1.8.0 검토 수정 3차: 배경 AI 작업은 자기 사이트 객체를 오래 들고 있다가 저장하므로, 그 사이 화면(기본 사항·모델 등급 등)이 다른 객체로 저장한 변경을 덮어쓸 수 있었다.
*/
function remember(site) {
if (!site || typeof site !== 'object') return site;
const base = { saveId: site.saveId || null, keys: {} }; for (const k of Object.keys(site)) base.keys[k] = JSON.stringify(site[k]);
Object.defineProperty(site, '__base', { value: base, enumerable: false, writable: true, configurable: true }); return site;
}
/**
* 디스크 기록이 이 객체가 읽은 뒤 다른 객체로 저장되었으면(saveId 가 다름), 이 객체가 읽은 뒤 **바꾼 잎만** 디스크의 최신 기록 위에 합친다(3자 병합 — 일반 객체는 안으로 들어가 잎 단위로, 배열·기본값은 잎).
* · 이 객체만 바꾼 잎 → 이 객체 값 / 디스크만 바뀐 잎 → 디스크 값 / 둘 다 같은 값으로 바꿈 → 그 값
* · 둘 다 **다르게** 바꾼 잎(진짜 동시 변경) → 합치지 않고 SaveConflict(code site_save_conflict, fields=잎 경로 목록, status 409)를 던진다 — 디스크의 먼저 쓴 값을 보존하고 늦은 저장자(배경 작업)에게 알린다.
* (1.8.0 검토 수정 3차: 같은 항목을 "마지막 저장자가 이김" 으로 두면 먼저 쓴 쪽이 조용히 사라진다. 늦은 쪽은 update() 로 최신 기록을 다시 읽어 자기 결과만 다시 적용한다.)
* · 저장이 관리하는 항목(updatedAt·saveId·rev)만 비교에서 뺀다. 호출자의 객체는 합친 결과로 바뀐다(충돌이면 바뀌지 않는다). 읽은 적 없는 객체(새 사이트·JSON.parse 스냅샷)·restore 저장은 합치지 않는다.
* @returns {object|null} 합치는 데 쓴 디스크 기록(없으면 null)
*/
function mergeOntoDisk(site) {
const base = site.__base; if (!base) return null;
let disk = null; try { disk = js.readJson(fileOf(site.id), null, {}); } catch (_) { disk = null; }
if (!disk || typeof disk !== 'object' || (disk.saveId || null) === base.saveId) return null;
const conflicts = []; const merged = {};
const keys = new Set([...Object.keys(site), ...Object.keys(disk), ...Object.keys(base.keys)]);
for (const k of keys) {
if (META_KEYS.has(k)) continue;
const has = Object.prototype.hasOwnProperty.call(site, k);
const r = mergeValue(has ? site[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, base.keys[k] === undefined ? undefined : JSON.parse(base.keys[k]), k, conflicts);
if (r.present) merged[k] = r.value;
}
if (conflicts.length) throw new SaveConflict(site.id, conflicts);
for (const k of Object.keys(site)) if (!META_KEYS.has(k) && !Object.prototype.hasOwnProperty.call(merged, k)) delete site[k];
for (const k of Object.keys(merged)) site[k] = merged[k];
return disk;
}
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** 잎 하나(또는 객체는 안으로 들어가며)의 3자 병합. mine/disk/base 는 값(없으면 undefined). 결과 {present, value}; 충돌은 conflicts 에 경로를 더한다 */
function mergeValue(mine, disk, base, pathKey, conflicts) {
const sm = JSON.stringify(mine === undefined ? null : mine), sd = JSON.stringify(disk === undefined ? null : disk), sb = JSON.stringify(base === undefined ? null : base);
const mineChanged = sm !== sb || (mine === undefined) !== (base === undefined); const diskChanged = sd !== sb || (disk === undefined) !== (base === undefined);
if (!mineChanged) return { present: disk !== undefined, value: disk };
if (!diskChanged) return { present: mine !== undefined, value: mine };
if (sm === sd && (mine === undefined) === (disk === undefined)) return { present: mine !== undefined, value: mine };
if (isPlain(mine) && isPlain(disk) && isPlain(base)) { // 둘 다 바꾼 객체: 안으로 들어가 잎 단위로 가른다
const out = {}; const ks = new Set([...Object.keys(mine), ...Object.keys(disk), ...Object.keys(base)]);
for (const k of ks) { const r = mergeValue(Object.prototype.hasOwnProperty.call(mine, k) ? mine[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, Object.prototype.hasOwnProperty.call(base, k) ? base[k] : undefined, `${pathKey}.${k}`, conflicts); if (r.present) out[k] = r.value; }
return { present: true, value: out };
}
conflicts.push(pathKey); return { present: mine !== undefined, value: mine };
}
/** 같은 잎을 둘 다 다르게 바꾼 저장 — 디스크는 그대로이고 호출자가 최신 기록 위에 다시 적용해야 한다(update) */
class SaveConflict extends Error {
constructor(id, fields) { super(`사이트 기록 저장 충돌: 다른 저장이 먼저 바꾼 항목을 이 저장도 다르게 바꿨습니다 (${fields.join(', ')}). 최신 기록을 다시 읽어 적용하세요`); this.code = 'site_save_conflict'; this.status = 409; this.siteId = id; this.fields = fields; }
}
/**
* 안전 경로: 최신 기록을 다시 읽어 mutate 를 적용한 뒤 저장한다(읽기 직후라 합칠 것이 없고, 그 사이 또 바뀌었으면 같은 규칙으로 충돌). 배경 작업이 충돌을 받았을 때 자기 결과만 다시 적용하는 데 쓴다.
* @returns {object|null} 저장된 최신 기록(없으면 null)
*/
function update(id, mutate, opts) { const fresh = load(id); if (!fresh) return null; mutate(fresh); return save(fresh, opts); }
/** 오래 들고 있던 객체를 최신 기록(fresh)으로 맞춘다 — 모든 열거 항목을 바꾸고 읽은 시점을 갱신한다(충돌 뒤 배경 작업이 같은 객체를 계속 쓸 수 있게) */
function adopt(site, fresh) { for (const k of Object.keys(site)) delete site[k]; Object.assign(site, fresh); return remember(site); }
/**
* 사이트 기록 저장. opts.restore=true 는 "실패한 저장을 되돌리는" 저장이다(server.js 직접 편집 저장 트랜잭션): 저장 전 스냅샷을 그대로 다시 쓴다.
* (Ariweb Suite 의 store.js 는 저장마다 개정 번호 rev 를 올리는데, restore 때는 올리지 않아 rev 도 저장 전으로 돌아간다. 단독 Webmaker 에는 rev 가 없어 opts 를 쓰지 않는다.)
*/
// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files·PUT /visual, Ariweb Suite §9.3).
// restore(되돌리기 저장)에서는 rev 를 올리지 않는다 — 스냅샷의 rev 가 그대로 남아 실패한 직접 편집 저장이 rev 를 바꾸지 않는다.
function save(site, opts) {
if (!(opts && opts.restore)) { const disk = mergeOntoDisk(site); if (disk && disk.rev !== undefined) site.rev = disk.rev; } // [Ariweb Suite 변경] 합칠 때 rev 는 디스크(최신) 값을 기준으로 올린다
recomputeTotals(site);
if (!(opts && (opts.restore || opts.keepRev))) site.rev = (Number(site.rev) || 0) + 1; // [Ariweb Suite 변경] keepRev: 원격 반영 조정처럼 내용 변경이 아닌 저장은 rev 를 올리지 않는다(합치기는 한다)
site.updatedAt = new Date().toISOString(); site.saveId = crypto.randomBytes(6).toString('hex');
js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true });
return remember(site);
}
/** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */
function load(id) {
if (!ID_RE.test(id)) return null;
return remember(js.readJson(fileOf(id), null, {}));
}
/** 손상된 사이트 파일 목록 (운영 복구 화면) */
function health() { return js.health().filter((h) => h.file.startsWith(DATA_DIR)); }
function list(ownerId) {
return fs.readdirSync(DATA_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => load(f.slice(0, -5)))
.filter(Boolean)
.filter((s) => !ownerId || s.ownerId === ownerId)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
// v1.2.0: 상단 사이트 선택 상자가 "생성 중 / 완성 / 가져옴·복사" 로 묶어 보여 주도록 mode·plan 단계도 함께 준다
.map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, status: s.status, totals: s.totals, commands: s.commands.length, mode: s.mode || null, stage: (s.plan && s.plan.stage) || null }));
}
function create(fields) {
const site = {
id: fields.id,
createdAt: new Date().toISOString(),
ownerId: fields.ownerId || null, ownerName: fields.ownerName || '',
title: fields.title, purpose: fields.purpose, content: fields.content,
design: fields.design, extra: fields.extra,
git: { seq: 0, changes: [] },
sessionId: fields.sessionId,
status: 'idle',
commands: [],
totals: emptyTotals(),
};
return save(site);
}
module.exports = { health, ID_RE, create, save, update, adopt, SaveConflict, load, list, DATA_DIR };