import { ModelId } from '../core/types'; import { Command, EditorState } from './types'; /** rev CAS 대상 축 — logical(컨테이너 rev) / layout(layout.version, LWW). M2 §4.2 */ export type CasUnit = 'logical' | 'layout'; /** * command가 산출하는 단일 op의 어휘 형태. `{target}.{verb}` kind + 로케이터 + 페이로드. * CAS 메타는 여기 없다 — emit 어댑터 책임(계약 3). */ export interface OpShape { /** `{target}.{verb}` — 'attribute.add' 등. M2 §3 어휘 인벤토리. */ kind: string; casUnit: CasUnit; /** rev CAS를 거는 대상 컨테이너(entity/association/group). layout op·top-level add/remove는 없음. */ containerRef?: ModelId; entityRef?: ModelId; attributeRef?: ModelId; associationRef?: ModelId; groupRef?: ModelId; noteRef?: ModelId; indexRef?: ModelId; operationRef?: ModelId; targetRef?: ModelId; end?: 'end1' | 'end2'; /** add: 직렬화 전 core 모델 (호스트가 lib 직렬화기로 doc element 변환, A2). */ value?: unknown; /** update: 부분 patch. */ patch?: Record; /** * 부수(incidental) 레이아웃 op 마커(M2 §3.3·§4.2) — 구조 op(entity/group/association * add·remove)가 동반하는 layout 엔트리 생성/삭제. `layout.version` 검사·증가 비대상. * 어댑터가 `cas.layout`에서 제외하는 분기 신호. */ incidental?: boolean; } /** * update patch의 **clear 표기 정규화** — 값이 `undefined`인 키(= 그 필드를 부재로 되돌린다)를 `null`로 바꾼다. * * ★왜 필요한가: op은 JSON으로 호스트에 전송되는데 `JSON.stringify`가 **값이 undefined인 키를 통째로 드롭**한다. * 그러면 그 키가 호스트 `$set`에 도달하지 못해 **로컬은 지웠는데 서버는 그대로**인 괴리가 남는다. 발동 경로는 * 둘 다 일상 편집이다 — * ① **undo**: 원래 비어 있던 필드를 채운 뒤 되돌리면 invert patch가 `{field: undefined}`가 된다 * (`updateEntity` 등이 `old[k] = e[k]`로 캡처하므로 부재 필드는 undefined로 잡힌다). * ② **명시적 clear**: 컨트롤러가 `{legacyEmbedRaw: undefined}`·`{attributeRef: undefined}`· * 임베드 단일 전환의 `{collectionType/collectionTable/orderColumn: undefined}`처럼 "비움"을 patch로 낸다. * * `null`은 wire를 통과하고 호스트가 `$set field: null`로 집행한다. 이 리포는 **null ≡ 부재** 규약을 이미 * 쓰고 있어(`synthesizeRestoreOps`의 `fieldPatch`가 같은 이유로 clear를 null로 표기 + `deepEqual`이 둘을 동치로 * 비교, FK 전파 `diffSyncedFields`도 저장 null을 undefined로 정규화해 비교) 저장값 null은 부재와 같게 읽힌다. * * ★**얕은 정규화만** 한다 — 중첩 객체(`jpaAttrs`·`jpa`·`dbAttrs`)는 호스트가 **통째로 `$set`**하므로 내부의 * undefined 키는 드롭돼도 결과가 "그 키 부재"라 의미가 그대로 보존된다. 최상위 키만 `$set` 경로로 1:1 매핑된다. */ export declare function toWirePatch(patch: object): Record; /** * apply 이후 호출 계약(계약 1)의 산출 — memento가 채워진 상태에서 do/undo 양방향 op. * invert는 *역방향 forward op*(계약 2) — 같은 op의 역값이 아니라 역연산 op. */ export interface OpEmission { /** do/redo 시 호스트로 보낼 op 배치 (apply 순서). */ forward: OpShape[]; /** undo 시 호스트로 보낼 역연산 forward op 배치 (invert 순서). */ invert: OpShape[]; } /** op 출처 — 레거시 updateId 관례 계승(감사·aggregation 트리거 분기용). */ export type OpOrigin = 'gui' | 'agent' | 'rest'; /** * 배치 CAS 단언 — 건드리는 logical 컨테이너의 base rev + (포함 시) base layout.version. * 호스트가 updateOne filter로 접어 충돌(matchedCount:0)을 감지한다(M2 §4.3). */ export interface OpCas { /** 건드리는 logical 컨테이너의 base rev (update/내부변경). top-level add/remove는 비대상. */ revs?: Record; /** 의도적(non-incidental) layout op 포함 시 base layout.version. */ layout?: number; } /** 한 제스처 = 한 원자 배치 = 한 updateOne(M2 §4.1). */ export interface PersistedOpBatch { diagramId: string; ops: OpShape[]; cas: OpCas; origin: OpOrigin; /** 멱등 키 — 재시도 중복 적용 차단(호스트가 직전 결과 반환). */ clientOpId: string; } /** * 하드 프리즈 사유 — **방출 주체가 kind마다 다르다**(선언과 실제를 맞춘 기록, 2026-08-15 실측). * * - `rev` — **호스트가 내는 유일한 kind**. CAS filter 불일치(`matchedCount:0`)를 전부 이걸로 접는다. * 호스트는 rev 불일치와 layout.version 불일치를 구분하지 않으므로(단일 updateOne filter) 두 원인이 * 모두 여기로 들어온다. = "다른 곳에서 이미 변경됨". * - `layout` — **현재 어떤 writer도 방출하지 않는다**(예약). 위 사유로 호스트가 `rev`로 접고, 클라도 * 합성하지 않는다. 소비처(배너·호스트 watch)는 `rev`와 같은 경로로 처리한다. * - `missing` — **클라이언트 합성 전용 = op 전송 실패**(4xx/5xx/네트워크로 transport가 throw). * 동시편집이 아니라 혼자 편집 중에도 발생하므로 "다른 사용자가 변경"으로 안내하면 오진이다 * (`opConflictNotice`·호스트 watch가 이 kind로 분기해 손실 고지 후 재적재를 확인받는다). * ★이름이 "대상 소실"처럼 읽히지만 그 의미로 쓰인 적이 없다 — 호스트 op 어휘엔 대상 소실 응답이 없다. * * ⚠️ 새 kind를 늘리기 전에 **소비처 분기**(`opConflictNotice` + 호스트 `opConflict` watch)를 함께 넓힐 것. * 넓히지 않으면 새 kind가 else 분기로 떨어져 조용히 다른 복구 경로를 탄다(CC-9의 무신호 실패 모드). */ export interface OpConflict { kind: 'rev' | 'layout' | 'missing'; ref?: ModelId; current?: number; } /** 호스트 op 집행 응답 — ok면 갱신 rev/version, 충돌이면 하드 프리즈 트리거. */ export type OpResult = { ok: true; revs: Record; layoutVersion: number; logicalVersion: number; } | { ok: false; conflict: OpConflict; }; /** * ── Command 계획을 **op 배열**로 펼친다 — GUI composite 를 AI(op) 경로에서 재사용하는 다리 ── * * ★**왜 필요한가**(2026-09-03 사용자 목표: *「궁극적으로는 사람이 조작 안 하는 게 목표」*): resolver 가 * 구조 거부로 막아 온 전환들(`stereotype-embeddable-transition-unsupported` 등)은 *표현 불가*가 아니라 * **「동반 명령을 가진 composite 이고 그건 GUI 소유」**였다. 그 composite 는 이미 순수 플래너 * (`editor/commandPlans`)로 존재하므로, 남은 것은 **그 계획을 op 로 옮기는 한 자리**뿐이다. * * ★★**왜 op 를 «다시 조립»하지 않는가** — 같은 산식을 두 곳에서 쓰면 갈린다. 여기서는 **GUI 가 태우는 * 바로 그 `Command` 들을 그대로 태우고** 각자의 `emitOps` 를 모은다 ⇒ 두 경로가 구조적으로 같은 결과를 * 낸다(이 리포의 「두 소비처 단일 출처」 관용구). * * ⚠️**apply 후 emitOps 계약**(`types.ts` `Command.emitOps` 주석)을 지킨다 — invert op 이 apply 가 캡처한 * memento 에 의존하므로 순서를 바꾸면 조용히 틀린 op 이 나온다. 그래서 **초안 상태를 실제로 전진시키며** * 하나씩 방출한다(계획 전체를 미리 적용한 뒤 한꺼번에 방출하는 형태가 아니다). * * ⚠️입력 상태는 **변형하지 않는다** — 초안 사본에만 적용한다(`fromPersisted` 의 입력 비공유 계약과 동형). * ⚠️`emitOps` 미구현 명령은 op 를 내지 않는다(옵셔널 계약) ⇒ 호출부가 **누락을 감지**해야 조용히 새지 * 않는다. 그래서 방출 0 인 명령을 `skipped` 로 돌려준다(무신호 금지). */ export interface CommandOpPlan { /** apply 순서의 forward op — 그대로 배치에 실으면 된다. */ ops: OpShape[]; /** `emitOps` 가 없거나 forward 가 비어 op 를 내지 않은 명령의 라벨(감사용 · 무신호 방지). */ skipped: string[]; } export declare function planOpsFromCommands(state: EditorState, commands: readonly Command[]): CommandOpPlan;