feat: 작업실 저장 개편 — 전체문서 PUT을 프로젝트 단위 PUT으로 + LOW 2건

저장 개편(리뷰 #4 근본치료):
- PUT /api/workshop/project/:id — 서버가 디스크의 현재 문서에 해당 프로젝트만
  upsert. 대시보드 저장과 채팅 도구 저장이 겹쳐도 서로 다른 프로젝트 편집이
  통째로 사라지지 않음. DELETE /api/workshop/project/:id,
  PUT /api/workshop/active(포인터 단독) 신설. 옛 전체문서 PUT은 스테일 탭
  호환으로 유지
- 클라이언트: dirtyProjects 추적 → saveProject는 바뀐 프로젝트만 순차 PUT,
  활성 포인터는 putActive() 즉시 저장, 프로젝트 삭제는 DELETE 직접 호출,
  beforeunload flush도 프로젝트 단위 keepalive PUT으로 전환
- upsertProject/removeProject를 순수함수로 추출, 단위테스트 4건

LOW 2건:
- loadFiles에 요청 일련번호 — 빠른 프로젝트 전환 시 옛 응답이 나중에 도착해
  이전 프로젝트 파일을 새 판에 그리는 레이스 제거
- 슬라이서 인계 ?load=를 JSON 배열 인코딩으로 전환(파일명에 쉼표 있으면
  옛 쉼표 join이 깨지던 문제). 슬라이서 쪽은 JSON 우선, 옛 형식 폴백 유지

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-24 07:38:53 +09:00
co-authored by Claude Code
parent f42a09b038
commit eb516e2b74
5 changed files with 170 additions and 9 deletions
+40 -1
View File
@@ -36,4 +36,43 @@ test('projectToReadme: 개요 섹션은 description이 있을 때만', () => {
test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => {
const md = projectToReadme(sampleProject());
assert.ok(md.includes(' - 구매처: https://example.com'));
});
});
// 프로젝트 단위 저장 API(2026-09-24)의 upsert/remove — 서버는 디스크의 현재 문서에
// 해당 프로젝트만 반영하므로 다른 프로젝트는 절대 덮이지 않아야 한다.
import { upsertProject, removeProject, WorkshopData } from '../src/gateway/routes/workshop-storage.js';
function sampleData(): WorkshopData {
const a = sampleProject({ id: 'proj_a', name: 'A' });
const b = sampleProject({ id: 'proj_b', name: 'B' });
return { projects: [a, b], activeProjectId: 'proj_a' };
}
test('upsertProject: 기존 프로젝트는 교체, 나머지는 보존', () => {
const data = sampleData();
const edited = sampleProject({ id: 'proj_b', name: 'B-수정됨', notes: '채팅 도구가 저장' });
upsertProject(data, edited);
assert.equal(data.projects.length, 2);
assert.equal(data.projects[1].name, 'B-수정됨');
// 다른 프로젝트(A)의 내용은 그대로 — 전체문서 PUT이던 시절엔 여기가 유실됐다
assert.equal(data.projects[0].name, 'A');
assert.equal(data.projects[0].parts[0].name, 'STS3215 서보');
});
test('upsertProject: 없는 프로젝트는 추가, 활성 포인터가 무효하면 복구', () => {
const data = sampleData();
data.activeProjectId = 'proj_gone';
upsertProject(data, sampleProject({ id: 'proj_new', name: '새것' }));
assert.equal(data.projects.length, 3);
assert.equal(data.activeProjectId, 'proj_a');
});
test('removeProject: 삭제되면 true, 활성 포인터가 가리키던 프로젝트면 첫 프로젝트로', () => {
const data = sampleData();
assert.equal(removeProject(data, 'proj_a'), true);
assert.equal(data.projects.length, 1);
assert.equal(data.activeProjectId, 'proj_b');
});
test('removeProject: 없는 프로젝트면 false(404 응답용)', () => {
assert.equal(removeProject(sampleData(), 'proj_none'), false);
});