Skip to content

Class: DomainState

createProxy()가 반환하는 도개교 세트. 외부에서는 proxy만 접근하고, 나머지는 DomainState 내부에서만 사용한다.

Constructors

Constructor

ts
new DomainState(proxyWrapper, options?): DomainState;

DomainState 인스턴스를 생성한다.

직접 호출 금지. fromJSON() / fromVO() 팩토리 메서드를 사용한다. FormBinder 플러그인 설치 후 fromForm()도 사용 가능하다.

생성 직후 debug: true이면 디버그 채널로 초기 상태를 broadcast한다.

Parameters

proxyWrapper

ProxyWrapper

createProxy()의 반환값 (도개교 세트)

options?

DomainStateOptions = {}

메타데이터 및 설정 옵션

Returns

DomainState

Properties

_clearChangeLog

ts
_clearChangeLog: () => void;

Returns

void


_clearDirtyFields

ts
_clearDirtyFields: () => void;

Returns

void


_debug

ts
_debug: boolean;

_errors

ts
_errors: any[];

_getChangeLog

ts
_getChangeLog: () => ChangeLogEntry[];

Returns

ChangeLogEntry[]


_getDirtyFields

ts
_getDirtyFields: () => Set<string>;

Returns

Set<string>


_getTarget

ts
_getTarget: () => object;

Returns

object


_handler

ts
_handler: ApiHandler | null;

_initialSnapshot

ts
_initialSnapshot: object | null;

lazy tracking mode의 diff 기준점. 인스턴스 생성(fromJSON/fromVO) 시점의 domainObject 깊은 복사본. save() 성공 후 현재 상태로 갱신된다. 'realtime' 모드에서는 null이며 사용되지 않는다.


_isNew

ts
_isNew: boolean;

_label

ts
_label: string;

_lazyItemKey

ts
_lazyItemKey: string | undefined;

lazy tracking mode에서 배열 LCS diff의 항목 동일성 기준 필드명. UILayout.static itemKey가 v1.4.x에서 덮어쓴다. 미지정 시 positional fallback.


_pendingFlush

ts
_pendingFlush: boolean;

_proxy

ts
_proxy: object;

_restoreChangeLog

ts
_restoreChangeLog: (entries) => void;

Parameters

entries

ChangeLogEntry[]

Returns

void


_restoreDirtyFields

ts
_restoreDirtyFields: (fields) => void;

Parameters

fields

Set<string>

Returns

void


_restoreTarget

ts
_restoreTarget: (data) => void;

Parameters

data

object

Returns

void


_trackingMode

ts
_trackingMode: "realtime" | "lazy";

_transformers

ts
_transformers: TransformerMap;

_urlConfig

ts
_urlConfig: 
  | NormalizedUrlConfig
  | null;

_validators

ts
_validators: ValidatorMap;

Accessors

data

Get Signature

ts
get data(): object;

변경 추적이 활성화된 Proxy 객체.

외부 개발자가 도메인 데이터에 접근하고 수정하는 유일한 공개 진입점이다. 이 Proxy를 통한 모든 필드 읽기/쓰기/삭제는 changeLog에 자동으로 기록된다.

Example
ts
const user = await api.get('/api/users/1');
console.log(user.data.name);        // 읽기
user.data.name = 'Davi';            // 쓰기 → changeLog: [{ op: 'replace', path: '/name', ... }]
user.data.address.city = 'Seoul';   // 중첩 쓰기 → path: '/address/city'
delete user.data.phone;             // 삭제 → op: 'remove'
Returns

object

Methods

_assertHandler()

ts
_assertHandler(method): ApiHandler;

handler가 주입되어 있는지 검사하고, 없으면 Error를 throw한다.

Parameters

method

string

호출한 메서드명 (에러 메시지 생성용)

Returns

ApiHandler

  • 안전한 핸들러 반환!

_broadcast()

ts
_broadcast(): void;

현재 DomainState의 스냅샷을 디버그 BroadcastChannel에 전파한다.

디버그 팝업이 열려있는 모든 탭이 이 메시지를 수신하여 data / changeLog / isNew / errors를 실시간으로 갱신한다.

