2026-10-26 11:02:58 +00:00
'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' );
2026-10-28 15:07:35 +00:00
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 는 디스크(최신) 값을 기준으로 올린다
2026-10-26 11:02:58 +00:00
recomputeTotals ( site );
2026-10-28 15:07:35 +00:00
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' );
2026-10-26 11:02:58 +00:00
js . writeJsonAtomic ( fileOf ( site . id ), JSON . stringify ( site , null , 2 ), { mode : 0o600 , keepBackup : true });
2026-10-28 15:07:35 +00:00
return remember ( site );
2026-10-26 11:02:58 +00:00
}
/** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */
function load ( id ) {
if ( ! ID_RE . test ( id )) return null ;
2026-10-28 15:07:35 +00:00
return remember ( js . readJson ( fileOf ( id ), null , {}));
2026-10-26 11:02:58 +00:00
}
/** 손상된 사이트 파일 목록 (운영 복구 화면) */
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 );
}
2026-10-28 15:07:35 +00:00
module . exports = { health , ID_RE , create , save , update , adopt , SaveConflict , load , list , DATA_DIR };