요약 (S0 correctness)
기존 object 멤버를 덮어쓰는 add/copy operation의 inverse가 replace(old value)가 아니라 remove로 계산되어, undo 시 원래 값이 조용히 유실됩니다.
재현 (public API)
const doc = createJSONDocument(z.object({ a: z.number(), d: z.number() }), { a: 50, d: 84 }, { history: 100 });
doc.patch({ op: "add", path: "/a", value: 64 }); // → { a: 64, d: 84 } (forward 정상)
doc.undo(); // → { d: 84 } ❌ a:50 유실 (기대 { a:50, d:84 })
// copy 도 동일
// { a:1, b:2 } 에서 copy /a→/b → { a:1, b:1 } → undo → { a:1 } ❌ b:2 유실
{ history: N } 로 undo 를 켠 정상 사용 경로에서 발생합니다.
원인
src/foundation/patch/inverse.ts:33-44 inverseOp 의 add/copy 케이스가 항상 { op: "remove", path } 를 반환합니다.
- 배열 index 삽입은 shift 이므로
remove 가 옳지만,
- object 멤버 덮어쓰기는 이전 값을
replace 로 복원해야 합니다.
multi-op 루트 변형 computeRootObjectAddInverses (inverse.ts:275) 는 objectHasOwn → replace(old value) 로 이미 올바르게 처리하지만, single-op 경로와 generic fallback loop 는 처리하지 않습니다.
core-standard §5/§11 (undo 는 기록된 document 상태로 복원 MUST) 위반.
수정 방향
inverseOp add/copy 케이스에서 대상 부모가 object 이고 최종 key 가 이미 존재하면 replace(old value), 그 외(배열 삽입/신규 key)는 remove. root path "" 는 replace("", before).
요약 (S0 correctness)
기존 object 멤버를 덮어쓰는
add/copyoperation의 inverse가replace(old value)가 아니라remove로 계산되어, undo 시 원래 값이 조용히 유실됩니다.재현 (public API)
{ history: N }로 undo 를 켠 정상 사용 경로에서 발생합니다.원인
src/foundation/patch/inverse.ts:33-44inverseOp의 add/copy 케이스가 항상{ op: "remove", path }를 반환합니다.remove가 옳지만,replace로 복원해야 합니다.multi-op 루트 변형
computeRootObjectAddInverses(inverse.ts:275) 는objectHasOwn→replace(old value)로 이미 올바르게 처리하지만, single-op 경로와 generic fallback loop 는 처리하지 않습니다.core-standard §5/§11 (undo 는 기록된 document 상태로 복원 MUST) 위반.
수정 방향
inverseOpadd/copy 케이스에서 대상 부모가 object 이고 최종 key 가 이미 존재하면replace(old value), 그 외(배열 삽입/신규 key)는remove. root path""는replace("", before).