debug: false이면 호출해도 broadcastUpdate가 채널을 초기화하지 않으므로 실질적으로 아무 동작도 하지 않는다.

Returns

void


_buildSnapshot()

ts
_buildSnapshot(currentData, prevSnapshot): object;

Structural Sharing 기반 불변 스냅샷을 빌드한다.

알고리즘 (depth-1 Structural Sharing)

dirtyFields(변경된 최상위 키 집합)를 기준으로 스냅샷을 구성한다.

조건처리 방식
prevSnapshot !== null + dirty 없음prevSnapshot 그대로 반환 (캐시 히트)
dirty 있음 / 최초 생성변경 키: 얕은 복사 / 나머지: 참조 재사용

배열: [...val], plain Object: { ...val }, Primitive: 값 그대로. Date / Map / Set은 참조를 그대로 공유한다. 현 VO 레이어에서 이들 타입이 실질적으로 사용되지 않으므로 단순화하였다.

Parameters

currentData

object

_getTarget()으로 얻은 원본 객체

prevSnapshot

object | null

이전 스냅샷. 최초 호출 시 null.

Returns

object

새로 조립된 스냅샷 객체 (freeze 이전 단계)


_notifyListeners()

ts
_notifyListeners(): void;

#listeners에 등록된 모든 리스너를 동기적으로 호출한다.

개별 리스너 에러를 격리하여 한 리스너의 실패가 나머지 실행을 막지 않는다.

Returns

void


_resolveURL()

ts
_resolveURL(requestPath): string;

_urlConfigrequestPath를 조합하여 최종 요청 URL을 생성한다.

URL 결정 우선순위

  1. this._urlConfig 사용
  2. this._urlConfig가 없으면 handler.getUrlConfig() 폴백
  3. 둘 다 없으면 빈 객체 {}buildURL 내부에서 Error throw

Parameters

requestPath

string | undefined

save() / remove()에서 전달된 경로

Returns

string

최종 완성된 요청 URL

Throws

URL을 확정할 수 없는 경우 (buildURL 내부에서 throw)


_rollback()

ts
_rollback(snapshot): void;

save() 실패 시 도메인 상태를 save() 진입 이전 스냅샷으로 복원한다.

복원 대상 4가지

대상복원 이유
domainObjectProxy target이 이미 변경된 상태. 서버와 불일치 제거.
changeLogsave() 재시도 시 올바른 PATCH payload 재생성 보장.
dirtyFieldssave() 재시도 시 올바른 PUT/PATCH 분기 판단 보장.
this._isNewPOST 실패 후 isNew 플래그 일관성 유지.

Proxy 우회

restoreTarget()은 Proxy가 아닌 원본 domainObject에 직접 접근하므로 복원 작업 자체가 changeLogdirtyFields에 기록되지 않는다.

디버그 채널 전파

debug: true이면 롤백 완료 후 _broadcast()를 호출하여 디버그 패널이 롤백된 상태를 즉시 반영하도록 한다.

Parameters

snapshot

save() 진입 직전에 확보한 상태 스냅샷

changeLog

ChangeLogEntry[]

data

object

dirtyFields

Set<string>

isNew

boolean

Returns

void


_scheduleFlush()

ts
_scheduleFlush(): void;

동일 동기 블록 내 다중 상태 변경을 단일 flush로 병합하는 마이크로태스크 배칭 스케줄러.

동작 원리

_pendingFlushfalse일 때만 queueMicrotask()로 flush를 예약한다. 동일 동기 블록의 추가 변경은 플래그 체크에서 차단되어 중복 예약 없이 건너뛴다. Call Stack이 비워지면 Microtask Queue가 실행되어 flush가 정확히 한 번 실행된다.

flush 실행 순서

1. pendingFlush = false          (다음 flush 예약 허용)
2. _buildSnapshot()              (Structural Sharing 기반 스냅샷 재빌드)
3. #shadowCache 갱신             (새 참조일 때만)
4. _notifyListeners()            (React / 외부 구독자 알림)
5. if (debug) _broadcast()       (디버그 채널 전파)

배칭에서 제외되는 두 호출

  • constructor 초기 스냅샷 빌드 : 인스턴스 생성 시 _buildSnapshot() 직접 호출.
  • save() 완료 후 _broadcast() : 서버 동기화 완료. onMutate 경로 미경유.

