'use strict'; // 사이트 메타데이터/명령 이력/토큰 사용량을 data/sites/.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 };