Returns

void


getSnapshot()

ts
getSnapshot(): object;

가장 최근에 생성된 불변 스냅샷을 반환한다.

useSyncExternalStore 규약 준수

  • 변경이 없으면 반드시 이전과 동일한 참조를 반환한다. 매번 새 객체를 반환하면 React가 무한 리렌더링 루프에 빠진다.
  • 반환값은 동결된 불변 객체다. 개발 환경에서만 deepFreeze 적용, 프로덕션에서는 no-op.

Vanilla JS / Vue 환경

React 없이도 사용 가능하다. Proxy가 아닌 순수 불변 객체가 필요할 때 이 메서드를 직접 호출한다.

Returns

object

현재 상태의 불변 스냅샷. 변경 시 새 참조 반환.

Example

ts
const snap1 = state.getSnapshot();
state.data.name = 'Davi';
await Promise.resolve(); // microtask flush 대기
const snap2 = state.getSnapshot();
console.log(snap1 === snap2);           // false — 새 참조
console.log(snap1.email === snap2.email); // true  — 미변경 키 Structural Sharing

log()

ts
log(): void;

현재 changeLog를 콘솔 테이블로 출력한다.

debug: false이면 아무 동작도 하지 않는다. 변경 이력이 없으면 '(변경 이력 없음)'을 출력한다.

Returns

void

Example

ts
const user = await api.get('/api/users/1', { debug: true });
user.data.name = 'Davi';
user.log(); // 콘솔 테이블에 changeLog 출력

openDebugger()

ts
openDebugger(): void;

디버그 팝업 창을 열거나, 이미 열려있으면 포커스한다.

debug: false이면 아무 동작도 하지 않는다. 브라우저 팝업 차단이 활성화된 경우 콘솔 경고를 출력한다.

Returns

void

Example

ts
const user = DomainState.fromVO(new UserVO(), api, { debug: true, label: 'UserVO' });
user.openDebugger();

remove()

ts
remove(requestPath?): Promise<void>;

해당 리소스를 서버에서 삭제한다. (HTTP DELETE)

응답 본문은 사용하지 않는다. 성공/실패는 response.ok로만 판단한다.

ApiHandler._idempotenttrue이면 요청마다 신규 UUID를 발급하여 Idempotency-Key 헤더를 자동 주입한다. DELETE는 HTTP 스펙상 이미 멱등성을 가지나, idempotent: true 설정 시 API Gateway 또는 미들웨어 레이어에서의 중복 처리 방지에 활용할 수 있다.

save()와 달리 재시도 UUID를 저장하지 않는다. DELETE 실패 재시도 시에는 신규 UUID가 발급된다.

Parameters

requestPath?

string

엔드포인트 경로. 미입력 시 urlConfig 사용.

Returns

Promise<void>

Throws

handler가 주입되지 않은 경우

Throws

HTTP 에러

Example

ts
await user.remove('/api/users/user_001');

restore()

ts
restore(): boolean;

인메모리 도메인 상태를 save() 진입 이전 스냅샷으로 복원한다.

DomainPipeline의 보상 트랜잭션(Compensating Transaction)에서 파이프라인이 자동으로 호출한다. 소비자가 직접 호출할 수도 있다.

복원 대상 (4가지)

save() 진입 직전 캡처된 #snapshot의 네 가지 상태를 복원한다.

  • domainObject (원본 데이터)
  • changeLog (변경 이력)
  • dirtyFields (변경된 필드 집합)
  • isNew 플래그

멱등성 보장

#snapshotundefined이면 경고 로그 후 false를 반환한다. 동일 인스턴스에 여러 번 호출해도 에러 없이 동일 결과를 낸다.

책임 범위

이 메서드는 프론트엔드 인메모리 상태만 복원한다. 서버에 이미 커밋된 상태를 되돌리는 것은 라이브러리 책임 범위 밖이며, 소비자가 dsm:rollback 이벤트를 구독하여 서버 롤백 API를 직접 호출해야 한다.

dsm:rollback 이벤트

복원 완료 후 브라우저 환경에서 CustomEvent('dsm:rollback')를 발행한다. 소비자 앱이 이 이벤트를 구독하여 사용자 알림을 표시할 수 있다.

Returns

boolean

복원 성공 시 true, 스냅샷 없어 no-op 시 false

Examples

ts
const result = await DomainState.all({ a: ..., b: ..., c: ... }, {
    failurePolicy: 'rollback-all',
}).after('a', s => s.save('/api/a'))
  .after('b', s => s.save('/api/b'))
  .after('c', s => s.save('/api/c'))
  .run();
ts
try {
    await userState.save('/api/users/1');
    await profileState.save('/api/profiles/1');
} catch (err) {
    userState.restore();  // 인메모리 상태 복원
    // 서버 롤백은 소비자 책임: DELETE /api/users/1 등
}
ts
window.addEventListener('dsm:rollback', (e) => {
    console.warn(`[UI] ${e.detail.label} 상태가 복원되었습니다.`);
    showErrorNotification('저장에 실패하여 이전 상태로 복원되었습니다.');
});

save()

ts
save(requestPath?): Promise<void>;

도메인 상태를 서버(DB)와 동기화한다.

HTTP 메서드 분기 전략 (Dirty Checking 기반)

isNew === true
    → POST  (toPayload — 전체 객체 직렬화)

isNew === false
    dirtyRatio = dirtyFields.size / Object.keys(target).length

    dirtyFields.size === 0           → PUT   (변경 없는 의도적 재저장)
    dirtyRatio >= DIRTY_THRESHOLD    → PUT   (변경 비율 70% 이상 — 전체 교체가 효율적)
    dirtyRatio <  DIRTY_THRESHOLD    → PATCH (변경 부분만 RFC 6902 Patch 배열로 전송)

Optimistic Update 롤백

save() 진입 직전 structuredClone()으로 현재 상태의 깊은 복사 스냅샷을 생성한다. HTTP 요청이 실패(4xx / 5xx / 네트워크 오류)하면 _rollback(snapshot)을 호출하여 domainObject, changeLog, dirtyFields, _isNew 4개 상태를 일관되게 복원한다. 복원 후 에러를 반드시 re-throw하여 호출자가 처리할 수 있게 한다.

structuredClone 전제

스냅샷은 structuredClone()을 사용하므로 domainObject 내부에 함수, DOM 노드, Symbol 등 구조화된 복제가 불가능한 값이 있으면 throw된다. REST API JSON 응답 데이터(문자열, 숫자, 배열, 플레인 객체)만 담는 일반적인 DTO에서는 문제가 발생하지 않는다.

동기화 성공 후 처리

  • PUT / PATCH 성공 → clearChangeLog() + clearDirtyFields() 동시 초기화
  • POST 성공 → isNew = false 전환 후 동일하게 초기화
  • debug: true_broadcast() 호출

requestPath 결정 순서

  1. requestPath 인자 명시 → 그대로 사용
  2. 없음 → this._urlConfig 또는 handler.getUrlConfig() 사용
  3. 둘 다 없음 → buildURL 내부에서 Error throw

Idempotency-Key 자동 발급

ApiHandler 인스턴스의 _idempotent 옵션이 활성화된 경우:

  • save() 최초 진입 시 crypto.randomUUID()로 UUID를 발급하여 #idempotencyKey에 저장한다.
  • 모든 _fetch() 호출의 headersIdempotency-Key: {uuid} 헤더를 자동 주입한다.
  • 성공 시: #idempotencyKey = undefined (다음 save()는 신규 UUID 발급).
  • 실패 시: #idempotencyKey 유지 — 소비자 catch 블록에서 save()를 재호출하면 동일 UUID가 자동으로 재사용되어 서버 측 중복 처리를 방지한다.

Parameters

requestPath?

string

엔드포인트 경로 (예: '/api/users/1'). urlConfig와 조합된다.

Returns

Promise<void>

Throws

handler가 주입되지 않은 경우 (_assertHandler)

Throws

URL을 확정할 수 없는 경우 (buildURL)

Throws

HTTP 에러 (서버가 4xx / 5xx 반환 시)

Examples

ts
await user.save('/api/users/user_001');
ts
try {
    await user.save('/api/users/1');
} catch (err) {
    // err: { status: 409, statusText: 'Conflict', body: '...' }
    // 이 시점에 user.data는 save() 호출 이전 상태로 자동 복원되어 있다.
    console.error('저장 실패, 상태 롤백 완료:', err.status);
}
ts
for (let attempt = 0; attempt < 3; attempt++) {
    try {
        await user.save('/api/users/1');
        break;
    } catch (err) {
        if (attempt === 2) throw err; // 3회 실패 시 상위로 전파
        // 롤백된 상태 그대로 재시도 가능
    }
}

subscribe()

ts
subscribe(listener): () => void;

상태 변경 시 호출될 리스너를 등록한다.

useSyncExternalStoresubscribe 인자로 직접 전달할 수 있다. Proxy 변경 → microtask 배치 완료 → _buildSnapshot() 직후 리스너가 호출된다.

Parameters

listener

() => void

상태 변경 시 호출될 콜백. 인자를 받지 않는다.

Returns

구독 해제 함수. useSyncExternalStore에 전달하는 cleanup.

() => void

Examples

ts
const data = useSyncExternalStore(
    (cb) => state.subscribe(cb),
    ()   => state.getSnapshot()
);
ts
// import { useDomainState } from '@2davi/rest-domain-state-manager/adapters/react';
const data = useDomainState(state);

all()

ts
static all(resourceMap, options?): object;

여러 DomainState를 병렬로 fetch하고, 후처리 핸들러를 순서대로 체이닝하는 DomainPipeline 인스턴스를 반환한다.

내부적으로 _pipelineFactory(모듈 클로저 변수)를 호출한다. DomainState.configure()를 통해 팩토리가 주입되지 않으면 즉시 Error를 throw한다.

Parameters

resourceMap

ResourceMap

키: 리소스 식별자, 값: Promise<DomainState>

options?

PipelineOptions = {}

파이프라인 실행 옵션

Returns

object

체이닝 가능한 DomainPipeline 인스턴스. after() / run() 메서드를 제공한다.

Throws

configure()가 호출되지 않은 경우

Example

ts
const result = await DomainState.all({
    roles: api.get('/api/roles'),
    user:  api.get('/api/users/1'),
}, { strict: false })
.after('roles', async roles => { roles.renderTo('#roleDiv', { type: 'select', ... }); })
.after('user',  async user  => { user.bindForm('#userForm'); })
.run();

if (result._errors?.length) console.warn(result._errors);

configure()

ts
static configure(config?): typeof DomainState;

라이브러리 의존성 및 전역 동작을 설정하는 메서드.

DomainStateDomainPipeline의 순환 참조를 제거하기 위해, DomainPipeline 생성자를 직접 import하지 않고 팩토리 함수로 주입받는다.

pipelineFactory는 직접 호출 불필요. 라이브러리 진입점(index.js)이 모듈 평가 시점에 자동으로 주입한다.

Vitest 환경에서는 configure({ pipelineFactory: vi.fn() })으로 DomainPipeline을 로드하지 않고도 DomainState 단독 테스트가 가능하다.

Parameters

config?

configure()의 config 파라미터

pipelineFactory?

(...args) => object

(resourceMap, options) => DomainPipeline 형태의 팩토리 함수. DomainState.all() 호출 전에 반드시 주입되어야 한다.

silent?

boolean

true이면 라이브러리 내부의 모든 console 출력을 억제한다. 통합 테스트 또는 콘솔 오염을 막아야 하는 운영 환경에서 사용한다.

Returns

typeof DomainState

체이닝용 DomainState 클래스 반환

Throws

pipelineFactory가 전달됐지만 함수가 아닐 때

Examples

ts
DomainState.configure({
    pipelineFactory: (resourceMap, options) => new DomainPipeline(resourceMap, options)
});
ts
DomainState.configure({ pipelineFactory: vi.fn(() => ({ run: vi.fn() })) });
ts
DomainState.configure({ silent: true });
ts
DomainState.configure({ pipelineFactory: factory, silent: true }).use(FormBinder);

fromJSON()

ts
static fromJSON(
   jsonText, 
   handler, 
   options?): DomainState;

REST API GET 응답 JSON 문자열로부터 DomainState를 생성한다. (isNew: false)

주로 ApiHandler.get() 내부에서 호출된다. 직접 호출 시 이미 가진 JSON 문자열을 DomainState로 변환할 때 사용한다.

처리 흐름

  1. toDomain(jsonText, onMutate)로 JSON 파싱 및 Proxy 생성.
  2. DomainState 인스턴스 생성 (isNew: false).
  3. options.vo가 주어진 경우: 스키마 검증 후 validators / transformers 주입.

onMutate 콜백 클로저 패턴

state 변수를 null로 먼저 선언한 뒤 createProxyonMutate에서 참조한다. 이렇게 하면 DomainState 생성 전에 Proxy가 먼저 만들어지는 순서 문제를 클로저를 통해 자연스럽게 해소할 수 있다.

Parameters

jsonText

string

response.text()로 읽은 JSON 문자열

handler

ApiHandler

ApiHandler 인스턴스

options?

FromJsonOptions = {}

추가 옵션

Returns

DomainState

isNew: false인 새 DomainState 인스턴스

Throws

jsonText가 유효하지 않은 JSON일 때

Examples

ts
const user = await api.get('/api/users/1');
user.data.name = 'Davi'; // → changeLog에 replace 기록
await user.save('/api/users/1'); // → PATCH 전송
ts
const user = DomainState.fromJSON(jsonText, api, { vo: new UserVO(), debug: true });
ts
const user = DomainState.fromJSON(jsonText, api);
user.bindForm('userForm'); // FormBinder.bindForm() 호출

fromVO()

ts
static fromVO(
   vo, 
   handler, 
   options?): DomainState;

DomainVO 인스턴스로부터 기본값 골격 DomainState를 생성한다. (isNew: true)

DomainVO.toSkeleton()으로 기본값 객체를 생성하고 Proxy로 감싼다. validators / transformers가 자동으로 주입되며, save() 시 POST를 전송한다.

urlConfig 결정 순서

  1. options.urlConfig 명시 → 그대로 사용
  2. options.urlConfig 없음 + vo.getBaseURL() 있음 → normalizeUrlConfig({ baseURL }) 적용
  3. 둘 다 없음 → null (save() 시 handler.getUrlConfig() 폴백)

Parameters

vo

DomainVO

기본값 / 검증 / 변환 규칙을 선언한 DomainVO 인스턴스

handler

ApiHandler

ApiHandler 인스턴스

options?

FromVoOptions = {}

추가 옵션

Returns

DomainState

isNew: true인 새 DomainState 인스턴스

Throws

voDomainVO 인스턴스가 아닐 때

Examples

ts
class UserVO extends DomainVO {
    static baseURL = 'localhost:8080/api/users';
    static fields  = {
        userId: { default: '' },
        name:   { default: '', validate: v => v.trim().length > 0 },
    };
}
const newUser = DomainState.fromVO(new UserVO(), api, { debug: true });
newUser.data.userId = 'user_' + Date.now();
newUser.data.name   = 'Davi';
await newUser.save(); // → POST to static baseURL
ts
const newUser = DomainState.fromVO(new UserVO(), api, {
    urlConfig: { host: 'staging.server.com', basePath: '/api' },
});

use()

ts
static use(plugin): typeof DomainState;

플러그인을 DomainState에 등록한다.

plugin.install(DomainState)를 호출하여 prototype 또는 정적 멤버를 확장한다. 동일한 플러그인 객체(참조 기준)를 여러 번 use()해도 install()은 1회만 실행된다. use() 자체가 DomainState 클래스를 반환하므로 체이닝이 가능하다.

Parameters

plugin

DsmPlugin

{ install(DomainState): void } 계약을 가진 플러그인 객체

Returns

typeof DomainState

체이닝용 DomainState 클래스 반환

Throws

plugin.install이 함수가 아닐 때

Examples

ts
DomainState.use(DomainRenderer).use(FormBinder);
ts
const CsvPlugin = {
    install(DomainStateClass) {
        DomainStateClass.prototype.toCSV = function () {
            return Object.values(this._getTarget()).join(',');
        };
    }
};
DomainState.use(CsvPlugin);

Released under the ISC License.