From 71814a8320d50062139b18088b2eb62a3240f0b8 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 7 Jul 2026 23:49:20 +0900 Subject: [PATCH 01/28] add context --- CONTEXT.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..222b13044 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,59 @@ +# Stackflow — Ubiquitous Language + +## Terms + +### 초기화 (Init) + +새 Stack을 처음부터 만들어내는 진입 경로. 보존된 탐색 맥락이 없는 상태에서 시작한다. +defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 의미상 push다 — +따라서 activity 진입 가드의 적용 대상이다. + +### 복원 (Load) + +보존된 탐색 맥락(스냅샷)으로부터 Stack을 재구성하는 모든 진입 경로. 저장 매체와 +무관하다 — persister의 storage 스냅샷이든 `history.state` 복원이든, 스냅샷에서 +스택을 되살리면 load다. load로 만들어진 스택은 보존 시점에 이미 검증된 것으로 +간주하므로 activity 진입 가드를 건너뛴다. + +### 진입 (Entry) + +Activity가 Stack에 나타나게 되는 사건. Init 경로의 최초 진입은 의미상 push이며, +플러그인이 가로챌(검사·차단·변경) 수 있어야 한다. Load 경로의 진입은 push가 +아니다 — 이미 검증된 맥락의 재구성이므로 가로채기 대상이 아니다. + +### 스냅샷 (Snapshot) + +보존된 탐색 맥락. load 경로의 입력이 되는, 과거에 정상 상태였던 Stack의 보존물. +형식은 core가 소유한다 — 현재 Stack에서 스냅샷을 얻는 방법(캡처)과 스냅샷으로부터 +Stack을 만드는 방법(load)이 모두 core 계약에 속하며, 캡처→보존→load 왕복은 core +계약만으로 성립한다. 공급자(persister)는 언제 캡처하고 어디에 보존하며 언제 +버릴지만 책임진다. + +### 정상 상태 (Normal State) + +Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plugins +(+ react integration) 조합의 동작으로 만들어질 수 있는 상태는 모두 정상 상태다. +활성 activity 개수(1개 이상 무엇이든), exit 상태 activity의 존재, 전환 진행 여부와 +무관하다 — 도달 가능성(reachability)이 기준이다. Load의 사후조건은 "스냅샷이 +보존한 정상 상태의 충실한 재구성"이다. + +## Relationships + +- Stack은 Init 또는 Load 중 정확히 하나의 경로로 만들어진다. +- Init ↔ Load 구분은 core가 표현한다 — 플러그인(guard, persister, history-sync)이 + 진입 경로별 정책을 세우는 근거가 된다. +- Load는 Stack 생성 시점에만 일어난다. 스냅샷은 생성 시점에 동기적으로 주어져야 + 하며, 생성된 Stack은 그 순간부터 항상 정상 상태다 — "복원 대기 중" 같은 중간 + 상태는 존재하지 않는다. 비동기 스냅샷 소스의 수용은 스택 생성을 지연하는 상위 + 레이어의 부트스트랩 문제다. +- Load 실패(손상된 스냅샷, 등록 해제된 activity 등)는 조용한 폴백이 아니라 + 명시적 에러다. 유효하지 않은 스냅샷으로부터 정상 상태 Stack이 몰래 만들어지는 + 일은 없다. +- Load 실패 에러의 1차 처리 책임은 스냅샷 공급자(persister 등)에게 있다. + 공급자가 복구 정책(스냅샷 폐기, init 재시도 등)을 결정하며, 앱 개발자는 + 기본적으로 이 에러를 다루지 않는다. +- Init ↔ Load 구분은 Stack 생성 시점의 일회성 신호다. 구분이 Stack 상태에 지속 + 속성으로 남지는 않는다 — 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 + 한다. +- Stack 생성은 스냅샷을 최대 하나만 받는다. 복수 복원 후보의 선택·조정은 Stack + 생성 이전 계층(공급자들)의 책임이며, core는 조정자 역할을 맡지 않는다. From 33e0805e72a043122fa82bfe07a7ac64c39ddf33 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 00:36:44 +0900 Subject: [PATCH 02/28] docs: add FEP-2548 mechanism design run plan, extend domain glossary - CONTEXT.md: add Navigation History term; narrow load fidelity scope to navigation history (R12); snapshot codec is outside core contract (R13) - run-plan-fep-2548-mechanism.md: diverge-converge-refine run plan (7 seeded generators -> curation -> league tournament -> review-loop) Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 14 ++- run-plan-fep-2548-mechanism.md | 183 +++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 run-plan-fep-2548-mechanism.md diff --git a/CONTEXT.md b/CONTEXT.md index 222b13044..5cd67f6c5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -21,13 +21,25 @@ Activity가 Stack에 나타나게 되는 사건. Init 경로의 최초 진입은 플러그인이 가로챌(검사·차단·변경) 수 있어야 한다. Load 경로의 진입은 push가 아니다 — 이미 검증된 맥락의 재구성이므로 가로채기 대상이 아니다. +### 탐색 기록 (Navigation History) + +Stack이 표현하는 항해의 기록 — 무엇을 지금 보고 있고, 무엇을 봤었으며, 어떤 순서로 +보았는가. 탐색 맥락의 필수 핵심이다. 복원 후 이전과 같이 네비게이션할 수 있으면 +탐색 기록은 보존된 것이다. + ### 스냅샷 (Snapshot) 보존된 탐색 맥락. load 경로의 입력이 되는, 과거에 정상 상태였던 Stack의 보존물. +필수 내용은 탐색 기록이다 — 전환 진행 상태, 일시정지 상태, 등록 activity 목록 같은 +나머지 상태는 부가적이며 복원 비필수다(특히 전환 진행 정보는 프로세스 생애주기를 +넘나들며 다루기 어려워 폐기가 열린 선택이다). 형식은 core가 소유한다 — 현재 Stack에서 스냅샷을 얻는 방법(캡처)과 스냅샷으로부터 Stack을 만드는 방법(load)이 모두 core 계약에 속하며, 캡처→보존→load 왕복은 core 계약만으로 성립한다. 공급자(persister)는 언제 캡처하고 어디에 보존하며 언제 버릴지만 책임진다. +단, core가 소유하는 형식은 구조(무엇이 담기는가)다 — 직렬화(codec)는 core 계약이 +아니며 스냅샷을 보존 매체에 싣는 방법은 사용하는 쪽의 책임이다. core는 스냅샷 안의 +값(activityParams·activityContext 등)이 직렬화 가능한지 전제하지 않는다. ### 정상 상태 (Normal State) @@ -35,7 +47,7 @@ Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plug (+ react integration) 조합의 동작으로 만들어질 수 있는 상태는 모두 정상 상태다. 활성 activity 개수(1개 이상 무엇이든), exit 상태 activity의 존재, 전환 진행 여부와 무관하다 — 도달 가능성(reachability)이 기준이다. Load의 사후조건은 "스냅샷이 -보존한 정상 상태의 충실한 재구성"이다. +보존한 정상 상태의 충실한 재구성"이며, 그 충실성의 필수 범위는 탐색 기록이다. ## Relationships diff --git a/run-plan-fep-2548-mechanism.md b/run-plan-fep-2548-mechanism.md new file mode 100644 index 000000000..60a7070f7 --- /dev/null +++ b/run-plan-fep-2548-mechanism.md @@ -0,0 +1,183 @@ +# Run Plan — FEP-2548 core init/load 구분 메커니즘 설계 확정 (발산→수렴→정련) + +## 1. 과제 스펙 · 판단 기준 + +### 과제 + +stackflow core의 Stack 초기화(init)/복원(load) 경로 구분에 대한 **메커니즘 설계를 확정**한다. +요구사항은 2026-07-07 인터뷰로 확정 완료(Linear FEP-2548 코멘트, 레포 `CONTEXT.md` 용어 정의). +서로 다른 메커니즘이 트레이드오프를 두고 경쟁할 수 있는 문제이므로, 후보를 발산 생성해 +선별·상대 비교로 수렴한 뒤 우승안을 완전한 설계서로 정련한다. 코드 구현은 후속 작업이며 +이 run의 산출물이 아니다. + +**최종 산출물**: 메커니즘 설계서 1부(마크다운) + `decision-log.md`. 설계서는 자기완결이어야 +하며 다음을 포함한다: + +- 플러그인 향한 공개 계약 — 스냅샷 형식(소유: core), 캡처 방법, load 진입 방법, load 실패 + 에러 계약, init 진입 가로채기 지점 +- init 경로·load 경로 각각의 생성 시퀀스(타이밍·이벤트 흐름)와 기존 경로와의 관계 +- 확정 요구사항 R1–R13 각각이 이 메커니즘으로 충족됨의 논증 (특히 R8 non-breaking: + 기존 생태계·현행 history-sync `overrideInitialEvents` 무변경 동작 논증) +- 소비자 성립 논증 — 이 계약만으로 ① persister(FEP-2546)의 캡처→보존→load 왕복 + ② guard(FEP-2521)의 init 가로채기·load 스킵 ③ history-sync(FEP-2001)의 "스택을 진실의 + 원천으로 동기화"가 성립함을 시나리오로 보인다 (완료 기준: "persister를 흉내낸 테스트 + 플러그인이 core API만으로 load 경로를 탈 수 있다"의 설계 수준 증명) +- 이연된 설계 결정 4건의 해소(각각 D-채번, 근거·기각 대안 포함): + ① init 가로채기 지점과 기존 `onBeforePush` 파이프라인의 통일 여부 + ② 스냅샷 형식의 구체적 모양(이벤트 이력 vs 집계 상태 등) + ③ activity 단위 출처(어느 activity가 스냅샷 출신인지) 표현 여부 + ④ load 실패 에러의 구체적 전달 형태(throw vs 콜백 등) + +**중간 산출물 — 후보 스케치** (발산·선별·대결 단계의 단위; 자기완결 — 선별과 대결은 +후보 문서만으로 이뤄진다): + +① 메커니즘 개요(원리) ② 공개 계약의 형태(개념적 시그니처·타입 구조) ③ 이연 4건 각각에 +대한 입장과 근거 ④ R1–R13·비목표 충족 방식 요약 — 충족 불가·긴장 지점은 은폐 없이 명시 +⑤ 트레이드오프(지불하는 것/획득하는 것) ⑥ 전제하는 현행 소스 사실(파일 위치 인용). + +**확정 요구사항 (요약 임베드 — 정본: Linear FEP-2548 코멘트 2026-07-07, 레포 CONTEXT.md)**: + +- R1 load 경계 소스 불문: 스냅샷 복원은 저장 매체 무관 전부 load +- R2 이진 분류: core 어휘는 init/load 둘뿐 (deep link 세분은 core 어휘 아님) +- R3 생성 시점 동기 load만: "복원 대기 중" 중간 상태 없음, 비동기 소스는 상위 레이어 부트스트랩 문제 +- R4 load 실패 = 명시적 에러 (조용한 폴백 금지) +- R5 에러 1차 처리자 = 스냅샷 공급자 (앱 개발자는 기본 무관여) +- R6 init 진입은 가로채기 가능(의미상 push), load 진입은 가로채기 대상 아님 +- R7 init/load 구분은 생성 시점 일회성 신호 (지속 속성 아님) +- R8 non-breaking: `overrideInitialEvents` 유지, 그 결과는 init 취급 +- R9 단일 스냅샷 자리: 생성은 스냅샷 최대 1개, 경합 조정은 core 위 계층 +- R10 스냅샷 왕복(캡처→보존→load)은 core 계약만으로 닫힘 +- R11 load 사후조건: 스냅샷이 보존한 정상 상태(불변식 충족 = 도달 가능 상태)의 충실한 재구성 +- R12 복원 범위 — 탐색 기록이 필수, 나머지는 부가: 반드시 보존·복원해야 하는 것은 + 탐색 기록(`stack.activities` — 무엇을 지금 보고 있고, 무엇을 봤었고, 어떤 순서로 + 보았는가)이며, 복원 후 이전과 같이 네비게이션할 수 있으면 충분. `transitionDuration`· + `globalTransitionState`·`pausedEvents`·`registeredActivities`는 복원 비필수. + transition 관련 정보는 프로세스 생애주기를 넘나들며 다루기 어려우므로 폐기도 열린 + 선택. R11의 "충실한 재구성"은 이 필수 범위(탐색 기록)에 적용 +- R13 직렬화는 core 밖 — codec은 스냅샷 사용자 책임: 스냅샷의 직렬화·역직렬화(codec)는 + 스냅샷을 사용하는 쪽(persister 등 공급자)이 마련한다. core는 `activityParams`· + `activityContext`에 어떤 값이 들어와도 동작한다고 전제하며 serialization issue를 + 다루지 않는다. R10의 "형식 소유"는 구조(무엇이 담기는가)의 소유이며 보존 매체 + 인코딩(codec)은 제외. 참고(소비자 계획): loader plugin은 스냅샷에서 loader data를 + 제거하고 load 시 loader를 재실행해 promise를 재주입할 예정 — 재파생 가능한 런타임 + 데이터는 스냅샷에 담지 않고 load 후 재파생하는 패턴 + +비목표: late load / init 하위 세분화의 core 어휘화 / 구분의 지속 속성화 / +react 앱 개발자 향한 신규 표면 / 스냅샷 버전 마이그레이션 보장(비호환 = load 실패). + +**소스**: 레포 `CONTEXT.md`(용어 정본) · `core/src/makeCoreStore.ts` · +`core/src/aggregate.ts` · `core/src/event-types/` · +`extensions/plugin-history-sync/src/historySyncPlugin.tsx` · +Linear FEP-2548(요구사항 코멘트)·FEP-2546·FEP-2521·FEP-2001(소비자 요구). + +### 이번 작업 고유 판단 기준 + +메커니즘이 확정 요구사항 R1–R13과 비목표에 정합하고(위배 0), 이연 4건이 근거와 함께 +결정되었으며, 설계가 전제하는 현행 core·플러그인 동작이 전부 소스 사실과 일치하고, +R8 non-breaking 논증과 세 소비자(persister·guard·history-sync) 성립 논증이 서는 상태. +후보 간 우열은 이 기준 충족을 전제로 트레이드오프의 질(지불 대비 획득, 미래 확장 여지, +오용 여지의 적음)로 가른다. 목표 고도는 메커니즘(원리·공개 계약·타이밍·불변식)이며, +구현(코드 변경)은 후속 작업으로 이 run의 범위 밖이다. + +## 2. 워크플로우 + +`diverge-converge-refine` (4절 인라인 정의 — generate-filter → tournament → review-loop +합성, wfspec.compose). 임베드 자산의 절차·판정 어휘·라운드 캡은 각 자산 정의 그대로. +run 한정 오버라이드: 없음. 중간 사용자 확인 없이 최종 산출물까지 자율 진행한다 +(에스컬레이션은 각 워크플로우의 기존 규칙 — 교착·판단 불가·생존 0건 — 에 한정). + +## 3. 세션 테이블 + +| 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | +|---|---|---|---|---|---|---| +| diverge-generator-claude1 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **이벤트 재생형** — 스냅샷=이벤트 이력(`DomainEvent[]`), load=aggregate 재생. 공통 규칙(전 generator): 시드는 출발점일 뿐 — 소스·요구사항이 다른 방향을 가리키면 근거를 명시하고 이탈 가능 | problem, design | +| diverge-generator-claude2 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **상태 주입형** — 스냅샷=집계된 `Stack` 상태, load=상태 직접 주입(재생 없음) | problem, design | +| diverge-generator-claude3 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **신규 이벤트 어휘형** — `Loaded` 계열 도메인 이벤트를 1급 어휘로 신설, load가 이벤트 로그에 남는 사건이 됨(R7 "일회성 신호"와의 긴장 정면 탐구) | problem, design | +| diverge-generator-claude4 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **계약 역산형(from usage)** — persister·guard·history-sync 세 소비자의 이상적 사용 코드를 먼저 쓰고 core 계약을 역산 | problem, design | +| diverge-generator-claude5 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **최소 변경형** — 현행 `makeCoreStore`·`overrideInitialEvents` 구조 최대 보존, 구분만 증분으로 얹기 | problem, design | +| diverge-generator-claude6 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **생성 API 분리형(from nothing)** — 스택 생성 경로 자체를 재설계, init/load를 별도 팩토리·생성 모드로 분리하는 데서 출발 | problem, design | +| diverge-generator-claude7 | generator | worker | Claude | fable-5[1m] / effort xhigh | 시드: **선례 이식형** — 타 생태계 복원 패턴(브라우저 탭 복원, Android SavedInstanceState, react-navigation persistence, XState snapshot 등) 벤치마킹에서 역산 | problem, design | +| filter-curator-claude | curator | curator | Claude | 기본 / effort xhigh | 탈락 기준의 축: 요구사항·비목표 위배(치유 불가), 자기완결성 미달, 본질 동일 후보는 접기. 소스 사실의 심층 검증은 정련 단계 게이트 몫 — 여기선 명백한 오류만 | problem, design | +| converge-judge-claude | judge | judge | Claude | 기본 / effort xhigh | 비교 축: 판단 기준 충족을 전제로 트레이드오프의 질. 판정문에 패자·차점안의 **이식 가치**(우승안에 가져올 강점)를 명시 | problem, design | +| refine-worker-claude | worker | worker | Claude | fable-5[1m] / effort xhigh | 우승안을 완전한 설계서로 정련. 차점안 강점 이식은 judge 판정문이 지목한 것에 한정(설계 뒤섞기 금지). 모든 결정을 현행 소스로 검증·근거 위치 기록, 이연 4건 D-채번. 우승안의 방향 자체를 뒤집을 발견·사용자 판단이 필요한 트레이드오프는 자체 결정하지 말고 에스컬레이션 | problem, design | +| review-reviewer-claude | reviewer | reviewer | Claude | 기본 / effort xhigh | 전담 축: 요구사항 정합·메커니즘 완결성 — R1–R13·비목표 위배, 계약 간 모순, 미결정 잔존, 소비자 성립 논증의 구멍 | problem, design | +| review-reviewer-codex | reviewer | reviewer | Codex | 기본 / high | 전담 축: 소스 사실 검증 — 설계가 전제하는 현행 core·history-sync 동작을 레포 코드와 대조, R8 non-breaking 논증을 실제 코드 경로로 검증 | problem, design | + +## 4. 인라인 자산 정의 + +### workflow: diverge-converge-refine (run-local) + +경쟁 가능한 설계 접근이 여럿일 때, 후보를 발산 생성해 선별·상대 비교로 수렴하고 +우승안을 정련해 독립 검증 게이트를 통과시키는 합성 워크플로우입니다. + +#### 슬롯 + +| 슬롯 | 롤 카드 | 책임 한 줄 | +|---|---|---| +| generator | ~/.agents/orchestration/roles/worker.md | 배정 접근법으로 후보 스케치 작성 (generate-filter 임베드 슬롯) | +| curator | ~/.agents/orchestration/roles/curator.md | 중복 접기·생존/탈락 판정 (generate-filter 임베드 슬롯) | +| judge | ~/.agents/orchestration/roles/judge.md | 대결별 비교 판정 (tournament 임베드 슬롯) | +| worker | ~/.agents/orchestration/roles/worker.md | 우승안 기반 설계서 정련 (review-loop 임베드 슬롯) | +| reviewer | ~/.agents/orchestration/roles/reviewer.md | 설계서 독립 리뷰·판정 (review-loop 임베드 슬롯) | + +#### 절차 + +1. **발산·선별** — generate-filter 워크플로우 실행 (정련 라운드 0회) + - 바인딩: generator ← 세션 테이블 diverge-generator-\*, curator ← filter-curator-claude + - 입력: `handoff.md`(과제 스펙·판단 기준·후보 스케치 형식) + - 산출: 생존 후보 목록 → 2단계 입력 +2. **수렴** — tournament 워크플로우 실행 (생존 후보가 2건 이상일 때; 1건이면 그 후보가 + 우승으로 3단계 직행) + - 바인딩: 후보 집합 ← 1단계 생존 목록(외부 제공 — generator 미바인딩), + judge ← converge-judge-claude + - 대진 방식: 리그전(round-robin), 전체 순위 산출 + - 산출: 우승안·전체 순위·대결별 판정문 → 3단계 입력 +3. **정련·게이트** — review-loop 워크플로우 실행 + - 바인딩: worker ← refine-worker-claude, reviewer ← review-reviewer-\* + - 입력: `handoff.md` + 우승 후보 전문 + tournament 판정문·순위 + curator 판정문 + - 산출: 확정 메커니즘 설계서 (run 최종 산출; 확정 시 Linear FEP-2548 게재) + +#### 판정·종료 + +- 판정 어휘는 임베드 워크플로우 각자의 것을 그대로 소비합니다(생존/탈락 · 대결 승자 · + `APPROVE`/`REQUEST_CHANGES`). +- 각 임베드 단계의 라운드 캡·생존 0건 처리·판단 불가 처리·에스컬레이션 규칙은 해당 + 워크플로우 정의를 따릅니다. +- **종료 조건**: 4단계 review-loop의 종료(전원 `APPROVE`). + +#### 보고 의무 + +- 임베드 워크플로우 각자의 보고 의무를 그대로 따릅니다(candidates/·curation/· + tournament/·reviews/ 경로 규약 포함). +- 오케스트레이터: 단계 전환마다 진행 상황과 산출 경로를 보고합니다(확인 대기 없이 + 진행). 수렴 단계 종료 보고는 순위·쟁점별 근거·이식 가치 목록을 포함합니다. + +## 5. 설계 메타 + +- **적용된 디폴트**: reviewer 2명(Claude+Codex 교차, 동일 role) — 합의 게이트 슬롯 + 디폴트. curator·judge·worker 각 1명 — 일반 슬롯 디폴트. 리뷰어 전담 축·worker 1m은 + 교훈 반영(아래). +- **사용자 명시(디폴트보다 우선)**: 발산→수렴 접근 자체("다양한 메커니즘이 트레이드오프를 + 두고 경쟁, 넓은 커버리지로 발산 후 수렴"). generator 전원 Claude Code / fable-5[1m] / + effort xhigh. 시드 후보 7종 전부 채택(generator 7세션). plan(고도) 렌즈 전 세션 제거 — + 렌즈는 problem·design만. 중간 사용자 확인 없이 최종 산출물까지 자율 진행. 요구사항 + R12(복원 범위 — 탐색 기록 필수, transition 정보 등 비필수)·R13(직렬화는 core 밖 — + codec은 스냅샷 사용자 책임) 추가. +- **설계 근거**: 매핑 — 후보를 넓게 만들고 걸러냄 → generate-filter, 남은 후보 중 + 최선을 상대 비교 → tournament(자산 정의가 명시하는 canonical 후속), 우승안의 완전한 + 설계서화 + 독립 검증 → review-loop. 발산 다양화는 fan-out 바인딩의 접근법 시드로 달성. + 정련 worker는 신규 세션(fresh) — 우승 generator 재바인딩 대신 컨텍스트 여유 확보, + 승계는 후보 문서·판정문으로 충분. +- **메모리 반영**: + - `planning-run-needs-mechanism-altitude-reconciliation-clause`(design) — plan(고도) + 렌즈 적용을 제안했으나 사용자 지시로 전 세션에서 제거(이번 run 비적용, seen 3→2 + 되돌림). 목표 고도 명시는 §1 판단 기준의 한 문장으로만 유지. + - `worker-context-exhaustion-restart-on-reopen-or-bind-1m-for-multicycle` + (operations, seen 6) — 대형 설계서 + 재오픈 가능 과제라 정련 worker를 fable-5[1m]으로 + 선제 바인딩. generator는 스케치 수준이라 기본 모델. + - `empirical-behavioral-review-catches-input-defects-logic-review-approves` + (operations) — "논리 검증과 별개의 사실 검증 축" 교훈을 문서 산출물에 번안: + reviewer-codex에 소스 사실 검증 전담 축 부여. + - 그 외 히트(orchestrator-routes-design-questions-to-worker, + large-deliverable-stalls-midstream 등)는 operations scope — 실행 시 orchestrate가 + 조회·적용할 대상이라 plan에는 비반영. From 16e6a5cdb7d7f410ed2819483f9a771432002a63 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 02:19:11 +0900 Subject: [PATCH 03/28] docs: add FEP-2548 init/load distinction mechanism design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed mechanism design for distinguishing Stack init (creation) from load (snapshot restoration) in stackflow core. - Snapshot = navigation event log; load = replay through existing aggregate (reachability + fidelity hold by construction — no new validator) - Public contract: StackSnapshot, actions.captureSnapshot(), provideSnapshot / onLoadError / onBeforeInitialPush hooks, onInit(createdBy) one-shot signal - Non-breaking: unchanged creation path when no snapshot provided; overrideInitialEvents preserved and treated as init - Load validation rejects any snapshot event (Pushed/Replaced/future activity-introducing) whose activity is unregistered in load-time config - Resolves the four deferred decisions; proves persister / guard / history-sync consumers close over the core contract alone Co-Authored-By: Claude Opus 4.8 (1M context) --- design-fep-2548-init-load-mechanism.md | 756 +++++++++++++++++++++++++ 1 file changed, 756 insertions(+) create mode 100644 design-fep-2548-init-load-mechanism.md diff --git a/design-fep-2548-init-load-mechanism.md b/design-fep-2548-init-load-mechanism.md new file mode 100644 index 000000000..44ae58d21 --- /dev/null +++ b/design-fep-2548-init-load-mechanism.md @@ -0,0 +1,756 @@ +# Stackflow core — Stack 생성의 init/load 구분 메커니즘 설계 (FEP-2548) + +> 이 문서는 stackflow core에 Stack 초기화(init)와 복원(load)의 구분을 도입하는 메커니즘 설계서다. +> 요구사항 정본은 Linear FEP-2548 코멘트(2026-07-07 인터뷰 확정)와 레포 `CONTEXT.md`(용어 정의)이며, +> 본문에서 R1–R13으로 인용한다. 코드 구현은 후속 작업이다 — 이 문서의 고도는 메커니즘 +> (원리·공개 계약·타이밍·불변식)이다. 설계가 전제하는 현행 소스 동작은 §10에 파일:라인으로 모두 인용했다. + +--- + +## 0. 요약 + +**스냅샷 = 탐색 이벤트 로그, load = 기존 aggregate로의 재생(replay).** + +현행 core는 이벤트 소싱으로 동작한다 — 스택 상태는 항상 `aggregate(events, now)`의 산출물이다(§10-S1). +따라서 "보존된 탐색 맥락"의 자연스러운 물화는 core가 이미 처리한 탐색 이벤트의 로그이고, +"복원"의 자연스러운 물화는 그 로그를 기존 aggregate에 다시 먹이는 것이다. +load를 위한 새 상태 주입 경로·새 도메인 이벤트를 만들지 않는다 — 검증기의 본체는 +기존 `validateEvents` + 리듀서이고, load 경로는 여기에 기존 등록 술어(activityName ∈ 등록 집합)를 +activity 도입 이벤트 전수(Pushed·Replaced)로 확장 적용하는 검사 하나만 더한다 +(현행 `validateEvents`가 Replaced를 검사하지 않는 간극의 보완 — §3.4). + +신규 공개 표면은 다섯 조각이 전부다: + +| 표면 | 종류 | 용도 | +|---|---|---| +| `StackSnapshot` | 타입 (core 소유) | 스냅샷 형식 | +| `actions.captureSnapshot()` | actions 메서드 | 캡처 | +| `provideSnapshot` | 플러그인 옵셔널 훅 | load 진입 (단일 스냅샷 자리) | +| `onLoadError` | 플러그인 옵셔널 훅 | load 실패 1차 처리 (공급자 전용) | +| `onBeforeInitialPush` | 플러그인 옵셔널 훅 | init 최초 진입 가로채기 | + +여기에 기존 `onInit` 훅의 인자에 일회성 신호 `createdBy: "init" | "load"`를 추가한다. +신규 도메인 이벤트 0, 신규 Stack 상태 속성 0, react 앱 개발자 향한 신규 표면 0, +`makeCoreStore` 옵션 추가 0. 스냅샷 공급자가 없으면 생성 시퀀스는 오늘의 코드 경로와 +관찰상 동일하다(§6 R8). + +--- + +## 1. 문제와 요구사항 + +### 1.1 문제 + +core에는 현재 "Stack이 어떻게 태어났는가"의 어휘가 없다. 모든 생성은 `makeCoreStore(initialEvents)` +한 경로이며, 초기 진입은 플러그인의 `overrideInitialEvents`로만 변형할 수 있다(§10-S3). +그 결과: + +- **persister(FEP-2546)**: 탐색 맥락을 보존했다가 되살리는 왕복(캡처→보존→load)을 core 계약만으로 + 구성할 방법이 없다. 스냅샷의 형식·캡처·복원이 전부 미정의다. +- **activity guard(FEP-2521)**: 런타임 push는 `onBeforePush`로 가로챌 수 있지만, 생성 시점의 + 최초 진입은 액션 파이프라인을 타지 않아(§10-S4) 가로챌 표면이 없다. 또한 "복원된 스택은 보존 + 시점에 이미 검증되었으므로 가드를 건너뛴다"는 정책을 세울 근거(init/load 구분)가 없다. +- **history-sync(FEP-2001)**: "스택을 진실의 원천으로 삼아 브라우저 히스토리를 동기화"하는 방향으로 + 개정하려면, 자신이 URL을 해석해 스택을 만드는 경우(init)와 보존된 스택을 되살려 히스토리를 맞추는 + 경우(load)를 구분할 신호가 필요하다. + +### 1.2 확정 요구사항 (정본: Linear FEP-2548, `CONTEXT.md`) + +- **R1** load 경계 소스 불문: 스냅샷 복원은 저장 매체 무관 전부 load +- **R2** 이진 분류: core 어휘는 init/load 둘뿐 (deep link 세분은 core 어휘 아님) +- **R3** 생성 시점 동기 load만: "복원 대기 중" 중간 상태 없음, 비동기 소스는 상위 레이어 부트스트랩 문제 +- **R4** load 실패 = 명시적 에러 (조용한 폴백 금지) +- **R5** 에러 1차 처리자 = 스냅샷 공급자 (앱 개발자는 기본 무관여) +- **R6** init 진입은 가로채기 가능(의미상 push), load 진입은 가로채기 대상 아님 +- **R7** init/load 구분은 생성 시점 일회성 신호 (지속 속성 아님) +- **R8** non-breaking: `overrideInitialEvents` 유지, 그 결과는 init 취급 +- **R9** 단일 스냅샷 자리: 생성은 스냅샷 최대 1개, 경합 조정은 core 위 계층 +- **R10** 스냅샷 왕복(캡처→보존→load)은 core 계약만으로 닫힘 +- **R11** load 사후조건: 스냅샷이 보존한 정상 상태(불변식 충족 = 도달 가능 상태)의 충실한 재구성 +- **R12** 복원 범위: 탐색 기록(`stack.activities`)이 필수, 나머지(transitionDuration· + globalTransitionState·pausedEvents·registeredActivities)는 부가·복원 비필수. 전환 정보 폐기는 열린 선택 +- **R13** 직렬화는 core 밖: codec은 스냅샷 사용자 책임. core는 `activityParams`·`activityContext`에 + 어떤 값이 와도 동작을 전제하지 않음. "형식 소유"는 구조의 소유이며 보존 매체 인코딩은 제외 + +**비목표**: late load / init 하위 세분화의 core 어휘화 / 구분의 지속 속성화 / +react 앱 개발자 향한 신규 표면 / 스냅샷 버전 마이그레이션 보장(비호환 = load 실패). + +### 1.3 방법론 참고 — 복원 시스템의 알려진 실패 모드 + +타 생태계의 복원 시스템(브라우저 세션 복원, Android `SavedInstanceState`, react-navigation +state persistence, XState persisted state)이 공통으로 겪는 실패 모드를 설계 단계에서 회피한다: + +1. **무한 성장 로그** — 보존물이 세션 길이에 비례해 자람 → §9 compaction 로드맵으로 대응 + (계약은 크기가 아니라 재생 결과만 보장). +2. **스키마-저장 결합** — 코드가 바뀌면 낡은 보존물이 조용히 깨짐 → `$schema` 태그로 시끄러운 + 실패(비목표 승인: 비호환 = load 실패), 정적 정보(등록 목록·전환 시간)는 보존하지 않고 load 시점 + 현행 config에서 재파생. +3. **복원 대기 레이스** — "복원 중" 중간 상태가 초기 렌더와 경합 → R3(생성 시점 동기 load)이 + 이 상태 자체를 금지. 비동기 소스는 스택 생성을 지연하는 상위 부트스트랩의 문제. + +--- + +## 2. 소비자로부터의 역산 — 이 계약이 어디서 나왔는가 + +계약을 제시하기 전에, 세 소비자가 자연스럽게 쓰게 될 코드를 먼저 보인다. 아래 세 코드가 성립하는 데 +필요한 최소 core 표면이 곧 §3의 공개 계약이다. **소비자의 사용 코드에 등장하지 않는 표면은 만들지 +않는다**가 이 설계의 절제 원칙이다. + +### 2.1 persister (FEP-2546) — 캡처→보존→load 왕복 + +```ts +const persisterPlugin = ({ storage, codec }): StackflowPlugin => () => ({ + key: "persister", + + // [캡처] 스택이 바뀔 때마다 스냅샷을 떠서 보존한다. codec은 내 책임(R13). + onChanged({ actions }) { + storage.write(codec.encode(actions.captureSnapshot())); + }, + + // [load 진입] 스택 생성 시점에 동기적으로 스냅샷을 공급한다. + provideSnapshot() { + const raw = storage.read(); + return raw ? codec.decode(raw) : null; // null = "복원할 것 없음" → init + }, + + // [에러 1차 처리] 손상 스냅샷은 내가 치우고 init 재시도를 지시한다. + onLoadError({ error }) { + storage.remove(); + report(error); + return { recover: "init" }; // 명시적 결정 — 조용한 폴백이 아님 + }, +}); +``` + +### 2.2 activity guard (FEP-2521) — init 가로채기·load 스킵 + +```ts +const guardPlugin = ({ canEnter }): StackflowPlugin => () => { + const check: OnBeforePush = ({ actionParams, actions }) => { + if (!canEnter(actionParams.activityName, actionParams.activityParams)) { + actions.preventDefault(); + } + }; + return { + key: "guard", + onBeforePush: check, // 런타임 push — 기존 파이프라인 + onBeforeInitialPush: check, // init 최초 진입 — 동형 시그니처, 핸들러 재사용 + // load 스킵을 위한 코드는 없다 — load 경로에서는 두 훅 모두 애초에 불리지 않는다. + }; +}; +``` + +### 2.3 history-sync (FEP-2001 개정 방향) — 스택을 진실의 원천으로 + +```ts +// 미래의 history-sync: history.state에 스냅샷을 실어 두고 복원 시 공급자가 된다. +provideSnapshot() { + return decodeSnapshotFromHistoryState(history.location.state); // 없으면 null +}, +onInit({ actions, createdBy }) { + if (createdBy === "load") { + rewriteBrowserHistoryToMatch(actions.getStack()); // 스택이 진실 — history를 맞춘다 + } else { + /* 현행 init 동작 그대로 */ + } +}, +``` + +역산 결과가 §0의 다섯 표면 + `onInit` 인자 1개다. 이 외의 표면(신규 도메인 이벤트, Stack 상태 속성, +react 표면, `makeCoreStore` 옵션)은 어떤 소비자의 사용 코드에도 등장하지 않으므로 만들지 않는다. + +--- + +## 3. 공개 계약 + +### 3.1 스냅샷 형식 — 소유: core, 직렬화: 소비자 (R10·R13) + +```ts +/** 탐색 이벤트 6종 — 기존 이벤트 타입의 부분합(신규 어휘 없음) */ +type NavigationEvent = + | PushedEvent | ReplacedEvent | PoppedEvent + | StepPushedEvent | StepReplacedEvent | StepPoppedEvent; + +/** core가 구조를 소유하는 plain-data 값. 보존 매체 인코딩(codec)은 사용자 책임. */ +type StackSnapshot = { + /** 구조 판별 태그. 불일치 → SnapshotLoadError (마이그레이션은 비목표). */ + $schema: "stackflow.snapshot.v1"; + /** 탐색 이벤트만 담는다. Initialized·ActivityRegistered는 담지 않는다 — load 시점의 + * 현행 config에서 재파생한다(R12: transitionDuration·registeredActivities는 복원 비필수). + * Paused·Resumed도 담지 않는다 — 전환·일시정지 정보로서 폐기한다(R12: 폐기가 열린 선택). */ + events: NavigationEvent[]; +}; +``` + +계약의 성질: + +- **plain-data**: 소비자가 자기 codec으로 직렬화/역직렬화한다. core는 `activityParams`· + `activityContext`에 어떤 값이 있어도 동작을 전제하지 않는다(R13). +- **투명하되 재생 결과만 보장**: 구조가 문서화된 core 소유 타입이므로 공급자가 값을 변환할 수 있다 + (예: 스냅샷 이벤트의 `activityContext`에서 loader data를 제거하고 load 시 재파생해 주입 — §7.1.3). + 단, **계약이 보장하는 것은 "재생 시 탐색 기록의 충실 재구성"뿐이다.** 두 규칙을 분리해 명시한다: + ① **값 변환은 허용** — 존재하는 이벤트의 필드 값(`activityContext` 등)을 읽고 변환하는 것은 + 문서화된 타입 위의 정당한 사용이다. ② **집합·개수 가정은 금지** — "이 activity의 Pushed가 반드시 + 존재한다", "push 횟수만큼 이벤트가 있다" 같은 이벤트 집합의 구성·완전성에 대한 가정은 계약 밖이다. + ②가 core에 캡처 시 정준 축약(compaction)의 자유를 준다(§9). +- **정적 정보는 보존하지 않는다**: 등록 activity 목록과 transitionDuration은 보존 시점이 아니라 + **load 시점의 현행 config가 진실**이어야 한다. 앱 업데이트로 activity가 사라졌으면 낡은 등록 + 정보로 스택을 살리는 게 아니라 load 실패로 시끄럽게 드러나야 한다(R4). 이를 봉인하는 것이 + load 등록 검사다(§3.4·L6): **activity를 물화하는 모든 이벤트(현행 어휘에서 Pushed·Replaced)의 + `activityName`이 load 시점 등록 집합에 속해야 한다.** Pushed는 기존 `validateEvents`가 이미 + 검사하지만(§10-S6) Replaced는 검사하지 않으므로(§10-S6a — Replaced도 `activityName`으로 + activity를 물화한다), load 경로가 같은 술어를 Replaced까지 확장 적용한다. + +### 3.2 캡처 방법 + +```ts +interface StackflowActions { + // ...기존 그대로... + /** 어느 훅에서든, 언제든 호출 가능. 현재 이벤트 로그를 탐색 이벤트로 정규화해 반환. */ + captureSnapshot(): StackSnapshot; +} +``` + +- **정규화**: 이벤트 로그(`events.value`)를 탐색 이벤트 6종으로 필터하고, aggregate의 전처리와 + 같은 순서(eventDate 오름차순 정렬 + id 중복 제거 — §10-S7)로 정렬해 반환한다. 따라서 + **`events` 배열 순서가 곧 의미상 재생 순서**다 — load의 재기저 규칙(§4.2)이 이 순서를 입력으로 삼는다. +- **언제든 호출 가능**: 플러그인은 모든 훅에서 `actions`를 받으므로 추가 배선이 없다. 캡처 시점·보존 + 위치·폐기 시점만이 공급자의 책임이라는 역할 분담(`CONTEXT.md` 스냅샷 항목) 그대로다. +- **전환 중 캡처**: 재생 시 전환 진행 상태는 폐기되고 정착 상태로 복원된다(R12가 허용하는 폐기). +- **pause 중 캡처**: Paused/Resumed는 스냅샷에서 제외되지만, pause 중 디스패치된 탐색 이벤트는 + 이벤트 로그에 그대로 누적되므로(§10-S14) 캡처에 포함된다. 재생 결과는 "pause가 없었던 것처럼" + 큐잉된 항해가 전부 적용된 상태다 — R12가 열어 둔 폐기 선택의 행사이며, 이 문단이 그 명문화다. + 캡처 시점의 가시 상태와 복원 결과가 다를 수 있으므로, 이 사후조건은 공급자(persister 등)의 + 사용자 문서에 명시할 항목이다. +- 반환값은 이벤트 로그와 구조를 공유하는 plain-data 값이다. 변환이 필요하면 복사 후 변환한다 + (직접 변이는 미정의 동작). + +### 3.3 load 진입 방법 — 단일 스냅샷 자리 (R9) + +```ts +type StackflowPlugin = () => { + // ...기존 그대로... + /** 스택 생성 시점에 동기 호출(R3). null(또는 undefined) = 공급할 것 없음. + * non-null을 반환한 플러그인이 2개 이상이면 core는 조정하지 않고 생성 에러를 던진다(R9). */ + provideSnapshot?: (args: { initialContext: any }) => StackSnapshot | null; +}; +``` + +- **진입점은 이 훅 하나뿐이다.** `makeCoreStore` 옵션 파라미터는 두지 않는다 — 진입점이 둘이면 + R9의 "자리 하나"가 흐려진다. 비동기 소스를 기다렸다 스택 생성을 지연하는 상위 부트스트랩 + 레이어(R3)는 인라인 플러그인 `() => ({ key: "boot", provideSnapshot: () => snap })` 한 줄로 충분하다. +- **선언적 반환 (명령형 `load()` 호출이 아니라)**: 값을 반환하는 형태이므로 "생성 중 1회만 호출 + 가능한 함수"류의 호출 창(window) 계약과 그 오용 실패 모드(창 밖 호출·중복 호출)가 존재하지 않는다. + 또한 R9 강제가 시간 순서("먼저 부른 쪽이 이김")가 아니라 구조(전원 폴링 후 non-null 개수)로 + 성립한다 — 플러그인 배열 순서에 무관하다. +- **환경 계약**: `provideSnapshot`은 스토어가 생성되는 모든 환경에서 폴링된다(SSR 렌더 포함). + 브라우저 전용 매체(localStorage·history.state 등)에 의존하는 공급자는 비브라우저 환경에서 + null을 반환할 책임이 있다 — 현행 history-sync가 서버에서 memory history로 스스로 강등하는 + 것과 같은 역할 분담이다. 이 분담의 귀결로 서버는 init·클라이언트는 load로 생성될 수 있고, + 그때의 하이드레이션 불일치는 공급자·앱 계층이 다뤄야 한다(예: 서버 마크업과 무관한 시점으로 + 복원을 미루거나 서버에도 같은 스냅샷을 공급) — core는 환경 간 일관성을 중재하지 않는다. +- **R9 위반은 설정 오류**: non-null 공급이 2개 이상이면 core는 충돌 플러그인 key들을 명시한 + 생성 에러를 즉시 던진다. 이것은 `SnapshotLoadError`가 아니고 `onLoadError`로 라우팅되지 않는다 — + 특정 스냅샷의 결함이 아니라 배선 버그이며, "1차 처리자 = 공급자"를 적용할 단일 공급자가 정의되지 + 않기 때문이다. 복수 공급 후보의 조정은 core 위 계층의 책임이다(R9 — §7.3 국면 3). + +### 3.4 load 실패 에러 계약 (R4·R5) + +```ts +class SnapshotLoadError extends Error { + cause: + | { kind: "incompatible-schema" } // $schema 불일치 또는 v1 구조 위반 + | { kind: "invalid-events"; detail: unknown } // 재생 검증 실패 (validateEvents 위반 등) + | { kind: "empty-navigation" }; // 재생 결과 enter 상태 activity 0개 +} + +type StackflowPlugin = () => { + /** 자신이 공급한 스냅샷의 load가 실패했을 때, 그 공급자에게만 호출된다(R5). + * { recover: "init" } 반환 → core는 init 경로로 생성을 계속한다(공급자의 명시적 결정). + * void 반환 또는 핸들러 부재 → SnapshotLoadError가 makeCoreStore 밖으로 던져진다(R4). */ + onLoadError?: (args: { + error: SnapshotLoadError; + initialContext: any; + }) => { recover: "init" } | void; +}; +``` + +에러 분류 기준: + +| kind | 의미 | 검출 지점 | +|---|---|---| +| `incompatible-schema` | 이 값은 core가 아는 v1 스냅샷 구조가 아니다 — `$schema` 불일치, `events`가 배열이 아님, 항목이 탐색 이벤트 6종이 아님, `id`/`name` 결손 | 재생 전 구조 검사 | +| `invalid-events` | 구조는 유효하나 이벤트 열이 현행 config에서 유효하지 않다 — 미등록 activity를 물화하는 이벤트(Pushed·Replaced) 등 | load 등록 검사(activity 도입 이벤트 전수의 activityName ∈ 등록 집합, §4.2-4) + 기존 `validateEvents`(§10-S6) throw 래핑 | +| `empty-navigation` | 재생은 성공했으나 enter 상태 activity가 0개 | 재생 후 사후조건 검사(§5 L3) | + +- **load 등록 검사가 별도로 존재하는 이유**: 현행 `validateEvents`는 Pushed의 등록만 검사하고 + Replaced는 검사하지 않는다(§10-S6a). 이 간극은 런타임에도 존재하지만(`replace()`로 미등록 + activity 진입이 오늘도 침묵 통과한다), 런타임 `validateEvents`의 전역 확장은 기존 앱의 관찰 + 가능 동작을 바꿀 수 있는 독립적 core 수정 후보라 이 설계에 결합하지 않는다(R8 절대 보존). + load 경로는 새 표면이므로 처음부터 올바른 술어(activity 도입 이벤트 전수)를 적용하며, 전역 + 수정이 후에 채택되면 load 검사를 자연히 흡수한다. +- `empty-navigation`을 에러로 두는 이유: 복원할 탐색 기록이 없으면 공급자는 null을 반환했어야 + 한다(계약). activity 0개짜리 스택을 조용히 만들어 주는 것은 사용자 눈에 빈 화면 = 사실상 조용한 + 실패이므로 R4의 정신에 따라 시끄럽게 처리한다. `{ events: [] }`도, 전부 pop된 이력도 여기서 잡힌다. +- **에러 전달이 콜백인 이유**: `makeCoreStore`가 그냥 throw하면 try/catch 가능한 유일한 위치인 + 앱 개발자가 1차 처리자가 되어 R5 위반이다. 콜백의 반환값 `{ recover: "init" }`는 "조용한 폴백 + 금지(R4)"와 "공급자가 복구 정책 결정(R5)"을 한 지점에서 화해시킨다 — 폴백이 일어나되, 스냅샷을 + 폐기하고 로그를 남긴 공급자의 명시적 서명이 있는 폴백이다. 핸들러 부재·void 반환 시 throw가 + 기본값인 것은 R4의 안전측이다(에러를 다루지 않는 공급자의 스냅샷 실패는 시끄럽게 죽는다). +- **복구는 재폴링하지 않는다**: `{ recover: "init" }`는 init 경로의 초기 이벤트 파이프라인부터 + 재개하며 `provideSnapshot`을 다시 폴링하지 않는다 — 무한 루프를 차단하고 load 시도를 생성당 + 1회로 고정한다(R3). +- 에러 채널은 동기다: load가 생성 시점 동기(R3)이므로 복구 결정도 동기여야 한다. 비동기 채널 + (이벤트·프라미스)은 "복원 대기 중" 중간 상태를 재도입하므로 두지 않는다. + +### 3.5 init 진입 가로채기 지점 (R6) + +```ts +type StackflowPlugin = () => { + /** init 경로에서 초기 Pushed 이벤트 각각에 대해, 집계 전에 호출된다. + * 시그니처·의미는 onBeforePush와 동형: preventDefault / overrideActionParams. + * load 경로에서는 절대 호출되지 않는다(R6). */ + onBeforeInitialPush?: StackflowPluginPreEffectHook< + Omit + >; +}; +``` + +계약 세부: + +- **prevent 단위**: preventDefault된 Pushed에 뒤따르던 StepPushed 그룹은 함께 탈락한다. + step 단독 가로채기 훅은 두지 않는다 — 이 훅의 named 소비자는 activity guard(FEP-2521)다. + step 단위 요구가 실증되면 동형 원칙으로 `onBeforeInitialStepPush`를 additive하게 증분하면 된다. +- **`getStack()`의 의미**: 이 훅 안에서 `getStack()`은 "그 시점까지 수용된 진입"의 스택 + (정적 이벤트 + 앞서 수용된 초기 이벤트의 집계)을 반환한다. 첫 진입인지, 앞서 어떤 진입이 + 수용되었는지를 판별할 수 있으므로, 배치 시야가 필요한 정책(예: deep link 대상만 차단하고 루트 + 유지)도 per-push 훅으로 표현된다. +- **생성 중 재진입 금지**: 생성 중 훅(`onBeforeInitialPush`·`provideSnapshot`·`onLoadError`) + 안에서 항해 액션(`push`/`pop`/`dispatchEvent` 등) 호출은 계약 위반이다 — 스토어가 아직 완성 + 전이다. 가로챈 뒤 대체 진입은 `overrideActionParams`로, 진입 후 리다이렉트는 `onInit` 이후에 한다. +- **런타임 onBeforePush와의 관계 — 동형이되 동력이 같지는 않다**: 시그니처와 + prevent/override 의미론은 동형이라 가드 정책 코드는 한 벌로 두 훅에 물릴 수 있다(§2.2). + 단 위 두 제약(수용 프리픽스 스택·항해 액션 금지)은 런타임 훅에 없는 생성 중 제약이며 문서로 명시한다. + +**왜 기존 onBeforePush 파이프라인과 통일하지 않는가** — 이것은 이 설계에서 가장 중요한 부정 결정이다: + +1. **완전 통일(초기 진입을 액션 경로로 흘리기)은 post-effect 훅까지 발화시킨다.** 액션 경로는 + pre-effect 훅 후 dispatch하고, dispatch는 post-effect 훅을 발화시킨다(§10-S5). 초기 진입이 + `onPushed`를 발화시키면 현행 history-sync의 `onPushed`(§10-S12)가 초기 activity마다 + `pushState`를 호출해 브라우저 히스토리에 중복 엔트리를 쌓는다 — 기존 앱이 즉시 깨진다(R8 위반). +2. **pre-effect 훅만 통일하는 절충도 R8을 확률로 떨어뜨린다.** 기존 onBeforePush 핸들러들은 + "스택이 완성된 뒤 런타임 push에만 불린다"는 전제로 작성되어 있다. 레포 안에 실증 반례가 있다: + loader plugin의 `onBeforePush`는 loader가 pending이면 `pause()`를 호출한다(§10-S15) — 초기 + 이벤트에 이 훅을 발화시키면 **생성 도중 Paused 이벤트가 디스패치**되어 현행과 다른 생성 시퀀스가 + 된다. non-breaking은 확률이 아니라 보장이어야 한다. +3. 비통일의 지불은 guard가 같은 검사 함수를 두 훅에 등록하는 **한 줄**뿐이다(§2.2). 한 줄로 R8이 + 보장으로 바뀌므로 비통일이 우세하다. 의미상 통일(동형 시그니처·동형 의미론)은 유지해 정책 코드 + 중복을 없앤다. + +기각한 다른 대안: + +- **`overrideInitialEvents` 재사용(신규 훅 0개)**: 이 훅은 대체 배열 반환만 가능하고 + preventDefault 시맨틱이 없으며(§10-S3) 플러그인 배열 순서에 취약하다. guard가 요구하는 + "검사·차단" 표면에 미달한다. +- **배치 단위 훅(`onBeforeInit(events[])` 류)**: onBeforePush와 비동형이라 guard가 정책 코드를 + 두 벌 쓰게 된다. 배치 시야는 위 `getStack()` 계약으로 per-push 훅에서도 얻는다. +- **가로채기 없음**: R6 위반. + +### 3.6 일회성 신호 (R7) + +```ts +/** onInit 인자 확장(순수 additive) — Stack 상태에는 어떤 흔적도 남지 않는다. */ +onInit?: (args: { + actions: StackflowActions; + createdBy: "init" | "load"; +}) => void; +``` + +- 신호는 `onInit` 인자로만 존재한다. Stack에 조회 가능한 지속 속성이 없고, 신규 도메인 이벤트도 + 없으므로 이벤트 로그에도 구분의 흔적이 없다 — R7과 비목표("구분의 지속 속성화")가 by construction + 성립한다. 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 한다는 `CONTEXT.md`의 관계 + 정의 그대로다. +- 이름이 `createdBy`인 이유: `CONTEXT.md`의 유비쿼터스 언어에서 **진입(Entry)은 activity 수준 + 용어**다("Activity가 Stack에 나타나게 되는 사건"). 스택 생성 신호에 entry류 이름을 쓰면 도메인 + 어휘가 충돌한다. `createdBy`는 "Stack은 Init 또는 Load 중 정확히 하나의 경로로 만들어진다"는 + 관계 정의에 직결된다. 값이 이진 문자열 리터럴인 것은 R2(이진 분류가 core 어휘의 전부)의 표현이다. +- 현행 `onInit`은 `{ actions }` 단일 인자로 발화하므로(§10-S13) 필드 추가는 순수 additive다. + 레포 내 `onInit` 사용자 6개(blocker·devtools·GA4·history-sync·lifecycle·stack-depth-change)는 + 인자를 무시하거나(GA4는 무인자 `onInit()` 선언) `actions`만 구조분해한다 — 어떤 기존 플러그인도 + 영향받지 않는다. +- **activity 단위 출처는 표현하지 않는다**: load는 생성 시점에만 일어나므로(R3) 출처가 의미 있을 + 유일한 순간(생성 직후)에는 답이 자명하다 — 전부 스냅샷 출신이다. `onInit`에서 `createdBy === "load"`를 + 본 소비자는 `getStack()`의 모든 activity가 복원물임을 이미 알고, 이후 push되는 activity는 정의상 + 신규다. 세 소비자의 사용 코드(§2) 어디에도 per-activity 출처 조회가 등장하지 않는다. 재생된 + activity의 `enteredBy`는 스냅샷 속 원본 Pushed/Replaced 이벤트로 유지되어(§10-S9), 생성 이후의 + 세계는 init 출신과 구별 불가능하게 균질하다. 기각 대안 — `enteredBy`에 Loaded류 마킹이나 신규 + 이벤트 어휘: 새 도메인 이벤트 + 지속 속성 + 소비자 부재의 삼중 지불. 관측 요구(devtools 등)가 + 실증되면 `onInit` 신호를 구독해 자체 기록하는 비침습 경로가 있다. + +--- + +## 4. 생성 시퀀스 + +### 4.1 init 경로 — 스냅샷 공급자 없음 (전원 null) + +``` +makeCoreStore(options) + 1. 플러그인 인스턴스화 (현행 그대로) + 2. provideSnapshot 전원 폴링 → 전부 null → init 경로 확정 + 3. options.initialEvents를 [Pushed/StepPushed | 나머지(정적)]로 분리 (현행 §10-S3) + 4. overrideInitialEvents 체인 (현행 그대로 — 무변경 유지, R8) + 5. (신규) 결과의 각 Pushed에 onBeforeInitialPush 파이프라인 + — prevent된 Pushed는 딸린 StepPushed 그룹과 함께 탈락 + — 등록한 플러그인이 없으면 빈 순회 = 관찰상 무(無) + 6. onInitialActivityIgnored / onInitialActivityNotFound 핸들러 + — 파이프라인(4+5) 전체가 끝난 최종 결과에 대해 평가 (신규 훅 미사용 시 현행 판정과 동일) + — Ignored의 의미론: 옵션 제공 초기 이벤트가 있고 최종 수용 집합이 그것과 다르면 + (override 체인의 대체뿐 아니라 가로채기의 변형·탈락 포함) 발화 — "옵션으로 지정한 + 초기 activity가 무시되었다"는 현행 의미의 자연 확장 + 7. events.value = [정적 이벤트, ...최종 초기 이벤트] → aggregate → store 완성 (현행) + 8. (react 통합 등이) store.init() 호출 → onInit({ actions, createdBy: "init" }) +``` + +guard가 초기 진입을 전부 prevent하면 6에서 `onInitialActivityNotFound`가 발화하고 activity 0개 +스택으로 생성된다 — "초기 activity 없음"은 오늘도 정의된 상태이며(§10-S3의 빈 배열 경로) 같은 +상태에 착지한다. + +### 4.2 load 경로 — 정확히 하나가 non-null + +``` +makeCoreStore(options) + 1. 플러그인 인스턴스화 + 2. provideSnapshot 전원 폴링 + — non-null 0개 → init 경로(§4.1의 3부터) + — non-null 2개 이상 → 생성 에러 throw (설정 오류, §3.3) + — non-null 1개 → load 경로, 공급 플러그인 확정 + 3. 구조 검사: $schema 일치·events 배열·항목이 탐색 이벤트 6종 + — 위반 → SnapshotLoadError{incompatible-schema} → 7로 + 4. 등록 검사: 스냅샷의 activity 도입 이벤트(Pushed·Replaced) 전수의 activityName이 + 정적 이벤트(ActivityRegistered)가 이루는 load 시점 등록 집합에 속하는지 검사 + — 위반 → SnapshotLoadError{invalid-events} → 7로 + — Pushed는 이후 재생의 validateEvents도 잡지만 Replaced는 여기서만 잡힌다(§10-S6a) + 5. 재기저(rebase): 스냅샷 이벤트의 eventDate를 아래 규칙으로 재부여 (그 외 필드 전부 보존) + 6. events.value = [정적 이벤트, ...재기저된 스냅샷 이벤트] → aggregate 재생 + — 정적 이벤트 = options.initialEvents 중 Pushed/StepPushed가 아닌 전부. 이 설계는 그것이 + Initialized·ActivityRegistered(현행 react 통합의 관행 — §10-S10)라고 전제한다. 다른 탐색 + 이벤트(Popped 등)를 initialEvents에 직접 넣는 비관용 임베딩은 이 전제 밖이다 + — options의 초기 Pushed/StepPushed(예: initialActivity)는 폐기: 스냅샷이 곧 탐색 기록이며, + "무엇으로 시작하는가"는 init 경로의 어휘다. 복원된 탐색 위에 initialActivity를 겹치면 + 보존 시점에 없던 activity가 생겨 R11(충실한 재구성)을 깬다 + — overrideInitialEvents · onBeforeInitialPush · initial activity 핸들러 전부 건너뜀: + 셋 다 "초기 진입을 무엇으로 정하는가"라는 init 어휘의 표면이고, load 진입은 가로채기 + 대상이 아니다(R6) + — validateEvents throw → SnapshotLoadError{invalid-events} → 7로 + — 재생 성공 후 사후조건 검사: enter 상태 activity ≥ 1 — 위반 → {empty-navigation} → 7로 + 7. (실패 시) 공급 플러그인의 onLoadError({ error, initialContext }) + — { recover: "init" } 반환 → §4.1의 3부터 재개 (provideSnapshot 재폴링 없음) + — void/핸들러 부재 → makeCoreStore 밖으로 throw + 8. (성공 시) store 완성 + 9. store.init() → onInit({ actions, createdBy: "load" }) +``` + +**재기저 규칙** (4단계): + +- **RB1 — 순서 보존**: 스냅샷 `events` 배열 순서대로 강한 단조 증가하는 eventDate를 부여한다. + 캡처가 배열 순서를 aggregate 전처리 순서로 정규화하므로(§3.2) 배열 순서가 곧 의미상 재생 순서다. +- **RB2 — 정착 보장**: 모든 재기저 date ≤ 생성 시각 − transitionDuration. Pushed 리듀서는 + `now − eventDate ≥ transitionDuration`이면 enter-done으로, Popped 리듀서는 동형으로 exit-done으로 + fold하므로(§10-S8) 재생 결과는 전원 정착 상태다. Replaced의 진입 activity 역시 같은 + isTransitionDone 규칙(기존 activity의 transitionState 계승 폴백 포함 — §10-S8)으로 enter-done에 + 정착한다. Paused/Resumed가 스냅샷에 없으므로(§3.1) `globalTransitionState`는 idle로 착지한다. 이는 react 통합이 정적 이벤트에 쓰는 + `enoughPastTime`(= 생성 시각 − 2·transitionDuration, §10-S10)과 history-sync가 초기 이벤트에 쓰는 + 과거 backdating(§10-S11)과 같은 기법의 정식화다. +- **RB3 — 정적 이벤트 선행(일반 경우)**: 정적 이벤트 date보다 뒤의 창 + (max(정적 date), 생성 시각 − transitionDuration] 안에 배치한다. eventDate는 JS number라 소수가 + 허용되므로(현행 `time()`이 이미 소수 반환) 이벤트 개수와 무관하게 창 안에 채울 수 있다. 창이 + 퇴화하는 경우(core를 직접 embedding하며 정적 이벤트를 생성 시각 부근으로 준 경우 등)에도 RB2만 + 지키면 fold 의미론은 안전하다 — Pushed가 Initialized보다 먼저 fold되면 transitionDuration이 + 기본값 0으로 평가되어 과거 date면 여전히 enter-done이고(§10-S8), 등록 검사는 배열 순서 + 무관(§10-S6)이다. 동률 date는 안정 정렬로 입력 배열 순서가 보존된다(§10-S7). +- **RB4 — 신규 이벤트 후행**: load 후 디스패치되는 이벤트는 현재 시각으로 만들어지므로 RB2의 + 상한보다 항상 뒤다 — 스냅샷 이벤트 뒤로 정렬된다. 재기저가 원본 date **값**이 아니라 캡처 + **순서** 기반이므로, 캡처 세션과 load 세션 사이의 시계역행(시계 조정·NTP·타임존)이 스냅샷 내부 + 순서에 영향을 주지 않는다. 원본 date를 그대로 쓰는 대안은 캡처 세션 시계가 앞서 있던 경우 스냅샷 + 이벤트가 미래 date가 되어 신규 이벤트가 그 **앞**으로 정렬되는 순서 붕괴와 enter-active 잔류를 + 일으키므로 기각했다. +- **RB5 — id 보존**: 원본 이벤트 `id`·`activityId`·`stepId`는 바이트 보존한다. history-sync의 + popstate 방향 판정이 activity id 대소 비교에 의존하고(§10-S16), 현행 history-sync의 + history.state 복원이 이미 원본 이벤트를 id·activityId까지 보존해 재사용하는 선례가 있다(§10-S17). + 세션 간 시계역행 시 "신규 push의 id < 복원 activity id"가 될 수 있는 잔여 노출은 현행 + history.state 복원과 동일한 기존 노출이며 이 설계가 새로 만드는 것이 아니다. + +### 4.3 기존 경로와의 관계 + +- 생성 중에는 어느 경로든 post-effect 훅(`onPushed`·`onChanged` 등)이 발화하지 않는다 — 초기 + 집계는 `stack.value`에 직접 대입되며 effect 산출 경로를 타지 않는 현행 구조(§10-S2) 그대로다. +- `overrideInitialEvents`는 시그니처·호출 시점·의미 전부 무변경이다. 그 결과는 init 취급이며(R8) + 이어지는 `onBeforeInitialPush`의 가로채기 대상이다 — deep link(URL 해석)도 init이고 guard의 + 적용 대상이라는 `CONTEXT.md` 정의와 정확히 맞물린다. +- `store.init()`/`onInit`의 호출 주체·타이밍(react 통합이 브라우저에서 1회 호출, §10-S13·S10)도 + 무변경이다. load 경로에서도 같은 지점에서 발화하며 신호만 다르다. + +--- + +## 5. 불변식 + +**경로 공통** + +- **C1 (경로 배타)**: 한 번의 생성은 정확히 init 또는 load 하나를 탄다. load 실패 복구는 공급자의 + 명시적 `{ recover: "init" }` 결정으로만 init에 재진입하며, 재진입 후 다시 load로 돌아올 수 없다 + (재폴링 없음). +- **C2 (자리 하나)**: non-null 공급 2개 이상 = 생성 에러. core는 조정하지 않는다 — 조정자가 아니라 + 강제자다(R9). +- **C3 (신호 일회성)**: `createdBy`는 생성 시점 훅 인자로만 존재하고 Stack 상태·이벤트 로그 어디에도 + 남지 않는다(R7). +- **C4 (생성 중 무발화)**: 생성 완료 전에는 어떤 post-effect 훅도 발화하지 않는다(§10-S2). + +**init 경로** + +- **N1 (현행 동일성)**: 스냅샷 공급자가 없고 `onBeforeInitialPush` 등록자가 없으면, 생성 시퀀스는 + 오늘의 코드 경로와 관찰상 동일하다(R8의 구조적 근거 — §6 R8). +- **N2 (가로채기 완전성)**: 집계에 도달하는 모든 초기 Pushed는 `overrideInitialEvents` 체인과 + `onBeforeInitialPush` 파이프라인을 순서대로 통과한 것이다(R6). +- **N3 (핸들러 보존)**: initial activity 핸들러(ignored/notFound)는 파이프라인 최종 결과에 대해 + 현행과 같은 판정으로 발화한다. + +**load 경로** + +- **L1 (무가로채기)**: `overrideInitialEvents`·`onBeforeInitialPush` 모두 호출되지 않는다(R6) — + 분기가 초기 이벤트 파이프라인 자체를 건너뛰므로 규약이 아니라 구조의 귀결이다. +- **L2 (도달 가능성 — by construction)**: 재생 결과는 "현행 core가 이 이벤트 열을 처리했다면 + 도달했을 상태" 그 자체다 — 기존 `aggregate`+`validateEvents`를 무변경 통과했으므로 R11의 + 도달 가능성이 구성적으로 보장된다. config 진실(등록)은 L6이 봉인한다. +- **L3 (사후조건)**: enter 상태(enter-done) activity ≥ 1이고 `globalTransitionState === "idle"`이며, + 탐색 기록(활성 activity 열·순서·steps·params·`enteredBy`·현재 위치)이 스냅샷 재생 결과와 + 일치한다. enter activity 0개는 `empty-navigation` 에러다. +- **L4 (재기저 규칙)**: RB1–RB5(§4.2). 특히 원본 id 보존과 신규 이벤트 후행 정렬. +- **L5 (왕복 안정)**: load 직후 `captureSnapshot()`은 같은 탐색 기록을 재구성하는 스냅샷을 반환한다 + — 캡처∘load∘캡처가 재생 결과 기준으로 안정이다(재기저·압축은 `events` 내용을 바꿀 수 있으나 + 재생 결과는 보존). +- **L6 (등록 봉인)**: 재생에 도달하는 스냅샷의 모든 activity 도입 이벤트(Pushed·Replaced)의 + `activityName`은 load 시점 등록 집합에 속한다 — 위반은 `invalid-events`로 시끄럽게 실패한다. + 낡은 config의 activity가 어떤 이벤트 종류를 통해서도 조용히 살아나는 경로가 없다(R4·R11의 + config 진실). + +--- + +## 6. 요구사항 충족 논증 (R1–R13·비목표) + +| 요구 | 충족 방식 | +|---|---| +| R1 소스 불문 | 진입은 `provideSnapshot` 하나 — storage든 history.state든 인메모리든 같은 훅으로 공급 | +| R2 이진 분류 | 경로 = 스냅샷 존재 여부로 이분. core 어휘는 `createdBy: "init" \| "load"`뿐. deep link는 init의 내부 사정(`overrideInitialEvents`)으로 남음 | +| R3 동기 load | `provideSnapshot`은 생성 중 동기 호출·동기 반환. 비동기 소스는 생성 지연(상위 부트스트랩) + 인라인 공급 플러그인. 에러 복구 결정(`onLoadError`)도 동기 | +| R4 명시적 에러 | `SnapshotLoadError` 3분류 + 기본 throw. 폴백은 공급자의 명시적 `{ recover: "init" }`로만. `empty-navigation`도 에러로 승격. 미등록 activity 물화(Pushed·Replaced 불문)는 load 등록 검사가 잡음(L6) | +| R5 공급자 1차 처리 | `onLoadError`는 스냅샷을 공급한 플러그인에게만 호출. 앱 개발자는 잘 만든 공급자 뒤에서 무관여 | +| R6 init 가로채기·load 비대상 | `onBeforeInitialPush`는 init 경로 전용(N2), load 경로 불호출(L1 — 구조의 귀결) | +| R7 일회성 신호 | `createdBy`는 `onInit` 인자로만 존재, Stack·이벤트 로그 무흔적(C3). 재생 activity의 `enteredBy`도 원본 이벤트 | +| R8 non-breaking | 아래 상세 논증 | +| R9 단일 자리 | non-null 2개 이상 = 생성 에러(C2). 선언적 폴링으로 순서 무관 강제 | +| R10 왕복 폐쇄 | `captureSnapshot()` → plain-data `StackSnapshot` → `provideSnapshot` — 셋 다 core 계약. 외부 지식 불요(§7.1의 설계 수준 증명) | +| R11 충실 재구성 | 재생 = 현행 aggregate 통과 → 도달 가능성 구성적 보장(L2) + 등록 봉인(L6) + 사후조건 봉인(L3). 충실성의 필수 범위(탐색 기록)는 이벤트 바이트 보존으로 성립 | +| R12 탐색 기록 필수·나머지 부가 | 스냅샷 = 탐색 이벤트만. transitionDuration·registeredActivities는 load 시 현행 config에서 재파생. Paused·전환 진행은 폐기(재기저로 전부 정착·idle 착지). exit-done activity도 이벤트 이력이므로 재생으로 재구성되어(§10-S9) "무엇을 봤었고"까지 보존 | +| R13 codec 소비자 책임 | 스냅샷은 plain-data 값, core는 인코딩 무관여. loader data 제거·재파생 패턴은 이벤트 `activityContext` 변환으로 성립(§7.1.3) | + +**R8 상세 — non-breaking이 확률이 아니라 구조로 성립하는 이유** + +1. **신규 표면은 전부 additive다**: 옵셔널 훅 3개(`provideSnapshot`·`onLoadError`· + `onBeforeInitialPush`) + actions 메서드 1개 + `onInit` 인자 필드 1개 + 타입 2개. 기존 시그니처 + 변경 0, 기존 훅 제거 0, `makeCoreStore` 옵션 변경 0, `aggregate`/`validateEvents`/리듀서 변경 0. +2. **스냅샷 미공급 시 코드 경로가 오늘과 동일하다(N1)**: 공급자가 없으면 폴링은 전부 null이고 + 가로채기 파이프라인은 등록자가 없으면 빈 순회다. 기존 플러그인의 어떤 훅도 새로운 시점에 불리지 + 않고, 불리던 훅이 안 불리게 되지도 않는다. `overrideInitialEvents`는 무변경 유지되고 그 결과는 + init 취급된다 — 현행 history-sync는 한 줄도 안 바꾸고 오늘처럼 동작한다(§7.3 국면 1). +3. **onInit 인자 추가의 안전성**: 현행 `onInit`은 `{ actions }` 단일 인자 객체로 발화하며(§10-S13), + 레포 내 사용자 6개는 인자를 무시하거나(GA4는 무인자 `onInit()` 선언) `actions`만 구조분해한다. + 필드 추가는 관찰 불가능하다. +4. **load 경로는 opt-in으로만 도달 가능하다**: load는 `provideSnapshot`을 구현한 신규 API 플러그인을 + 설치해야만 발생한다. 기존 앱은 정의상 이 플러그인이 없으므로 새 분기에 진입할 수 없다. +5. **초기 진입을 액션 파이프라인에 태우지 않는 것 자체가 R8 장치다**: §3.5의 비통일 논증 — + post-effect 발화(history-sync `pushState` 중복)와 pre-effect 오발화(loader plugin의 생성 중 + `pause()`)를 구조적으로 차단한다. + +**비목표 정합**: late load 없음(생성 시점 전용, 재폴링 없음) / init 세분화 없음(deep link는 core +어휘 밖) / 지속 속성 없음(C3) / react 앱 개발자 신규 표면 없음(전부 플러그인 계약) / 버전 +마이그레이션 없음(`$schema` 불일치 = `incompatible-schema` 에러). + +--- + +## 7. 소비자 성립 논증 + +### 7.1 persister (FEP-2546) — 캡처→보존→load 왕복의 설계 수준 증명 + +완료 기준: **"persister를 흉내낸 테스트 플러그인이 core API만으로 load 경로를 탈 수 있다."** +§2.1의 플러그인이 그 테스트 플러그인이다. 왕복의 각 단계가 계약 표면에 닫혀 있음을 잇는다: + +1. **캡처**: 임의 훅(예: `onChanged`)에서 `actions.captureSnapshot()` — actions는 모든 훅에 + 전달되는 기존 표면. 반환값은 plain-data `StackSnapshot`(§3.2). +2. **보존**: `codec.encode(snapshot)` → storage. codec·storage는 공급자 소유(R13) — core 계약 밖이되 + 계약이 요구하는 유일한 성질(plain-data)을 §3.1이 보장. +3. **load**: 다음 생성에서 core가 `provideSnapshot({ initialContext })`을 동기 폴링(§4.2-2) — + 공급자는 storage를 동기로 읽어 `codec.decode` 후 반환. null이면 init. +4. **재구성**: core가 구조·등록 검사→재기저→재생→사후조건(§4.2-3~6). 성공 시 `onInit({ createdBy: "load" })`. + 결과 스택의 탐색 기록은 캡처 시점과 일치(L3)하고, 이후 항해는 오늘과 동일한 액션 경로. +5. **실패 처리**: 손상 스냅샷이면 core가 이 공급자의 `onLoadError`만 호출(§3.4) — 공급자는 스냅샷을 + 폐기하고 `{ recover: "init" }` 반환 → 앱은 초기 화면으로 정상 기동. 앱 개발자 코드는 어디에도 + 등장하지 않는다(R5). +6. **왕복 재개**: load 직후 `captureSnapshot()`이 같은 탐색 기록을 재구성하는 스냅샷을 반환(L5)하므로 + 주기적 persist가 자연스럽다. + +1–6에 등장한 표면은 `captureSnapshot`·`StackSnapshot`·`provideSnapshot`·`onLoadError`·`onInit(createdBy)` +— 전부 core 계약이다. 외부 지식(react 내부·다른 플러그인)이 필요한 단계가 없다(R10). ∎ + +**7.1.3 loader data 재파생 패턴 (R13 참고 시나리오)**: 재파생 가능한 런타임 데이터는 스냅샷에 담지 +않고 load 후 재파생한다. 이 계약에서의 성립: 스냅샷이 투명한 plain-data이므로 공급자(또는 loader와 +협조하는 상위 계층)는 ① 보존 시 이벤트의 `activityContext`에서 loader data를 제거하고(대개 codec +직렬화가 자연히 떨어뜨림) ② `provideSnapshot` 반환 직전에 각 Pushed 이벤트에 대해 loader를 재실행해 +promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 동기이므로(현행 loader plugin의 +동기 resolve 래핑과 동일 기법, §10-S15) R3(동기 load)과 충돌하지 않는다. + +### 7.2 activity guard (FEP-2521) — init 가로채기·load 스킵 + +- **런타임 push**: 기존 `onBeforePush`(현행 파이프라인, §10-S5) — 무변경. +- **init 최초 진입**: `onBeforeInitialPush` — deep link든 initialActivity든 `overrideInitialEvents` + 결과든, 집계에 도달하는 모든 초기 Pushed가 이 파이프라인을 통과한다(N2). 시그니처가 동형이므로 + 검사 함수는 한 벌이다(§2.2). +- **load 스킵**: guard에 스킵 코드가 없다 — load 경로에서 두 훅 모두 구조적으로 불리지 않는다(L1). + "보존 시점에 이미 검증된 맥락의 재구성은 가로채지 않는다"(R6·`CONTEXT.md`)가 정책 코드가 아니라 + 경로 구조로 성립한다. +- **엣지**: guard가 초기 진입을 전부 차단하면 `onInitialActivityNotFound` + 빈 스택 — 현행 "초기 + activity 없음"과 같은 정의된 상태(§4.1). 차단 대신 대체 진입은 `overrideActionParams`, 진입 후 + 리다이렉트는 `onInit` 이후(§3.5). + +### 7.3 history-sync (FEP-2001) — 스택을 진실의 원천으로 + +세 국면으로 성립을 논증한다. **core 계약은 세 국면에서 동일하다 — 개정되는 것은 플러그인뿐이다.** + +- **국면 1 — 현행 (이 설계 배포 직후, history-sync 미개정)**: 공급자가 없으므로 모든 생성이 init + 경로다. history-sync의 `overrideInitialEvents`(history.state/URL 해석)·`onInit`(히스토리 정렬)· + `onPushed`(pushState) 전부 오늘과 동일하게 발화한다(R8). `overrideInitialEvents`가 만든 초기 + 이벤트는 init 취급이므로 guard가 설치되면 가로채기 대상이다 — deep link는 init이라는 어휘 + 정의(R2·`CONTEXT.md`)와 일치. +- **국면 2 — 과도 (persister 도입, history-sync 미개정)**: persister가 유일 공급자로 load. 이때 + history-sync의 `overrideInitialEvents`는 발화하지 않는다(L1) — 이것은 새 의미론이지만 opt-in + 사용자에게만 나타난다. `onInit`은 발화한다: `history.location.state`가 비어 있으면(콜드 스타트) + 현행 로직이 복원된 스택 전체를 브라우저 히스토리에 정렬한다(§10-S18) — 이미 올바른 방향이다. + state가 있으면(리로드) 정렬을 건너뛰는데, 원본 activity id 보존(RB5) 덕에 같은 세션의 state와는 + id가 일치해 popstate 방향 판정이 동작하나, 스냅샷과 브라우저 히스토리가 어긋난 경우의 정합은 + 미보장이다 — **이 간극을 닫는 것이 FEP-2001의 범위**이며, 그때까지 persister 문서가 이 조합의 + 한계를 명시해야 한다. +- **국면 3 — 목표 (FEP-2001 개정 후)**: history-sync 자신이 공급자가 된다 — `provideSnapshot`으로 + history.state에서 스냅샷을 공급하고(§2.3), `onInit`에서 `createdBy === "load"`를 받아 "loaded + 스택 → 브라우저 히스토리 동기화"를 수행한다. 스택이 진실의 원천이 되는 개정의 신호·진입점·충실성 + (id·`enteredBy` 보존)이 이 계약으로 전부 공급된다. persister×history-sync 동시 공급은 R9 생성 + 에러로 조기 가시화되며, 조정(예: history.state 존재 시 persister 양보)은 core 위 계층 + (FEP-2546×FEP-2001)의 설계 숙제다 — core는 이 숙제를 없애 주지 않고 에러로 드러낸다. + +--- + +## 8. 스냅샷 형식의 결정 근거와 기각 대안 + +**채택 — 탐색 이벤트 이력 (정적 이벤트는 load 시 재파생)** + +현행 런타임은 매 dispatch·매 전환 프레임마다 이벤트 전체를 재집계한다(§10-S1). 이벤트 로그가 +런타임 모델의 1급 시민이므로, 스냅샷을 이벤트 이력으로 두면: + +- load 재구성이 기존 재생 기계(`aggregate`·리듀서·`validateEvents`)의 무변경 재사용이 된다 — + R11의 가장 비싼 증명 의무(도달 가능성)가 구성적으로 무료다. 추가되는 검증은 load 등록 검사 + 하나뿐이며(§3.4·L6), 이는 새 술어가 아니라 `validateEvents`가 Pushed에 쓰는 기존 등록 술어를 + activity 도입 이벤트 전수(Pushed·Replaced)로 확장 적용한 것이다 — 상태를 물화·검증하는 병렬 + 검증기가 아니라 이벤트 필드 하나의 멤버십 검사다. +- `enteredBy`가 원본 이벤트로 바이트 보존된다(§10-S9) — history-sync의 popstate·isRoot 판정 + 의존(§10-S16)이 무변조로 유지된다. exit-done activity도 재생으로 재구성된다. +- load 직후 이벤트 로그가 차 있으므로 이후 dispatch·재캡처가 오늘의 런타임 모델 그대로 동작한다. + +**기각 대안** + +- **집계 상태(`Stack`) 스냅샷**: 상태를 주입하면 직후 이벤트 로그가 비어 "전체 재집계" 런타임 + 모델이 성립하지 않는다 — 상태→이벤트 역합성 또는 증분 리듀서 재설계가 필요하고, `aggregate` + 시그니처(현행: 하드코딩된 빈 시드, §10-S7) 변경과 R11용 도달 가능성 검증기 신설(validateEvents는 + 이벤트만 검사)까지 지불한다. 비파괴 메커니즘의 지불 초과. +- **전체 이벤트 로그(정적 이벤트 포함)**: 보존 시점의 Initialized·ActivityRegistered가 load 시점 + 현행 config를 가린다(낡은 transitionDuration·사라진 activity의 등록 정보로 스택이 살아남 — R4 + 위반 방향). Initialized 2개는 `validateEvents` 위반이기도 하다(§10-S6). 크기 문제도 가중. +- **중립 제3 포맷(activity 배열 등) 신설**: 이벤트와 스택 사이의 세 번째 표현을 만들어 변환기 + 2개(포맷→이벤트/상태, 캡처→포맷)를 유지보수하게 된다. 이벤트 어휘 결합(아래 트레이드오프)을 + 피하지 못하면서 표면만 는다. + +--- + +## 9. 크기 성장과 compaction 로드맵 (v1 미포함 — 계약 무변경 내부 진화) + +이벤트 이력 스냅샷의 실재 지불은 **세션 길이에 비례한 성장**이다(이벤트 로그는 누적만 된다, +§10-S1a). 대응: + +- **v1은 compaction을 포함하지 않는다.** 장수 세션의 실측 크기 문제가 실증되기 전의 선제 최적화이며, + 축약 기준은 정보가 늘었을 때 더 잘 결정된다. +- **계약은 이미 축약을 허용한다**: §3.1이 명문화한 대로 소비자는 `events`의 구체 내용이 아니라 + 재생 결과에만 의존해야 한다. 따라서 캡처가 "미래 항해와 재생 결과에 영향을 주지 않는 이벤트 + 그룹"(대표: 완전히 exit된 activity의 이벤트 그룹 — 단 exit 이력 재구성의 충실성 요구 수준을 + R12 필수 범위 판단과 함께 결정)을 접는 것은 `$schema` 유지·소비자 코드 무변경의 내부 진화다. +- **구현 대안 2개**: ① 캡처 시 이벤트 그룹 접기(로그 필터형) ② 살아있는 activity 중심의 프로젝션으로 + 저장하되 캡처 시점에 표준 탐색 이벤트로 합성(합성형). 합성형을 택할 경우 **`enteredBy` 원본 이벤트를 + 보존해야 한다** — generic Pushed로 합성하면 Replaced로 진입했던 activity의 진입 이력이 소실되어 + history-sync 충실성(§10-S16·S17)이 깨진다. 이것이 합성형의 필수 교정 조건이다. + +--- + +## 10. 전제하는 현행 소스 사실 (전부 이 설계 작성 시점에 재검증) + +| # | 사실 | 위치 | +|---|---|---| +| S1 | 스택 상태는 이벤트 전체의 재집계로만 산출 — 생성 시 `aggregate(events.value, now)`, dispatch마다 `aggregate([...events, new], …)`, 전환 중 매 프레임 재집계 | `core/src/makeCoreStore.ts:83-85, 96-99, 108-121` | +| S1a | 이벤트 로그는 누적만 되고 소거되지 않음(`events.value.push`), `pullEvents()`로 노출 | `core/src/makeCoreStore.ts:101, 159` | +| S2 | 초기 집계는 `stack.value` 직접 대입 — `setStackValue`(→`produceEffects`→post-effect 훅)는 dispatch에서만 호출되므로 생성 중 post-effect 훅 무발화 | `core/src/makeCoreStore.ts:83-85` vs `:102, 134-138` | +| S3 | `overrideInitialEvents`는 Pushed/StepPushed만 분리해 플러그인 배열 순서로 reduce하는 동기 체인. preventDefault 시맨틱 없음(대체 배열 반환만). 빈 결과 → `onInitialActivityNotFound`, 참조 변경 → `onInitialActivityIgnored` | `core/src/makeCoreStore.ts:52-77`, `core/src/interfaces/StackflowPlugin.ts:133-136` | +| S4 | 초기 이벤트는 액션 파이프라인을 타지 않고 직접 집계 — 현행 init 진입은 onBeforePush 비대상 | `core/src/makeCoreStore.ts:79-85` | +| S5 | 액션 경로: pre-effect 훅(preventDefault/overrideActionParams) → dispatch → 집계 → post-effect 훅 | `core/src/utils/makeActions.ts:16-29`, `core/src/utils/triggerPreEffectHooks.ts:32-68`, `core/src/makeCoreStore.ts:93-123, 134-138` | +| S6 | `validateEvents`는 ①비어있지 않음 ②Initialized ≤ 1개 ③모든 Pushed.activityName 등록 존재 — 세 가지만, 배열 순서 무관. **Replaced는 검사 대상이 아니다**(Pushed만 필터) | `core/src/event-utils/validateEvents.ts:4-26` (Pushed 필터: `:21-24`) | +| S6a | `ReplacedEvent`도 `activityName`을 보유하고, 리듀서는 Replaced로부터 activity를 물화한다 — 미등록 activity의 Replaced가 현행 `validateEvents`를 침묵 통과한다(런타임 `replace()`도 동일한 현행 간극) | `core/src/event-types/ReplacedEvent.ts:3-13`, `core/src/activity-utils/makeActivitiesReducer.ts:48-65`, `core/src/activity-utils/findNewActivityIndex.ts:9-16`, `core/src/activity-utils/makeActivityFromEvent.ts:8-27` | +| S7 | `aggregate`는 eventDate 오름차순 정렬(안정 정렬 — 동률은 입력 순서) + id 중복 제거 후, 하드코딩된 빈 스택 시드로 reduce | `core/src/aggregate.ts:11-31` | +| S8 | Pushed 리듀서: `skipEnterActiveState \|\| now − eventDate ≥ transitionDuration`이면 enter-done. Popped 리듀서 동형으로 exit-done. Replaced 진입 activity는 기존 activity의 transitionState 계승 또는 동일 isTransitionDone 폴백. transitionDuration은 fold 시점 스택 값(Initialized 전이면 0) | `core/src/activity-utils/makeActivitiesReducer.ts:27-43, 48-66`, `core/src/activity-utils/makeActivityReducer.ts:38-61`, `core/src/activity-utils/makeStackReducer.ts:87-97` | +| S9 | activity의 `enteredBy`는 진입 이벤트 객체 그대로, `exitedBy`도 동일. exit-done activity는 `stack.activities`에 잔존(visibleActivities에서만 제외, zIndex=-1) | `core/src/activity-utils/makeActivityFromEvent.ts:8-27`, `core/src/Stack.ts:36-37`, `core/src/aggregate.ts:36-41, 53-57` | +| S10 | react 통합: 정적 이벤트(Initialized·ActivityRegistered)를 config에서 `enoughPastTime()`(= now − 2·transitionDuration)으로 생성, initialActivity Pushed도 동일, 브라우저에서 `store.init()` 1회 호출 | `integrations/react/src/stackflow.tsx:90-107, 135-145, 178-181` | +| S11 | 현행 history-sync: URL 해석 초기 이벤트에 `now − (length − index)` 과거 backdating + 첫 Pushed에 `skipEnterActiveState` | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:429-447` | +| S12 | 현행 history-sync `onPushed`는 (플래그 미설정 시) push마다 `pushState` 호출 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:634-657` | +| S13 | `onInit`은 `store.init()`(once)에서 `{ actions }` 단일 인자로 1회 발화 | `core/src/makeCoreStore.ts:150-158` | +| S14 | pause 중 디스패치된 이벤트도 `events.value`에는 무조건 누적(pausedEvents 큐잉은 리듀서 내부 사정) | `core/src/makeCoreStore.ts:101`, `core/src/activity-utils/makeStackReducer.ts:14-29` | +| S15 | loader plugin: `overrideInitialEvents`로 loaderData를 `activityContext`에 동기 주입(resolve 래핑), `onBeforePush`는 pending 시 `pause()` 호출 | `integrations/react/src/loader/loaderPlugin.tsx:31-80, 81-82, 100-140` | +| S16 | history-sync popstate 방향 판정은 activity id 문자열 대소 비교. id는 시각 기반 hex(세션 내 단조). isRoot의 Replaced 절은 `zIndex===1 && enter-active` 동시 성립 시에만 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:539-541`, `core/src/utils/id.ts`, `core/src/utils/time.ts`, `core/src/aggregate.ts:92-96` | +| S17 | 현행 history-sync는 history.state 존재 시 원본 이벤트를 스프레드로 재사용(id·activityId·eventDate 보존) — "원본 id 보존 복원"의 현행 선례 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:252-270` | +| S18 | 현행 history-sync `onInit`: history.state가 비어 있으면 enter 상태 activity들을 브라우저 히스토리에 replaceState/pushState로 정렬 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:449-503` | +| S19 | `makeEvent`는 `{ id: id(), eventDate: time(), ...parameters, name }` — 파라미터로 id·eventDate 덮어쓰기 가능(재생 시 원본 id·재기저 date 주입이 기존 표면으로 가능) | `core/src/event-utils/makeEvent.ts:13-18` | +| S20 | 레포 내 `overrideInitialEvents` 사용자는 history-sync·loader plugin 둘. `onInit` 사용자는 blocker·devtools·GA4·history-sync·lifecycle·stack-depth-change 6개 — 인자를 무시하거나(GA4는 무인자 `onInit()` 선언, `googleAnalyticsPlugin.tsx:35`) `actions`만 구조분해 | `extensions/*/src`, `integrations/react/src` (grep 검증) | + +--- + +## 11. 트레이드오프 + +**지불하는 것** + +- `makeCoreStore` 생성부의 분기 증가(폴링 → load 시도 → 실패 복구 → init 재개). 생성 시퀀스의 + 상태도가 오늘의 직선 코드보다 하나 늘어난다. +- **스냅샷-이벤트 어휘 결합**: 스냅샷 호환성이 core 이벤트 스키마의 안정성에 묶인다. 이벤트 필드가 + 비호환으로 바뀌면 옛 스냅샷은 load 실패한다. "비호환 = load 실패"가 비목표로 승인돼 있어 계약 + 위반은 아니지만, 이벤트 스키마 변경의 비용을 키운다는 사실은 남는다 — `$schema` 태그가 그 비용을 + 조용한 오동작이 아니라 명시적 에러로 바꾼다. +- guard는 훅 두 곳 등록(통일 대신 동형을 산 대가 — 한 줄). +- load 등록 검사 1건 — 등록 술어가 두 곳(런타임 `validateEvents`의 Pushed 검사, load의 + activity 도입 이벤트 전수 검사)에서 적용된다. 술어 자체는 동형이지만 규칙 변경 시 함께 고칠 + 지점이 둘이 되는 소폭의 긴장. 런타임 `validateEvents`의 전역 확장(독립적 core 수정 후보 — + §3.4)이 채택되면 한 곳으로 합쳐진다. +- 이벤트 이력은 세션 길이에 비례해 자란다 — compaction 로드맵(§9)으로 계약 무변경 완화가 예약되어 + 있으나 v1에서는 장수 세션의 스냅샷이 비대해질 수 있다. + +**획득하는 것** + +- **표면 최소**: 신규 개념은 타입 2(스냅샷·에러) + actions 메서드 1 + 옵셔널 훅 3 + 기존 훅 인자 1. + 신규 도메인 이벤트 0, 신규 Stack 속성 0, react 표면 0, `aggregate`/리듀서/`validateEvents` 변경 0. +- **R8이 확률이 아니라 구조로 보장**(§6 R8): 스냅샷 없음 → 기존 코드 경로 그대로. 기존 플러그인의 + 어떤 훅도 새로운 시점에 불리지 않는다. +- **R11의 도달 가능성이 공짜**: 재생이 기존 집계·검증 기계를 재사용하므로 도달 가능성 증명이 + 구성적. load에 추가되는 검증은 기존 등록 술어의 적용 범위 확장(Pushed → activity 도입 이벤트 + 전수) 1건뿐 — 새 술어도, 상태를 물화·검증하는 병렬 검증기도 없다. +- **소비자 3인 즉시 성립**(§7): persister 왕복은 core API로 닫히고(완료 기준 충족), guard는 정책 + 한 벌 + 등록 두 줄, history-sync는 신호 분기 하나로 개정 경로가 열린다. +- **왕복 안정(L5)**: load 직후 재캡처가 안정적이라 주기적 persist가 자연스럽다. From fb20c559aed8454c4f06f2bc3a88cfc145386c6b Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 04:41:21 +0900 Subject: [PATCH 04/28] =?UTF-8?q?docs:=20revise=20FEP-2548=20mechanism=20?= =?UTF-8?q?=E2=80=94=20drop=20dedicated=20init-interception=20hook,=20rena?= =?UTF-8?q?me=20init=E2=86=92create=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer-confirmed revision of the init/load distinction design. - Remove onBeforeInitialPush; public surface 5 → 4. Create-path entry interception is done via the existing overrideInitialEvents chain (inspect / strip = block / replace = redirect), since initial events are pre-aggregate array data — stripping is the pre-aggregate equivalent of preventDefault, so no dedicated hook is warranted. Guard now requires zero new core surface; ordering discipline + an onInit validation belt cover it. - Terminology: createdBy → initializedBy ("create" | "load"); the "init" creation path is renamed "create" (built from scratch). "initialize" is the bootstrap umbrella (onInit / store.init, fires on both paths), so onInit({ initializedBy: "load" }) is no longer self-contradictory. The initial* inputs (initialEvents, initialActivity, …) and the Initialized event keep their names. - CONTEXT.md ubiquitous language updated: 초기화(Init) → 생성(Create). - The anti-unification rejection (post-effect pushState duplication, loader plugin pause counterexample) still stands; only "realize it via a dedicated hook" is reversed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 16 +- design-fep-2548-init-load-mechanism.md | 399 +++++++++++++++---------- 2 files changed, 250 insertions(+), 165 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5cd67f6c5..124eb99bb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,10 +2,10 @@ ## Terms -### 초기화 (Init) +### 생성 (Create) -새 Stack을 처음부터 만들어내는 진입 경로. 보존된 탐색 맥락이 없는 상태에서 시작한다. -defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 의미상 push다 — +새 Stack을 처음부터(from scratch) 만들어내는 진입 경로. 보존된 탐색 맥락이 없는 상태에서 +시작한다. defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 의미상 push다 — 따라서 activity 진입 가드의 적용 대상이다. ### 복원 (Load) @@ -17,7 +17,7 @@ defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 ### 진입 (Entry) -Activity가 Stack에 나타나게 되는 사건. Init 경로의 최초 진입은 의미상 push이며, +Activity가 Stack에 나타나게 되는 사건. Create 경로의 최초 진입은 의미상 push이며, 플러그인이 가로챌(검사·차단·변경) 수 있어야 한다. Load 경로의 진입은 push가 아니다 — 이미 검증된 맥락의 재구성이므로 가로채기 대상이 아니다. @@ -51,8 +51,8 @@ Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plug ## Relationships -- Stack은 Init 또는 Load 중 정확히 하나의 경로로 만들어진다. -- Init ↔ Load 구분은 core가 표현한다 — 플러그인(guard, persister, history-sync)이 +- Stack은 Create 또는 Load 중 정확히 하나의 경로로 만들어진다. +- Create ↔ Load 구분은 core가 표현한다 — 플러그인(guard, persister, history-sync)이 진입 경로별 정책을 세우는 근거가 된다. - Load는 Stack 생성 시점에만 일어난다. 스냅샷은 생성 시점에 동기적으로 주어져야 하며, 생성된 Stack은 그 순간부터 항상 정상 상태다 — "복원 대기 중" 같은 중간 @@ -62,9 +62,9 @@ Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plug 명시적 에러다. 유효하지 않은 스냅샷으로부터 정상 상태 Stack이 몰래 만들어지는 일은 없다. - Load 실패 에러의 1차 처리 책임은 스냅샷 공급자(persister 등)에게 있다. - 공급자가 복구 정책(스냅샷 폐기, init 재시도 등)을 결정하며, 앱 개발자는 + 공급자가 복구 정책(스냅샷 폐기, create 재시도 등)을 결정하며, 앱 개발자는 기본적으로 이 에러를 다루지 않는다. -- Init ↔ Load 구분은 Stack 생성 시점의 일회성 신호다. 구분이 Stack 상태에 지속 +- Create ↔ Load 구분은 Stack 생성 시점의 일회성 신호다. 구분이 Stack 상태에 지속 속성으로 남지는 않는다 — 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 한다. - Stack 생성은 스냅샷을 최대 하나만 받는다. 복수 복원 후보의 선택·조정은 Stack diff --git a/design-fep-2548-init-load-mechanism.md b/design-fep-2548-init-load-mechanism.md index 44ae58d21..74aecf253 100644 --- a/design-fep-2548-init-load-mechanism.md +++ b/design-fep-2548-init-load-mechanism.md @@ -1,6 +1,6 @@ -# Stackflow core — Stack 생성의 init/load 구분 메커니즘 설계 (FEP-2548) +# Stackflow core — Stack 초기화의 create/load 구분 메커니즘 설계 (FEP-2548) -> 이 문서는 stackflow core에 Stack 초기화(init)와 복원(load)의 구분을 도입하는 메커니즘 설계서다. +> 이 문서는 stackflow core에 Stack 초기화(bootstrap) 시 생성(create)과 복원(load) 경로의 구분을 도입하는 메커니즘 설계서다. > 요구사항 정본은 Linear FEP-2548 코멘트(2026-07-07 인터뷰 확정)와 레포 `CONTEXT.md`(용어 정의)이며, > 본문에서 R1–R13으로 인용한다. 코드 구현은 후속 작업이다 — 이 문서의 고도는 메커니즘 > (원리·공개 계약·타이밍·불변식)이다. 설계가 전제하는 현행 소스 동작은 §10에 파일:라인으로 모두 인용했다. @@ -19,7 +19,7 @@ load를 위한 새 상태 주입 경로·새 도메인 이벤트를 만들지 activity 도입 이벤트 전수(Pushed·Replaced)로 확장 적용하는 검사 하나만 더한다 (현행 `validateEvents`가 Replaced를 검사하지 않는 간극의 보완 — §3.4). -신규 공개 표면은 다섯 조각이 전부다: +신규 공개 표면은 네 조각이 전부다: | 표면 | 종류 | 용도 | |---|---|---| @@ -27,9 +27,12 @@ activity 도입 이벤트 전수(Pushed·Replaced)로 확장 적용하는 검사 | `actions.captureSnapshot()` | actions 메서드 | 캡처 | | `provideSnapshot` | 플러그인 옵셔널 훅 | load 진입 (단일 스냅샷 자리) | | `onLoadError` | 플러그인 옵셔널 훅 | load 실패 1차 처리 (공급자 전용) | -| `onBeforeInitialPush` | 플러그인 옵셔널 훅 | init 최초 진입 가로채기 | -여기에 기존 `onInit` 훅의 인자에 일회성 신호 `createdBy: "init" | "load"`를 추가한다. +여기에 기존 `onInit` 훅의 인자에 일회성 신호 `initializedBy: "create" | "load"`를 추가한다. +초기화(initialize)는 부트스트랩 상위 개념이다 — `onInit`·`store.init`·`initializedBy`는 create·load +두 경로 모두에서 발화하며, `create`/`load`는 그 경로 값이다(`initializedBy: "load"`는 "스토어가 +초기화됐다 — load 경로로"의 뜻이지 모순이 아니다). create 경로의 최초 진입 가로채기는 전용 훅을 +두지 않고 기존 `overrideInitialEvents` 체인에서의 검사·strip(차단)·치환(리다이렉트)으로 이뤄진다(§3.5). 신규 도메인 이벤트 0, 신규 Stack 상태 속성 0, react 앱 개발자 향한 신규 표면 0, `makeCoreStore` 옵션 추가 0. 스냅샷 공급자가 없으면 생성 시퀀스는 오늘의 코드 경로와 관찰상 동일하다(§6 R8). @@ -48,21 +51,21 @@ core에는 현재 "Stack이 어떻게 태어났는가"의 어휘가 없다. 모 구성할 방법이 없다. 스냅샷의 형식·캡처·복원이 전부 미정의다. - **activity guard(FEP-2521)**: 런타임 push는 `onBeforePush`로 가로챌 수 있지만, 생성 시점의 최초 진입은 액션 파이프라인을 타지 않아(§10-S4) 가로챌 표면이 없다. 또한 "복원된 스택은 보존 - 시점에 이미 검증되었으므로 가드를 건너뛴다"는 정책을 세울 근거(init/load 구분)가 없다. + 시점에 이미 검증되었으므로 가드를 건너뛴다"는 정책을 세울 근거(create/load 구분)가 없다. - **history-sync(FEP-2001)**: "스택을 진실의 원천으로 삼아 브라우저 히스토리를 동기화"하는 방향으로 - 개정하려면, 자신이 URL을 해석해 스택을 만드는 경우(init)와 보존된 스택을 되살려 히스토리를 맞추는 + 개정하려면, 자신이 URL을 해석해 스택을 만드는 경우(create)와 보존된 스택을 되살려 히스토리를 맞추는 경우(load)를 구분할 신호가 필요하다. ### 1.2 확정 요구사항 (정본: Linear FEP-2548, `CONTEXT.md`) - **R1** load 경계 소스 불문: 스냅샷 복원은 저장 매체 무관 전부 load -- **R2** 이진 분류: core 어휘는 init/load 둘뿐 (deep link 세분은 core 어휘 아님) +- **R2** 이진 분류: core 어휘는 create/load 둘뿐 (deep link 세분은 core 어휘 아님) - **R3** 생성 시점 동기 load만: "복원 대기 중" 중간 상태 없음, 비동기 소스는 상위 레이어 부트스트랩 문제 - **R4** load 실패 = 명시적 에러 (조용한 폴백 금지) - **R5** 에러 1차 처리자 = 스냅샷 공급자 (앱 개발자는 기본 무관여) -- **R6** init 진입은 가로채기 가능(의미상 push), load 진입은 가로채기 대상 아님 -- **R7** init/load 구분은 생성 시점 일회성 신호 (지속 속성 아님) -- **R8** non-breaking: `overrideInitialEvents` 유지, 그 결과는 init 취급 +- **R6** create 진입은 가로채기 가능(의미상 push), load 진입은 가로채기 대상 아님 +- **R7** create/load 구분은 생성 시점 일회성 신호 (지속 속성 아님) +- **R8** non-breaking: `overrideInitialEvents` 유지, 그 결과는 create 취급 - **R9** 단일 스냅샷 자리: 생성은 스냅샷 최대 1개, 경합 조정은 core 위 계층 - **R10** 스냅샷 왕복(캡처→보존→load)은 core 계약만으로 닫힘 - **R11** load 사후조건: 스냅샷이 보존한 정상 상태(불변식 충족 = 도달 가능 상태)의 충실한 재구성 @@ -71,7 +74,7 @@ core에는 현재 "Stack이 어떻게 태어났는가"의 어휘가 없다. 모 - **R13** 직렬화는 core 밖: codec은 스냅샷 사용자 책임. core는 `activityParams`·`activityContext`에 어떤 값이 와도 동작을 전제하지 않음. "형식 소유"는 구조의 소유이며 보존 매체 인코딩은 제외 -**비목표**: late load / init 하위 세분화의 core 어휘화 / 구분의 지속 속성화 / +**비목표**: late load / create 하위 세분화의 core 어휘화 / 구분의 지속 속성화 / react 앱 개발자 향한 신규 표면 / 스냅샷 버전 마이그레이션 보장(비호환 = load 실패). ### 1.3 방법론 참고 — 복원 시스템의 알려진 실패 모드 @@ -109,36 +112,73 @@ const persisterPlugin = ({ storage, codec }): StackflowPlugin => () => ({ // [load 진입] 스택 생성 시점에 동기적으로 스냅샷을 공급한다. provideSnapshot() { const raw = storage.read(); - return raw ? codec.decode(raw) : null; // null = "복원할 것 없음" → init + return raw ? codec.decode(raw) : null; // null = "복원할 것 없음" → create }, - // [에러 1차 처리] 손상 스냅샷은 내가 치우고 init 재시도를 지시한다. + // [에러 1차 처리] 손상 스냅샷은 내가 치우고 create 재시도를 지시한다. onLoadError({ error }) { storage.remove(); report(error); - return { recover: "init" }; // 명시적 결정 — 조용한 폴백이 아님 + return { recover: "create" }; // 명시적 결정 — 조용한 폴백이 아님 }, }); ``` -### 2.2 activity guard (FEP-2521) — init 가로채기·load 스킵 +### 2.2 activity guard (FEP-2521) — create 가로채기·load 스킵 ```ts -const guardPlugin = ({ canEnter }): StackflowPlugin => () => { - const check: OnBeforePush = ({ actionParams, actions }) => { - if (!canEnter(actionParams.activityName, actionParams.activityParams)) { +// makePushed·isEntered는 guard 저자의 헬퍼(초기 이벤트 배열 위에서만 도는 순수 함수)다. +const guardPlugin = ({ canEnter }): StackflowPlugin => () => ({ + key: "guard", + + // [create 진입] 초기 이벤트 배열을 검사해 strip(차단)/치환(리다이렉트)한다. + // guard는 초기 이벤트 생성자(history-sync 등)보다 뒤에 배치해야 한다(순서 규율 — §7.2). + overrideInitialEvents({ initialEvents, initialContext }) { + const out: typeof initialEvents = []; + let dropGroup = false; + for (const e of initialEvents) { + if (e.name === "Pushed") { + const verdict = canEnter(e.activityName, e.activityParams); + dropGroup = !verdict.ok; + if (!verdict.ok) { + if (verdict.redirect) out.push(makePushed(verdict.redirect, e.eventDate)); + continue; // strip = 차단 + } + out.push(e); + } else if (!dropGroup) { + out.push(e); // 딸린 StepPushed는 직전 Pushed 그룹으로 탈락 + } + } + return out; + }, + + // [런타임 push] 기존 파이프라인 — 무변경. + onBeforePush({ actionParams, actions }) { + if (!canEnter(actionParams.activityName, actionParams.activityParams).ok) { actions.preventDefault(); } - }; - return { - key: "guard", - onBeforePush: check, // 런타임 push — 기존 파이프라인 - onBeforeInitialPush: check, // init 최초 진입 — 동형 시그니처, 핸들러 재사용 - // load 스킵을 위한 코드는 없다 — load 경로에서는 두 훅 모두 애초에 불리지 않는다. - }; -}; + }, + + // [검증 벨트] 순서 오배치로 인한 침묵 우회를 개발 시점의 큰 소리로 전환한다. + // 집행이 아니라 검증이다(사후 축출은 SSR 공백·흔적·중간 수술 불가 — §3.5·§7.2). + onInit({ actions, initializedBy }) { + if (initializedBy !== "create") return; // load는 검증된 맥락 — 스킵(R6) + for (const a of actions.getStack().activities) { + if (isEntered(a) && !canEnter(a.name, a.params).ok) { + throw new Error( + `guard: '${a.name}' entered unguarded — guardPlugin must be placed ` + + "after initial-event generators (e.g. historySyncPlugin).", + ); + } + } + }, +}); ``` +create 진입 가로채기가 전용 훅이 아니라 기존 `overrideInitialEvents`의 용법이라는 것이 이 소비자의 +핵심이다 — strip/치환이 R6의 검사·차단·변경을 전부 제공한다(성립 논증은 §7.2). load 스킵은 정책 +코드가 아니라 경로 구조로 성립한다(load는 `overrideInitialEvents` 체인을 건너뜀). + ### 2.3 history-sync (FEP-2001 개정 방향) — 스택을 진실의 원천으로 ```ts @@ -146,17 +186,19 @@ const guardPlugin = ({ canEnter }): StackflowPlugin => () => { provideSnapshot() { return decodeSnapshotFromHistoryState(history.location.state); // 없으면 null }, -onInit({ actions, createdBy }) { - if (createdBy === "load") { +onInit({ actions, initializedBy }) { + if (initializedBy === "load") { rewriteBrowserHistoryToMatch(actions.getStack()); // 스택이 진실 — history를 맞춘다 } else { - /* 현행 init 동작 그대로 */ + /* 현행 create 동작 그대로 */ } }, ``` -역산 결과가 §0의 다섯 표면 + `onInit` 인자 1개다. 이 외의 표면(신규 도메인 이벤트, Stack 상태 속성, -react 표면, `makeCoreStore` 옵션)은 어떤 소비자의 사용 코드에도 등장하지 않으므로 만들지 않는다. +역산 결과가 §0의 네 표면 + `onInit` 인자 1개다. 특히 create 진입 가로채기는 새 표면이 아니라 기존 +`overrideInitialEvents`의 용법으로 성립한다(§2.2). 이 외의 표면(신규 도메인 이벤트, Stack 상태 속성, +react 표면, `makeCoreStore` 옵션, create 전용 가로채기 훅)은 어떤 소비자의 사용 코드에도 등장하지 +않으므로 만들지 않는다. --- @@ -245,7 +287,7 @@ type StackflowPlugin = () => { - **환경 계약**: `provideSnapshot`은 스토어가 생성되는 모든 환경에서 폴링된다(SSR 렌더 포함). 브라우저 전용 매체(localStorage·history.state 등)에 의존하는 공급자는 비브라우저 환경에서 null을 반환할 책임이 있다 — 현행 history-sync가 서버에서 memory history로 스스로 강등하는 - 것과 같은 역할 분담이다. 이 분담의 귀결로 서버는 init·클라이언트는 load로 생성될 수 있고, + 것과 같은 역할 분담이다. 이 분담의 귀결로 서버는 create·클라이언트는 load로 생성될 수 있고, 그때의 하이드레이션 불일치는 공급자·앱 계층이 다뤄야 한다(예: 서버 마크업과 무관한 시점으로 복원을 미루거나 서버에도 같은 스냅샷을 공급) — core는 환경 간 일관성을 중재하지 않는다. - **R9 위반은 설정 오류**: non-null 공급이 2개 이상이면 core는 충돌 플러그인 key들을 명시한 @@ -265,12 +307,12 @@ class SnapshotLoadError extends Error { type StackflowPlugin = () => { /** 자신이 공급한 스냅샷의 load가 실패했을 때, 그 공급자에게만 호출된다(R5). - * { recover: "init" } 반환 → core는 init 경로로 생성을 계속한다(공급자의 명시적 결정). + * { recover: "create" } 반환 → core는 create 경로로 생성을 계속한다(공급자의 명시적 결정). * void 반환 또는 핸들러 부재 → SnapshotLoadError가 makeCoreStore 밖으로 던져진다(R4). */ onLoadError?: (args: { error: SnapshotLoadError; initialContext: any; - }) => { recover: "init" } | void; + }) => { recover: "create" } | void; }; ``` @@ -292,46 +334,52 @@ type StackflowPlugin = () => { 한다(계약). activity 0개짜리 스택을 조용히 만들어 주는 것은 사용자 눈에 빈 화면 = 사실상 조용한 실패이므로 R4의 정신에 따라 시끄럽게 처리한다. `{ events: [] }`도, 전부 pop된 이력도 여기서 잡힌다. - **에러 전달이 콜백인 이유**: `makeCoreStore`가 그냥 throw하면 try/catch 가능한 유일한 위치인 - 앱 개발자가 1차 처리자가 되어 R5 위반이다. 콜백의 반환값 `{ recover: "init" }`는 "조용한 폴백 + 앱 개발자가 1차 처리자가 되어 R5 위반이다. 콜백의 반환값 `{ recover: "create" }`는 "조용한 폴백 금지(R4)"와 "공급자가 복구 정책 결정(R5)"을 한 지점에서 화해시킨다 — 폴백이 일어나되, 스냅샷을 폐기하고 로그를 남긴 공급자의 명시적 서명이 있는 폴백이다. 핸들러 부재·void 반환 시 throw가 기본값인 것은 R4의 안전측이다(에러를 다루지 않는 공급자의 스냅샷 실패는 시끄럽게 죽는다). -- **복구는 재폴링하지 않는다**: `{ recover: "init" }`는 init 경로의 초기 이벤트 파이프라인부터 +- **복구는 재폴링하지 않는다**: `{ recover: "create" }`는 create 경로의 초기 이벤트 파이프라인부터 재개하며 `provideSnapshot`을 다시 폴링하지 않는다 — 무한 루프를 차단하고 load 시도를 생성당 1회로 고정한다(R3). - 에러 채널은 동기다: load가 생성 시점 동기(R3)이므로 복구 결정도 동기여야 한다. 비동기 채널 (이벤트·프라미스)은 "복원 대기 중" 중간 상태를 재도입하므로 두지 않는다. -### 3.5 init 진입 가로채기 지점 (R6) - -```ts -type StackflowPlugin = () => { - /** init 경로에서 초기 Pushed 이벤트 각각에 대해, 집계 전에 호출된다. - * 시그니처·의미는 onBeforePush와 동형: preventDefault / overrideActionParams. - * load 경로에서는 절대 호출되지 않는다(R6). */ - onBeforeInitialPush?: StackflowPluginPreEffectHook< - Omit - >; -}; -``` - -계약 세부: - -- **prevent 단위**: preventDefault된 Pushed에 뒤따르던 StepPushed 그룹은 함께 탈락한다. - step 단독 가로채기 훅은 두지 않는다 — 이 훅의 named 소비자는 activity guard(FEP-2521)다. - step 단위 요구가 실증되면 동형 원칙으로 `onBeforeInitialStepPush`를 additive하게 증분하면 된다. -- **`getStack()`의 의미**: 이 훅 안에서 `getStack()`은 "그 시점까지 수용된 진입"의 스택 - (정적 이벤트 + 앞서 수용된 초기 이벤트의 집계)을 반환한다. 첫 진입인지, 앞서 어떤 진입이 - 수용되었는지를 판별할 수 있으므로, 배치 시야가 필요한 정책(예: deep link 대상만 차단하고 루트 - 유지)도 per-push 훅으로 표현된다. -- **생성 중 재진입 금지**: 생성 중 훅(`onBeforeInitialPush`·`provideSnapshot`·`onLoadError`) - 안에서 항해 액션(`push`/`pop`/`dispatchEvent` 등) 호출은 계약 위반이다 — 스토어가 아직 완성 - 전이다. 가로챈 뒤 대체 진입은 `overrideActionParams`로, 진입 후 리다이렉트는 `onInit` 이후에 한다. -- **런타임 onBeforePush와의 관계 — 동형이되 동력이 같지는 않다**: 시그니처와 - prevent/override 의미론은 동형이라 가드 정책 코드는 한 벌로 두 훅에 물릴 수 있다(§2.2). - 단 위 두 제약(수용 프리픽스 스택·항해 액션 금지)은 런타임 훅에 없는 생성 중 제약이며 문서로 명시한다. - -**왜 기존 onBeforePush 파이프라인과 통일하지 않는가** — 이것은 이 설계에서 가장 중요한 부정 결정이다: +### 3.5 create 진입 가로채기 지점 (R6) + +create 경로의 최초 진입 가로채기는 **전용 훅 없이 기존 `overrideInitialEvents` 체인에서** 이뤄진다 — +플러그인이 초기 이벤트 배열을 받아 **검사·strip(차단)·치환(리다이렉트)** 한다. 이 셋이 R6이 요구하는 +"검사·차단·변경"의 전부다(§2.2 guard 코드). 새 공개 훅을 도입하지 않는다. + +**왜 strip이 preventDefault를 대신하는가 — 초기 이벤트는 pre-aggregate 데이터다.** 초기 이벤트는 +액션 파이프라인을 타지 않는 배열 데이터이고(§10-S3·S4), `makeCoreStore`는 이 배열을 +`overrideInitialEvents` 체인으로 reduce한 뒤 그 결과를 직접 집계한다(§10-S1). 이 시점의 "차단"은 어떤 +실행 흐름을 멈추는 것이 아니라 **배열에서 원소를 빼는 것**이고, "변경"은 원소를 치환하는 것이다. +`preventDefault`라는 명령형 신호는 진행 중인 dispatch를 멈춰야 하는 런타임 액션의 어휘이지(§10-S5), +아직 데이터인 것의 어휘가 아니다. 또 초기 집계는 `stack.value` 직접 대입이라 post-effect 훅을 +발화시키지 않으므로(§10-S2), "prevent하지 않으면 부수효과가 새어나간다"는 걱정도 create 경로엔 없다. +따라서 strip은 곧 prevent의 pre-aggregate 등가물이며, create 진입에 별도의 preventDefault 표면이 +필요하지 않다. + +**strip 단위 — 그룹 탈락**: strip된 Pushed에 뒤따르던 StepPushed는 함께 탈락한다 — 평탄 배열에서 +StepPushed는 직전 Pushed가 만든 activity(그 시점 최신 활성 activity)를 타깃하므로(§10-S21), 배열 +순회에서 "직전 Pushed가 strip되면 그에 딸린 StepPushed도 뺀다"가 집계 의미론과 일치한다(§2.2 코드의 +`dropGroup`). Pushed를 빼고 StepPushed를 남기면 재생 시 그 StepPushed가 다른 activity로 오귀속된다. + +**onInit은 집행이 아니라 검증이다**: 스택이 완성된 뒤(`onInit`, `initializedBy === "create"`)의 +"가로채기"는 진입 차단이 아니라 **사후 축출**(pop/replace)이며, 집행 수단으로는 부적격이다 — +(1) `store.init()`은 브라우저 전용이라(§10-S10) 서버 렌더에는 차단 대상 activity가 그대로 실린다, +(2) 사후 pop은 Popped 이벤트·exit-done 잔존·post-effect 발화라는 관찰 가능한 흔적을 남긴다(§10-S9), +(3) 스택 중간 activity는 위를 전부 pop했다 다시 push하는 churn 없이 제거할 수 없다. 그러나 **같은 +시점이 검증에는 정확히 맞는다** — 스택이 완성됐고 `initializedBy`로 경로를 구분할 수 있으므로 guard는 +여기서 최종 스택을 정책과 대조해 위반 시 크게 실패시킬 수 있다(검증 벨트 — §7.2). 벨트는 순서 +오배치라는 설정 오류를 침묵이 아니라 개발 시점의 예외로 전환한다. + +**생성 중 항해 액션 금지**: 생성 중 훅(`provideSnapshot`·`onLoadError`)과 `overrideInitialEvents` +안에서 항해 액션(`push`/`pop`/`dispatchEvent` 등) 호출은 계약 위반이다 — 스토어가 아직 완성 전이다. +create 진입의 변형은 `overrideInitialEvents` 반환 배열로, 진입 후 리다이렉트는 `onInit` 이후에 한다. + +**왜 create 진입을 기존 onBeforePush 액션 파이프라인으로 흘리지 않는가** — 이것은 이 설계의 중요한 +부정 결정이다: 1. **완전 통일(초기 진입을 액션 경로로 흘리기)은 post-effect 훅까지 발화시킨다.** 액션 경로는 pre-effect 훅 후 dispatch하고, dispatch는 post-effect 훅을 발화시킨다(§10-S5). 초기 진입이 @@ -342,17 +390,22 @@ type StackflowPlugin = () => { loader plugin의 `onBeforePush`는 loader가 pending이면 `pause()`를 호출한다(§10-S15) — 초기 이벤트에 이 훅을 발화시키면 **생성 도중 Paused 이벤트가 디스패치**되어 현행과 다른 생성 시퀀스가 된다. non-breaking은 확률이 아니라 보장이어야 한다. -3. 비통일의 지불은 guard가 같은 검사 함수를 두 훅에 등록하는 **한 줄**뿐이다(§2.2). 한 줄로 R8이 - 보장으로 바뀌므로 비통일이 우세하다. 의미상 통일(동형 시그니처·동형 의미론)은 유지해 정책 코드 - 중복을 없앤다. + +그래서 create 가로채기는 액션 파이프라인이 아니라 `overrideInitialEvents` 체인(pre-aggregate 데이터 +변형)에 둔다 — 이 위치가 위 두 오발화를 구조적으로 차단한다. 기각한 다른 대안: -- **`overrideInitialEvents` 재사용(신규 훅 0개)**: 이 훅은 대체 배열 반환만 가능하고 - preventDefault 시맨틱이 없으며(§10-S3) 플러그인 배열 순서에 취약하다. guard가 요구하는 - "검사·차단" 표면에 미달한다. -- **배치 단위 훅(`onBeforeInit(events[])` 류)**: onBeforePush와 비동형이라 guard가 정책 코드를 - 두 벌 쓰게 된다. 배치 시야는 위 `getStack()` 계약으로 per-push 훅에서도 얻는다. +- **create 전용 훅 신설(동형 시그니처의 `onBefore*Push` 류)**: `overrideInitialEvents`의 strip/치환이 + 이미 R6의 검사·차단·변경을 전부 제공하므로 전용 훅은 부재보다 나은 표면이 아니다. 전용 훅만의 잔여 + 가치는 "순서 무관의 구조적 집행 보장"(순서 문서·검증 벨트 없이도 guard가 항상 최종 초기 이벤트를 + 본다)인데, (i) 이 생태계는 이미 플러그인 순서를 규율로 쓰고(loaderPlugin은 history-sync 뒤 — + §10-S23), (ii) 침묵 실패는 검증 벨트로 가시화되며, (iii) 없이 출발했다 실사용에서 순서 사고가 + 실증되면 동형 훅을 additive(비파괴)로 증분하는 길이 열려 있는 반면 훅을 넣었다 빼는 것은 breaking + 이라는 비대칭 위에서, 지금 지불할 표면이 아니다. 오배치 사고가 반복 실증되면 그것이 전용 훅을 + additive로 증분할 정확한 타이밍이다. +- **`onInit`에서 집행**: 위 "onInit은 집행이 아니라 검증" — 사후 축출은 SSR 공백·흔적·중간 수술 + 불가로 집행 부적격이다. 검증 벨트로만 유효하다. - **가로채기 없음**: R6 위반. ### 3.6 일회성 신호 (R7) @@ -361,7 +414,7 @@ type StackflowPlugin = () => { /** onInit 인자 확장(순수 additive) — Stack 상태에는 어떤 흔적도 남지 않는다. */ onInit?: (args: { actions: StackflowActions; - createdBy: "init" | "load"; + initializedBy: "create" | "load"; }) => void; ``` @@ -369,20 +422,25 @@ onInit?: (args: { 없으므로 이벤트 로그에도 구분의 흔적이 없다 — R7과 비목표("구분의 지속 속성화")가 by construction 성립한다. 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 한다는 `CONTEXT.md`의 관계 정의 그대로다. -- 이름이 `createdBy`인 이유: `CONTEXT.md`의 유비쿼터스 언어에서 **진입(Entry)은 activity 수준 - 용어**다("Activity가 Stack에 나타나게 되는 사건"). 스택 생성 신호에 entry류 이름을 쓰면 도메인 - 어휘가 충돌한다. `createdBy`는 "Stack은 Init 또는 Load 중 정확히 하나의 경로로 만들어진다"는 - 관계 정의에 직결된다. 값이 이진 문자열 리터럴인 것은 R2(이진 분류가 core 어휘의 전부)의 표현이다. +- 이름이 `initializedBy`인 이유 — 초기화(initialize)는 부트스트랩 상위 개념이다: `onInit`· + `store.init`·`initializedBy`는 create·load 두 경로 모두에서 발화하며, `create`/`load`는 그 경로 + 값이다. `initializedBy: "load"`는 모순이 아니라 "스토어가 초기화됐다 — load 경로로"를 뜻한다. + `onInit`이라는 훅 이름과 `Initialized` 도메인 이벤트를 그대로 두는 것도 이 상위 개념과 정합한다 + (설정 사건). 신호 이름에 entry류(진입)를 쓰지 않는 이유는 `CONTEXT.md`에서 **진입(Entry)은 + activity 수준 용어**이기 때문이다("Activity가 Stack에 나타나게 되는 사건") — 스택 초기화 신호와 + 도메인 어휘가 충돌한다. `initializedBy`는 "Stack은 Create 또는 Load 중 정확히 하나의 경로로 + 만들어진다"는 관계 정의에 직결되고, 값이 이진 문자열 리터럴인 것은 R2(이진 분류가 core 어휘의 + 전부)의 표현이다. - 현행 `onInit`은 `{ actions }` 단일 인자로 발화하므로(§10-S13) 필드 추가는 순수 additive다. 레포 내 `onInit` 사용자 6개(blocker·devtools·GA4·history-sync·lifecycle·stack-depth-change)는 인자를 무시하거나(GA4는 무인자 `onInit()` 선언) `actions`만 구조분해한다 — 어떤 기존 플러그인도 영향받지 않는다. - **activity 단위 출처는 표현하지 않는다**: load는 생성 시점에만 일어나므로(R3) 출처가 의미 있을 - 유일한 순간(생성 직후)에는 답이 자명하다 — 전부 스냅샷 출신이다. `onInit`에서 `createdBy === "load"`를 + 유일한 순간(생성 직후)에는 답이 자명하다 — 전부 스냅샷 출신이다. `onInit`에서 `initializedBy === "load"`를 본 소비자는 `getStack()`의 모든 activity가 복원물임을 이미 알고, 이후 push되는 activity는 정의상 신규다. 세 소비자의 사용 코드(§2) 어디에도 per-activity 출처 조회가 등장하지 않는다. 재생된 activity의 `enteredBy`는 스냅샷 속 원본 Pushed/Replaced 이벤트로 유지되어(§10-S9), 생성 이후의 - 세계는 init 출신과 구별 불가능하게 균질하다. 기각 대안 — `enteredBy`에 Loaded류 마킹이나 신규 + 세계는 create 출신과 구별 불가능하게 균질하다. 기각 대안 — `enteredBy`에 Loaded류 마킹이나 신규 이벤트 어휘: 새 도메인 이벤트 + 지속 속성 + 소비자 부재의 삼중 지불. 관측 요구(devtools 등)가 실증되면 `onInit` 신호를 구독해 자체 기록하는 비침습 경로가 있다. @@ -390,29 +448,25 @@ onInit?: (args: { ## 4. 생성 시퀀스 -### 4.1 init 경로 — 스냅샷 공급자 없음 (전원 null) +### 4.1 create 경로 — 스냅샷 공급자 없음 (전원 null) ``` makeCoreStore(options) 1. 플러그인 인스턴스화 (현행 그대로) - 2. provideSnapshot 전원 폴링 → 전부 null → init 경로 확정 + 2. provideSnapshot 전원 폴링 → 전부 null → create 경로 확정 3. options.initialEvents를 [Pushed/StepPushed | 나머지(정적)]로 분리 (현행 §10-S3) 4. overrideInitialEvents 체인 (현행 그대로 — 무변경 유지, R8) - 5. (신규) 결과의 각 Pushed에 onBeforeInitialPush 파이프라인 - — prevent된 Pushed는 딸린 StepPushed 그룹과 함께 탈락 - — 등록한 플러그인이 없으면 빈 순회 = 관찰상 무(無) - 6. onInitialActivityIgnored / onInitialActivityNotFound 핸들러 - — 파이프라인(4+5) 전체가 끝난 최종 결과에 대해 평가 (신규 훅 미사용 시 현행 판정과 동일) - — Ignored의 의미론: 옵션 제공 초기 이벤트가 있고 최종 수용 집합이 그것과 다르면 - (override 체인의 대체뿐 아니라 가로채기의 변형·탈락 포함) 발화 — "옵션으로 지정한 - 초기 activity가 무시되었다"는 현행 의미의 자연 확장 - 7. events.value = [정적 이벤트, ...최종 초기 이벤트] → aggregate → store 완성 (현행) - 8. (react 통합 등이) store.init() 호출 → onInit({ actions, createdBy: "init" }) + — guard 등 create 가로채기 소비자는 이 체인에서 초기 이벤트를 검사·strip·치환한다(§3.5) + 5. onInitialActivityIgnored / onInitialActivityNotFound 핸들러 + — overrideInitialEvents 체인이 끝난 결과에 대해 평가 (현행 판정과 동일 — §10-S13) + 6. events.value = [정적 이벤트, ...최종 초기 이벤트] → aggregate → store 완성 (현행) + 7. (react 통합 등이) store.init() 호출 → onInit({ actions, initializedBy: "create" }) ``` -guard가 초기 진입을 전부 prevent하면 6에서 `onInitialActivityNotFound`가 발화하고 activity 0개 -스택으로 생성된다 — "초기 activity 없음"은 오늘도 정의된 상태이며(§10-S3의 빈 배열 경로) 같은 -상태에 착지한다. +guard가 초기 진입을 전부 strip하면(overrideInitialEvents가 빈 배열 반환) 5에서 +`onInitialActivityNotFound`가 발화하고 activity 0개 스택으로 생성된다 — "초기 activity 없음"은 오늘도 +정의된 상태이며(§10-S3의 빈 배열 경로) 같은 상태에 착지한다. 이 판정은 `overrideInitialEvents`가 +무변경이므로 현행과 동일하다(가로채기 파이프라인 신설이 없어 Ignored 의미 확장도 불필요하다). ### 4.2 load 경로 — 정확히 하나가 non-null @@ -420,7 +474,7 @@ guard가 초기 진입을 전부 prevent하면 6에서 `onInitialActivityNotFoun makeCoreStore(options) 1. 플러그인 인스턴스화 2. provideSnapshot 전원 폴링 - — non-null 0개 → init 경로(§4.1의 3부터) + — non-null 0개 → create 경로(§4.1의 3부터) — non-null 2개 이상 → 생성 에러 throw (설정 오류, §3.3) — non-null 1개 → load 경로, 공급 플러그인 확정 3. 구조 검사: $schema 일치·events 배열·항목이 탐색 이벤트 6종 @@ -435,18 +489,19 @@ makeCoreStore(options) Initialized·ActivityRegistered(현행 react 통합의 관행 — §10-S10)라고 전제한다. 다른 탐색 이벤트(Popped 등)를 initialEvents에 직접 넣는 비관용 임베딩은 이 전제 밖이다 — options의 초기 Pushed/StepPushed(예: initialActivity)는 폐기: 스냅샷이 곧 탐색 기록이며, - "무엇으로 시작하는가"는 init 경로의 어휘다. 복원된 탐색 위에 initialActivity를 겹치면 + "무엇으로 시작하는가"는 create 경로의 어휘다. 복원된 탐색 위에 initialActivity를 겹치면 보존 시점에 없던 activity가 생겨 R11(충실한 재구성)을 깬다 - — overrideInitialEvents · onBeforeInitialPush · initial activity 핸들러 전부 건너뜀: - 셋 다 "초기 진입을 무엇으로 정하는가"라는 init 어휘의 표면이고, load 진입은 가로채기 - 대상이 아니다(R6) + — overrideInitialEvents 체인 · initial activity 핸들러 전부 건너뜀: + 둘 다 "초기 진입을 무엇으로 정하는가"라는 create 어휘의 표면이고, load 진입은 가로채기 + 대상이 아니다(R6). create 가로채기 소비자(guard)가 overrideInitialEvents에서 동작하므로, + load 경로가 그 체인을 건너뛴다는 것이 곧 load 비가로채기다 — validateEvents throw → SnapshotLoadError{invalid-events} → 7로 — 재생 성공 후 사후조건 검사: enter 상태 activity ≥ 1 — 위반 → {empty-navigation} → 7로 7. (실패 시) 공급 플러그인의 onLoadError({ error, initialContext }) - — { recover: "init" } 반환 → §4.1의 3부터 재개 (provideSnapshot 재폴링 없음) + — { recover: "create" } 반환 → §4.1의 3부터 재개 (provideSnapshot 재폴링 없음) — void/핸들러 부재 → makeCoreStore 밖으로 throw 8. (성공 시) store 완성 - 9. store.init() → onInit({ actions, createdBy: "load" }) + 9. store.init() → onInit({ actions, initializedBy: "load" }) ``` **재기저 규칙** (4단계): @@ -483,9 +538,10 @@ makeCoreStore(options) - 생성 중에는 어느 경로든 post-effect 훅(`onPushed`·`onChanged` 등)이 발화하지 않는다 — 초기 집계는 `stack.value`에 직접 대입되며 effect 산출 경로를 타지 않는 현행 구조(§10-S2) 그대로다. -- `overrideInitialEvents`는 시그니처·호출 시점·의미 전부 무변경이다. 그 결과는 init 취급이며(R8) - 이어지는 `onBeforeInitialPush`의 가로채기 대상이다 — deep link(URL 해석)도 init이고 guard의 - 적용 대상이라는 `CONTEXT.md` 정의와 정확히 맞물린다. +- `overrideInitialEvents`는 시그니처·호출 시점·의미 전부 무변경이다. 그 결과는 create 취급이며(R8), + create 가로채기 소비자(guard)는 바로 이 `overrideInitialEvents` 체인 안에서 초기 이벤트를 검사· + strip·치환한다(§3.5) — deep link(URL 해석)도 create이고 guard의 적용 대상이라는 `CONTEXT.md` + 정의와 정확히 맞물린다. - `store.init()`/`onInit`의 호출 주체·타이밍(react 통합이 브라우저에서 1회 호출, §10-S13·S10)도 무변경이다. load 경로에서도 같은 지점에서 발화하며 신호만 다르다. @@ -495,28 +551,28 @@ makeCoreStore(options) **경로 공통** -- **C1 (경로 배타)**: 한 번의 생성은 정확히 init 또는 load 하나를 탄다. load 실패 복구는 공급자의 - 명시적 `{ recover: "init" }` 결정으로만 init에 재진입하며, 재진입 후 다시 load로 돌아올 수 없다 +- **C1 (경로 배타)**: 한 번의 생성은 정확히 create 또는 load 하나를 탄다. load 실패 복구는 공급자의 + 명시적 `{ recover: "create" }` 결정으로만 create에 재진입하며, 재진입 후 다시 load로 돌아올 수 없다 (재폴링 없음). - **C2 (자리 하나)**: non-null 공급 2개 이상 = 생성 에러. core는 조정하지 않는다 — 조정자가 아니라 강제자다(R9). -- **C3 (신호 일회성)**: `createdBy`는 생성 시점 훅 인자로만 존재하고 Stack 상태·이벤트 로그 어디에도 +- **C3 (신호 일회성)**: `initializedBy`는 생성 시점 훅 인자로만 존재하고 Stack 상태·이벤트 로그 어디에도 남지 않는다(R7). - **C4 (생성 중 무발화)**: 생성 완료 전에는 어떤 post-effect 훅도 발화하지 않는다(§10-S2). -**init 경로** +**create 경로** -- **N1 (현행 동일성)**: 스냅샷 공급자가 없고 `onBeforeInitialPush` 등록자가 없으면, 생성 시퀀스는 - 오늘의 코드 경로와 관찰상 동일하다(R8의 구조적 근거 — §6 R8). -- **N2 (가로채기 완전성)**: 집계에 도달하는 모든 초기 Pushed는 `overrideInitialEvents` 체인과 - `onBeforeInitialPush` 파이프라인을 순서대로 통과한 것이다(R6). -- **N3 (핸들러 보존)**: initial activity 핸들러(ignored/notFound)는 파이프라인 최종 결과에 대해 - 현행과 같은 판정으로 발화한다. +- **N1 (현행 동일성)**: 스냅샷 공급자가 없으면(`provideSnapshot` 전원 null) create 시퀀스는 오늘의 + 코드 경로와 관찰상 동일하다. create 경로에 신규 step이 없고 `overrideInitialEvents`가 무변경이므로, + guard 등 create 가로채기 소비자의 설치 여부와 무관하게 성립한다(R8의 구조적 근거 — §6 R8). +- **N2 (핸들러 보존)**: initial activity 핸들러(ignored/notFound)는 `overrideInitialEvents` 체인 + 결과에 대해 현행과 같은 판정으로 발화한다(§10-S13). **load 경로** -- **L1 (무가로채기)**: `overrideInitialEvents`·`onBeforeInitialPush` 모두 호출되지 않는다(R6) — - 분기가 초기 이벤트 파이프라인 자체를 건너뛰므로 규약이 아니라 구조의 귀결이다. +- **L1 (무가로채기)**: `overrideInitialEvents` 체인이 호출되지 않는다(R6) — load 분기가 초기 이벤트 + 파이프라인 자체를 건너뛰므로 규약이 아니라 구조의 귀결이다. 그 체인에서 create 진입을 가로채는 + guard도 load 경로에서는 구조적으로 작동하지 않는다. - **L2 (도달 가능성 — by construction)**: 재생 결과는 "현행 core가 이 이벤트 열을 처리했다면 도달했을 상태" 그 자체다 — 기존 `aggregate`+`validateEvents`를 무변경 통과했으므로 R11의 도달 가능성이 구성적으로 보장된다. config 진실(등록)은 L6이 봉인한다. @@ -539,12 +595,12 @@ makeCoreStore(options) | 요구 | 충족 방식 | |---|---| | R1 소스 불문 | 진입은 `provideSnapshot` 하나 — storage든 history.state든 인메모리든 같은 훅으로 공급 | -| R2 이진 분류 | 경로 = 스냅샷 존재 여부로 이분. core 어휘는 `createdBy: "init" \| "load"`뿐. deep link는 init의 내부 사정(`overrideInitialEvents`)으로 남음 | +| R2 이진 분류 | 경로 = 스냅샷 존재 여부로 이분. core 어휘는 `initializedBy: "create" \| "load"`뿐. deep link는 create의 내부 사정(`overrideInitialEvents`)으로 남음 | | R3 동기 load | `provideSnapshot`은 생성 중 동기 호출·동기 반환. 비동기 소스는 생성 지연(상위 부트스트랩) + 인라인 공급 플러그인. 에러 복구 결정(`onLoadError`)도 동기 | -| R4 명시적 에러 | `SnapshotLoadError` 3분류 + 기본 throw. 폴백은 공급자의 명시적 `{ recover: "init" }`로만. `empty-navigation`도 에러로 승격. 미등록 activity 물화(Pushed·Replaced 불문)는 load 등록 검사가 잡음(L6) | +| R4 명시적 에러 | `SnapshotLoadError` 3분류 + 기본 throw. 폴백은 공급자의 명시적 `{ recover: "create" }`로만. `empty-navigation`도 에러로 승격. 미등록 activity 물화(Pushed·Replaced 불문)는 load 등록 검사가 잡음(L6) | | R5 공급자 1차 처리 | `onLoadError`는 스냅샷을 공급한 플러그인에게만 호출. 앱 개발자는 잘 만든 공급자 뒤에서 무관여 | -| R6 init 가로채기·load 비대상 | `onBeforeInitialPush`는 init 경로 전용(N2), load 경로 불호출(L1 — 구조의 귀결) | -| R7 일회성 신호 | `createdBy`는 `onInit` 인자로만 존재, Stack·이벤트 로그 무흔적(C3). 재생 activity의 `enteredBy`도 원본 이벤트 | +| R6 create 가로채기·load 비대상 | create 진입 가로채기 = `overrideInitialEvents` 체인의 검사·strip·치환(§3.5), load 경로는 그 체인을 구조적으로 건너뜀(L1) | +| R7 일회성 신호 | `initializedBy`는 `onInit` 인자로만 존재, Stack·이벤트 로그 무흔적(C3). 재생 activity의 `enteredBy`도 원본 이벤트 | | R8 non-breaking | 아래 상세 논증 | | R9 단일 자리 | non-null 2개 이상 = 생성 에러(C2). 선언적 폴링으로 순서 무관 강제 | | R10 왕복 폐쇄 | `captureSnapshot()` → plain-data `StackSnapshot` → `provideSnapshot` — 셋 다 core 계약. 외부 지식 불요(§7.1의 설계 수준 증명) | @@ -554,23 +610,25 @@ makeCoreStore(options) **R8 상세 — non-breaking이 확률이 아니라 구조로 성립하는 이유** -1. **신규 표면은 전부 additive다**: 옵셔널 훅 3개(`provideSnapshot`·`onLoadError`· - `onBeforeInitialPush`) + actions 메서드 1개 + `onInit` 인자 필드 1개 + 타입 2개. 기존 시그니처 - 변경 0, 기존 훅 제거 0, `makeCoreStore` 옵션 변경 0, `aggregate`/`validateEvents`/리듀서 변경 0. +1. **신규 표면은 전부 additive다**: 옵셔널 훅 2개(`provideSnapshot`·`onLoadError`) + actions + 메서드 1개 + `onInit` 인자 필드 1개 + 타입 2개. 기존 시그니처 변경 0, 기존 훅 제거 0, + `makeCoreStore` 옵션 변경 0, `aggregate`/`validateEvents`/리듀서 변경 0, `overrideInitialEvents` + 변경 0. 2. **스냅샷 미공급 시 코드 경로가 오늘과 동일하다(N1)**: 공급자가 없으면 폴링은 전부 null이고 - 가로채기 파이프라인은 등록자가 없으면 빈 순회다. 기존 플러그인의 어떤 훅도 새로운 시점에 불리지 - 않고, 불리던 훅이 안 불리게 되지도 않는다. `overrideInitialEvents`는 무변경 유지되고 그 결과는 - init 취급된다 — 현행 history-sync는 한 줄도 안 바꾸고 오늘처럼 동작한다(§7.3 국면 1). + create 경로엔 신규 step이 없다. 기존 플러그인의 어떤 훅도 새로운 시점에 불리지 않고, 불리던 훅이 + 안 불리게 되지도 않는다. `overrideInitialEvents`는 무변경 유지되고 그 결과는 create 취급된다 — + 현행 history-sync는 한 줄도 안 바꾸고 오늘처럼 동작한다(§7.3 국면 1). 3. **onInit 인자 추가의 안전성**: 현행 `onInit`은 `{ actions }` 단일 인자 객체로 발화하며(§10-S13), 레포 내 사용자 6개는 인자를 무시하거나(GA4는 무인자 `onInit()` 선언) `actions`만 구조분해한다. 필드 추가는 관찰 불가능하다. 4. **load 경로는 opt-in으로만 도달 가능하다**: load는 `provideSnapshot`을 구현한 신규 API 플러그인을 설치해야만 발생한다. 기존 앱은 정의상 이 플러그인이 없으므로 새 분기에 진입할 수 없다. -5. **초기 진입을 액션 파이프라인에 태우지 않는 것 자체가 R8 장치다**: §3.5의 비통일 논증 — +5. **create 진입을 액션 파이프라인에 태우지 않는 것 자체가 R8 장치다**: §3.5의 비통일 논증 — post-effect 발화(history-sync `pushState` 중복)와 pre-effect 오발화(loader plugin의 생성 중 - `pause()`)를 구조적으로 차단한다. + `pause()`)를 구조적으로 차단한다. create 가로채기를 `overrideInitialEvents`(pre-aggregate 데이터 + 변형)에 두는 것이 이 차단의 위치다. -**비목표 정합**: late load 없음(생성 시점 전용, 재폴링 없음) / init 세분화 없음(deep link는 core +**비목표 정합**: late load 없음(생성 시점 전용, 재폴링 없음) / create 세분화 없음(deep link는 core 어휘 밖) / 지속 속성 없음(C3) / react 앱 개발자 신규 표면 없음(전부 플러그인 계약) / 버전 마이그레이션 없음(`$schema` 불일치 = `incompatible-schema` 에러). @@ -588,16 +646,16 @@ makeCoreStore(options) 2. **보존**: `codec.encode(snapshot)` → storage. codec·storage는 공급자 소유(R13) — core 계약 밖이되 계약이 요구하는 유일한 성질(plain-data)을 §3.1이 보장. 3. **load**: 다음 생성에서 core가 `provideSnapshot({ initialContext })`을 동기 폴링(§4.2-2) — - 공급자는 storage를 동기로 읽어 `codec.decode` 후 반환. null이면 init. -4. **재구성**: core가 구조·등록 검사→재기저→재생→사후조건(§4.2-3~6). 성공 시 `onInit({ createdBy: "load" })`. + 공급자는 storage를 동기로 읽어 `codec.decode` 후 반환. null이면 create. +4. **재구성**: core가 구조·등록 검사→재기저→재생→사후조건(§4.2-3~6). 성공 시 `onInit({ initializedBy: "load" })`. 결과 스택의 탐색 기록은 캡처 시점과 일치(L3)하고, 이후 항해는 오늘과 동일한 액션 경로. 5. **실패 처리**: 손상 스냅샷이면 core가 이 공급자의 `onLoadError`만 호출(§3.4) — 공급자는 스냅샷을 - 폐기하고 `{ recover: "init" }` 반환 → 앱은 초기 화면으로 정상 기동. 앱 개발자 코드는 어디에도 + 폐기하고 `{ recover: "create" }` 반환 → 앱은 초기 화면으로 정상 기동. 앱 개발자 코드는 어디에도 등장하지 않는다(R5). 6. **왕복 재개**: load 직후 `captureSnapshot()`이 같은 탐색 기록을 재구성하는 스냅샷을 반환(L5)하므로 주기적 persist가 자연스럽다. -1–6에 등장한 표면은 `captureSnapshot`·`StackSnapshot`·`provideSnapshot`·`onLoadError`·`onInit(createdBy)` +1–6에 등장한 표면은 `captureSnapshot`·`StackSnapshot`·`provideSnapshot`·`onLoadError`·`onInit(initializedBy)` — 전부 core 계약이다. 외부 지식(react 내부·다른 플러그인)이 필요한 단계가 없다(R10). ∎ **7.1.3 loader data 재파생 패턴 (R13 참고 시나리오)**: 재파생 가능한 런타임 데이터는 스냅샷에 담지 @@ -607,28 +665,45 @@ makeCoreStore(options) promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 동기이므로(현행 loader plugin의 동기 resolve 래핑과 동일 기법, §10-S15) R3(동기 load)과 충돌하지 않는다. -### 7.2 activity guard (FEP-2521) — init 가로채기·load 스킵 +### 7.2 activity guard (FEP-2521) — create 가로채기·load 스킵 - **런타임 push**: 기존 `onBeforePush`(현행 파이프라인, §10-S5) — 무변경. -- **init 최초 진입**: `onBeforeInitialPush` — deep link든 initialActivity든 `overrideInitialEvents` - 결과든, 집계에 도달하는 모든 초기 Pushed가 이 파이프라인을 통과한다(N2). 시그니처가 동형이므로 - 검사 함수는 한 벌이다(§2.2). -- **load 스킵**: guard에 스킵 코드가 없다 — load 경로에서 두 훅 모두 구조적으로 불리지 않는다(L1). - "보존 시점에 이미 검증된 맥락의 재구성은 가로채지 않는다"(R6·`CONTEXT.md`)가 정책 코드가 아니라 - 경로 구조로 성립한다. -- **엣지**: guard가 초기 진입을 전부 차단하면 `onInitialActivityNotFound` + 빈 스택 — 현행 "초기 - activity 없음"과 같은 정의된 상태(§4.1). 차단 대신 대체 진입은 `overrideActionParams`, 진입 후 - 리다이렉트는 `onInit` 이후(§3.5). +- **create 최초 진입**: `overrideInitialEvents` 체인에서 초기 이벤트 배열을 검사·strip(차단)· + 치환(리다이렉트)한다(§2.2·§3.5). deep link든 initialActivity든, 집계에 도달하는 모든 초기 Pushed가 + 이 체인을 지난다. guard가 배열 전체를 한 번에 보므로 배치 정책("하나라도 차단이면 전체를 로그인 + 스택으로 치환")이 자명하게 표현되고, guard가 loaderPlugin(자동 마지막 배치 — §10-S23)보다 앞에서 + strip하면 차단될 activity의 loader가 아예 실행되지 않는 부수 이득도 있다. +- **성립 조건 ① 순서 규율**: history-sync의 `overrideInitialEvents`는 들어온 `initialEvents`를 받지 + 않고 자기 배열을 새로 만든다(§10-S22). 따라서 guard가 history-sync보다 **앞**에 있으면 guard의 + 출력이 통째로 버려져 인증 가드가 침묵으로 우회된다. guard는 초기 이벤트 생성자(history-sync 등)보다 + **뒤**에 배치해야 한다 — 이 생태계가 이미 쓰는 순서 규율과 동종이다(loaderPlugin은 history-sync + 뒤에 와야 하고 react 통합이 이를 코드로 강제 — §10-S23). +- **성립 조건 ② 검증 벨트**: 순서 오배치의 침묵성을 개발 시점의 큰 소리로 전환하는 안전망으로, + guard는 `onInit`(`initializedBy === "create"`)에서 최종 스택을 정책과 대조해 위반 시 throw한다 + (§2.2 코드). 이 시점은 **집행이 아니라 검증**이다: 사후 pop은 SSR에 이미 실린 마크업을 되돌리지 + 못하고(§10-S10 브라우저 전용 `store.init`), Popped·exit-done 잔존·post-effect 발화라는 흔적을 + 남기며(§10-S9), 스택 중간 activity는 churn 없이 제거할 수 없다(§3.5). 그러나 검증에는 정확히 맞다 — + 스택이 완성됐고 `initializedBy`로 경로를 구분할 수 있으며, 위반의 유일한 원인이 "guard가 초기 + 이벤트를 못 봤다"(순서 오배치)이므로 설정 오류로 크게 실패시키면 된다. 벨트는 guard 플러그인 저자가 + 한 번 구현하는 패턴이지 앱 개발자 표면이 아니다. +- **load 스킵**: guard에 스킵 코드가 없다 — load 경로는 `overrideInitialEvents` 체인 자체를 + 건너뛰므로(L1) 구조적으로 불리지 않는다. `onInit` 벨트도 `initializedBy === "load"`면 즉시 반환한다 + (load는 보존 시점에 이미 검증된 맥락 — R6·`CONTEXT.md`). "이미 검증된 맥락의 재구성은 가로채지 + 않는다"가 정책 코드가 아니라 경로 구조로 성립한다. +- **엣지**: guard가 초기 진입을 전부 strip하면 `onInitialActivityNotFound` + 빈 스택 — 현행 "초기 + activity 없음"과 같은 정의된 상태(§4.1). strip 대신 대체 진입은 배열 치환으로, 진입 후 리다이렉트는 + `onInit` 이후에 한다(§3.5). ### 7.3 history-sync (FEP-2001) — 스택을 진실의 원천으로 세 국면으로 성립을 논증한다. **core 계약은 세 국면에서 동일하다 — 개정되는 것은 플러그인뿐이다.** -- **국면 1 — 현행 (이 설계 배포 직후, history-sync 미개정)**: 공급자가 없으므로 모든 생성이 init +- **국면 1 — 현행 (이 설계 배포 직후, history-sync 미개정)**: 공급자가 없으므로 모든 생성이 create 경로다. history-sync의 `overrideInitialEvents`(history.state/URL 해석)·`onInit`(히스토리 정렬)· `onPushed`(pushState) 전부 오늘과 동일하게 발화한다(R8). `overrideInitialEvents`가 만든 초기 - 이벤트는 init 취급이므로 guard가 설치되면 가로채기 대상이다 — deep link는 init이라는 어휘 - 정의(R2·`CONTEXT.md`)와 일치. + 이벤트는 create 취급이므로, guard가 (history-sync 뒤에 — §7.2 순서 규율) 설치되면 같은 + `overrideInitialEvents` 체인에서 가로채기 대상이다 — deep link는 create라는 어휘 정의 + (R2·`CONTEXT.md`)와 일치. - **국면 2 — 과도 (persister 도입, history-sync 미개정)**: persister가 유일 공급자로 load. 이때 history-sync의 `overrideInitialEvents`는 발화하지 않는다(L1) — 이것은 새 의미론이지만 opt-in 사용자에게만 나타난다. `onInit`은 발화한다: `history.location.state`가 비어 있으면(콜드 스타트) @@ -638,7 +713,7 @@ promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 미보장이다 — **이 간극을 닫는 것이 FEP-2001의 범위**이며, 그때까지 persister 문서가 이 조합의 한계를 명시해야 한다. - **국면 3 — 목표 (FEP-2001 개정 후)**: history-sync 자신이 공급자가 된다 — `provideSnapshot`으로 - history.state에서 스냅샷을 공급하고(§2.3), `onInit`에서 `createdBy === "load"`를 받아 "loaded + history.state에서 스냅샷을 공급하고(§2.3), `onInit`에서 `initializedBy === "load"`를 받아 "loaded 스택 → 브라우저 히스토리 동기화"를 수행한다. 스택이 진실의 원천이 되는 개정의 신호·진입점·충실성 (id·`enteredBy` 보존)이 이 계약으로 전부 공급된다. persister×history-sync 동시 공급은 R9 생성 에러로 조기 가시화되며, 조정(예: history.state 존재 시 persister 양보)은 core 위 계층 @@ -703,7 +778,7 @@ promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 | S1a | 이벤트 로그는 누적만 되고 소거되지 않음(`events.value.push`), `pullEvents()`로 노출 | `core/src/makeCoreStore.ts:101, 159` | | S2 | 초기 집계는 `stack.value` 직접 대입 — `setStackValue`(→`produceEffects`→post-effect 훅)는 dispatch에서만 호출되므로 생성 중 post-effect 훅 무발화 | `core/src/makeCoreStore.ts:83-85` vs `:102, 134-138` | | S3 | `overrideInitialEvents`는 Pushed/StepPushed만 분리해 플러그인 배열 순서로 reduce하는 동기 체인. preventDefault 시맨틱 없음(대체 배열 반환만). 빈 결과 → `onInitialActivityNotFound`, 참조 변경 → `onInitialActivityIgnored` | `core/src/makeCoreStore.ts:52-77`, `core/src/interfaces/StackflowPlugin.ts:133-136` | -| S4 | 초기 이벤트는 액션 파이프라인을 타지 않고 직접 집계 — 현행 init 진입은 onBeforePush 비대상 | `core/src/makeCoreStore.ts:79-85` | +| S4 | 초기 이벤트는 액션 파이프라인을 타지 않고 직접 집계 — 현행 create 진입은 onBeforePush 비대상 | `core/src/makeCoreStore.ts:79-85` | | S5 | 액션 경로: pre-effect 훅(preventDefault/overrideActionParams) → dispatch → 집계 → post-effect 훅 | `core/src/utils/makeActions.ts:16-29`, `core/src/utils/triggerPreEffectHooks.ts:32-68`, `core/src/makeCoreStore.ts:93-123, 134-138` | | S6 | `validateEvents`는 ①비어있지 않음 ②Initialized ≤ 1개 ③모든 Pushed.activityName 등록 존재 — 세 가지만, 배열 순서 무관. **Replaced는 검사 대상이 아니다**(Pushed만 필터) | `core/src/event-utils/validateEvents.ts:4-26` (Pushed 필터: `:21-24`) | | S6a | `ReplacedEvent`도 `activityName`을 보유하고, 리듀서는 Replaced로부터 activity를 물화한다 — 미등록 activity의 Replaced가 현행 `validateEvents`를 침묵 통과한다(런타임 `replace()`도 동일한 현행 간극) | `core/src/event-types/ReplacedEvent.ts:3-13`, `core/src/activity-utils/makeActivitiesReducer.ts:48-65`, `core/src/activity-utils/findNewActivityIndex.ts:9-16`, `core/src/activity-utils/makeActivityFromEvent.ts:8-27` | @@ -721,6 +796,9 @@ promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 | S18 | 현행 history-sync `onInit`: history.state가 비어 있으면 enter 상태 activity들을 브라우저 히스토리에 replaceState/pushState로 정렬 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:449-503` | | S19 | `makeEvent`는 `{ id: id(), eventDate: time(), ...parameters, name }` — 파라미터로 id·eventDate 덮어쓰기 가능(재생 시 원본 id·재기저 date 주입이 기존 표면으로 가능) | `core/src/event-utils/makeEvent.ts:13-18` | | S20 | 레포 내 `overrideInitialEvents` 사용자는 history-sync·loader plugin 둘. `onInit` 사용자는 blocker·devtools·GA4·history-sync·lifecycle·stack-depth-change 6개 — 인자를 무시하거나(GA4는 무인자 `onInit()` 선언, `googleAnalyticsPlugin.tsx:35`) `actions`만 구조분해 | `extensions/*/src`, `integrations/react/src` (grep 검증) | +| S21 | `StepPushed`·`StepReplaced`는 `findLatestActiveActivity`(최신 활성 activity, eventDate 최대)를 타깃한다 — 평탄 초기 배열에서 StepPushed는 직전 Pushed가 만든 activity에 붙는다. 그 Pushed를 빼면 StepPushed는 다른 activity로 오귀속된다 | `core/src/activity-utils/findTargetActivityIndices.ts:78-91` | +| S22 | 현행 history-sync `overrideInitialEvents`는 들어온 `initialEvents`를 **받지 않고**(`{ initialContext }`만 구조분해) `history.location.state`/URL 해석으로 배열을 새로 만든다 — 이 체인 앞의 플러그인이 만든 초기 이벤트 출력은 history-sync가 덮어쓴다 | `extensions/plugin-history-sync/src/historySyncPlugin.tsx:252` | +| S23 | react 통합은 loaderPlugin을 플러그인 배열 **맨 뒤에 append**하며, 소스 주석이 "`loaderPlugin()` must be placed after `historySyncPlugin()`"로 순서 규율을 못박는다 — 플러그인 순서를 코드로 강제하는 기존 생태계 규율 | `integrations/react/src/stackflow.tsx:79-88` | --- @@ -728,13 +806,18 @@ promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 **지불하는 것** -- `makeCoreStore` 생성부의 분기 증가(폴링 → load 시도 → 실패 복구 → init 재개). 생성 시퀀스의 +- `makeCoreStore` 생성부의 분기 증가(폴링 → load 시도 → 실패 복구 → create 재개). 생성 시퀀스의 상태도가 오늘의 직선 코드보다 하나 늘어난다. - **스냅샷-이벤트 어휘 결합**: 스냅샷 호환성이 core 이벤트 스키마의 안정성에 묶인다. 이벤트 필드가 비호환으로 바뀌면 옛 스냅샷은 load 실패한다. "비호환 = load 실패"가 비목표로 승인돼 있어 계약 위반은 아니지만, 이벤트 스키마 변경의 비용을 키운다는 사실은 남는다 — `$schema` 태그가 그 비용을 조용한 오동작이 아니라 명시적 에러로 바꾼다. -- guard는 훅 두 곳 등록(통일 대신 동형을 산 대가 — 한 줄). +- guard는 두 어댑터를 유지한다 — 런타임(`onBeforePush`+preventDefault)과 create(`overrideInitialEvents` + 배열 필터/치환)의 모양이 달라 정책 함수를 두 어댑터에 물린다. 여기에 순서 규율(guard를 초기 이벤트 + 생성자 뒤 배치)과 검증 벨트(`onInit`) 구현이 더해진다. 이 이원화·규율·벨트의 지불 주체는 guard + 플러그인 저자이지 앱 개발자가 아니며, 전용 create 훅을 두지 않은 대가다 — 그 대신 공개 표면이 하나 + 줄고, breaking 없이 후에 전용 훅을 additive로 증분할 옵션이 보존된다(전용 훅은 순서 규율·벨트 없이 + 구조적 집행을 보장했을 것이나, 지금 지불할 표면이 아니다 — §3.5). - load 등록 검사 1건 — 등록 술어가 두 곳(런타임 `validateEvents`의 Pushed 검사, load의 activity 도입 이벤트 전수 검사)에서 적용된다. 술어 자체는 동형이지만 규칙 변경 시 함께 고칠 지점이 둘이 되는 소폭의 긴장. 런타임 `validateEvents`의 전역 확장(독립적 core 수정 후보 — @@ -744,13 +827,15 @@ promise를 `activityContext`에 재주입할 수 있다. promise **생성**은 **획득하는 것** -- **표면 최소**: 신규 개념은 타입 2(스냅샷·에러) + actions 메서드 1 + 옵셔널 훅 3 + 기존 훅 인자 1. - 신규 도메인 이벤트 0, 신규 Stack 속성 0, react 표면 0, `aggregate`/리듀서/`validateEvents` 변경 0. +- **표면 최소**: 신규 개념은 타입 2(스냅샷·에러) + actions 메서드 1 + 옵셔널 훅 2 + 기존 훅 인자 1. + 신규 도메인 이벤트 0, 신규 Stack 속성 0, react 표면 0, create 전용 가로채기 훅 0, + `aggregate`/리듀서/`validateEvents`/`overrideInitialEvents` 변경 0. - **R8이 확률이 아니라 구조로 보장**(§6 R8): 스냅샷 없음 → 기존 코드 경로 그대로. 기존 플러그인의 어떤 훅도 새로운 시점에 불리지 않는다. - **R11의 도달 가능성이 공짜**: 재생이 기존 집계·검증 기계를 재사용하므로 도달 가능성 증명이 구성적. load에 추가되는 검증은 기존 등록 술어의 적용 범위 확장(Pushed → activity 도입 이벤트 전수) 1건뿐 — 새 술어도, 상태를 물화·검증하는 병렬 검증기도 없다. -- **소비자 3인 즉시 성립**(§7): persister 왕복은 core API로 닫히고(완료 기준 충족), guard는 정책 - 한 벌 + 등록 두 줄, history-sync는 신호 분기 하나로 개정 경로가 열린다. +- **소비자 3인 즉시 성립**(§7): persister 왕복은 core API로 닫히고(완료 기준 충족), guard는 기존 + `overrideInitialEvents`의 strip/치환 + 순서 규율 + `onInit` 검증 벨트로 성립하며, history-sync는 + 신호 분기 하나로 개정 경로가 열린다. - **왕복 안정(L5)**: load 직후 재캡처가 안정적이라 주기적 persist가 자연스럽다. From 0006fb3fc7a56b52df41a5a27639d5f4237b605e Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 05:14:29 +0900 Subject: [PATCH 05/28] add impl plan --- run-plan-fep-2548-impl.md | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 run-plan-fep-2548-impl.md diff --git a/run-plan-fep-2548-impl.md b/run-plan-fep-2548-impl.md new file mode 100644 index 000000000..9c6c386ba --- /dev/null +++ b/run-plan-fep-2548-impl.md @@ -0,0 +1,142 @@ +# Run Plan — FEP-2548 create/load 구분 메커니즘 core 구현 (harness-first) + +## 1. 과제 스펙 · 판단 기준 + +### 과제 + +확정 설계서 `design-fep-2548-init-load-mechanism.md`(레포 루트 — 이 run의 **스펙 정본**, +사용자 확정본)의 메커니즘을 `@stackflow/core`에 구현한다. 설계는 확정 완료 — 이 run은 +설계를 다시 열지 않는다. 구현 중 설계서의 결함·모호·현행 소스와의 괴리를 발견하면 +임의 해석·자체 설계 변경 없이 표면화해 에스컬레이션한다(설계 정본 개정은 사용자 결정). + +**구현 범위** (설계서 §0·§3 — 신규 공개 표면 네 조각 + 신호 1): + +- `StackSnapshot` 타입(+ `NavigationEvent` = 기존 탐색 이벤트 6종 부분합) — + `$schema: "stackflow.snapshot.v1"`, 탐색 이벤트만 담음 (§3.1) +- `actions.captureSnapshot()` — 이벤트 로그를 탐색 이벤트로 필터·정규화(eventDate + 오름차순 + id 중복 제거)해 반환, 어느 훅에서든 호출 가능 (§3.2) +- 플러그인 옵셔널 훅 `provideSnapshot({ initialContext })` — 생성 시점 동기 폴링, + 단일 스냅샷 자리(non-null 2개 이상 = 충돌 key 명시한 생성 에러, `onLoadError` + 비라우팅) (§3.3) +- 플러그인 옵셔널 훅 `onLoadError({ error, initialContext })` + `SnapshotLoadError` + (cause 3분류: `incompatible-schema` / `invalid-events` / `empty-navigation`) — + `{ recover: "create" }` 반환 시 create 재개(재폴링 없음), void/부재 시 + `makeCoreStore` 밖으로 throw (§3.4) +- `onInit` 인자에 `initializedBy: "create" | "load"` 추가 (순수 additive, §3.6) +- create 경로 = 설계서 §4.1 시퀀스 — 공급자 전무 시 오늘의 코드 경로와 관찰상 + 동일(불변식 N1·N2) +- load 경로 = 설계서 §4.2 시퀀스 — 구조 검사 → 등록 검사(activity 도입 이벤트 + Pushed·**Replaced** 전수의 activityName ∈ 등록 집합) → 재기저(RB1–RB5) → + 기존 `aggregate`+`validateEvents` 재생 → 사후조건(enter activity ≥ 1) → + 실패 시 공급자 `onLoadError` 라우팅 +- **금지 (설계서 §0·§6 R8)**: 신규 도메인 이벤트 0, 신규 Stack 상태 속성 0, react 앱 + 개발자 향 신규 표면 0, `makeCoreStore` 옵션 추가 0, + `aggregate`/`validateEvents`/리듀서/`overrideInitialEvents` 변경 0 + +**최종 산출**: ① 테스트 기획서 ② 하니스(테스트 스위트) ③ 하니스를 전부 통과하는 +core 구현 + `@stackflow/core` minor changeset — 전부 `feature/fep-2548` 브랜치 커밋, +풀 스위트(`yarn test`)·`yarn typecheck`·`yarn lint` green. PR 생성은 run 범위 밖 +(게이트 통과 후 사용자 결정). + +**하니스 인프라 전제**: core 테스트 컨벤션은 jest, 소스 옆 `*.spec.ts` +(`core/src/makeCoreStore.spec.ts` 등 기존 선례), core 패키지에서 `yarn test`. +하니스 단계(2단계)는 스위트가 컴파일되도록 신규 공개 표면의 **타입 시그니처 +스텁**(설계서 §3 계약 그대로, 본문은 unimplemented throw)을 함께 산출할 수 있다 — +red는 런타임 실패로 성립하고, 3단계(구현)가 스텁 본문을 채운다. + +**검증 항목의 원천 — 기획 중심은 설계서 §3·§4·§5 세 섹션이다** (사용자 지정; +상세 열거는 기획서의 몫): + +- **§3 공개 계약** 전수: 스냅샷 형식(§3.1)·캡처와 그 엣지 — 전환 중·pause 중 + 캡처(§3.2)·`provideSnapshot` 단일 자리와 R9 위반 생성 에러(§3.3)·에러 3분류 + 각각의 검출과 `onLoadError` 라우팅 — recover / throw / 핸들러 부재(§3.4)·create + 가로채기 지점(§3.5)·`initializedBy` 신호(§3.6). §7.1 persister 완료 기준 — + "persister를 흉내낸 테스트 플러그인이 core API만으로 load 경로를 탈 수 있다" — + 은 §3 계약 표면만으로 왕복이 닫힘을 검증하는 통합 시나리오로 포함한다 +- **§4 생성 시퀀스** 전수: create 경로 §4.1(스텝별 관찰 가능 결과)·load 경로 + §4.2(구조 검사→등록 검사→재기저 RB1–RB5(세션 간 시계역행·신규 이벤트 후행 + 정렬·id 보존 포함)→재생→사후조건→실패 라우팅)·기존 경로와의 관계 §4.3 +- **§5 불변식** 전수: C1–C4(경로 배타·자리 하나·신호 일회성·생성 중 무발화), + N1–N2(create 현행 동일성·핸들러 보존), L1–L6(무가로채기·도달 가능성·사후조건· + 재기저·왕복 안정·등록 봉인 — 특히 L6의 미등록 **Replaced** 케이스) +- R8 무회귀: 기존 전체 스위트 green이 1차 안전망 — 기획서는 기존 스위트가 담당하는 + 축과 신규 하니스가 담당하는 축의 경계를 명시한다 + +**소스·정본**: `design-fep-2548-init-load-mechanism.md`(스펙 정본 — §10에 현행 소스 +사실 S1–S23 파일:라인 인용) · 레포 `CONTEXT.md`(용어 정본) · `core/src/**` · +Linear FEP-2548(요구사항 R1–R13 코멘트). 소비자 맥락: FEP-2546(persister) · +FEP-2521(guard) · FEP-2001(history-sync 개정). + +### 이번 작업 고유 판단 기준 + +확정 설계서의 충실한 구현: 공개 계약(§3)·생성 시퀀스(§4)·불변식(§5 전수)이 코드로 +성립하고, 하니스가 설계 불변식·소비자 시나리오를 판별력 있게 인코딩하며(결함 구현이 +실제로 red를 냄), 구현이 하니스를 전부 통과하고, R8 non-breaking이 실측으로 +확인된 상태(기존 풀 스위트·typecheck·lint green, 기존 코드 경로 무변경 — §6 R8의 +구조 논증이 코드에서 성립). 타입·동작 주장은 tsc·테스트 실행으로 실측한다 — 추론 +단정 금지. 설계서와 구현의 괴리는 어느 방향이든 침묵 처리 금지 — 하니스 결함은 +harness-first 결함 절차로, 설계서 결함은 사용자 에스컬레이션으로 표면화한다. +하니스를 통과시키기 위한 근거 없는 하니스 우회·약화 금지. + +## 2. 워크플로우 + +`harness-first` (자산 — `~/.agents/orchestration/workflows/harness-first.md`). +run 한정 오버라이드: 없음. 중간 사용자 확인 없이 최종 산출물까지 자율 진행한다 +(에스컬레이션은 워크플로우 기존 규칙 — 라운드 캡·교착·하니스/설계 결함 — 에 한정). + +## 3. 세션 테이블 + +| 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | +|---|---|---|---|---|---|---| +| hplan-worker-claude | harness-plan-worker | worker | Claude | fable-5[1m] | 기획 중심은 설계서 §3 공개 계약·§4 생성 시퀀스·§5 불변식(§1 "검증 항목의 원천"). 항목마다 given-when-then과 "이 항목이 잡는 결함 구현"을 명시(판별력). 기존 스위트 담당 축과 신규 하니스 담당 축의 경계를 기획서에 명시 | test, problem | +| hplan-reviewer-claude | harness-plan-reviewer | reviewer | Claude | fable-5[1m] | 전담 섹션: **§3 공개 계약·§5 불변식** — 담당 섹션(3.1–3.6 전수·C1–C4·N1–N2·L1–L6 전수)이 판별력 있는 하니스로 온전히 기획되었는지 확인(누락 항목 적발, 항목이 결함 구현에 실제로 red를 내는지) | test, problem | +| hplan-reviewer-codex | harness-plan-reviewer | reviewer | Codex | 기본 | 전담 섹션: **§4 생성 시퀀스** — 담당 섹션(§4.1 create·§4.2 load 각 스텝, 재기저 RB1–RB5, §4.3 기존 경로 관계)이 판별력 있는 하니스로 온전히 기획되었는지 확인(누락 스텝·엣지 적발, 항목의 판별력) | test, problem | +| himpl-worker-claude | harness-impl-worker | worker | Claude | fable-5[1m] | 기획서와 1:1(누락·초과 없음) 스위트 구현. 신규 표면은 설계서 §3 시그니처의 스텁(본문 throw)으로 컴파일 확보 — 인도 상태: 신규 하니스 red(올바른 지점에서 실패)·기존 스위트 green. 타입 주장은 tsc 실측 | test, implementation, architecture | +| himpl-reviewer-claude | harness-impl-reviewer | reviewer | Claude | fable-5[1m] | 전담 축: 기획 1:1 대응·테스트 질 — 각 테스트가 기획 항목의 검증 의도를 실제로 검사하는가, red가 올바른 이유의 red인가(스텁 throw가 아닌 단언 실패로 오인되는 항목 적발) | test, implementation, architecture | +| himpl-reviewer-codex | harness-impl-reviewer | reviewer | Codex | 기본 | 전담 축: 실측 — 스위트를 실제 구동해 red 상태·실패 지점 확인, 기존 스위트·typecheck 무회귀를 실행으로 확인. 추론 단정 금지 | test, implementation, architecture | +| impl-worker-claude | impl-worker | worker | Claude | fable-5[1m] | 설계서가 스펙 정본 — 괴리·모호는 에스컬레이션(임의 해석 금지). 하니스 우회·약화 금지(결함 시 harness-first 절차). 금지 목록(§1) 준수. 타입 주장은 tsc 실측. changeset 포함. yarn(Berry)·Biome 등 레포 규율 준수 | implementation, design, architecture | +| impl-reviewer-claude | impl-reviewer | reviewer | Claude | fable-5[1m] | 전담 축: 설계 정합 — 구현 ↔ 설계서 §3 계약·§4 시퀀스·§5 불변식 대조, §6 R8 구조 논증의 코드 성립(신규 표면 전부 additive·미공급 시 기존 경로 무변경·금지 목록 0건) | implementation, design, architecture | +| impl-reviewer-codex | impl-reviewer | reviewer | Codex | 기본 | 전담 축: 실측 — 하니스 전 통과·기존 풀 스위트·typecheck·lint를 실제 실행으로 확인. 경계 시나리오(미등록 Replaced load·R9 이중 공급·시계역행 재기저)를 직접 재현. 추론 단정 금지 | implementation, architecture | + +## 4. 인라인 자산 정의 + +해당 없음 (자산 워크플로우·자산 role·자산 lens만 사용). + +## 5. 설계 메타 + +- **사용자 명시(디폴트보다 우선)**: ① 전 Claude 세션 모델 fable-5[1m](Codex는 + 기본 유지). ② 하니스 기획 중심 = 설계서 §3 공개 계약·§4 생성 시퀀스·§5 불변식. + ③ 1단계 리뷰어 분담은 섹션 기준 — Claude 리뷰어가 §3·§5, Codex 리뷰어가 §4를 + 전담해 "담당 섹션이 판별력 있는 하니스로 온전히 기획되었는가"를 확인(스킬이 + 초안했던 축 분담 — 커버리지 vs 소스 사실 — 을 대체). ④ 2단계(하니스 구현) 전 + 세션에 `implementation`·`architecture` 렌즈, 3단계(구현) 전 세션에 `architecture` + 렌즈 부여. +- **적용된 디폴트**: 게이트 슬롯 3개(각 단계 reviewer) 각 2명 Claude+Codex 교차 — + 합의 게이트 슬롯 디폴트. worker 3개 슬롯 각 1명 — 일반 슬롯 디폴트. lens는 + harness-first 권장 바인딩(1·2단계 `test`, 3단계 `implementation`)에 사용자 명시 + 렌즈를 가산한 구성 + 1단계 `problem`(검증 전략을 요구사항 맥락에서), 3단계 + worker·claude 리뷰어 `design`(설계 정합 축) 가산. +- **워크플로우 제안 근거** (harness-first — 스킬 제안): 확정 설계서가 불변식 + (C·N·L 12개)·에러 계약·재기저 규칙·소비자 완료 기준까지 검증 가능한 형태로 이미 + 닫아 두어, 구현 전에 검증 체계를 독립 확정할 수 있는 전형적 조건이다. R8 + non-breaking이 "확률이 아니라 보장"이어야 하는 core 프로덕션 변경이므로 구현자 + 자신이 만들지 않은 외부 안전망(하니스 세션 ≠ 구현 세션)이 게이트로 필요하다. + persister 완료 기준(§7.1) 자체가 "테스트 플러그인"으로 정의되어 있어 하니스가 + 산출물 가치를 겸한다(후속 FEP-2546이 소비). 매핑: "검증 체계(하니스)를 먼저 닫고 + 그 안에서 구현" → harness-first. +- **메모리 반영**: + - `worker-context-exhaustion-restart-on-reopen-or-bind-1m-for-multicycle` + (operations, seen 7) — "PR/머지 단계까지 가는 구현 과제는 worker를 1m으로 선제 + 바인딩": 스킬 초안에서 himpl-worker·impl-worker에 선제 적용했고, 이후 사용자 + 명시(전 Claude 세션 1m)가 이를 포괄·확장. + - `typeheavy-design-impl-gate-must-compile-not-reason-tsc-beats-review-by-inspection` + (design, seen 2) — "타입·동작 주장은 tsc·실행으로 실측, 추론 단정 금지"를 판단 + 기준과 worker·실측 리뷰어 세부 지침에 게이트 기준으로 명시. + - `empirical-behavioral-review-catches-input-defects-logic-review-approves` + (operations, seen 6) — 논리 검증 축과 별개의 **ground-truth 축**을 게이트에 + 전담 배치: 2·3단계 codex 리뷰어가 실측(실행) 전담(직전 FEP-2548 설계 run에서 + 이 축이 유일 Critical을 잡은 검증된 구성). 1단계는 사용자 명시의 섹션 분담이 + 축 구성을 대체. + - 그 외 히트(fanout 실측 동시 실행 경합 flake, reviewer 워크트리 격리, 세션 재시작 + 신호 등)는 operations scope — 실행 시 orchestrate가 조회·적용할 대상이라 plan에는 + 비반영. From a353f51f64b76f2df300690d4cd1791c4121b57b Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 05:56:11 +0900 Subject: [PATCH 06/28] opus 4.8 --- run-plan-fep-2548-impl.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/run-plan-fep-2548-impl.md b/run-plan-fep-2548-impl.md index 9c6c386ba..a32428759 100644 --- a/run-plan-fep-2548-impl.md +++ b/run-plan-fep-2548-impl.md @@ -88,14 +88,14 @@ run 한정 오버라이드: 없음. 중간 사용자 확인 없이 최종 산출 | 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | |---|---|---|---|---|---|---| -| hplan-worker-claude | harness-plan-worker | worker | Claude | fable-5[1m] | 기획 중심은 설계서 §3 공개 계약·§4 생성 시퀀스·§5 불변식(§1 "검증 항목의 원천"). 항목마다 given-when-then과 "이 항목이 잡는 결함 구현"을 명시(판별력). 기존 스위트 담당 축과 신규 하니스 담당 축의 경계를 기획서에 명시 | test, problem | -| hplan-reviewer-claude | harness-plan-reviewer | reviewer | Claude | fable-5[1m] | 전담 섹션: **§3 공개 계약·§5 불변식** — 담당 섹션(3.1–3.6 전수·C1–C4·N1–N2·L1–L6 전수)이 판별력 있는 하니스로 온전히 기획되었는지 확인(누락 항목 적발, 항목이 결함 구현에 실제로 red를 내는지) | test, problem | +| hplan-worker-claude | harness-plan-worker | worker | Claude | opus-4.8[1m] | 기획 중심은 설계서 §3 공개 계약·§4 생성 시퀀스·§5 불변식(§1 "검증 항목의 원천"). 항목마다 given-when-then과 "이 항목이 잡는 결함 구현"을 명시(판별력). 기존 스위트 담당 축과 신규 하니스 담당 축의 경계를 기획서에 명시 | test, problem | +| hplan-reviewer-claude | harness-plan-reviewer | reviewer | Claude | opus-4.8[1m] | 전담 섹션: **§3 공개 계약·§5 불변식** — 담당 섹션(3.1–3.6 전수·C1–C4·N1–N2·L1–L6 전수)이 판별력 있는 하니스로 온전히 기획되었는지 확인(누락 항목 적발, 항목이 결함 구현에 실제로 red를 내는지) | test, problem | | hplan-reviewer-codex | harness-plan-reviewer | reviewer | Codex | 기본 | 전담 섹션: **§4 생성 시퀀스** — 담당 섹션(§4.1 create·§4.2 load 각 스텝, 재기저 RB1–RB5, §4.3 기존 경로 관계)이 판별력 있는 하니스로 온전히 기획되었는지 확인(누락 스텝·엣지 적발, 항목의 판별력) | test, problem | -| himpl-worker-claude | harness-impl-worker | worker | Claude | fable-5[1m] | 기획서와 1:1(누락·초과 없음) 스위트 구현. 신규 표면은 설계서 §3 시그니처의 스텁(본문 throw)으로 컴파일 확보 — 인도 상태: 신규 하니스 red(올바른 지점에서 실패)·기존 스위트 green. 타입 주장은 tsc 실측 | test, implementation, architecture | -| himpl-reviewer-claude | harness-impl-reviewer | reviewer | Claude | fable-5[1m] | 전담 축: 기획 1:1 대응·테스트 질 — 각 테스트가 기획 항목의 검증 의도를 실제로 검사하는가, red가 올바른 이유의 red인가(스텁 throw가 아닌 단언 실패로 오인되는 항목 적발) | test, implementation, architecture | +| himpl-worker-claude | harness-impl-worker | worker | Claude | opus-4.8[1m] | 기획서와 1:1(누락·초과 없음) 스위트 구현. 신규 표면은 설계서 §3 시그니처의 스텁(본문 throw)으로 컴파일 확보 — 인도 상태: 신규 하니스 red(올바른 지점에서 실패)·기존 스위트 green. 타입 주장은 tsc 실측 | test, implementation, architecture | +| himpl-reviewer-claude | harness-impl-reviewer | reviewer | Claude | opus-4.8[1m] | 전담 축: 기획 1:1 대응·테스트 질 — 각 테스트가 기획 항목의 검증 의도를 실제로 검사하는가, red가 올바른 이유의 red인가(스텁 throw가 아닌 단언 실패로 오인되는 항목 적발) | test, implementation, architecture | | himpl-reviewer-codex | harness-impl-reviewer | reviewer | Codex | 기본 | 전담 축: 실측 — 스위트를 실제 구동해 red 상태·실패 지점 확인, 기존 스위트·typecheck 무회귀를 실행으로 확인. 추론 단정 금지 | test, implementation, architecture | -| impl-worker-claude | impl-worker | worker | Claude | fable-5[1m] | 설계서가 스펙 정본 — 괴리·모호는 에스컬레이션(임의 해석 금지). 하니스 우회·약화 금지(결함 시 harness-first 절차). 금지 목록(§1) 준수. 타입 주장은 tsc 실측. changeset 포함. yarn(Berry)·Biome 등 레포 규율 준수 | implementation, design, architecture | -| impl-reviewer-claude | impl-reviewer | reviewer | Claude | fable-5[1m] | 전담 축: 설계 정합 — 구현 ↔ 설계서 §3 계약·§4 시퀀스·§5 불변식 대조, §6 R8 구조 논증의 코드 성립(신규 표면 전부 additive·미공급 시 기존 경로 무변경·금지 목록 0건) | implementation, design, architecture | +| impl-worker-claude | impl-worker | worker | Claude | opus-4.8[1m] | 설계서가 스펙 정본 — 괴리·모호는 에스컬레이션(임의 해석 금지). 하니스 우회·약화 금지(결함 시 harness-first 절차). 금지 목록(§1) 준수. 타입 주장은 tsc 실측. changeset 포함. yarn(Berry)·Biome 등 레포 규율 준수 | implementation, design, architecture | +| impl-reviewer-claude | impl-reviewer | reviewer | Claude | opus-4.8[1m] | 전담 축: 설계 정합 — 구현 ↔ 설계서 §3 계약·§4 시퀀스·§5 불변식 대조, §6 R8 구조 논증의 코드 성립(신규 표면 전부 additive·미공급 시 기존 경로 무변경·금지 목록 0건) | implementation, design, architecture | | impl-reviewer-codex | impl-reviewer | reviewer | Codex | 기본 | 전담 축: 실측 — 하니스 전 통과·기존 풀 스위트·typecheck·lint를 실제 실행으로 확인. 경계 시나리오(미등록 Replaced load·R9 이중 공급·시계역행 재기저)를 직접 재현. 추론 단정 금지 | implementation, architecture | ## 4. 인라인 자산 정의 @@ -104,7 +104,7 @@ run 한정 오버라이드: 없음. 중간 사용자 확인 없이 최종 산출 ## 5. 설계 메타 -- **사용자 명시(디폴트보다 우선)**: ① 전 Claude 세션 모델 fable-5[1m](Codex는 +- **사용자 명시(디폴트보다 우선)**: ① 전 Claude 세션 모델 opus-4.8[1m](Codex는 기본 유지). ② 하니스 기획 중심 = 설계서 §3 공개 계약·§4 생성 시퀀스·§5 불변식. ③ 1단계 리뷰어 분담은 섹션 기준 — Claude 리뷰어가 §3·§5, Codex 리뷰어가 §4를 전담해 "담당 섹션이 판별력 있는 하니스로 온전히 기획되었는가"를 확인(스킬이 From 31005885aa3edc973baee1a8158a1c4c2374c328 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 07:41:07 +0900 Subject: [PATCH 07/28] =?UTF-8?q?test(core):=20add=20create/load=20mechani?= =?UTF-8?q?sm=20harness=20+=20=C2=A73=20type-signature=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the FEP-2548 create/load verification suite (core/src/*.spec.ts), 1:1 with the confirmed test plan, plus type-signature stubs for the new public surface so the suite compiles. Implementation is deferred: the new harness is red at the unimplemented points (stub throw / assertion), while the existing core suite stays green (no behavioral change). New public surface (stubs, per design §3): - StackSnapshot / NavigationEvent types (navigation-event subset union) - SnapshotLoadError class with a three-kind cause (incompatible-schema / invalid-events / empty-navigation) - StackflowActions.captureSnapshot() — throws "not implemented yet" - StackflowPlugin.provideSnapshot? / onLoadError? optional hooks - StackflowPluginHook gains initializedBy: "create" | "load", with a "create" placeholder at the onInit call site Suites (core/src): - captureSnapshot: snapshot format + capture edges (transition/pause) - provideSnapshot: single snapshot slot, conflict, null/undefined - createPath: create sequence, override interception, N1/N2, no-hook - loadPath: structure/registration/replay/postcondition/routing, L1/L3/L6, sync - rebase: order (RB1), settle (RB2), clock reversal (RB4), id preserve (RB5) - initializedBy: create signal, no persisted trace - snapshotRoundtrip: capture∘load∘capture stability (L5) - persisterRoundtrip: capture→JSON→load round trip + corrupt-snapshot recovery The empty-navigation "all-popped" case uses a pops-only snapshot (events:[Popped]) because core cannot pop the root activity, so a Pushed→Popped history never reaches zero enter-state activities; the pops-only snapshot reaches that state and preserves the item's intent (non-empty events, zero enter activities). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/src/SnapshotLoadError.ts | 33 + core/src/StackSnapshot.ts | 40 ++ core/src/captureSnapshot.spec.ts | 363 ++++++++++ core/src/createPath.spec.ts | 283 ++++++++ core/src/index.ts | 2 + core/src/initializedBy.spec.ts | 112 +++ core/src/interfaces/StackflowActions.ts | 7 + core/src/interfaces/StackflowPlugin.ts | 23 + core/src/interfaces/StackflowPluginHook.ts | 9 +- core/src/loadPath.spec.ts | 756 +++++++++++++++++++++ core/src/makeCoreStore.ts | 6 + core/src/persisterRoundtrip.spec.ts | 125 ++++ core/src/provideSnapshot.spec.ts | 144 ++++ core/src/rebase.spec.ts | 190 ++++++ core/src/snapshotRoundtrip.spec.ts | 87 +++ core/src/utils/makeActions.ts | 5 +- 16 files changed, 2183 insertions(+), 2 deletions(-) create mode 100644 core/src/SnapshotLoadError.ts create mode 100644 core/src/StackSnapshot.ts create mode 100644 core/src/captureSnapshot.spec.ts create mode 100644 core/src/createPath.spec.ts create mode 100644 core/src/initializedBy.spec.ts create mode 100644 core/src/loadPath.spec.ts create mode 100644 core/src/persisterRoundtrip.spec.ts create mode 100644 core/src/provideSnapshot.spec.ts create mode 100644 core/src/rebase.spec.ts create mode 100644 core/src/snapshotRoundtrip.spec.ts diff --git a/core/src/SnapshotLoadError.ts b/core/src/SnapshotLoadError.ts new file mode 100644 index 000000000..b650dcfc9 --- /dev/null +++ b/core/src/SnapshotLoadError.ts @@ -0,0 +1,33 @@ +/** + * Why a snapshot load failed, in three kinds: + * - `incompatible-schema`: the value is not a core-known v1 snapshot structure + * (`$schema` mismatch, `events` not an array, an item that is not one of the + * six navigation events, or a missing `id`/`name`). + * - `invalid-events`: the structure is valid but the event sequence is not + * valid against the current config (e.g. an event that materializes an + * unregistered activity). + * - `empty-navigation`: replay succeeded but no activity is in an enter state. + */ +export type SnapshotLoadErrorCause = + | { kind: "incompatible-schema" } + | { kind: "invalid-events"; detail: unknown } + | { kind: "empty-navigation" }; + +/** + * Thrown when loading a provided snapshot fails. Routed to the providing + * plugin's `onLoadError` first (R5); if unrecovered, thrown out of + * `makeCoreStore` (R4). + */ +export class SnapshotLoadError extends Error { + cause: SnapshotLoadErrorCause; + + constructor(cause: SnapshotLoadErrorCause, message?: string) { + super(message ?? `failed to load snapshot: ${cause.kind}`); + this.name = "SnapshotLoadError"; + this.cause = cause; + + // Restore the prototype chain so `instanceof` holds after down-level + // transpilation of a built-in subclass. + Object.setPrototypeOf(this, SnapshotLoadError.prototype); + } +} diff --git a/core/src/StackSnapshot.ts b/core/src/StackSnapshot.ts new file mode 100644 index 000000000..2b33d65d1 --- /dev/null +++ b/core/src/StackSnapshot.ts @@ -0,0 +1,40 @@ +import type { + PoppedEvent, + PushedEvent, + ReplacedEvent, + StepPoppedEvent, + StepPushedEvent, + StepReplacedEvent, +} from "./event-types"; + +/** + * The six navigation events — a subset union of the existing domain event + * types (no new vocabulary is introduced). A snapshot carries only these. + */ +export type NavigationEvent = + | PushedEvent + | ReplacedEvent + | PoppedEvent + | StepPushedEvent + | StepReplacedEvent + | StepPoppedEvent; + +/** + * A plain-data value whose structure is owned by core. Encoding to a + * persistence medium (codec) is the consumer's responsibility. + */ +export type StackSnapshot = { + /** + * Structural discriminator tag. A mismatch fails the load as + * `SnapshotLoadError` — version migration is a non-goal. + */ + $schema: "stackflow.snapshot.v1"; + + /** + * Navigation events only. `Initialized` and `ActivityRegistered` are not + * carried — the current config re-derives them at load time. `Paused` and + * `Resumed` are not carried either — they are discarded as transition and + * pause information. + */ + events: NavigationEvent[]; +}; diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts new file mode 100644 index 000000000..70cce63ae --- /dev/null +++ b/core/src/captureSnapshot.spec.ts @@ -0,0 +1,363 @@ +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +/** A settled-in-the-past timestamp with a strictly increasing tail. */ +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const provideSnapshot = + (snapshot: StackSnapshot | null): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snapshot, + }); + +test('captureSnapshot - 반환 스냅샷의 $schema가 "stackflow.snapshot.v1"입니다', () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.$schema).toEqual("stackflow.snapshot.v1"); +}); + +test("captureSnapshot - 스냅샷 events에서 Initialized·ActivityRegistered를 제외합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a1", + ), + ).toBe(true); +}); + +test("captureSnapshot - 스냅샷 events에서 Paused·Resumed를 제외합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.pause(); + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + actions.resume(); + + const snapshot = actions.captureSnapshot(); + + const names = snapshot.events.map((e) => e.name); + expect(names).not.toContain("Paused"); + expect(names).not.toContain("Resumed"); +}); + +test("captureSnapshot - 6종 탐색 이벤트를 모두 보존합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + // Produce each of the six navigation events at least once. + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + actions.stepPush({ stepId: "s1", stepParams: {} }); + actions.stepReplace({ stepId: "s1b", stepParams: {} }); + actions.stepPop(); + actions.replace({ + activityId: "a3", + activityName: "hello", + activityParams: {}, + }); + actions.pop(); + + const snapshot = actions.captureSnapshot(); + const names = new Set(snapshot.events.map((e) => e.name)); + + expect(names).toContain("Pushed"); + expect(names).toContain("Replaced"); + expect(names).toContain("Popped"); + expect(names).toContain("StepPushed"); + expect(names).toContain("StepReplaced"); + expect(names).toContain("StepPopped"); +}); + +test("captureSnapshot - events를 eventDate 오름차순으로 정렬해 반환합니다", () => { + const initDate = enoughPastTime(); + const regDate = enoughPastTime(); + const earlier = enoughPastTime(); + const later = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: initDate, + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: regDate, + }), + // Log order is (later, earlier) — the reverse of eventDate order. + makeEvent("Pushed", { + activityId: "a-later", + activityName: "hello", + activityParams: {}, + eventDate: later, + }), + makeEvent("Pushed", { + activityId: "a-earlier", + activityName: "hello", + activityParams: {}, + eventDate: earlier, + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.events.map((e) => e.eventDate)).toEqual([earlier, later]); + expect( + snapshot.events.map((e) => (e.name === "Pushed" ? e.activityId : e.name)), + ).toEqual(["a-earlier", "a-later"]); +}); + +test("captureSnapshot - 동일 id 이벤트를 중복 제거합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "duplicated-id", + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "duplicated-id", + activityId: "a2", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.events.filter((e) => e.id === "duplicated-id")).toHaveLength( + 1, + ); +}); + +test("captureSnapshot - 생성 이후 디스패치된 탐색 이벤트를 포함합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + + const snapshot = actions.captureSnapshot(); + const pushedIds = snapshot.events + .filter((e) => e.name === "Pushed") + .map((e) => (e.name === "Pushed" ? e.activityId : undefined)); + + expect(pushedIds).toContain("a1"); + expect(pushedIds).toContain("a2"); +}); + +test("captureSnapshot - pause 중 디스패치된 탐색 이벤트를 포함하되 Paused는 제외합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.pause(); + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + + // While paused, the pushed event is queued, so it is not yet a visible + // activity — capturing from aggregated state would silently miss it. + expect( + actions.getStack().activities.some((a) => a.id === "a2"), + ).toBe(false); + + try { + const snapshot = actions.captureSnapshot(); + const names = snapshot.events.map((e) => e.name); + + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a2", + ), + ).toBe(true); + expect(names).not.toContain("Paused"); + } finally { + // Resume so the paused store settles and its polling interval clears, + // even when the capture above throws. + actions.resume(); + } +}); + +test("captureSnapshot - 전환 진행 중 캡처한 스냅샷을 load하면 정착 상태로 복원됩니다", () => { + const source = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + // Push at the current time so the new activity is mid-transition. + source.actions.push({ + activityId: "a2", + activityName: "hello", + activityParams: {}, + }); + expect(source.actions.getStack().globalTransitionState).toEqual("loading"); + + const snapshot = source.actions.captureSnapshot(); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + + const restoredStack = restored.actions.getStack(); + const topActivity = restoredStack.activities.find((a) => a.isTop); + + expect(topActivity?.transitionState).toEqual("enter-done"); + expect(restoredStack.globalTransitionState).toEqual("idle"); +}); diff --git a/core/src/createPath.spec.ts b/core/src/createPath.spec.ts new file mode 100644 index 000000000..7bcc1feb5 --- /dev/null +++ b/core/src/createPath.spec.ts @@ -0,0 +1,283 @@ +import type { PushedEvent, StepPushedEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const nullProvider = (snapshot: StackSnapshot | null = null): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snapshot, + }); + +test("create - 스냅샷 공급자가 없으면 initialEvents로 create 경로를 재구성합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const stack = actions.getStack(); + const a1 = stack.activities.find((a) => a.id === "a1"); + + expect(a1?.transitionState).toEqual("enter-done"); + expect(a1?.isActive).toBe(true); + expect(a1?.isTop).toBe(true); + expect(a1?.isRoot).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test('create - provideSnapshot 전원 null이면 create 경로를 타고 initializedBy가 "create"입니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [nullProvider(), () => ({ key: "observer", onInit })], + }); + + store.init(); + + expect(store.actions.getStack().activities.map((a) => a.id)).toEqual(["a1"]); + expect(onInit).toHaveBeenCalledTimes(1); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); + +test("create - overrideInitialEvents가 초기 진입을 전부 strip하면 onInitialActivityNotFound가 발화하고 빈 스택입니다", () => { + const onInitialActivityNotFound = jest.fn(); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "stripper", + overrideInitialEvents: () => [], + }), + ], + handlers: { + onInitialActivityNotFound, + }, + }); + + expect(onInitialActivityNotFound).toHaveBeenCalledTimes(1); + expect(actions.getStack().activities).toHaveLength(0); +}); + +test("create - overrideInitialEvents가 초기 이벤트 참조를 바꾸면 onInitialActivityIgnored가 발화합니다", () => { + const onInitialActivityIgnored = jest.fn(); + + makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "replacer", + overrideInitialEvents: (): (PushedEvent | StepPushedEvent)[] => [ + makeEvent("Pushed", { + activityId: "a2", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + handlers: { + onInitialActivityIgnored, + }, + }); + + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(1); +}); + +test("create - overrideInitialEvents에서 초기 Pushed를 strip하면 해당 activity가 스택에 없습니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "stripper", + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter( + (e) => !(e.name === "Pushed" && e.activityId === "a1"), + ), + }), + ], + }); + + const ids = actions.getStack().activities.map((a) => a.id); + expect(ids).toContain("b1"); + expect(ids).not.toContain("a1"); +}); + +test("create - overrideInitialEvents에서 초기 Pushed를 치환하면 치환된 activity로 진입합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "Redirect", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "redirector", + overrideInitialEvents: (): (PushedEvent | StepPushedEvent)[] => [ + makeEvent("Pushed", { + activityId: "redirect", + activityName: "Redirect", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + const ids = actions.getStack().activities.map((a) => a.id); + expect(ids).toContain("redirect"); + expect(ids).not.toContain("a1"); +}); + +test("create - 생성 중에는 어떤 post-effect 훅(onPushed·onChanged 등)도 발화하지 않습니다", () => { + const onPushed = jest.fn(); + const onReplaced = jest.fn(); + const onChanged = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "observer", + onPushed, + onReplaced, + onChanged, + }), + ], + }); + + store.init(); + + expect(onPushed).toHaveBeenCalledTimes(0); + expect(onReplaced).toHaveBeenCalledTimes(0); + expect(onChanged).toHaveBeenCalledTimes(0); +}); diff --git a/core/src/index.ts b/core/src/index.ts index a5c440d5d..a1341dd5c 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -5,6 +5,7 @@ export { DispatchEvent, makeEvent } from "./event-utils"; export * from "./interfaces"; export * from "./makeCoreStore"; export { produceEffects } from "./produceEffects"; +export { SnapshotLoadError, SnapshotLoadErrorCause } from "./SnapshotLoadError"; export { Activity, ActivityStep, @@ -12,4 +13,5 @@ export { RegisteredActivity, Stack, } from "./Stack"; +export { NavigationEvent, StackSnapshot } from "./StackSnapshot"; export { id } from "./utils"; diff --git a/core/src/initializedBy.spec.ts b/core/src/initializedBy.spec.ts new file mode 100644 index 000000000..7d55bc4af --- /dev/null +++ b/core/src/initializedBy.spec.ts @@ -0,0 +1,112 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = ( + events: NavigationEvent[], + extra?: Partial>, +): StackflowPlugin => { + return () => ({ + key: "provider", + provideSnapshot: (): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, + }), + ...extra, + }); +}; + +/** The ten existing domain events — the snapshot mechanism adds none. */ +const DOMAIN_EVENT_NAMES = new Set([ + "Initialized", + "ActivityRegistered", + "Pushed", + "Replaced", + "Popped", + "StepPushed", + "StepReplaced", + "StepPopped", + "Paused", + "Resumed", +]); + +test('initializedBy - create 경로에서 onInit의 initializedBy가 "create"입니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["A"]), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [() => ({ key: "observer", onInit })], + }); + + store.init(); + + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); + +test("initializedBy - load 후 구분 신호가 Stack 상태·이벤트 로그에 남지 않고 복원 activity의 enteredBy는 원본 이벤트입니다", () => { + const store = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + id: "ep", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + id: "er", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + // No new domain-event vocabulary leaks into the event log. + expect( + store.pullEvents().every((e) => DOMAIN_EVENT_NAMES.has(e.name)), + ).toBe(true); + + // The restored top activity keeps the original Replaced event as enteredBy — + // no "Loaded"-style marking, id preserved. + const top = store.actions.getStack().activities.find((a) => a.isTop); + expect(top?.enteredBy?.name).toEqual("Replaced"); + expect(top?.enteredBy?.id).toEqual("er"); +}); diff --git a/core/src/interfaces/StackflowActions.ts b/core/src/interfaces/StackflowActions.ts index bc1b1bfa5..1fa953029 100644 --- a/core/src/interfaces/StackflowActions.ts +++ b/core/src/interfaces/StackflowActions.ts @@ -11,6 +11,7 @@ import type { import type { BaseDomainEvent } from "../event-types/_base"; import type { DispatchEvent } from "../event-utils"; import type { Stack } from "../Stack"; +import type { StackSnapshot } from "../StackSnapshot"; export type StackflowActions = { /** @@ -18,6 +19,12 @@ export type StackflowActions = { */ getStack: () => Stack; + /** + * Capture the current navigation history as a snapshot. Callable from any + * hook, at any time; normalizes the event log into navigation events. + */ + captureSnapshot: () => StackSnapshot; + /** * Dispatch new event to the core without pre-effect hooks */ diff --git a/core/src/interfaces/StackflowPlugin.ts b/core/src/interfaces/StackflowPlugin.ts index 83e668ffc..0a4a7e269 100644 --- a/core/src/interfaces/StackflowPlugin.ts +++ b/core/src/interfaces/StackflowPlugin.ts @@ -9,6 +9,8 @@ import type { StepReplacedEvent, } from "../event-types"; import type { BaseDomainEvent } from "../event-types/_base"; +import type { SnapshotLoadError } from "../SnapshotLoadError"; +import type { StackSnapshot } from "../StackSnapshot"; import type { StackflowPluginHook, StackflowPluginPostEffectHook, @@ -134,4 +136,25 @@ export type StackflowPlugin = () => { initialEvents: (PushedEvent | StepPushedEvent)[]; initialContext: any; }) => (PushedEvent | StepPushedEvent)[]; + + /** + * Called synchronously at stack creation time to provide a snapshot to load + * from. Returning `null` (or `undefined`) means "nothing to provide" and the + * create path continues. If more than one plugin returns a non-null snapshot, + * core throws a creation error naming the conflicting keys — it does not + * arbitrate (R9). + */ + provideSnapshot?: (args: { initialContext: any }) => StackSnapshot | null; + + /** + * Called — only on the plugin that provided the failing snapshot (R5) — when + * that snapshot fails to load. Returning `{ recover: "create" }` resumes the + * create path without re-polling; returning nothing (or having no handler) + * throws the `SnapshotLoadError` out of `makeCoreStore` (R4). + */ + onLoadError?: (args: { + error: SnapshotLoadError; + initialContext: any; + // biome-ignore lint/suspicious/noConfusingVoidType: `void` is intentional — it lets a handler return nothing to signal "throw the error". Narrowing to `undefined` would reject the common void-returning handler implementation. + }) => { recover: "create" } | void; }; diff --git a/core/src/interfaces/StackflowPluginHook.ts b/core/src/interfaces/StackflowPluginHook.ts index 53bc15baf..5ae0791cf 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -1,7 +1,14 @@ import type { Effect } from "../Effect"; import type { StackflowActions } from "./StackflowActions"; -export type StackflowPluginHook = (args: { actions: StackflowActions }) => void; +export type StackflowPluginHook = (args: { + actions: StackflowActions; + /** + * Which path created this stack — `"create"` (fresh) or `"load"` (restored + * from a snapshot). A one-shot signal that leaves no trace on the stack. + */ + initializedBy: "create" | "load"; +}) => void; export type StackflowPluginPreEffectHook = (args: { actionParams: T; diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts new file mode 100644 index 000000000..2c9d6298f --- /dev/null +++ b/core/src/loadPath.spec.ts @@ -0,0 +1,756 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +/** Static config events (Initialized + one ActivityRegistered per name). */ +const config = ( + activityNames: string[], + transitionDuration = 350, +): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const snapshot = (events: NavigationEvent[]): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, +}); + +/** Escape hatch for structurally-malformed snapshots the type would reject. */ +const rawSnapshot = (value: unknown): StackSnapshot => value as StackSnapshot; + +const provideSnapshotPlugin = ( + snap: StackSnapshot, + extra?: Partial>, +): StackflowPlugin => { + return () => ({ + key: "provider", + provideSnapshot: () => snap, + ...extra, + }); +}; + +/** Run makeCoreStore with a single snapshot provider and return any throw. */ +const catchLoad = ( + snap: StackSnapshot, + initialEvents: DomainEvent[], +): unknown => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents, + plugins: [provideSnapshotPlugin(snap)], + }); + } catch (error) { + caught = error; + } + return caught; +}; + +// --------------------------------------------------------------------------- +// happy path · L2 · L3 +// --------------------------------------------------------------------------- + +test("load - 유효한 스냅샷의 탐색 기록을 충실히 재구성합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + expect(a?.transitionState).toEqual("enter-done"); + expect(b?.transitionState).toEqual("enter-done"); + expect(b?.isTop).toBe(true); + expect(b?.isActive).toBe(true); + // z-order A→B observed via zIndex/isTop (activities are sorted by id, not + // z-order — see aggregate post-processing). + expect((a?.zIndex ?? 0) < (b?.zIndex ?? 0)).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - step 이벤트를 담은 스냅샷의 steps·현재 위치·stepId를 충실히 재구성합니다", () => { + const originalStepDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("StepPushed", { + id: "e2", + targetActivityId: "a1", + stepId: "s2", + stepParams: { step: "2" }, + eventDate: originalStepDate, + }), + makeEvent("StepPushed", { + id: "e3", + targetActivityId: "a1", + stepId: "s3", + stepParams: { step: "3" }, + eventDate: enoughPastTime(), + }), + makeEvent("StepReplaced", { + id: "e4", + targetActivityId: "a1", + stepId: "s3b", + stepParams: { step: "3b" }, + eventDate: enoughPastTime(), + }), + makeEvent("StepPopped", { + id: "e5", + targetActivityId: "a1", + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + + // Replaying StepPushed×2 → StepReplaced → StepPopped leaves steps [a1, s2]. + expect(a?.steps.map((s) => s.id)).toEqual(["a1", "s2"]); + expect(a?.steps[0].enteredBy.id).toEqual("e1"); + expect(a?.steps[1].params.step).toEqual("2"); + expect(a?.steps[1].enteredBy.id).toEqual("e2"); + // Current position is the last remaining step. + expect(a?.params.step).toEqual("2"); + // Only eventDate is rebased; id/stepId are byte-preserved. + expect(a?.steps[1].enteredBy.eventDate).not.toEqual(originalStepDate); + expect(a?.transitionState).toEqual("enter-done"); + expect(actions.getStack().globalTransitionState).toEqual("idle"); +}); + +test("load - options의 초기 Pushed(initialActivity)를 폐기하고 스냅샷만을 탐색 기록으로 삼습니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + ...config(["Home", "A"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const ids = actions.getStack().activities.map((x) => x.id); + expect(ids).toContain("a1"); + expect(ids).not.toContain("home1"); +}); + +test("load - 현행 config에서 transitionDuration·등록집합을 재파생합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A"], 350), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + expect(stack.transitionDuration).toEqual(350); + expect(stack.activities.some((x) => x.id === "a1")).toBe(true); +}); + +// --------------------------------------------------------------------------- +// L1 · no interception +// --------------------------------------------------------------------------- + +test("load - overrideInitialEvents 체인을 호출하지 않습니다", () => { + const overrideInitialEvents = jest.fn(() => []); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { overrideInitialEvents }, + ), + ], + }); + + expect(overrideInitialEvents).toHaveBeenCalledTimes(0); + // The strip attempt had no effect — the restored activity survives. + expect(actions.getStack().activities.some((x) => x.id === "a1")).toBe(true); +}); + +test("load - 초기 activity 핸들러를 호출하지 않습니다", () => { + const onInitialActivityIgnored = jest.fn(); + const onInitialActivityNotFound = jest.fn(); + + const { actions } = makeCoreStore({ + // No option-level initial Pushed: under the create path this would fire + // onInitialActivityNotFound. The load path must skip that evaluation. + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + handlers: { + onInitialActivityIgnored, + onInitialActivityNotFound, + }, + }); + + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(0); + expect(onInitialActivityNotFound).toHaveBeenCalledTimes(0); + expect(actions.getStack().activities.some((x) => x.id === "a1")).toBe(true); +}); + +// --------------------------------------------------------------------------- +// §3.6 signal on load · §4.3 timing +// --------------------------------------------------------------------------- + +test('load - onInit이 init()에서 정확히 1회 initializedBy "load"로 발화합니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onInit }, + ), + ], + }); + + // onInit does not fire during makeCoreStore — only on init(). + expect(onInit).toHaveBeenCalledTimes(0); + + store.init(); + + expect(onInit).toHaveBeenCalledTimes(1); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("load"); +}); + +test("load - 재생 중 post-effect 훅(onPushed·onReplaced·onChanged)이 발화하지 않습니다", () => { + const onPushed = jest.fn(); + const onReplaced = jest.fn(); + const onChanged = jest.fn(); + + const store = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onPushed, onReplaced, onChanged }, + ), + ], + }); + + store.init(); + + expect(onPushed).toHaveBeenCalledTimes(0); + expect(onReplaced).toHaveBeenCalledTimes(0); + expect(onChanged).toHaveBeenCalledTimes(0); + // Non-vacuous: the snapshot was actually reconstructed. + expect(store.actions.getStack().activities.some((x) => x.id === "b1")).toBe( + true, + ); +}); + +test("load - makeCoreStore 반환 시점에 복원된 스택이 동기적으로 정착 완료돼 있습니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + // Observed synchronously right after the call returns — no microtask/timer. + const stack = actions.getStack(); + expect(stack.activities.some((x) => x.id === "a1")).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +// --------------------------------------------------------------------------- +// structure check → incompatible-schema +// --------------------------------------------------------------------------- + +test("load - $schema 불일치 스냅샷은 SnapshotLoadError{incompatible-schema}로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v2", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-schema", + ); +}); + +test("load - events가 배열이 아닌 스냅샷은 incompatible-schema로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ $schema: "stackflow.snapshot.v1", events: "nope" }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-schema", + ); +}); + +test("load - events에 탐색 이벤트가 아닌 항목이 있으면 incompatible-schema로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + // A non-navigation event embedded in the snapshot. Without a structure + // check this would be silently registered and the load would succeed. + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-schema", + ); +}); + +test("load - events 항목에 id가 결손되면 incompatible-schema로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + { + name: "Pushed", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }, + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-schema", + ); +}); + +test("load - events 항목에 name이 결손되면 incompatible-schema로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }, + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-schema", + ); +}); + +// --------------------------------------------------------------------------- +// registration check → invalid-events · L6 +// --------------------------------------------------------------------------- + +test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{invalid-events}로 실패합니다", () => { + const caught = catchLoad( + snapshot([ + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + const cause = (caught as SnapshotLoadError).cause; + expect(cause.kind).toEqual("invalid-events"); + if (cause.kind === "invalid-events") { + expect(cause.detail).toBeDefined(); + } +}); + +test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{invalid-events}로 실패합니다", () => { + const caught = catchLoad( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("invalid-events"); +}); + +test("load - 등록된 activity를 물화하는 Replaced는 정상 load됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + const b = stack.activities.find((x) => x.id === "b1"); + const a = stack.activities.find((x) => x.id === "a1"); + + expect(b?.isTop).toBe(true); + expect(b?.transitionState).toEqual("enter-done"); + expect(a?.transitionState).toEqual("exit-done"); +}); + +// --------------------------------------------------------------------------- +// postcondition → empty-navigation · L3 +// --------------------------------------------------------------------------- + +test("load - events가 빈 스냅샷은 SnapshotLoadError{empty-navigation}로 실패합니다", () => { + const caught = catchLoad(snapshot([]), config(["A"])); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-navigation"); +}); + +test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-navigation으로 실패합니다", () => { + // A pop with no activity to pop replays to zero activities: non-empty events + // but zero enter-state activities. (Core cannot pop the root, so a + // Pushed→Popped sequence never reaches zero — a pops-only history does.) + const caught = catchLoad( + snapshot([ + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-navigation"); +}); + +// --------------------------------------------------------------------------- +// onLoadError routing +// --------------------------------------------------------------------------- + +test('load - 실패 시 onLoadError가 {recover:"create"}를 반환하면 throw 없이 create 경로로 재개합니다', () => { + const onInit = jest.fn(); + const onLoadError = jest.fn(() => ({ recover: "create" as const })); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError, onInit }, + ), + ], + }); + + store.init(); + + // Recovery was actually triggered, then resumed cleanly on the create path. + expect(onLoadError).toHaveBeenCalledTimes(1); + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); + +test("load - recover:create 재개 시 provideSnapshot을 재폴링하지 않습니다", () => { + const provideSnapshot = jest.fn(() => + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + ); + + const { actions } = makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "provider", + provideSnapshot, + onLoadError: () => ({ recover: "create" as const }), + }), + ], + }); + + // Polled exactly once — recovery does not re-poll. + expect(provideSnapshot).toHaveBeenCalledTimes(1); + expect(actions.getStack().activities.map((x) => x.id)).toEqual(["home1"]); +}); + +test("load - onLoadError가 void를 반환하면 SnapshotLoadError를 makeCoreStore 밖으로 던집니다", () => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError: () => undefined }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + +test("load - onLoadError 핸들러가 없으면 SnapshotLoadError를 makeCoreStore 밖으로 던집니다", () => { + const caught = catchLoad( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + +test("load - onLoadError는 스냅샷을 공급한 플러그인에게만 호출됩니다", () => { + const supplierOnLoadError = jest.fn(() => ({ recover: "create" as const })); + const bystanderOnLoadError = jest.fn(); + + makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { key: "supplier", onLoadError: supplierOnLoadError }, + ), + () => ({ + key: "bystander", + provideSnapshot: () => null, + onLoadError: bystanderOnLoadError, + }), + ], + }); + + expect(supplierOnLoadError).toHaveBeenCalledTimes(1); + expect(bystanderOnLoadError).toHaveBeenCalledTimes(0); +}); + +test("load - provideSnapshot·onLoadError는 생성 시 options.initialContext를 인자로 전달받습니다", () => { + const provideSnapshot = jest.fn(() => + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + ); + const onLoadError = jest.fn(() => ({ recover: "create" as const })); + + makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + initialContext: { foo: "bar" }, + plugins: [ + () => ({ + key: "provider", + provideSnapshot, + onLoadError, + }), + ], + }); + + expect(provideSnapshot).toHaveBeenCalledWith({ + initialContext: { foo: "bar" }, + }); + expect(onLoadError).toHaveBeenCalledWith( + expect.objectContaining({ initialContext: { foo: "bar" } }), + ); +}); diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 297ac948c..3ee1b74cd 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -90,6 +90,9 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { getStack() { return stack.value; }, + captureSnapshot() { + throw new Error("captureSnapshot is not implemented yet"); + }, dispatchEvent(name, params) { const newEvent = makeEvent(name, params); @@ -153,6 +156,9 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { pluginInstances.forEach((pluginInstance) => { pluginInstance.onInit?.({ actions, + // Placeholder until the create/load distinction is wired in. The + // load path and the real signal are implemented separately. + initializedBy: "create", }); }); }), diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts new file mode 100644 index 000000000..56725ecbe --- /dev/null +++ b/core/src/persisterRoundtrip.spec.ts @@ -0,0 +1,125 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const initialHome = () => + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }); + +test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 provideSnapshot load가 core API만으로 닫힙니다", () => { + // A persister mimic: capture on change, JSON codec, in-memory storage. + const memory: { value: string | null } = { value: null }; + + const persister = ( + onInit: ReturnType["onInit"], + ): StackflowPlugin => { + return () => ({ + key: "persister", + onChanged({ actions }) { + memory.value = JSON.stringify(actions.captureSnapshot()); + }, + provideSnapshot: () => + memory.value ? (JSON.parse(memory.value) as StackSnapshot) : null, + onInit, + }); + }; + + // Session 1: create Home, then navigate to Article (each change persists). + const session1 = makeCoreStore({ + initialEvents: [...config(["Home", "Article"]), initialHome()], + plugins: [persister(() => {})], + }); + session1.actions.push({ + activityId: "article1", + activityName: "Article", + activityParams: {}, + }); + + // Session 2: same config + plugin; storage now holds the snapshot. + const onInit2 = jest.fn(); + const session2 = makeCoreStore({ + initialEvents: [...config(["Home", "Article"]), initialHome()], + plugins: [persister(onInit2)], + }); + session2.init(); + + const stack = session2.actions.getStack(); + const home = stack.activities.find((x) => x.id === "home1"); + const article = stack.activities.find((x) => x.id === "article1"); + + expect(home?.name).toEqual("Home"); + expect(home?.transitionState).toEqual("enter-done"); + expect(article?.name).toEqual("Article"); + expect(article?.transitionState).toEqual("enter-done"); + expect(article?.isTop).toBe(true); + expect((home?.zIndex ?? -1) < (article?.zIndex ?? -1)).toBe(true); + expect(onInit2.mock.calls[0][0].initializedBy).toEqual("load"); +}); + +test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 recover:create로 초기 화면 기동합니다", () => { + // Storage already holds a corrupt snapshot (wrong $schema). + const memory: { value: string | null } = { + value: JSON.stringify({ $schema: "stackflow.snapshot.v2", events: [] }), + }; + + const onInit = jest.fn(); + const persister: StackflowPlugin = () => ({ + key: "persister", + provideSnapshot: () => + memory.value ? (JSON.parse(memory.value) as StackSnapshot) : null, + onLoadError: () => { + memory.value = null; + return { recover: "create" }; + }, + onInit, + }); + + let threw = false; + let store: ReturnType | undefined; + try { + store = makeCoreStore({ + initialEvents: [...config(["Home"]), initialHome()], + plugins: [persister], + }); + store.init(); + } catch { + threw = true; + } + + expect(threw).toBe(false); + expect(store?.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + // The corrupt snapshot was discarded by the supplier. + expect(memory.value).toBeNull(); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); diff --git a/core/src/provideSnapshot.spec.ts b/core/src/provideSnapshot.spec.ts new file mode 100644 index 000000000..17ddefbff --- /dev/null +++ b/core/src/provideSnapshot.spec.ts @@ -0,0 +1,144 @@ +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const staticEvents = () => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), +]; + +const snapshotOf = (activityId: string): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId, + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], +}); + +type OnLoadError = ReturnType["onLoadError"]; + +const provider = ( + key: string, + snapshot: StackSnapshot | null, + onLoadError?: OnLoadError, +): StackflowPlugin => { + return () => ({ + key, + provideSnapshot: () => snapshot, + ...(onLoadError ? { onLoadError } : null), + }); +}; + +test("provideSnapshot - non-null 공급이 2개 이상이면 충돌 key를 명시한 생성 에러를 던집니다", () => { + // Distinctive keys so message assertions cannot pass on an incidental + // substring of a generic error message. + const runWith = (keys: [string, string]) => { + const onLoadErrorAlpha = jest.fn(); + const onLoadErrorBravo = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: staticEvents(), + plugins: [ + provider(keys[0], snapshotOf("a1"), onLoadErrorAlpha), + provider(keys[1], snapshotOf("a2"), onLoadErrorBravo), + ], + }); + } catch (error) { + caught = error; + } + + return { caught, onLoadErrorAlpha, onLoadErrorBravo }; + }; + + for (const keys of [ + ["providerAlpha", "providerBravo"], + ["providerBravo", "providerAlpha"], + ] as [string, string][]) { + const { caught, onLoadErrorAlpha, onLoadErrorBravo } = runWith(keys); + + // A wiring bug (two suppliers), not a specific snapshot's defect: a plain + // creation Error, not SnapshotLoadError, and not routed to onLoadError. + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(SnapshotLoadError); + expect((caught as Error).message).toContain("providerAlpha"); + expect((caught as Error).message).toContain("providerBravo"); + expect(onLoadErrorAlpha).toHaveBeenCalledTimes(0); + expect(onLoadErrorBravo).toHaveBeenCalledTimes(0); + } +}); + +test("provideSnapshot - non-null 공급이 정확히 1개면 나머지 null은 무시하고 load합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: staticEvents(), + plugins: [ + provider("first", null), + provider("second", snapshotOf("a1")), + provider("third", null), + ], + }); + + const restored = actions + .getStack() + .activities.find((a) => a.id === "a1"); + + expect(restored?.name).toEqual("A"); + expect(restored?.transitionState).toEqual("enter-done"); +}); + +test("provideSnapshot - undefined 반환을 null과 동일하게(공급 없음) 취급합니다", () => { + const undefinedProvider: StackflowPlugin = () => ({ + key: "provider", + // Simulates a JS provider (e.g. optional chaining) that yields `undefined` + // at runtime; core must treat it like `null` — nothing to provide. + provideSnapshot: () => undefined as unknown as StackSnapshot | null, + }); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "created", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [undefinedProvider], + }); + + const activities = actions.getStack().activities; + + // Create path: the option's initial activity is present, no snapshot loaded. + expect(activities.map((a) => a.id)).toEqual(["created"]); +}); diff --git a/core/src/rebase.spec.ts b/core/src/rebase.spec.ts new file mode 100644 index 000000000..a5c16cbcd --- /dev/null +++ b/core/src/rebase.spec.ts @@ -0,0 +1,190 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +/** A future timestamp — models a capture session whose clock ran ahead. */ +const futureTime = () => { + dt += 1; + return new Date(Date.now() + MINUTE).getTime() + dt; +}; + +const config = ( + activityNames: string[], + transitionDuration = 350, +): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = + (events: NavigationEvent[]): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: (): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, + }), + }); + +test("load - 스냅샷 events의 배열 순서대로 z-order를 재구성합니다(원본 eventDate 순서가 아님)", () => { + const laterDate = enoughPastTime(); + const earlierDate = enoughPastTime(); + // laterDate < earlierDate is false; make A's original date the *later* one so + // array order [A, B] disagrees with original-date order. + const aDate = Math.max(laterDate, earlierDate); + const bDate = Math.min(laterDate, earlierDate); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: aDate, + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: bDate, + }), + ]), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + const b = actions.getStack().activities.find((x) => x.id === "b1"); + + // Array order wins: A below, B on top — not the original-date order. + expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); + expect(b?.isTop).toBe(true); +}); + +test("load - transitionDuration이 커도 모든 복원 activity를 enter-done으로, globalTransitionState를 idle로 정착시킵니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + const stack = actions.getStack(); + expect(stack.activities.find((x) => x.id === "a1")?.transitionState).toEqual( + "enter-done", + ); + expect(stack.activities.find((x) => x.id === "b1")?.transitionState).toEqual( + "enter-done", + ); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - 캡처 세션 시계가 앞서 있어도 load 후 새 push가 복원 activity들 뒤로 정렬됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B", "C"], 350), + plugins: [ + provideSnapshotPlugin([ + // Original dates are in the future (capture clock ran ahead). + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: futureTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: futureTime(), + }), + ]), + ], + }); + + actions.push({ activityId: "c1", activityName: "C", activityParams: {} }); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + const c = stack.activities.find((x) => x.id === "c1"); + + // A new push lands on top of the restored activities; no order collapse. + expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); + expect((b?.zIndex ?? -1) < (c?.zIndex ?? -1)).toBe(true); + expect(c?.isTop).toBe(true); + // Restored activities remain settled despite the reordered new push. + expect(a?.transitionState).toEqual("enter-done"); + expect(b?.transitionState).toEqual("enter-done"); +}); + +test("load - 원본 이벤트의 id·activityId·stepId를 바이트 보존하고 eventDate만 재기저합니다", () => { + const originalPushDate = enoughPastTime(); + const originalStepDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: originalPushDate, + }), + makeEvent("StepPushed", { + id: "e2", + targetActivityId: "a1", + stepId: "s2", + stepParams: {}, + eventDate: originalStepDate, + }), + ]), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + + // id / activityId / stepId preserved byte-for-byte. + expect(a?.id).toEqual("a1"); + expect(a?.enteredBy.id).toEqual("e1"); + expect(a?.steps[1].id).toEqual("s2"); + expect(a?.steps[1].enteredBy.id).toEqual("e2"); + // Only eventDate is rebased. + expect(a?.enteredBy.eventDate).not.toEqual(originalPushDate); + expect(a?.steps[1].enteredBy.eventDate).not.toEqual(originalStepDate); +}); diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts new file mode 100644 index 000000000..9a7b9531b --- /dev/null +++ b/core/src/snapshotRoundtrip.spec.ts @@ -0,0 +1,87 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = + (snap: StackSnapshot): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snap, + }); + +test("load - load 직후 captureSnapshot이 같은 탐색 기록을 재구성하는 스냅샷을 반환합니다", () => { + const original: StackSnapshot = { + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("StepPushed", { + targetActivityId: "b1", + stepId: "s2", + stepParams: { step: "2" }, + eventDate: enoughPastTime(), + }), + ], + }; + + const first = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(original)], + }); + + const recaptured = first.actions.captureSnapshot(); + + const second = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(recaptured)], + }); + + const stack = second.actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // Same activity column and z-order A→B (observed via zIndex/isTop). + expect(a?.name).toEqual("A"); + expect(b?.name).toEqual("B"); + expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); + expect(b?.isTop).toBe(true); + + // Same steps on B, including the preserved stepId. + expect(b?.steps.map((s) => s.id)).toEqual(["b1", "s2"]); + expect(b?.steps[1].params.step).toEqual("2"); +}); diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index 31022aa4c..bd759dc4e 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -11,7 +11,10 @@ export function makeActions({ dispatchEvent, pluginInstances, actions, -}: ActionCreatorOptions): Omit { +}: ActionCreatorOptions): Omit< + StackflowActions, + "dispatchEvent" | "getStack" | "captureSnapshot" +> { return { push(params) { const { isPrevented, nextActionParams } = triggerPreEffectHook( From d83506eafa379a7d203da8e5b4ebc98649edf127 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 08:12:38 +0900 Subject: [PATCH 08/28] feat(core): implement create/load distinction mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the create/load stubs so a stack knows how it was born — freshly (`create`) or restored from a snapshot (`load`) — and close the snapshot round-trip (capture → persist → load) through additive plugin contracts. A store with no snapshot provider takes the same code path as before. - captureSnapshot() normalizes the raw event log (sort by eventDate, dedupe by id, keep only navigation events), so it reflects pause-queued events and the array order equals the replay order. - makeCoreStore polls provideSnapshot across plugins once: zero non-null -> create; exactly one -> load; more than one -> a plain creation error naming the conflicting keys (not a SnapshotLoadError, not routed). - loadSnapshot reconstructs the stack by replaying the snapshot's navigation events through the existing aggregate machinery: structure check (incompatible-schema), registration check over Pushed and Replaced (invalid-events), rebase to a settled past window, replay, then an enter-state postcondition (empty-navigation). Static info (transitionDuration, registered set) is re-derived from the current config; id/activityId/stepId are byte-preserved, only eventDate rebased. - A failed load is routed only to the providing plugin's onLoadError; { recover: "create" } resumes the create path without re-polling, and void/no-handler throws out of makeCoreStore. - onInit now receives the real initializedBy signal. No new domain events, stack state properties, makeCoreStore options, or changes to aggregate/validateEvents/reducers/overrideInitialEvents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fep-2548-create-load-distinction.md | 30 ++++ core/src/event-utils/index.ts | 1 + core/src/event-utils/isNavigationEvent.ts | 30 ++++ core/src/loadSnapshot.ts | 142 +++++++++++++++++ core/src/makeCoreStore.ts | 144 ++++++++++++++---- 5 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 .changeset/fep-2548-create-load-distinction.md create mode 100644 core/src/event-utils/isNavigationEvent.ts create mode 100644 core/src/loadSnapshot.ts diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md new file mode 100644 index 000000000..bb13e0bae --- /dev/null +++ b/.changeset/fep-2548-create-load-distinction.md @@ -0,0 +1,30 @@ +--- +"@stackflow/core": minor +--- + +Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, entirely through additive plugin contracts. A store with no snapshot provider behaves exactly as before. + +- `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility. +- `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time. +- `provideSnapshot?(({ initialContext }))` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. +- `onLoadError?(({ error, initialContext }))` plugin hook + `SnapshotLoadError` (with a `cause` of `incompatible-schema`, `invalid-events`, or `empty-navigation`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. +- `onInit` now receives `initializedBy: "create" | "load"` — a one-shot signal that leaves no trace on the stack state or event log. + +```ts +const persisterPlugin = ({ storage, codec }) => () => ({ + key: "persister", + onChanged({ actions }) { + storage.write(codec.encode(actions.captureSnapshot())); + }, + provideSnapshot() { + const raw = storage.read(); + return raw ? codec.decode(raw) : null; + }, + onLoadError({ error }) { + storage.remove(); + return { recover: "create" }; + }, +}); +``` + +A load reconstructs the stack by replaying the snapshot's navigation events through the existing aggregate machinery: it re-derives static information (transition duration, the registered-activity set) from the current config, re-dates the events so the restored stack settles synchronously, and preserves each event's `id`/`activityId`/`stepId` byte-for-byte. No new domain events or stack state properties are introduced. diff --git a/core/src/event-utils/index.ts b/core/src/event-utils/index.ts index 6ca98c51a..baf27811b 100644 --- a/core/src/event-utils/index.ts +++ b/core/src/event-utils/index.ts @@ -1,4 +1,5 @@ export * from "./dispatchEvent"; export * from "./filterEvents"; +export * from "./isNavigationEvent"; export * from "./makeEvent"; export * from "./validateEvents"; diff --git a/core/src/event-utils/isNavigationEvent.ts b/core/src/event-utils/isNavigationEvent.ts new file mode 100644 index 000000000..82c048cb1 --- /dev/null +++ b/core/src/event-utils/isNavigationEvent.ts @@ -0,0 +1,30 @@ +import type { DomainEvent } from "../event-types"; +import type { NavigationEvent } from "../StackSnapshot"; + +/** + * The six navigation events a snapshot carries. Single source of truth for the + * capture-side filter and the load-side structure check. + */ +const NAVIGATION_EVENT_NAMES: ReadonlySet = new Set([ + "Pushed", + "Replaced", + "Popped", + "StepPushed", + "StepReplaced", + "StepPopped", +]); + +/** Whether an event is one of the six navigation events. */ +export function isNavigationEvent( + event: DomainEvent, +): event is NavigationEvent { + return NAVIGATION_EVENT_NAMES.has(event.name); +} + +/** Whether a value is one of the six navigation event names. */ +export function isNavigationEventName(name: unknown): boolean { + return ( + typeof name === "string" && + NAVIGATION_EVENT_NAMES.has(name as DomainEvent["name"]) + ); +} diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts new file mode 100644 index 000000000..0044e4106 --- /dev/null +++ b/core/src/loadSnapshot.ts @@ -0,0 +1,142 @@ +import { aggregate } from "./aggregate"; +import type { DomainEvent } from "./event-types"; +import { filterEvents, isNavigationEventName } from "./event-utils"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { Stack } from "./Stack"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; + +/** + * Reconstruct a stack from a provided snapshot by replaying its navigation + * events through the existing aggregate machinery. Static information + * (transitionDuration, the registered-activity set) is re-derived from the + * current config's static events, never from the snapshot. + * + * The four checks a snapshot must pass, each mapped to a `SnapshotLoadError` + * cause: + * - structure → `incompatible-schema` + * - registration (every activity-introducing event's name is registered now) → + * `invalid-events` + * - replay validity (existing `validateEvents`) → `invalid-events` + * - postcondition (at least one activity ends in an enter state) → + * `empty-navigation` + * + * On success it returns the replay event log (static events followed by the + * rebased snapshot events) and the settled stack. + */ +export function loadSnapshot( + snapshot: StackSnapshot, + staticEvents: DomainEvent[], +): { events: DomainEvent[]; stack: Stack } { + assertSnapshotStructure(snapshot); + + const registeredActivityNames = new Set( + filterEvents(staticEvents, "ActivityRegistered").map( + (event) => event.activityName, + ), + ); + + // Registration check (L6). The current config is the source of truth for + // which activities exist. Unlike the runtime `validateEvents` — which checks + // only Pushed — this also covers Replaced, since Replaced materializes an + // activity by name too. An unregistered name here means the config changed + // out from under a stale snapshot: fail loudly rather than resurrect it. + for (const event of snapshot.events) { + if ( + (event.name === "Pushed" || event.name === "Replaced") && + !registeredActivityNames.has(event.activityName) + ) { + throw new SnapshotLoadError({ + kind: "invalid-events", + detail: `activity "${event.activityName}" is not registered in the current config`, + }); + } + } + + const transitionDuration = + filterEvents(staticEvents, "Initialized")[0]?.transitionDuration ?? 0; + + const now = Date.now(); + const rebasedEvents = rebaseNavigationEvents(snapshot.events, { + now, + transitionDuration, + }); + + const events = [...staticEvents, ...rebasedEvents]; + + let stack: Stack; + try { + stack = aggregate(events, now); + } catch (error) { + // A structurally-valid event sequence that the replay machinery rejects + // (e.g. `validateEvents`) is an invalid-events failure, not a crash. + throw new SnapshotLoadError({ + kind: "invalid-events", + detail: error instanceof Error ? error.message : error, + }); + } + + const hasEnteredActivity = stack.activities.some( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "enter-done", + ); + + if (!hasEnteredActivity) { + // Replay succeeded but left no visible activity (empty events, or a history + // that pops everything). A blank screen is a silent failure — surface it. + throw new SnapshotLoadError({ kind: "empty-navigation" }); + } + + return { events, stack }; +} + +/** + * Verify the value is a core-known v1 snapshot before any replay: the schema + * tag matches, `events` is an array, and every item is a navigation event + * carrying an `id` and `name`. Anything else is `incompatible-schema`. + */ +function assertSnapshotStructure(snapshot: StackSnapshot): void { + if (snapshot?.$schema !== "stackflow.snapshot.v1") { + throw new SnapshotLoadError({ kind: "incompatible-schema" }); + } + + const events: unknown = snapshot.events; + + if (!Array.isArray(events)) { + throw new SnapshotLoadError({ kind: "incompatible-schema" }); + } + + for (const event of events) { + if ( + !event || + typeof event !== "object" || + typeof (event as { id?: unknown }).id !== "string" || + !isNavigationEventName((event as { name?: unknown }).name) + ) { + throw new SnapshotLoadError({ kind: "incompatible-schema" }); + } + } +} + +/** + * Re-date snapshot events so replay settles deterministically (RB1–RB5): + * assign strictly increasing dates in array order (array order is the replay + * order), all far enough in the past (`≤ now − transitionDuration`) that every + * reducer folds to a settled state, while preserving every other field + * byte-for-byte (id/activityId/stepId included). Basing the new dates on + * capture order rather than the original values keeps a clock skew between the + * capture and load sessions from disturbing intra-snapshot order, and makes + * post-load navigation (dispatched at the current time) always sort after the + * restored events. + */ +function rebaseNavigationEvents( + events: NavigationEvent[], + context: { now: number; transitionDuration: number }, +): NavigationEvent[] { + const settledUpperBound = context.now - context.transitionDuration; + + return events.map((event, index) => ({ + ...event, + eventDate: settledUpperBound - (events.length - index), + })); +} diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 3ee1b74cd..f3ad6997f 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,11 +1,14 @@ import isEqual from "react-fast-compare"; import { aggregate } from "./aggregate"; import type { DomainEvent, PushedEvent, StepPushedEvent } from "./event-types"; -import { makeEvent } from "./event-utils"; +import { isNavigationEvent, makeEvent } from "./event-utils"; import type { StackflowActions, StackflowPlugin } from "./interfaces"; +import { loadSnapshot } from "./loadSnapshot"; import { produceEffects } from "./produceEffects"; +import { SnapshotLoadError } from "./SnapshotLoadError"; import type { Stack } from "./Stack"; -import { divideBy, once } from "./utils"; +import type { StackSnapshot } from "./StackSnapshot"; +import { divideBy, once, uniqBy } from "./utils"; import { makeActions } from "./utils/makeActions"; import { triggerPostEffectHooks } from "./utils/triggerPostEffectHooks"; @@ -49,39 +52,116 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { ...options.plugins.map((plugin) => plugin()), ]; + const initialContext = options.initialContext ?? {}; + const [initialPushedEventsByOption, initialRemainingEvents] = divideBy( options.initialEvents, (e) => e.name === "Pushed" || e.name === "StepPushed", ); - const initialPushedEvents = pluginInstances.reduce( - (initialEvents, pluginInstance) => - pluginInstance.overrideInitialEvents?.({ - initialEvents, - initialContext: options.initialContext ?? {}, - }) ?? initialEvents, - initialPushedEventsByOption, - ); + const events: { value: DomainEvent[] } = { + value: [], + }; - const isInitialActivityIgnored = - initialPushedEvents.length > 0 && - initialPushedEventsByOption.length > 0 && - initialPushedEvents !== initialPushedEventsByOption; + /** + * Build the stack via the create path: run the `overrideInitialEvents` chain + * and the initial-activity handlers, then aggregate. Unchanged from the + * pre-snapshot behavior — a store with no snapshot provider is observably + * identical to before. + */ + const createStack = (): Stack => { + const initialPushedEvents = pluginInstances.reduce( + (initialEvents, pluginInstance) => + pluginInstance.overrideInitialEvents?.({ + initialEvents, + initialContext, + }) ?? initialEvents, + initialPushedEventsByOption, + ); - if (isInitialActivityIgnored) { - options.handlers?.onInitialActivityIgnored?.(initialPushedEvents); - } + const isInitialActivityIgnored = + initialPushedEvents.length > 0 && + initialPushedEventsByOption.length > 0 && + initialPushedEvents !== initialPushedEventsByOption; - if (initialPushedEvents.length === 0) { - options.handlers?.onInitialActivityNotFound?.(); - } + if (isInitialActivityIgnored) { + options.handlers?.onInitialActivityIgnored?.(initialPushedEvents); + } - const events: { value: DomainEvent[] } = { - value: [...initialRemainingEvents, ...initialPushedEvents], + if (initialPushedEvents.length === 0) { + options.handlers?.onInitialActivityNotFound?.(); + } + + events.value = [...initialRemainingEvents, ...initialPushedEvents]; + + return aggregate(events.value, new Date().getTime()); }; + // Poll every plugin for a snapshot to load from (§3.3). `null`/`undefined` + // means "nothing to provide". More than one non-null supply is a wiring bug, + // not a snapshot defect — throw a plain creation error naming the keys, + // without routing to any `onLoadError` (R9). + const suppliedSnapshots = pluginInstances + .map((pluginInstance) => ({ + pluginInstance, + snapshot: pluginInstance.provideSnapshot?.({ initialContext }) ?? null, + })) + .filter( + ( + supply, + ): supply is { + pluginInstance: ReturnType; + snapshot: StackSnapshot; + } => supply.snapshot != null, + ); + + if (suppliedSnapshots.length > 1) { + const keys = suppliedSnapshots.map((supply) => supply.pluginInstance.key); + throw new Error( + `More than one plugin provided a snapshot (${keys.join( + ", ", + )}). A stack loads from at most one snapshot; resolve which provider wins in a layer above core.`, + ); + } + + let initializedBy: "create" | "load"; + let stackValue: Stack; + + if (suppliedSnapshots.length === 1) { + const { pluginInstance, snapshot } = suppliedSnapshots[0]; + + try { + const loaded = loadSnapshot(snapshot, initialRemainingEvents); + events.value = loaded.events; + stackValue = loaded.stack; + initializedBy = "load"; + } catch (error) { + if (!(error instanceof SnapshotLoadError)) { + throw error; + } + + // The failing snapshot's provider gets first refusal (R5). An explicit + // `{ recover: "create" }` resumes the create path without re-polling + // (C1); anything else rethrows out of makeCoreStore (R4). + const recovery = pluginInstance.onLoadError?.({ + error, + initialContext, + }); + + if (recovery?.recover !== "create") { + throw error; + } + + stackValue = createStack(); + initializedBy = "create"; + } + } else { + stackValue = createStack(); + initializedBy = "create"; + } + const stack = { - value: aggregate(events.value, new Date().getTime()), + value: stackValue, }; let currentInterval: ReturnType | null = null; @@ -91,7 +171,19 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { return stack.value; }, captureSnapshot() { - throw new Error("captureSnapshot is not implemented yet"); + // Read the raw event log (not aggregated state, so pause-queued events + // are still included) and normalize it the way aggregate pre-processes: + // sort by eventDate ascending, dedupe by id, then keep only navigation + // events. The resulting array order is the replay order. + const navigationEvents = uniqBy( + [...events.value].sort((a, b) => a.eventDate - b.eventDate), + (e) => e.id, + ).filter(isNavigationEvent); + + return { + $schema: "stackflow.snapshot.v1", + events: navigationEvents, + }; }, dispatchEvent(name, params) { const newEvent = makeEvent(name, params); @@ -156,9 +248,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { pluginInstances.forEach((pluginInstance) => { pluginInstance.onInit?.({ actions, - // Placeholder until the create/load distinction is wired in. The - // load path and the real signal are implemented separately. - initializedBy: "create", + initializedBy, }); }); }), From fc9fbce079880505b9106c9d05b48e68ffb43cbc Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:10:45 +0900 Subject: [PATCH 09/28] fix(core): allow undefined/void return from provideSnapshot (C1#8) The JSDoc, changeset, and runtime (`?? null`) all promise that returning `undefined` means "nothing to provide", but the declared return type `StackSnapshot | null` rejected a bare `return;`. Widen it to `StackSnapshot | null | void`, following the sibling hook `onLoadError`'s existing `| void` precedent (including the biome-ignore rationale), and drop the forced cast the old type required in provideSnapshot.spec.ts. Runtime behavior is unchanged. Co-Authored-By: Claude Fable 5 --- core/src/interfaces/StackflowPlugin.ts | 5 ++++- core/src/provideSnapshot.spec.ts | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/interfaces/StackflowPlugin.ts b/core/src/interfaces/StackflowPlugin.ts index 0a4a7e269..4a1b3a98a 100644 --- a/core/src/interfaces/StackflowPlugin.ts +++ b/core/src/interfaces/StackflowPlugin.ts @@ -144,7 +144,10 @@ export type StackflowPlugin = () => { * core throws a creation error naming the conflicting keys — it does not * arbitrate (R9). */ - provideSnapshot?: (args: { initialContext: any }) => StackSnapshot | null; + provideSnapshot?: (args: { + initialContext: any; + // biome-ignore lint/suspicious/noConfusingVoidType: `void` is intentional — it lets a provider `return;` to signal "nothing to provide", as the JSDoc above promises. Narrowing to `undefined` would reject the common void-returning provider implementation. + }) => StackSnapshot | null | void; /** * Called — only on the plugin that provided the failing snapshot (R5) — when diff --git a/core/src/provideSnapshot.spec.ts b/core/src/provideSnapshot.spec.ts index 17ddefbff..475b940ab 100644 --- a/core/src/provideSnapshot.spec.ts +++ b/core/src/provideSnapshot.spec.ts @@ -112,9 +112,9 @@ test("provideSnapshot - non-null 공급이 정확히 1개면 나머지 null은 test("provideSnapshot - undefined 반환을 null과 동일하게(공급 없음) 취급합니다", () => { const undefinedProvider: StackflowPlugin = () => ({ key: "provider", - // Simulates a JS provider (e.g. optional chaining) that yields `undefined` - // at runtime; core must treat it like `null` — nothing to provide. - provideSnapshot: () => undefined as unknown as StackSnapshot | null, + // The contract allows returning `undefined` (a bare `return;`) — core + // must treat it like `null`: nothing to provide. + provideSnapshot: () => undefined, }); const { actions } = makeCoreStore({ From 66705fe2851cd2037a08052d03625e67b9b0fc69 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:11:30 +0900 Subject: [PATCH 10/28] docs(changeset): fix hook signature typos, cause.kind notation, and soften additive claim (C1#9, C2#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `provideSnapshot?(({ initialContext }))` / `onLoadError?(({ error, ... }))` double-parenthesis typos — these render verbatim in the CHANGELOG. - `SnapshotLoadError`'s cause is a discriminated object, not a string enum: describe it as `cause.kind` so release-note readers don't write `error.cause === "invalid-events"` (always false). - "entirely additive" overstated the change: `StackflowActions.captureSnapshot` and `onInit`'s `initializedBy` are required members, which is compile-breaking for mock constructors and wrap-and-forward hook callers. State the claim as runtime-additive with a type-level caution. Co-Authored-By: Claude Fable 5 --- .changeset/fep-2548-create-load-distinction.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md index bb13e0bae..8e01d6e56 100644 --- a/.changeset/fep-2548-create-load-distinction.md +++ b/.changeset/fep-2548-create-load-distinction.md @@ -2,12 +2,12 @@ "@stackflow/core": minor --- -Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, entirely through additive plugin contracts. A store with no snapshot provider behaves exactly as before. +Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initializedBy`). - `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility. - `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time. -- `provideSnapshot?(({ initialContext }))` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. -- `onLoadError?(({ error, initialContext }))` plugin hook + `SnapshotLoadError` (with a `cause` of `incompatible-schema`, `invalid-events`, or `empty-navigation`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. +- `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. +- `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"incompatible-schema"`, `"invalid-events"`, or `"empty-navigation"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. - `onInit` now receives `initializedBy: "create" | "load"` — a one-shot signal that leaves no trace on the stack state or event log. ```ts From 86351e2a62cc8219c3f23c29d1e42c23bd5883c9 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:14:43 +0900 Subject: [PATCH 11/28] test(core): pin raw propagation when provideSnapshot/onLoadError throw; make persister mimic self-handle decode failure (C1#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A throwing hook is undefined behavior today: the error propagates raw out of makeCoreStore and onLoadError is never involved. Two characterization tests pin that propagation so any future change to it is a conscious contract decision. The persister mimic previously demonstrated the fragile pattern (raw JSON.parse inside provideSnapshot — a truncated storage write would crash boot). As the reference implementation persister authors will copy, it now self-handles decode failure: discard the stored value and return null so creation falls back to the create path. A third round-trip test exercises that branch end-to-end. Existing assertions are unchanged. Co-Authored-By: Claude Fable 5 --- core/src/loadPath.spec.ts | 59 +++++++++++++++++++++++++++++ core/src/persisterRoundtrip.spec.ts | 56 +++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index 2c9d6298f..43b06424b 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -754,3 +754,62 @@ test("load - provideSnapshot·onLoadError는 생성 시 options.initialContext expect.objectContaining({ initialContext: { foo: "bar" } }), ); }); + +// --------------------------------------------------------------------------- +// hook-thrown errors — characterization (undefined behavior, pinned) +// --------------------------------------------------------------------------- + +test("load - provideSnapshot이 throw하면 에러가 makeCoreStore 밖으로 그대로 전파되고 onLoadError는 호출되지 않습니다", () => { + // Characterization, not contract: a throwing hook is undefined behavior. + // Pinned so that changing today's raw propagation (e.g. routing provider + // failures into onLoadError) is a conscious contract decision, not a slip. + const decodeFailure = new SyntaxError("Unexpected end of JSON input"); + const onLoadError = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + () => ({ + key: "provider", + provideSnapshot: () => { + throw decodeFailure; + }, + onLoadError, + }), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(decodeFailure); + expect(onLoadError).toHaveBeenCalledTimes(0); +}); + +test("load - onLoadError가 throw하면 그 에러가 SnapshotLoadError 대신 makeCoreStore 밖으로 그대로 전파됩니다", () => { + // Characterization, not contract — same rationale as above. + const handlerFailure = new Error("storage cleanup failed"); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { + onLoadError: () => { + throw handlerFailure; + }, + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(handlerFailure); +}); diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts index 56725ecbe..7e375268b 100644 --- a/core/src/persisterRoundtrip.spec.ts +++ b/core/src/persisterRoundtrip.spec.ts @@ -35,6 +35,27 @@ const initialHome = () => eventDate: enoughPastTime(), }); +/** + * The reference decode shape for persister authors. Decoding happens inside + * `provideSnapshot`, so a stored value that cannot even be decoded never + * reaches core — the codec is the supplier's responsibility (R13) and decode + * failures are outside core's `onLoadError` contract. Self-handle them: + * discard the stored value and return null, falling back to the create path. + */ +const decodeOrDiscard = (memory: { + value: string | null; +}): StackSnapshot | null => { + if (!memory.value) { + return null; + } + try { + return JSON.parse(memory.value) as StackSnapshot; + } catch { + memory.value = null; + return null; + } +}; + test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 provideSnapshot load가 core API만으로 닫힙니다", () => { // A persister mimic: capture on change, JSON codec, in-memory storage. const memory: { value: string | null } = { value: null }; @@ -47,8 +68,7 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov onChanged({ actions }) { memory.value = JSON.stringify(actions.captureSnapshot()); }, - provideSnapshot: () => - memory.value ? (JSON.parse(memory.value) as StackSnapshot) : null, + provideSnapshot: () => decodeOrDiscard(memory), onInit, }); }; @@ -94,8 +114,7 @@ test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 recover const onInit = jest.fn(); const persister: StackflowPlugin = () => ({ key: "persister", - provideSnapshot: () => - memory.value ? (JSON.parse(memory.value) as StackSnapshot) : null, + provideSnapshot: () => decodeOrDiscard(memory), onLoadError: () => { memory.value = null; return { recover: "create" }; @@ -123,3 +142,32 @@ test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 recover expect(memory.value).toBeNull(); expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); }); + +test("persister 왕복 - 디코드 불가한 보존물(잘린 write)은 공급자가 스스로 폐기하고 create로 기동합니다", () => { + // Storage holds a truncated write (quota eviction, interrupted write) — + // undecodable, so core never sees a snapshot and onLoadError has nothing + // to route. The supplier's decode shape must absorb this itself. + const memory: { value: string | null } = { + value: '{"$schema":"stackflow.snapshot.v1","ev', + }; + + const onInit = jest.fn(); + const persister: StackflowPlugin = () => ({ + key: "persister", + provideSnapshot: () => decodeOrDiscard(memory), + onInit, + }); + + const store = makeCoreStore({ + initialEvents: [...config(["Home"]), initialHome()], + plugins: [persister], + }); + store.init(); + + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + // The undecodable stored value was discarded by the supplier. + expect(memory.value).toBeNull(); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); From ac5c7ba73bb0ae64726bb2c8427deab002c61b1c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:16:06 +0900 Subject: [PATCH 12/28] test(core): verify recover:create resumes the full create pipeline (C1#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recover contract is that a { recover: "create" } return resumes the create path from its start — not merely that some stack comes out. Existing recover tests only asserted the resulting activity list, so a regression that skipped the overrideInitialEvents chain or the initial-activity handlers on the recovery path would have passed unnoticed. Pin the coexistence case of a failing provider and an overrideInitialEvents plugin: the chain runs over the option's initial events, its substitution is what the stack is built from, the initial-activity handler judges that substitution, and onInit reports initializedBy "create". Co-Authored-By: Claude Fable 5 --- core/src/loadPath.spec.ts | 62 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index 43b06424b..c3f24203e 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -1,4 +1,8 @@ -import type { DomainEvent } from "./event-types"; +import type { + DomainEvent, + PushedEvent, + StepPushedEvent, +} from "./event-types"; import { makeEvent } from "./event-utils"; import type { StackflowPlugin } from "./interfaces"; import { makeCoreStore } from "./makeCoreStore"; @@ -633,6 +637,62 @@ test('load - 실패 시 onLoadError가 {recover:"create"}를 반환하면 throw expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); }); +test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial-activity 핸들러를 포함한 create 파이프라인을 온전히 태웁니다', () => { + const onInit = jest.fn(); + const onInitialActivityIgnored = jest.fn(); + const overrideInitialEvents = jest.fn( + (_args: { + initialEvents: (PushedEvent | StepPushedEvent)[]; + initialContext: any; + }) => [ + makeEvent("Pushed", { + activityId: "redirect1", + activityName: "Redirect", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + ); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home", "Redirect"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError: () => ({ recover: "create" as const }), onInit }, + ), + () => ({ key: "redirector", overrideInitialEvents }), + ], + handlers: { onInitialActivityIgnored }, + }); + store.init(); + + // The chain ran, over the option's initial events (not snapshot leftovers). + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + expect(overrideInitialEvents.mock.calls[0][0].initialEvents).toMatchObject([ + { name: "Pushed", activityId: "home1" }, + ]); + // The chain's substitution is what the stack is built from... + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "redirect1", + ]); + // ...and the initial-activity handler judged that substitution, as on any + // create. + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(1); + expect(onInitialActivityIgnored.mock.calls[0][0]).toMatchObject([ + { name: "Pushed", activityId: "redirect1" }, + ]); + expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); +}); + test("load - recover:create 재개 시 provideSnapshot을 재폴링하지 않습니다", () => { const provideSnapshot = jest.fn(() => rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), From 2268a2478cfc69b26ac20c62923f0b40c21ceb2c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:17:31 +0900 Subject: [PATCH 13/28] =?UTF-8?q?test(core):=20pin=20load=20contract=20edg?= =?UTF-8?q?es=20=E2=80=94=20non-create=20recovery=20rethrows,=20duplicate-?= =?UTF-8?q?id=20last-wins,=20unknown-property=20tolerance=20(C1-nit-3,=20C?= =?UTF-8?q?2#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three edges of the load contract existed only as unstated behavior: - onLoadError returning a truthy value other than the exact { recover: "create" } decision rethrows the SnapshotLoadError — the recovery check must never loosen into truthiness. - A consumer-transformed snapshot with duplicate event ids is accepted, and dedup keeps the last occurrence (the earlier event is dropped). Note the direction: last-wins, the opposite of what C2#7's prose described. - Unknown properties — both on the snapshot object and on individual event items — are tolerated (forward compatibility), so the structure check isn't tightened by accident. Co-Authored-By: Claude Fable 5 --- core/src/loadPath.spec.ts | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index c3f24203e..40a2a9747 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -601,6 +601,78 @@ test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-navi expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-navigation"); }); +// --------------------------------------------------------------------------- +// consumer-transformed snapshot boundaries — accepted edges, pinned +// --------------------------------------------------------------------------- + +test("load - 중복 id 스냅샷은 거부되지 않고 마지막 출현이 이깁니다(last-wins)", () => { + // Duplicate event ids can only come from a consumer-transformed snapshot + // (capture dedupes). They are an accepted boundary, not a rejection case, + // and dedup keeps the LAST occurrence — pinned so the direction doesn't + // silently flip. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "dup", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "dup", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + // The later entry survived; the earlier one was dropped. + const ids = actions.getStack().activities.map((x) => x.id); + expect(ids).toEqual(["b1"]); + expect(actions.getStack().activities[0].transitionState).toEqual( + "enter-done", + ); +}); + +test("load - 스냅샷과 이벤트 항목의 미지 프로퍼티를 수용합니다(전방 호환)", () => { + // A snapshot written by a newer version may carry fields this version does + // not know. The structure check validates what it needs and tolerates the + // rest — pinned so the tolerance isn't tightened by accident. + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + futureField: { anything: true }, + events: [ + { + ...makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + futureEventField: "tolerated", + }, + ], + }), + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + expect(a?.transitionState).toEqual("enter-done"); +}); + // --------------------------------------------------------------------------- // onLoadError routing // --------------------------------------------------------------------------- @@ -741,6 +813,31 @@ test("load - onLoadError가 void를 반환하면 SnapshotLoadError를 makeCoreSt expect(caught).toBeInstanceOf(SnapshotLoadError); }); +test('load - onLoadError가 {recover:"create"} 아닌 truthy 값을 반환하면 SnapshotLoadError를 그대로 던집니다', () => { + // Recovery takes the exact { recover: "create" } decision. A JS consumer + // returning some other truthy shape must not be mistaken for it — pinned so + // the check never loosens into truthiness. + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { + onLoadError: () => + ({ recover: "retry" }) as unknown as { recover: "create" }, + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + test("load - onLoadError 핸들러가 없으면 SnapshotLoadError를 makeCoreStore 밖으로 던집니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), From 62cef369780379d7f603108a0d0ea5c94d899bcb Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:19:34 +0900 Subject: [PATCH 14/28] test(core): cover pop/step navigation after load (C2#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-load navigation was only tested for push, leaving half of the homogeneity postcondition unpinned: a rebase regression that broke pop/step targeting would have passed the suite. Both interact directly with rebased dates — pop exits the latest activity and StepPushed resolves its target as the latest active activity by eventDate. Pin both on a restored two-activity stack: pop starts the exit transition on the restored top and re-exposes the restored activity below as active; stepPush lands its step on the restored top, not the activity underneath. Co-Authored-By: Claude Fable 5 --- core/src/rebase.spec.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/core/src/rebase.spec.ts b/core/src/rebase.spec.ts index a5c16cbcd..3a44b646c 100644 --- a/core/src/rebase.spec.ts +++ b/core/src/rebase.spec.ts @@ -188,3 +188,74 @@ test("load - 원본 이벤트의 id·activityId·stepId를 바이트 보존하 expect(a?.enteredBy.eventDate).not.toEqual(originalPushDate); expect(a?.steps[1].enteredBy.eventDate).not.toEqual(originalStepDate); }); + +test("load - load 후 pop이 복원 최상단을 exit 전환시키고 아래 복원 activity를 재노출합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + actions.pop(); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // Pop targeted the restored top: it began its exit transition (the Popped + // event, dispatched at the current time, sorted after the rebased events)... + expect(b?.transitionState).toEqual("exit-active"); + expect(b?.exitedBy?.name).toEqual("Popped"); + // ...and the restored activity below is re-exposed as the active one. + expect(a?.transitionState).toEqual("enter-done"); + expect(a?.isActive).toBe(true); +}); + +test("load - load 후 stepPush가 최신 활성 activity(복원 최상단)를 타깃합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // StepPushed resolves its target by eventDate (latest active activity) — + // it must land on the restored top, which only holds if rebased dates kept + // the restored order below the new event. + expect(b?.steps.map((s) => s.id)).toEqual(["b1", "s1"]); + expect(b?.params.step).toEqual("1"); + expect(a?.steps.map((s) => s.id)).toEqual(["a1"]); +}); From 382a20974af60da4d0a1fecf2b313ee75827e5a5 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:21:15 +0900 Subject: [PATCH 15/28] test(core): load postcondition for a snapshot captured while paused (C2#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture contract promises that replaying a pause-time snapshot yields the state as if the pause never happened — queued navigation fully applied. Only the capture half was tested (the queued event is included in the snapshot); nothing verified that loading such a snapshot actually materializes the queued activity. Close the round trip: pause, push (queued, not yet visible), capture, then load into a fresh store — the queued activity comes out settled (enter-done, on top) and the restored stack is idle. Co-Authored-By: Claude Fable 5 --- core/src/snapshotRoundtrip.spec.ts | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts index 9a7b9531b..e804fdd30 100644 --- a/core/src/snapshotRoundtrip.spec.ts +++ b/core/src/snapshotRoundtrip.spec.ts @@ -85,3 +85,58 @@ test("load - load 직후 captureSnapshot이 같은 탐색 기록을 재구성하 expect(b?.steps.map((s) => s.id)).toEqual(["b1", "s2"]); expect(b?.steps[1].params.step).toEqual("2"); }); + +test("load - pause 중 캡처한 스냅샷은 큐잉됐던 항해가 전부 적용된 정착 상태로 복원됩니다", () => { + const source = makeCoreStore({ + initialEvents: [ + ...config(["A", "B"]), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + source.actions.pause(); + source.actions.push({ + activityId: "b1", + activityName: "B", + activityParams: {}, + }); + + // At capture time the queued push is not yet a visible activity — the + // contract is that replay applies queued navigation as if the pause never + // happened, so the restore below must nonetheless materialize it. + expect(source.actions.getStack().activities.some((x) => x.id === "b1")).toBe( + false, + ); + + let captured: StackSnapshot; + try { + captured = source.actions.captureSnapshot(); + } finally { + // Resume so the paused source store settles and its polling interval + // clears, even when the capture above throws. + source.actions.resume(); + } + + const restored = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(captured)], + }); + + const stack = restored.actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // The queued activity materialized, settled, on top of the pre-pause one. + expect(b?.transitionState).toEqual("enter-done"); + expect(b?.isTop).toBe(true); + expect(a?.transitionState).toEqual("enter-done"); + expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); + // No pause survives the round-trip: the restored stack is idle. + expect(stack.globalTransitionState).toEqual("idle"); +}); From ab22fe46433dd8088cc8cd4e66a52d43b5b09920 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:22:18 +0900 Subject: [PATCH 16/28] chore(core): trim HOW-restating comments to WHY-only (C1#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments restated what the code (or a type) already says and would drift as the code changes: - loadSnapshot.ts header enumerated the four checks and their error mapping — that truth lives in the SnapshotLoadError cause type and the function body. The WHY that stays: static info is re-derived from the current config, never the snapshot. - assertSnapshotStructure's JSDoc walked through its own checks; keep only why the check exists (fail loudly before replay, not fold into a corrupt stack). - createStack's JSDoc narrated chain/handlers/aggregate steps; keep only the guarantee it encodes (no provider → observably identical to before). Design-doc marker references (WHY pointers) are intentionally kept. Co-Authored-By: Claude Fable 5 --- core/src/loadSnapshot.ts | 17 ++--------------- core/src/makeCoreStore.ts | 6 ++---- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 0044e4106..0fbc9648c 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -10,18 +10,6 @@ import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; * events through the existing aggregate machinery. Static information * (transitionDuration, the registered-activity set) is re-derived from the * current config's static events, never from the snapshot. - * - * The four checks a snapshot must pass, each mapped to a `SnapshotLoadError` - * cause: - * - structure → `incompatible-schema` - * - registration (every activity-introducing event's name is registered now) → - * `invalid-events` - * - replay validity (existing `validateEvents`) → `invalid-events` - * - postcondition (at least one activity ends in an enter state) → - * `empty-navigation` - * - * On success it returns the replay event log (static events followed by the - * rebased snapshot events) and the settled stack. */ export function loadSnapshot( snapshot: StackSnapshot, @@ -91,9 +79,8 @@ export function loadSnapshot( } /** - * Verify the value is a core-known v1 snapshot before any replay: the schema - * tag matches, `events` is an array, and every item is a navigation event - * carrying an `id` and `name`. Anything else is `incompatible-schema`. + * A value that is not a core-known v1 snapshot must fail loudly before any + * replay (`incompatible-schema`) instead of folding into a corrupt stack. */ function assertSnapshotStructure(snapshot: StackSnapshot): void { if (snapshot?.$schema !== "stackflow.snapshot.v1") { diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index f3ad6997f..850910886 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -64,10 +64,8 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { }; /** - * Build the stack via the create path: run the `overrideInitialEvents` chain - * and the initial-activity handlers, then aggregate. Unchanged from the - * pre-snapshot behavior — a store with no snapshot provider is observably - * identical to before. + * The create path is unchanged from the pre-snapshot behavior — a store + * with no snapshot provider is observably identical to before. */ const createStack = (): Stack => { const initialPushedEvents = pluginInstances.reduce( From 6bb72dbd2b3e37c134d1a4a49a70083cddfe9561 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 18:25:32 +0900 Subject: [PATCH 17/28] fix(core): rebase snapshot events into the window after static events (C1#5, C2#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase used a fixed 1ms integer spacing that never looked at static event dates. The react integration backdates static events by only 2×transitionDuration, so a snapshot with more events than transitionDuration ms (350 by default) had its earliest events folding before Initialized/ActivityRegistered — the design's general placement rule (inside the window after the static events, fractional dates fitting any count) existed only on paper, while the code comment claimed RB1–RB5. Give the rebase the latest static event date as the window's exclusive lower bound and space events fractionally: min(1, window/(N+1)) ms keeps any history length inside the window, and a roomy window degrades to the previous 1ms spacing. A degenerate window (static events dated at or past the settled bound) falls back to settledness alone, as the design's exception path argues is safe. Pin the placement with a 400-event snapshot against react-style static events: every rebased date lands after every static event, at or before creation − transitionDuration, strictly increasing, and the replay settles. The pin fails on the previous fixed-spacing implementation. Co-Authored-By: Claude Fable 5 --- core/src/loadSnapshot.ts | 34 ++++++++++++++++------ core/src/rebase.spec.ts | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 0fbc9648c..10ff19c77 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -47,6 +47,10 @@ export function loadSnapshot( const rebasedEvents = rebaseNavigationEvents(snapshot.events, { now, transitionDuration, + latestStaticEventDate: staticEvents.reduce( + (latest, event) => Math.max(latest, event.eventDate), + Number.NEGATIVE_INFINITY, + ), }); const events = [...staticEvents, ...rebasedEvents]; @@ -108,22 +112,34 @@ function assertSnapshotStructure(snapshot: StackSnapshot): void { /** * Re-date snapshot events so replay settles deterministically (RB1–RB5): * assign strictly increasing dates in array order (array order is the replay - * order), all far enough in the past (`≤ now − transitionDuration`) that every - * reducer folds to a settled state, while preserving every other field - * byte-for-byte (id/activityId/stepId included). Basing the new dates on - * capture order rather than the original values keeps a clock skew between the - * capture and load sessions from disturbing intra-snapshot order, and makes - * post-load navigation (dispatched at the current time) always sort after the - * restored events. + * order), placed inside the window after the static events and far enough in + * the past (`≤ now − transitionDuration`) that every reducer folds to a + * settled state. Fractional spacing fits any number of events inside the + * window, so snapshot events fold after the static events regardless of + * history length. Every other field is preserved byte-for-byte + * (id/activityId/stepId included). Basing the new dates on capture order + * rather than the original values keeps a clock skew between the capture and + * load sessions from disturbing intra-snapshot order, and makes post-load + * navigation (dispatched at the current time) always sort after the restored + * events. */ function rebaseNavigationEvents( events: NavigationEvent[], - context: { now: number; transitionDuration: number }, + context: { + now: number; + transitionDuration: number; + latestStaticEventDate: number; + }, ): NavigationEvent[] { const settledUpperBound = context.now - context.transitionDuration; + const window = settledUpperBound - context.latestStaticEventDate; + // A degenerate window (static events dated at or past the settled bound — + // e.g. a direct core embedding dating them near creation time) falls back + // to settledness alone: fold semantics are safe without the placement. + const spacing = window > 0 ? Math.min(1, window / (events.length + 1)) : 1; return events.map((event, index) => ({ ...event, - eventDate: settledUpperBound - (events.length - index), + eventDate: settledUpperBound - (events.length - index) * spacing, })); } diff --git a/core/src/rebase.spec.ts b/core/src/rebase.spec.ts index 3a44b646c..07d4f9634 100644 --- a/core/src/rebase.spec.ts +++ b/core/src/rebase.spec.ts @@ -189,6 +189,68 @@ test("load - 원본 이벤트의 id·activityId·stepId를 바이트 보존하 expect(a?.steps[1].enteredBy.eventDate).not.toEqual(originalStepDate); }); +test("load - 이벤트 수가 transitionDuration(ms)을 넘어도 재기저 date가 정적 이벤트 뒤의 창 안에 배치됩니다", () => { + // The react integration backdates static events by only 2×transitionDuration, + // leaving a window of transitionDuration ms after them. A fixed 1ms integer + // spacing would push ≥350 events out of that window, folding the earliest + // snapshot events before Initialized/ActivityRegistered — fractional + // spacing must fit any history length inside the window. + const transitionDuration = 350; + const staticBackdate = Date.now() - 2 * transitionDuration; + const staticEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration, + eventDate: staticBackdate, + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: staticBackdate + 1, + }), + ]; + const snapshotEvents = Array.from({ length: 400 }, (_, index) => + makeEvent("Pushed", { + activityId: `a${index}`, + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ); + + const store = makeCoreStore({ + initialEvents: staticEvents, + plugins: [provideSnapshotPlugin(snapshotEvents)], + }); + + const log = store.pullEvents(); + const staticDates = log + .filter((e) => e.name === "Initialized" || e.name === "ActivityRegistered") + .map((e) => e.eventDate); + const rebasedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + expect(rebasedDates).toHaveLength(400); + // Every rebased date lies inside the window: after every static event... + expect(Math.min(...rebasedDates)).toBeGreaterThan(Math.max(...staticDates)); + // ...and at or before creation time − transitionDuration (settled). + expect(Math.max(...rebasedDates)).toBeLessThanOrEqual( + Date.now() - transitionDuration, + ); + // Strictly increasing in array order. + expect( + rebasedDates.every((date, i) => i === 0 || date > rebasedDates[i - 1]), + ).toBe(true); + + // The replay itself settled: every restored activity folded to enter-done, + // with the last snapshot event on top. + const stack = store.actions.getStack(); + expect(stack.globalTransitionState).toEqual("idle"); + expect( + stack.activities.every((x) => x.transitionState === "enter-done"), + ).toBe(true); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a399"); +}); + test("load - load 후 pop이 복원 최상단을 exit 전환시키고 아래 복원 activity를 재노출합니다", () => { const { actions } = makeCoreStore({ initialEvents: config(["A", "B"], 350), From 8cc091b041ede25a40d2e823ebe9acb98aa0321a Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Wed, 8 Jul 2026 19:57:20 +0900 Subject: [PATCH 18/28] plans --- run-plan-fep-2548-comment-disposition.md | 93 ++++++++++++++++++++++++ run-plan-fep-2548-review.md | 83 +++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 run-plan-fep-2548-comment-disposition.md create mode 100644 run-plan-fep-2548-review.md diff --git a/run-plan-fep-2548-comment-disposition.md b/run-plan-fep-2548-comment-disposition.md new file mode 100644 index 000000000..dcf4dfcbc --- /dev/null +++ b/run-plan-fep-2548-comment-disposition.md @@ -0,0 +1,93 @@ +# Run Plan — PR #723 리뷰 코멘트 처분 (FEP-2548) + +## 1. 과제 스펙 · 판단 기준 + +### 과제 스펙 + +GitHub PR #723 (daangn/stackflow, base `main` ← head `feature/fep-2548`)에 달린 리뷰 코멘트를 전수 처분한다. 로컬 worktree `/Users/anakin/Programming/stackflow--feature-fep-2548-wt`(브랜치 `feature-fep-2548-wt`, upstream `feature/fep-2548`)가 PR head(`d83506ea`)와 동기 상태다. + +**처리 대상** — PR conversation의 리뷰 보고서 코멘트 2건(작성자 ENvironmentSet, 이전 리뷰 run의 산출물). GitHub review thread(인라인 코멘트)는 0건이다: + +- **C1** = comment `4912272833` — 종합 리뷰: 머지 전 필수 2건(#1 구조 검사 강화, #2 루트 문서 큐레이션) + Major 2건(#3 로드 검증 경계, #4 훅 throw 테스트) + Minor 9건(#5–#13) + Nit 5건 + 기각된 후보 3건(참고용 — 처분 불요). +- **C2** = comment `4912272962` — 설계 충실성·요구사항 검증: findings 7건(전부 minor/info) + open question 소견 2건(둘 다 "현상 유지 동의" — 처분 불요이나 확인 필요). + +**처분 단위는 개별 finding**이다(코멘트가 아니라). 각 finding에 대해: + +- **수용** → 조치 커밋 1개(유사 finding 병합 허용 — 병합 시 finding↔커밋 매핑을 처분표에 명시). cross-comment 중복이 실재한다: C1#1≈C2#2(구조 검사), C1#5≈C2#1(RB3 자구 차이), C1 nit "중복 id last-wins"≈C2#7 등. +- **기각** → 기각 사유(검증 가능한 근거 필수). +- **에스컬레이션** → 설계 변경을 요구하는 finding은 수용/기각하지 않고 사용자(인간) 판단으로 회부하고, 그 결정에 따라 처분한다. + +**공표(외부 작용)는 리뷰 게이트 최종 통과 후에만**: finding별 처분·커밋 해시를 원 코멘트별 답글 1개(C1용·C2용)로 게시하고, 커밋을 PR 브랜치에 push한다. 대상 코멘트는 issue comment라 GitHub resolve 메커니즘이 없다 — 답글 게시가 resolve를 대신한다(스레드 resolve 대상 없음). + +**후속 단계(run 범위 밖, 산출물 요건에 영향)**: 공표 후 사용자가 직접 PR에 **inline review thread를 달며 리뷰할 예정**이다. 따라서 이 run의 산출물은 그 리뷰의 입력으로 기능해야 한다 — finding↔커밋 1:1 추적성(커밋 메시지의 finding 태그), 답글의 처분표, 작은 커밋 단위가 사용자의 diff 단위 리뷰를 지원한다. 사용자 인라인 리뷰의 처리는 이 run에 포함되지 않는다. + +**참조 정본** (둘 다 branch에 동거 또는 Linear): + +- 설계 문서 `design-fep-2548-init-load-mechanism.md` — §3 공개 계약 / §4 생성 시퀀스 / §5 불변식. +- Linear FEP-2548 코멘트 `comment-3dbff893` — 요구사항 R1–R13·완료정의·비목표·설계이연. + +**주의**: worktree의 미커밋 파일 `run-plan-fep-2548-review.md`(이전 run의 로컬 산물)와 이 plan 파일은 과제 범위 밖 — 커밋에 혼입 금지. + +### 이번 작업 고유 판단 기준 (role 중립 — worker에겐 달성 목표, reviewer에겐 리뷰 포커스) + +1. **전수성** — C1·C2의 모든 finding(nit 포함)이 처분표에 등재되고 처분이 있다. 누락 0. "처분 불요" 항목(C1 기각된 후보 3건, C2 open question 소견)도 그렇게 판단한 근거와 함께 표에 남긴다. +2. **처분 정당성** — 수용/기각/에스컬레이션 분류가 설계 문서·Linear 요구사항·코드/실행 실측에 근거한다. 기각 사유는 검증 가능해야 한다(인상·추정 불가). +3. **에스컬레이션 분류 기준** — 다음을 요구하는 finding은 worker가 수용/기각을 결정하지 않고 에스컬레이션 버킷에 넣는다: ① 설계 문서의 내용 수정(특히 §3/§4/§5 — 예: C1#5/C2#1의 "RB3 구현 또는 설계 개정" 양자택일, C1#3의 설계 한계 명시·FEP-2546 등재, C1#13의 §7.1.3 승격) ② 스코프/이연 결정(예: C1#12 문서 사이트 유예의 명시적 결정 기록) ③ 그 밖에 요구사항·설계의 취지 자체를 바꾸는 변경. +4. **계약 보존** — 수용 조치는 설계 문서 §3 공개 계약·§4 생성 시퀀스·§5 불변식을 위반하지 않는다. 이 절들의 수정이 필요한 항목은 기준 3에 따라 애초에 에스컬레이션 대상이므로, "변경 자체가 해당 절 수정인 경우" 예외는 사용자 결정을 통해서만 발동된다 — worker 재량이 아니다. +5. **커밋 규율** — 수용 finding 1건 = 커밋 1개(병합 시 매핑 명시). 커밋은 path-scope로 무관 변경 혼입 금지. 이미 만들어진 커밋의 수정(amend/rebase/force-push) 금지 — 정정은 새 커밋 stack. 커밋 메시지에 트리거 finding(예: `C1#8`)을 명시한다. +6. **실측 의무** — 조치 완료 상태에서 `yarn typecheck`·`yarn build`·`yarn test`(core)를 실제 실행해 green을 확인한다. 타입·빌드·테스트 주장은 추론 단정이 아니라 실행 결과로만 성립한다. +7. **답글 정확성** — 답글 초안(C1용·C2용 각 1개)이 finding별 처분·근거·커밋 해시를 정확히 반영하고, 수행하지 않은 조치를 수행한 것처럼 서술하지 않는다. +8. **처분표 자기완결성** — 트리아지 산출물(처분표)은 조치 단계의 **별도 세션**이 추가 맥락 없이 그대로 집행·검증할 수 있어야 한다: finding별 원문 인용/위치, 처분·근거, 수용 항목의 계획된 조치와 커밋 단위(병합 매핑 포함), 에스컬레이션 항목의 쟁점·선택지 정리를 자체 포함한다. + +## 2. 워크플로우 + +**review-loop** (`~/.agents/orchestration/workflows/review-loop.md`) **2회 직렬 실행** — 단계별 독립 바인딩(사용자 지정: 트라이지 단계 세션과 조치 단계 세션 분리), 사이에 에스컬레이션 회부 단계. + +- **Loop A — 트리아지**: triage-worker가 finding 전수 처분표(판단 기준 1–4·8)를 산출 → triage-reviewer 게이트(전원 `APPROVE`까지 review-loop 절차 그대로). 이 게이트는 분류의 정당성을 검증한다 — 오처분이 구현·공표로 번지기 전에 차단하기 위함. +- **에스컬레이션 회부**: Loop A 종료 후, 에스컬레이션 버킷을 오케스트레이터가 사용자에게 판단 질문으로 회부한다(각 항목의 쟁점·선택지·트레이드오프 자기완결 서술, worker 권고가 있으면 "worker 권고"로 귀속). 사용자 결정은 Loop B 스코프에 편입된다. +- **Loop B — 조치**: impl-worker가 승인된 처분표(+사용자 결정)를 입력으로 수용 항목을 구현 — 커밋·실측·답글 초안까지가 산출물 → impl-reviewer 게이트(판단 기준 전체, 특히 5–7). 차단 시 review-loop 3·4단계(판정 처리·거부 처리) 그대로. +- **종료 조건**: Loop B에서 reviewer 전원 `APPROVE`. +- **공표**: 종료 후 게이트 통과 산출물 그대로 답글 게시 + PR 브랜치 push. 공표 내용이 게이트 통과본과 달라지면 안 된다. 공표로 run이 끝나고, 이후 사용자의 inline review 리뷰가 후속된다(1절). +- 판정 어휘·라운드 캡(각 loop 5라운드)·쟁점 중재는 review-loop 기본을 따른다. + +## 3. 세션 테이블 + +| 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | +|---|---|---|---|---|---|---| +| triage-worker-claude | worker (Loop A) | worker | Claude | Fable 5 (1M, `claude-fable-5[1m]`) · effort xhigh | C1·C2 원문에서 finding을 직접 전수 추출(요약본 의존 금지). 처분 근거는 설계 문서·Linear 요구사항·코드 실측으로 삼각측량. 산출물은 판단 기준 8의 자기완결 처분표. | design, architecture, implementation, test | +| triage-reviewer-claude | reviewer (Loop A) | reviewer | Claude | 기본 | 처분표의 분류·근거를 finding 원문·설계 문서·코드와 대조(특히 기각 사유의 검증 가능성과 에스컬레이션 경계 — 판단 기준 2·3). 자기완결성(기준 8)도 게이트 대상. | design, architecture, implementation, test | +| triage-reviewer-codex | reviewer (Loop A) | reviewer | Codex | 기본 | triage-reviewer-claude와 동일 지침 — 교차 런타임 합의 게이트. | design, architecture, implementation, test | +| impl-worker-claude | worker (Loop B) | worker | Claude | Fable 5 (1M, `claude-fable-5[1m]`) · effort xhigh | 승인된 처분표(+에스컬레이션 사용자 결정)를 집행. 조치는 finding 1건씩 편집→검증→커밋의 lockstep(다건 일괄 편집 후 사후 분할 금지). 처분표와 다른 판단이 필요해지면 임의 이탈하지 않고 에스컬레이션. | design, architecture, implementation, test | +| impl-reviewer-claude | reviewer (Loop B) | reviewer | Claude | 기본 | **§3 공개 계약·§4 생성 시퀀스·§5 불변식 검증 전담**(판단 기준 4) — 각 커밋 diff가 설계 계약을 위반하지 않는지, 계약 절 수정이 사용자 결정 없이 끼어들지 않았는지 설계 문서와 대조. | design, implementation | +| impl-reviewer-codex | reviewer (Loop B) | reviewer | Codex | 기본 | **커밋별 finding 해소 여부·실측 재현·답글 정확성 전담**(판단 기준 5–7) — 각 커밋이 대응 finding을 실제 해소하는지 diff 단위 확인, `yarn typecheck`/`build`/`test`(core) 직접 재현 실행, 답글 초안의 처분·커밋 해시 사실 대조. | implementation, test | + +- 각 loop의 reviewer 2행은 게이트 fan-out — 해당 loop 종료는 전원 `APPROVE`. Loop A는 동일 지침 합의 게이트, Loop B는 **포커스 분담 게이트**(사용자 지정: claude=계약 검증 / codex=해소·실측·답글 — 두 담당의 합집합이 Loop B 판단 기준 전체를 커버하며, 각자 자기 담당에서 차단 사유를 내면 게이트가 막힌다). +- 트리아지 단계와 조치 단계는 세션을 공유하지 않는다(사용자 지정) — 단계 간 전달물은 승인된 처분표뿐이며, 그래서 기준 8(자기완결성)이 게이트 대상이다. + +## 4. 인라인 자산 정의 + +해당 없음 — 워크플로우(review-loop)·role(worker/reviewer)·lens(design/implementation/test) 전부 자산 재사용. 2회 직렬 실행·에스컬레이션 회부는 run 한정 구성(2절)이지 신규 워크플로우가 아니다. + +## 5. 설계 메타 + +- **사용자 명시 입력** (디폴트보다 우선): + - 트리아지 단계 세션과 조치·리뷰 단계 세션 분리 → review-loop 2회 직렬, 단계별 독립 바인딩(세션 6개). + - worker role 세션 전원 **Fable 5 (1M, `claude-fable-5[1m]`) + effort xhigh** (사용자 메모리: "fable 5" 지정은 항상 1M variant). + - 공표 후 사용자 인라인 리뷰 후속 → 산출물 요건(finding↔커밋 추적성·작은 커밋)으로 1절에 반영. + - **architecture lens** 부여: Loop A 전원 + impl-worker-claude. + - **Loop B 게이트 포커스 분담**: impl-reviewer-claude = §3/§4/§5 계약 검증 전담(test 렌즈 제거), impl-reviewer-codex = 커밋별 해소 여부·실측 재현·답글 정확성 전담. +- **적용된 디폴트**: + - worker 각 단계 1명(일반 슬롯 디폴트). Loop B는 다수 finding이 같은 파일(`loadSnapshot.ts` 등)에 겹치고 커밋이 단일 스트림이어야 해 분할 이득이 없음. + - reviewer 각 단계 2명 Claude+Codex(합의 게이트 슬롯 디폴트) — 처분·커밋이 공표(외부 작용)로 이어지는 게이트라 교차 런타임 유지. Loop A는 동일 지침, Loop B는 사용자 지정 분담. + - reviewer 모델 미지정(런타임 기본). lens 자산 바인딩. + - impl-reviewer-codex의 lens에서 **design 제거**(implementation, test만 부착) — 계약 검증이 claude 전담으로 넘어간 분담에 맞춘 스킬 설계(사용자 미지정 — 이견 있으면 조정). +- **설계 근거**: + - 산출물(처분표+조치 커밋+답글)을 만들고 독립 검증 게이트를 통과시켜야 하는 과제 → review-loop. + - **단계 분할**: 처분 오류(잘못된 기각·누락된 에스컬레이션)는 공표되면 정정 비용이 큼 — 분류를 먼저 게이트하고, 에스컬레이션 항목은 사용자 결정이 선행돼야 구현 가능하므로 Loop A 직후 회부가 자연스러운 타이밍. + - **공표 후행**: 답글 게시·push는 외부 작용이라 게이트 전원 통과 후에만 — 게이트가 공표 품질의 방어선. +- **메모리 반영** (`~/.agents/orchestration/memory/` + 사용자 메모리): + - `worker-decision-fork-escalate-to-owner-not-orchestrator-pick` (seen 4) → 에스컬레이션 버킷의 사용자 회부 절차·자기완결 질문·worker 권고 귀속 규율을 2절에 내장. + - `empirical-behavioral-review-catches-input-defects-logic-review-approves` (seen 6) → 교차 런타임 합의 게이트 + reviewer 재현 실행 의무 유지. + - `typeheavy-design-impl-gate-must-compile-not-reason-tsc-beats-review-by-inspection` (seen 3) → 실측 의무(판단 기준 6)로 명문화 — 이 PR 후속 조치도 타입 헤비 계약을 만짐. + - `per-fix-commit-split-unidiff-zero-mismaps-prefer-lockstep-verify-tree-clean` (seen 2) → per-fix 커밋은 lockstep 기본(impl-worker 세부 지침)·path-scope·기존 커밋 무수정(판단 기준 5). + - 사용자 메모리 `feedback_fable5_means_1m` → worker 모델 표기를 `claude-fable-5[1m]`로 고정. diff --git a/run-plan-fep-2548-review.md b/run-plan-fep-2548-review.md new file mode 100644 index 000000000..fe9c5316e --- /dev/null +++ b/run-plan-fep-2548-review.md @@ -0,0 +1,83 @@ +# Run Plan — PR #723 (FEP-2548 create/load 구분 메커니즘) 리뷰 + +## 1. 과제 스펙 · 판단 기준 + +### 과제 스펙 + +GitHub PR #723 (`feat(core): create/load distinction mechanism for snapshot restoration`, base `main` ← head `feature/fep-2548`)를 리뷰한다. head branch가 현재 worktree(`/Users/anakin/Programming/stackflow--feature-fep-2548-wt`)에 checkout돼 있다. + +- **리뷰 범위 = PR의 `main...HEAD` diff.** 로컬 워킹트리의 미커밋 변경은 범위 밖. +- 산출물의 성격: `@stackflow/core`에 create(신규 생성) vs load(스냅샷 복원) 구분을 **순수 additive 플러그인 계약**으로 도입. 신규 공개 표면 = `StackSnapshot`/`NavigationEvent` 타입, `actions.captureSnapshot()`, 플러그인 훅 `provideSnapshot`/`onLoadError`, `SnapshotLoadError`, `onInit`의 `initializedBy` 인자. +- 핵심 참조 문서(둘 다 branch에 동거): 설계 `design-fep-2548-init-load-mechanism.md`, 요구사항 정본 Linear FEP-2548 코멘트 `comment-3dbff893`(R1–R13·완료정의·비목표·설계이연). + +### 이번 작업 고유 판단 기준 (role 중립 — reviewer에겐 리뷰 포커스) + +이 PR이 다음 둘을 **모두** 충족하는가: + +1. **설계 충실성** — `design-fep-2548-init-load-mechanism.md`의 메커니즘을 충실히 구현했는가: + - §3 공개 계약(스냅샷 형식·`captureSnapshot`·`provideSnapshot`·`onLoadError`·`initializedBy`·create 가로채기 지점) + - §4 생성 시퀀스와 재기저 규칙 RB1–RB5 + - §5 불변식 C1–C4 / N1–N2 / L1–L6 + - §8 스냅샷 형식 결정, §3.1의 "값 변환 허용·집합 가정 금지" 계약 +2. **요구사항 충족** — Linear FEP-2548 `comment-3dbff893`을 전수: + - R1–R13 각각 (소스 불문 load / 이진 분류 / 동기 load / 명시적 에러 / 공급자 1차 처리 / create 가로채기·load 비대상 / 일회성 신호 / non-breaking / 단일 스냅샷 자리 / 왕복 폐쇄 / 충실 재구성 / 복원 범위 / codec 소비자 책임) + - **완료정의**: persister 역할을 흉내낸 테스트 플러그인이 core API만으로 load 경로를 탈 수 있는가 + - **비목표**가 실수로 구현·누출되지 않았는가 + - **설계이연** 4항이 구현 메커니즘으로 실제 해소됐는가 + +### 범위·고도 화해 조항 (필수 — 오지적 방지) + +리뷰어는 아래를 준수한다. **범위 밖 항목을 결함으로 지적하면 안 된다.** + +- **범위 안(결함으로 지적)**: 설계 메커니즘 이탈, 요구사항 미충족, 공개 계약 불일치, 조용한 실패/폴백, non-breaking(R8) 위반, 근거 없는 설계 이탈, 비목표의 우발적 구현·누출. +- **범위 밖(결함 아님 — PR이 명시적으로 이연/비목표로 선언)**: + - `provideSnapshot`/`onLoadError` 훅 **자체가 throw**할 때의 동작 — 설계가 미정의로 남긴 것(PR 노트 open question 1). + - 설계 문서의 "all-popped history" 예시의 부정확성 — 메커니즘(`empty-navigation`)은 건전(PR 노트 open question 2). + - 설계 문서·run plan·glossary가 branch에 동거하는 것과 머지 전 문서 큐레이션·주석 sweep — 문서 관리 항목이지 코드 결함 아님. + - 선언된 비목표: late load / create 하위 세분화의 core 어휘화 / 구분의 지속 속성화 / react 앱 개발자 향한 신규 표면 / 스냅샷 버전 마이그레이션. +- **실측 의무(타입 헤비 계약)**: 타입·빌드·테스트 주장은 추론 단정이 아니라 `yarn typecheck`(tsc)·`yarn build`·`yarn test`(core)의 **실제 실행 결과**로 확정한다. 최소 두 리뷰어가 실측을 수행한다. + +## 2. 워크플로우 + +**review-loop** (`~/.agents/orchestration/workflows/review-loop.md`) — **run 한정 review-only 오버라이드**. + +- 산출물(PR)은 이미 작성돼 있고 저자는 run 밖(사용자 본인)이다. 따라서 이 run은 review-loop의 **리뷰 게이트 절반만** 실행한다: worker 슬롯을 바인딩하지 않고, reviewer fan-out → 판정 → 오케스트레이터 종합에서 **종료**한다. +- **종료 조건(오버라이드)**: reviewer 전원 판정 제출 + 오케스트레이터가 중복 제거·심각도 종합한 **통합 판정문**(차단 집합 = 잔존 Critical/Major, 비차단 = Minor/Advisory, 최종 APPROVE/REQUEST_CHANGES) 산출. 수정·재리뷰 라운드는 이 run의 범위 밖 — 차단 지적은 저자(사용자)에게 회부된다. +- **판정 어휘**: reviewer 카드의 출력 계약 그대로(통과=`APPROVE` / 차단=`REQUEST_CHANGES`, Critical/Major 잔존 시 차단). 심각도 분기(같은 사실을 리뷰어가 다른 심각도로 낼 때) 처리는 오케스트레이터 종합이 higher-severity를 차단 집합으로 삼고 corroboration을 중립 전달한다. +- **확장 경로(결정점)**: 저자가 리뷰에 그치지 않고 **차단 지적 수정까지** 원하면 worker 슬롯을 바인딩해 표준 review-loop(작성↔리뷰 루프)로 승격한다 — 이 경우 이 오버라이드는 해제된다. + +## 3. 세션 테이블 + +| 논리 세션명 | 슬롯 | role | 런타임 | 모델 | 세부 지침 | lens | +|---|---|---|---|---|---|---| +| spec-reviewer-claude | reviewer | reviewer | Claude | 기본 | 판단 기준 1(설계 충실성 §3–§5·RB·불변식) + 2(요구사항 R1–R13·완료정의·비목표·설계이연) 전수. 설계 문서·Linear 코멘트를 나란히 놓고 대조. | design | +| spec-reviewer-codex | reviewer | reviewer | Codex | 기본 | spec-reviewer-claude와 동일 포커스 — 사용자가 명시한 1차 rubric에 교차 런타임 합의 게이트. | design | +| correctness-reviewer-codex | reviewer | reviewer | Codex | 기본 | `loadSnapshot.ts`·`makeCoreStore.ts` 분기·`rebase` 수학(RB1–RB5 경계·퇴화 창·정밀도)·`captureSnapshot` 정규화·구조검사 깊이(payload 결손 이벤트)·중복 id 경계에 대한 적대적 버그 헌트. 타입 주장은 `yarn typecheck` 실측, 스위트는 `yarn test`(core) 실행 후 계약 커버리지 공백 식별. | implementation, test | +| ecosystem-reviewer-claude | reviewer | reviewer | Claude | 기본 | R8 non-breaking 전수 — core 외 변경 0 확인, `onInit` 소비자 6종·`overrideInitialEvents` 소비자·`StackflowActions` 생성부 무영향, 소비자 3인(persister FEP-2546·guard FEP-2521·history-sync FEP-2001) 성립 논증(§7) 검증. `yarn build` 전 패키지 + `yarn typecheck` 레포 전체 실측(무거운 빌드는 동시 경합 시 flake 유의 — 재현되면 quiescent 재실행으로 환경/제품 구분). | react, codebase-analysis | + +- fan-out 해석: 4행 모두 reviewer 슬롯의 fan-out(같은 대상=PR을 다른 포커스·다른 런타임으로 독립 리뷰). spec 포커스는 Claude+Codex 2명 합의 게이트, correctness·ecosystem은 각 1명(런타임을 교차 배치해 pool 전체가 2 Claude + 2 Codex). +- 각 reviewer는 reviewer 카드 출력 계약대로 `APPROVE`/`REQUEST_CHANGES` + 번호매긴 차단 지적(심각도·file:line·근거·요구 변경)을 낸다. + +## 4. 인라인 자산 정의 + +해당 없음 — 워크플로우(review-loop)·role(reviewer)·lens(design/implementation/test/react/codebase-analysis) 전부 자산 재사용. review-only는 run 한정 절차 오버라이드(2절)이지 신규 워크플로우가 아니다. + +## 5. 설계 메타 + +- **적용된 디폴트**: + - 워크플로우: review-loop를 **review-only로 오버라이드**(worker 미바인딩) — 산출물이 run 밖에서 이미 작성됨. → 결정점: 수정까지 원하면 worker 추가로 full loop 승격. + - reviewer 인원·구성: spec rubric에 **합의 게이트 슬롯 디폴트**(2명 Claude+Codex) 적용 + correctness(Codex)·ecosystem(Claude) 전문 커버리지 2명 = 총 4명. + - 런타임: 교차(2 Claude + 2 Codex). 모델: 미지정(런타임 기본 — 프롬프트에 모델 지정 없음). + - lens: 자산 lens 바인딩(design / implementation+test / react+codebase-analysis). +- **설계 근거**: + - 이미 존재하는 산출물의 **품질 게이트**이므로 review-loop 계열이 적합하나, in-run worker가 없어 리뷰 게이트만 실행(review-only). + - 사용자가 명시한 두 rubric(설계 충실성·요구사항)이 crown-jewel 기준이라, 여기에 **교차 런타임 합의 게이트**(Claude+Codex)를 직접 걸었다. + - PR이 **타입 헤비 플러그인 계약**(스냅샷 타입·훅·NavigationEvent union)이자 **플러그인 생태계**(loader·history-sync·devtools 등이 core 계약을 소비)를 건드리므로, correctness 리뷰어에 컴파일 실측 의무를, ecosystem 리뷰어에 참조모듈/DX 전수(소비자 non-breakage)를 각각 전담시켰다. + - 범위·고도 화해 조항으로 PR이 명시 이연한 open question·비목표를 오지적에서 제외 — round/verdict 낭비 방지. +- **메모리 반영** (`~/.agents/orchestration/memory/`): + - `empirical-behavioral-review-catches-input-defects-logic-review-approves` (seen 5) → 교차 런타임 게이트 유지 + 최소 2명 실측(빌드/테스트 실행) 의무화. + - `typeheavy-design-impl-gate-must-compile-not-reason-tsc-beats-review-by-inspection` (seen 2) → 타입 주장은 tsc/build 실측, 추론 단정 금지를 판단 기준·correctness 세부 지침에 명시. + - `plugin-ecosystem-review-needs-reference-module-and-ux-lenses` (seen 1) → ecosystem(참조모듈/DX) 리뷰어 슬롯 추가(소비자 3인 성립·전 패키지 빌드). UX 리뷰어는 이 PR이 UI 무변경(순수 core 계약)이라 제외. + - `fanout-reviewer-severity-split-corroboration-relay-blocking-set` (seen 2) → 종합 시 심각도 분기는 higher-severity를 차단 집합, corroboration 중립 전달(오케스트레이터 종합 규율). + - `planning-run-needs-mechanism-altitude-reconciliation-clause` (seen 3, 이미 `lenses/plan.md`로 자산화) → 메타패턴(고도/범위 화해)을 impl 리뷰용 **범위 화해 조항**으로 적용(plan 렌즈 자체는 impl 리뷰라 미부착). + - `fanout-empirical-reviewers-concurrent-heavy-suite-contention-flake` (seen 2) → 전 패키지 빌드 동시 실측 경합 flake 가능성을 ecosystem 세부 지침에 선제 명시(재현 시 quiescent 재실행). From b4be141ebce99131656c5a4fb26c418bc298587f Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 17:35:02 +0900 Subject: [PATCH 19/28] =?UTF-8?q?refactor(core):=20rename=20SnapshotLoadEr?= =?UTF-8?q?ror=20cause=20kinds=20to=20read=20as=20the=20snapshot=E2=86=92e?= =?UTF-8?q?vents=E2=86=92stack=20pipeline=20(review:=20error-kind=20rename?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three kinds misnamed what they detect. `incompatible-schema` suggested a version incompatibility, but the check is a catch-all for any unrecognizable snapshot shape ($schema mismatch, non-array events, an item that is not a navigation event) — now `unrecognized-snapshot`. `invalid-events` suggested a defect intrinsic to the events, but the failure is relational — the events are fine, they just don't fit the current config (e.g. they materialize an unregistered activity) — now `incompatible-events`. `empty-navigation` describes a replay that settles with zero enter-state activities, i.e. an empty stack — now `empty-stack`. Alongside the rename: - `unrecognized-snapshot` gains a required `detail: string` naming which structural check failed (its sibling `incompatible-events` already carried one); the item check includes the offending index. It is a `string` (not `unknown`) because core authors the message itself, whereas `incompatible-events` wraps arbitrary thrown values. - The cause JSDoc states `empty-stack`'s precise condition: zero enter-state activities, not an empty `activities` array — exit-done activities may remain. All three kinds are public exports but unreleased, so the rename is free now and breaking later. Co-Authored-By: Claude Fable 5 --- .../fep-2548-create-load-distinction.md | 2 +- core/src/SnapshotLoadError.ts | 29 +++++--- core/src/loadPath.spec.ts | 71 ++++++++++--------- core/src/loadSnapshot.ts | 33 ++++++--- 4 files changed, 80 insertions(+), 55 deletions(-) diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md index 8e01d6e56..9fab67eec 100644 --- a/.changeset/fep-2548-create-load-distinction.md +++ b/.changeset/fep-2548-create-load-distinction.md @@ -7,7 +7,7 @@ Distinguish how a stack is created — freshly (`create`) or restored from a sna - `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility. - `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time. - `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. -- `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"incompatible-schema"`, `"invalid-events"`, or `"empty-navigation"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. +- `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"unrecognized-snapshot"`, `"incompatible-events"`, or `"empty-stack"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. - `onInit` now receives `initializedBy: "create" | "load"` — a one-shot signal that leaves no trace on the stack state or event log. ```ts diff --git a/core/src/SnapshotLoadError.ts b/core/src/SnapshotLoadError.ts index b650dcfc9..5ef3d523b 100644 --- a/core/src/SnapshotLoadError.ts +++ b/core/src/SnapshotLoadError.ts @@ -1,17 +1,24 @@ /** - * Why a snapshot load failed, in three kinds: - * - `incompatible-schema`: the value is not a core-known v1 snapshot structure - * (`$schema` mismatch, `events` not an array, an item that is not one of the - * six navigation events, or a missing `id`/`name`). - * - `invalid-events`: the structure is valid but the event sequence is not - * valid against the current config (e.g. an event that materializes an - * unregistered activity). - * - `empty-navigation`: replay succeeded but no activity is in an enter state. + * Why a snapshot load failed, in three kinds that read as the + * snapshot → events → stack pipeline: + * - `unrecognized-snapshot`: the value is not a snapshot structure core + * recognizes — a catch-all over the structural checks (`$schema` mismatch, + * `events` not being an array, or an item that is not one of the six + * navigation events, including a missing `id`/`name`). `detail` names the + * check that failed. + * - `incompatible-events`: the structure is recognized but the event sequence + * is incompatible with the current config (e.g. it materializes an + * unregistered activity) — a relational failure against the config, not a + * defect intrinsic to the events. + * - `empty-stack`: replay succeeded but left zero activities in an enter + * state, so there is nothing to show. Note the condition is "zero + * enter-state activities", not an empty `activities` array — exit-done + * activities may remain. */ export type SnapshotLoadErrorCause = - | { kind: "incompatible-schema" } - | { kind: "invalid-events"; detail: unknown } - | { kind: "empty-navigation" }; + | { kind: "unrecognized-snapshot"; detail: string } + | { kind: "incompatible-events"; detail: unknown } + | { kind: "empty-stack" }; /** * Thrown when loading a provided snapshot fails. Routed to the providing diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index 40a2a9747..5b6eeed78 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -382,10 +382,10 @@ test("load - makeCoreStore 반환 시점에 복원된 스택이 동기적으로 }); // --------------------------------------------------------------------------- -// structure check → incompatible-schema +// structure check → unrecognized-snapshot // --------------------------------------------------------------------------- -test("load - $schema 불일치 스냅샷은 SnapshotLoadError{incompatible-schema}로 실패합니다", () => { +test("load - $schema 불일치 스냅샷은 SnapshotLoadError{unrecognized-snapshot}로 실패합니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v2", @@ -402,24 +402,26 @@ test("load - $schema 불일치 스냅샷은 SnapshotLoadError{incompatible-schem ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual( - "incompatible-schema", - ); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "$schema mismatch", + }); }); -test("load - events가 배열이 아닌 스냅샷은 incompatible-schema로 실패합니다", () => { +test("load - events가 배열이 아닌 스냅샷은 unrecognized-snapshot로 실패합니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v1", events: "nope" }), config(["A"]), ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual( - "incompatible-schema", - ); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); }); -test("load - events에 탐색 이벤트가 아닌 항목이 있으면 incompatible-schema로 실패합니다", () => { +test("load - events에 탐색 이벤트가 아닌 항목이 있으면 unrecognized-snapshot로 실패합니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v1", @@ -442,12 +444,15 @@ test("load - events에 탐색 이벤트가 아닌 항목이 있으면 incompatib ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual( - "incompatible-schema", - ); + // The offending item's position is named for diagnosis (the valid Pushed at + // index 0 passes; the non-navigation item sits at index 1). + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 1 is not a navigation event", + }); }); -test("load - events 항목에 id가 결손되면 incompatible-schema로 실패합니다", () => { +test("load - events 항목에 id가 결손되면 unrecognized-snapshot로 실패합니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v1", @@ -465,12 +470,13 @@ test("load - events 항목에 id가 결손되면 incompatible-schema로 실패 ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual( - "incompatible-schema", - ); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a navigation event", + }); }); -test("load - events 항목에 name이 결손되면 incompatible-schema로 실패합니다", () => { +test("load - events 항목에 name이 결손되면 unrecognized-snapshot로 실패합니다", () => { const caught = catchLoad( rawSnapshot({ $schema: "stackflow.snapshot.v1", @@ -488,16 +494,17 @@ test("load - events 항목에 name이 결손되면 incompatible-schema로 실패 ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual( - "incompatible-schema", - ); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a navigation event", + }); }); // --------------------------------------------------------------------------- -// registration check → invalid-events · L6 +// registration check → incompatible-events · L6 // --------------------------------------------------------------------------- -test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{invalid-events}로 실패합니다", () => { +test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{incompatible-events}로 실패합니다", () => { const caught = catchLoad( snapshot([ makeEvent("Pushed", { @@ -512,13 +519,13 @@ test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{inva expect(caught).toBeInstanceOf(SnapshotLoadError); const cause = (caught as SnapshotLoadError).cause; - expect(cause.kind).toEqual("invalid-events"); - if (cause.kind === "invalid-events") { + expect(cause.kind).toEqual("incompatible-events"); + if (cause.kind === "incompatible-events") { expect(cause.detail).toBeDefined(); } }); -test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{invalid-events}로 실패합니다", () => { +test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{incompatible-events}로 실패합니다", () => { const caught = catchLoad( snapshot([ makeEvent("Pushed", { @@ -538,7 +545,7 @@ test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{in ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual("invalid-events"); + expect((caught as SnapshotLoadError).cause.kind).toEqual("incompatible-events"); }); test("load - 등록된 activity를 물화하는 Replaced는 정상 load됩니다", () => { @@ -574,17 +581,17 @@ test("load - 등록된 activity를 물화하는 Replaced는 정상 load됩니다 }); // --------------------------------------------------------------------------- -// postcondition → empty-navigation · L3 +// postcondition → empty-stack · L3 // --------------------------------------------------------------------------- -test("load - events가 빈 스냅샷은 SnapshotLoadError{empty-navigation}로 실패합니다", () => { +test("load - events가 빈 스냅샷은 SnapshotLoadError{empty-stack}로 실패합니다", () => { const caught = catchLoad(snapshot([]), config(["A"])); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-navigation"); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); }); -test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-navigation으로 실패합니다", () => { +test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-stack으로 실패합니다", () => { // A pop with no activity to pop replays to zero activities: non-empty events // but zero enter-state activities. (Core cannot pop the root, so a // Pushed→Popped sequence never reaches zero — a pops-only history does.) @@ -598,7 +605,7 @@ test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-navi ); expect(caught).toBeInstanceOf(SnapshotLoadError); - expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-navigation"); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); }); // --------------------------------------------------------------------------- diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 10ff19c77..8f8cf8914 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -34,7 +34,7 @@ export function loadSnapshot( !registeredActivityNames.has(event.activityName) ) { throw new SnapshotLoadError({ - kind: "invalid-events", + kind: "incompatible-events", detail: `activity "${event.activityName}" is not registered in the current config`, }); } @@ -60,9 +60,9 @@ export function loadSnapshot( stack = aggregate(events, now); } catch (error) { // A structurally-valid event sequence that the replay machinery rejects - // (e.g. `validateEvents`) is an invalid-events failure, not a crash. + // (e.g. `validateEvents`) is an incompatible-events failure, not a crash. throw new SnapshotLoadError({ - kind: "invalid-events", + kind: "incompatible-events", detail: error instanceof Error ? error.message : error, }); } @@ -74,9 +74,10 @@ export function loadSnapshot( ); if (!hasEnteredActivity) { - // Replay succeeded but left no visible activity (empty events, or a history - // that pops everything). A blank screen is a silent failure — surface it. - throw new SnapshotLoadError({ kind: "empty-navigation" }); + // Replay succeeded but left zero enter-state activities (empty events, or + // a history that pops everything — exit-done activities may remain). A + // blank screen is a silent failure — surface it. + throw new SnapshotLoadError({ kind: "empty-stack" }); } return { events, stack }; @@ -84,27 +85,37 @@ export function loadSnapshot( /** * A value that is not a core-known v1 snapshot must fail loudly before any - * replay (`incompatible-schema`) instead of folding into a corrupt stack. + * replay (`unrecognized-snapshot`) instead of folding into a corrupt stack. + * `detail` names which structural check failed, for diagnosis. */ function assertSnapshotStructure(snapshot: StackSnapshot): void { if (snapshot?.$schema !== "stackflow.snapshot.v1") { - throw new SnapshotLoadError({ kind: "incompatible-schema" }); + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: "$schema mismatch", + }); } const events: unknown = snapshot.events; if (!Array.isArray(events)) { - throw new SnapshotLoadError({ kind: "incompatible-schema" }); + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); } - for (const event of events) { + for (const [index, event] of events.entries()) { if ( !event || typeof event !== "object" || typeof (event as { id?: unknown }).id !== "string" || !isNavigationEventName((event as { name?: unknown }).name) ) { - throw new SnapshotLoadError({ kind: "incompatible-schema" }); + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: `event item at index ${index} is not a navigation event`, + }); } } } From 1a74f70717604f56e4e354d090cbd0d4c002d7bb Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 17:37:32 +0900 Subject: [PATCH 20/28] refactor(core): promote onInit's initializedBy string to an initInfo record (review: initInfo record) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-shot create/load signal was a bare string (initializedBy: "create" | "load"). Promote it to a discriminated record, initInfo: { kind: "create" | "load" }, because the two shapes age differently: promoting a string to a record later is a breaking change to the hook signature, while adding fields to an existing record is additive. Per-path payloads are already latent — "create" is set both on plain creation and on recover-after-load-failure, which a consumer cannot currently tell apart but a record could expose additively (e.g. { kind: "create", recoveredFrom }), and the load side has room for provenance (e.g. { kind: "load", providedBy }). The discriminant is named kind, matching SnapshotLoadErrorCause's discriminated union. The signal is unreleased, so the rename is free now and breaking later. Co-Authored-By: Claude Fable 5 --- .changeset/fep-2548-create-load-distinction.md | 4 ++-- core/src/createPath.spec.ts | 4 ++-- core/src/{initializedBy.spec.ts => initInfo.spec.ts} | 6 +++--- core/src/interfaces/StackflowPluginHook.ts | 8 +++++--- core/src/loadPath.spec.ts | 8 ++++---- core/src/makeCoreStore.ts | 10 +++++----- core/src/persisterRoundtrip.spec.ts | 6 +++--- 7 files changed, 24 insertions(+), 22 deletions(-) rename core/src/{initializedBy.spec.ts => initInfo.spec.ts} (88%) diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md index 9fab67eec..a849e31ec 100644 --- a/.changeset/fep-2548-create-load-distinction.md +++ b/.changeset/fep-2548-create-load-distinction.md @@ -2,13 +2,13 @@ "@stackflow/core": minor --- -Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initializedBy`). +Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initInfo`). - `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility. - `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time. - `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. - `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"unrecognized-snapshot"`, `"incompatible-events"`, or `"empty-stack"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. -- `onInit` now receives `initializedBy: "create" | "load"` — a one-shot signal that leaves no trace on the stack state or event log. +- `onInit` now receives `initInfo: { kind: "create" | "load" }` — a one-shot signal that leaves no trace on the stack state or event log. It is a record rather than a bare string so per-path fields can be added later without breaking the hook signature. ```ts const persisterPlugin = ({ storage, codec }) => () => ({ diff --git a/core/src/createPath.spec.ts b/core/src/createPath.spec.ts index 7bcc1feb5..a37ca05db 100644 --- a/core/src/createPath.spec.ts +++ b/core/src/createPath.spec.ts @@ -51,7 +51,7 @@ test("create - 스냅샷 공급자가 없으면 initialEvents로 create 경로 expect(stack.globalTransitionState).toEqual("idle"); }); -test('create - provideSnapshot 전원 null이면 create 경로를 타고 initializedBy가 "create"입니다', () => { +test('create - provideSnapshot 전원 null이면 create 경로를 타고 initInfo.kind가 "create"입니다', () => { const onInit = jest.fn(); const store = makeCoreStore({ @@ -78,7 +78,7 @@ test('create - provideSnapshot 전원 null이면 create 경로를 타고 initial expect(store.actions.getStack().activities.map((a) => a.id)).toEqual(["a1"]); expect(onInit).toHaveBeenCalledTimes(1); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); test("create - overrideInitialEvents가 초기 진입을 전부 strip하면 onInitialActivityNotFound가 발화하고 빈 스택입니다", () => { diff --git a/core/src/initializedBy.spec.ts b/core/src/initInfo.spec.ts similarity index 88% rename from core/src/initializedBy.spec.ts rename to core/src/initInfo.spec.ts index 7d55bc4af..450d9f807 100644 --- a/core/src/initializedBy.spec.ts +++ b/core/src/initInfo.spec.ts @@ -55,7 +55,7 @@ const DOMAIN_EVENT_NAMES = new Set([ "Resumed", ]); -test('initializedBy - create 경로에서 onInit의 initializedBy가 "create"입니다', () => { +test('initInfo - create 경로에서 onInit의 initInfo.kind가 "create"입니다', () => { const onInit = jest.fn(); const store = makeCoreStore({ @@ -73,10 +73,10 @@ test('initializedBy - create 경로에서 onInit의 initializedBy가 "create"입 store.init(); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); -test("initializedBy - load 후 구분 신호가 Stack 상태·이벤트 로그에 남지 않고 복원 activity의 enteredBy는 원본 이벤트입니다", () => { +test("initInfo - load 후 구분 신호가 Stack 상태·이벤트 로그에 남지 않고 복원 activity의 enteredBy는 원본 이벤트입니다", () => { const store = makeCoreStore({ initialEvents: config(["A", "B"]), plugins: [ diff --git a/core/src/interfaces/StackflowPluginHook.ts b/core/src/interfaces/StackflowPluginHook.ts index 5ae0791cf..d1936f618 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -4,10 +4,12 @@ import type { StackflowActions } from "./StackflowActions"; export type StackflowPluginHook = (args: { actions: StackflowActions; /** - * Which path created this stack — `"create"` (fresh) or `"load"` (restored - * from a snapshot). A one-shot signal that leaves no trace on the stack. + * Which path created this stack — `{ kind: "create" }` (fresh) or + * `{ kind: "load" }` (restored from a snapshot). A one-shot signal that + * leaves no trace on the stack. A record rather than a bare string so + * per-path fields can be added later without breaking the hook signature. */ - initializedBy: "create" | "load"; + initInfo: { kind: "create" | "load" }; }) => void; export type StackflowPluginPreEffectHook = (args: { diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index 5b6eeed78..28c9ea1cd 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -290,7 +290,7 @@ test("load - 초기 activity 핸들러를 호출하지 않습니다", () => { // §3.6 signal on load · §4.3 timing // --------------------------------------------------------------------------- -test('load - onInit이 init()에서 정확히 1회 initializedBy "load"로 발화합니다', () => { +test('load - onInit이 init()에서 정확히 1회 initInfo { kind: "load" }로 발화합니다', () => { const onInit = jest.fn(); const store = makeCoreStore({ @@ -316,7 +316,7 @@ test('load - onInit이 init()에서 정확히 1회 initializedBy "load"로 발 store.init(); expect(onInit).toHaveBeenCalledTimes(1); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("load"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "load" }); }); test("load - 재생 중 post-effect 훅(onPushed·onReplaced·onChanged)이 발화하지 않습니다", () => { @@ -713,7 +713,7 @@ test('load - 실패 시 onLoadError가 {recover:"create"}를 반환하면 throw expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ "home1", ]); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial-activity 핸들러를 포함한 create 파이프라인을 온전히 태웁니다', () => { @@ -769,7 +769,7 @@ test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial- expect(onInitialActivityIgnored.mock.calls[0][0]).toMatchObject([ { name: "Pushed", activityId: "redirect1" }, ]); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); test("load - recover:create 재개 시 provideSnapshot을 재폴링하지 않습니다", () => { diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 850910886..7318feb00 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -122,7 +122,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { ); } - let initializedBy: "create" | "load"; + let initInfo: { kind: "create" | "load" }; let stackValue: Stack; if (suppliedSnapshots.length === 1) { @@ -132,7 +132,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { const loaded = loadSnapshot(snapshot, initialRemainingEvents); events.value = loaded.events; stackValue = loaded.stack; - initializedBy = "load"; + initInfo = { kind: "load" }; } catch (error) { if (!(error instanceof SnapshotLoadError)) { throw error; @@ -151,11 +151,11 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { } stackValue = createStack(); - initializedBy = "create"; + initInfo = { kind: "create" }; } } else { stackValue = createStack(); - initializedBy = "create"; + initInfo = { kind: "create" }; } const stack = { @@ -246,7 +246,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { pluginInstances.forEach((pluginInstance) => { pluginInstance.onInit?.({ actions, - initializedBy, + initInfo, }); }); }), diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts index 7e375268b..11ef6fa66 100644 --- a/core/src/persisterRoundtrip.spec.ts +++ b/core/src/persisterRoundtrip.spec.ts @@ -102,7 +102,7 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov expect(article?.transitionState).toEqual("enter-done"); expect(article?.isTop).toBe(true); expect((home?.zIndex ?? -1) < (article?.zIndex ?? -1)).toBe(true); - expect(onInit2.mock.calls[0][0].initializedBy).toEqual("load"); + expect(onInit2.mock.calls[0][0].initInfo).toEqual({ kind: "load" }); }); test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 recover:create로 초기 화면 기동합니다", () => { @@ -140,7 +140,7 @@ test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 recover ]); // The corrupt snapshot was discarded by the supplier. expect(memory.value).toBeNull(); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); test("persister 왕복 - 디코드 불가한 보존물(잘린 write)은 공급자가 스스로 폐기하고 create로 기동합니다", () => { @@ -169,5 +169,5 @@ test("persister 왕복 - 디코드 불가한 보존물(잘린 write)은 공급 ]); // The undecodable stored value was discarded by the supplier. expect(memory.value).toBeNull(); - expect(onInit.mock.calls[0][0].initializedBy).toEqual("create"); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); From c0f3d320c3453f02dbc9472b54894d6cb21edc18 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 17:56:01 +0900 Subject: [PATCH 21/28] feat(core): run the overrideInitialEvents chain on the load path with an initInfo signal (review: load interception) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activities restored from a snapshot never met an interception point: the replay bypassed both overrideInitialEvents (skipped on load) and onBeforePush (replay is not the action pipeline), so a consumer like plugin-loader could not attach loaderData to restored activities — "an entered activity runs its loader" broke exactly on the load path. Run the plugins' overrideInitialEvents chain on load too: - The hook signature widens to NavigationEvent[] in and out and gains initInfo: { kind: "create" | "load" } — the same one-shot record onInit receives (StackInitInfo, now a shared exported type). On load the chain receives the snapshot's full replay sequence and its return is adopted as the replay sequence. - The chain runs after the structure check (hooks never see an unrecognizable value) and before everything else, so the registration check, rebase, replay, and enter-state postcondition all apply to the sequence that actually replays. A reshaped return that fails validation surfaces as a SnapshotLoadError routed to the snapshot provider; a throwing hook is a plugin bug, not a snapshot defect, and propagates raw. - Load non-interception is no longer structural — it is each consumer's responsibility. A plugin with no load policy must return initialEvents unchanged, and plugins that fabricate initial events (history-sync's URL interpretation) or write activityContext from creation-scoped input (plugin-loader's initialLoaderData branch) must guard on initInfo.kind === "load" in their own packages before any snapshot provider ships alongside them — required follow-ups within the same unreleased window as this mechanism. - MakeCoreStoreOptions' onInitialActivityIgnored handler parameter widens with the chain's return type, and the history-sync spec's hand-built hook-argument call sites add the now-required initInfo. Co-Authored-By: Claude Fable 5 --- .../fep-2548-create-load-distinction.md | 5 +- core/src/createPath.spec.ts | 39 ++- core/src/interfaces/StackflowPlugin.ts | 25 +- core/src/interfaces/StackflowPluginHook.ts | 17 +- core/src/loadPath.spec.ts | 294 +++++++++++++++++- core/src/loadSnapshot.ts | 16 +- core/src/makeCoreStore.ts | 46 ++- .../src/historySyncPlugin.spec.ts | 9 +- 8 files changed, 402 insertions(+), 49 deletions(-) diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md index a849e31ec..a0f65f7b4 100644 --- a/.changeset/fep-2548-create-load-distinction.md +++ b/.changeset/fep-2548-create-load-distinction.md @@ -2,13 +2,14 @@ "@stackflow/core": minor --- -Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initInfo`). +Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initInfo`), and `overrideInitialEvents` implementations that explicitly annotate their parameter with the previous `(PushedEvent | StepPushedEvent)[]` shape must widen it to `NavigationEvent[]` (implementations with inferred parameters are unaffected). - `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility. - `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time. - `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. - `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"unrecognized-snapshot"`, `"incompatible-events"`, or `"empty-stack"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ recover: "create" }` resumes the create path; returning nothing (or having no handler) throws the error out of `makeCoreStore`. - `onInit` now receives `initInfo: { kind: "create" | "load" }` — a one-shot signal that leaves no trace on the stack state or event log. It is a record rather than a bare string so per-path fields can be added later without breaking the hook signature. +- `overrideInitialEvents` now runs on the load path too, and its signature widens to `NavigationEvent[]` in and out, with a new `initInfo` argument (the same record `onInit` receives). On create it keeps deciding the initial entries, as before. On load it receives the provided snapshot's full replay sequence, and its return is adopted as the replay sequence — re-dated in array order so the restored stack settles, then run through the same load validation as the snapshot itself, so a failing return surfaces as a `SnapshotLoadError` to the provider. Reshaping the sequence reshapes the reconstructed navigation history: a plugin with no load policy must return `initialEvents` unchanged, and plugins written before this signal that fabricate initial events (e.g. from a URL) should early-return on `initInfo.kind === "load"`. ```ts const persisterPlugin = ({ storage, codec }) => () => ({ @@ -27,4 +28,4 @@ const persisterPlugin = ({ storage, codec }) => () => ({ }); ``` -A load reconstructs the stack by replaying the snapshot's navigation events through the existing aggregate machinery: it re-derives static information (transition duration, the registered-activity set) from the current config, re-dates the events so the restored stack settles synchronously, and preserves each event's `id`/`activityId`/`stepId` byte-for-byte. No new domain events or stack state properties are introduced. +A load reconstructs the stack by replaying the snapshot's navigation events — as passed through the plugins' `overrideInitialEvents` chain — through the existing aggregate machinery: it re-derives static information (transition duration, the registered-activity set) from the current config, re-dates the events so the restored stack settles synchronously, and preserves each event's `id`/`activityId`/`stepId` byte-for-byte. No new domain events or stack state properties are introduced. diff --git a/core/src/createPath.spec.ts b/core/src/createPath.spec.ts index a37ca05db..b19c1ab9f 100644 --- a/core/src/createPath.spec.ts +++ b/core/src/createPath.spec.ts @@ -1,8 +1,8 @@ import type { PushedEvent, StepPushedEvent } from "./event-types"; import { makeEvent } from "./event-utils"; -import type { StackflowPlugin } from "./interfaces"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; import { makeCoreStore } from "./makeCoreStore"; -import type { StackSnapshot } from "./StackSnapshot"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; const SECOND = 1000; const MINUTE = 60 * SECOND; @@ -81,6 +81,41 @@ test('create - provideSnapshot 전원 null이면 create 경로를 타고 initInf expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); }); +test('create - overrideInitialEvents가 onInit과 동일한 형태의 initInfo { kind: "create" }를 전달받습니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: NavigationEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => args.initialEvents, + ); + + makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [() => ({ key: "observer", overrideInitialEvents })], + }); + + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + expect(overrideInitialEvents.mock.calls[0][0].initInfo).toEqual({ + kind: "create", + }); +}); + test("create - overrideInitialEvents가 초기 진입을 전부 strip하면 onInitialActivityNotFound가 발화하고 빈 스택입니다", () => { const onInitialActivityNotFound = jest.fn(); diff --git a/core/src/interfaces/StackflowPlugin.ts b/core/src/interfaces/StackflowPlugin.ts index 4a1b3a98a..188d599b7 100644 --- a/core/src/interfaces/StackflowPlugin.ts +++ b/core/src/interfaces/StackflowPlugin.ts @@ -10,11 +10,12 @@ import type { } from "../event-types"; import type { BaseDomainEvent } from "../event-types/_base"; import type { SnapshotLoadError } from "../SnapshotLoadError"; -import type { StackSnapshot } from "../StackSnapshot"; +import type { NavigationEvent, StackSnapshot } from "../StackSnapshot"; import type { StackflowPluginHook, StackflowPluginPostEffectHook, StackflowPluginPreEffectHook, + StackInitInfo, } from "./StackflowPluginHook"; export type StackflowPlugin = () => { @@ -130,12 +131,28 @@ export type StackflowPlugin = () => { onChanged?: StackflowPluginPostEffectHook<"%SOMETHING_CHANGED%">; /** - * Specifies the first `PushedEvent`, `StepPushedEvent` (Overrides the `initialActivity` option specified in the `stackflow()` function) + * Intercept the navigation-event sequence a stack is built from. Chained + * across plugins in array order — each plugin receives the previous one's + * return. `initInfo` says which path is running, in the same record shape + * `onInit` receives: + * - `{ kind: "create" }`: `initialEvents` holds the initial entry events + * (`PushedEvent`/`StepPushedEvent`, from the `initialActivity` option or + * earlier plugins). The return decides the initial entries. + * - `{ kind: "load" }`: `initialEvents` holds the provided snapshot's full + * replay sequence (structure-validated, original field values). The + * return is adopted as the replay sequence — re-dated in array order so + * the restored stack settles, then run through the same load validation + * as the snapshot itself (activity registration, replay, at least one + * enter-state activity), so a failing return surfaces as a + * `SnapshotLoadError` to the snapshot provider. Reshaping the sequence + * reshapes the reconstructed navigation history — a plugin with no load + * policy must return `initialEvents` unchanged. */ overrideInitialEvents?: (args: { - initialEvents: (PushedEvent | StepPushedEvent)[]; + initialEvents: NavigationEvent[]; initialContext: any; - }) => (PushedEvent | StepPushedEvent)[]; + initInfo: StackInitInfo; + }) => NavigationEvent[]; /** * Called synchronously at stack creation time to provide a snapshot to load diff --git a/core/src/interfaces/StackflowPluginHook.ts b/core/src/interfaces/StackflowPluginHook.ts index d1936f618..d45504959 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -1,15 +1,18 @@ import type { Effect } from "../Effect"; import type { StackflowActions } from "./StackflowActions"; +/** + * Which path created this stack — `{ kind: "create" }` (fresh) or + * `{ kind: "load" }` (restored from a snapshot). A one-shot signal that + * leaves no trace on the stack. A record rather than a bare string so + * per-path fields can be added later without breaking hook signatures. + * `onInit` and `overrideInitialEvents` receive the signal in this same shape. + */ +export type StackInitInfo = { kind: "create" | "load" }; + export type StackflowPluginHook = (args: { actions: StackflowActions; - /** - * Which path created this stack — `{ kind: "create" }` (fresh) or - * `{ kind: "load" }` (restored from a snapshot). A one-shot signal that - * leaves no trace on the stack. A record rather than a bare string so - * per-path fields can be added later without breaking the hook signature. - */ - initInfo: { kind: "create" | "load" }; + initInfo: StackInitInfo; }) => void; export type StackflowPluginPreEffectHook = (args: { diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts index 28c9ea1cd..ec205d057 100644 --- a/core/src/loadPath.spec.ts +++ b/core/src/loadPath.spec.ts @@ -1,10 +1,6 @@ -import type { - DomainEvent, - PushedEvent, - StepPushedEvent, -} from "./event-types"; +import type { DomainEvent } from "./event-types"; import { makeEvent } from "./event-utils"; -import type { StackflowPlugin } from "./interfaces"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; import { makeCoreStore } from "./makeCoreStore"; import { SnapshotLoadError } from "./SnapshotLoadError"; import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; @@ -227,32 +223,296 @@ test("load - 현행 config에서 transitionDuration·등록집합을 재파생 }); // --------------------------------------------------------------------------- -// L1 · no interception +// load interception — the override chain runs on the replay sequence // --------------------------------------------------------------------------- -test("load - overrideInitialEvents 체인을 호출하지 않습니다", () => { - const overrideInitialEvents = jest.fn(() => []); +test('load - overrideInitialEvents 체인이 스냅샷 재생열 전체와 initInfo { kind: "load" }로 호출됩니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: NavigationEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => args.initialEvents, + ); const { actions } = makeCoreStore({ - initialEvents: config(["A"]), + initialEvents: config(["A", "B"]), plugins: [ provideSnapshotPlugin( snapshot([ makeEvent("Pushed", { + id: "e1", activityId: "a1", activityName: "A", activityParams: {}, eventDate: enoughPastTime(), }), + makeEvent("Pushed", { + id: "e2", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Popped", { + id: "e3", + eventDate: enoughPastTime(), + }), ]), { overrideInitialEvents }, ), ], }); - expect(overrideInitialEvents).toHaveBeenCalledTimes(0); - // The strip attempt had no effect — the restored activity survives. - expect(actions.getStack().activities.some((x) => x.id === "a1")).toBe(true); + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + const args = overrideInitialEvents.mock.calls[0][0]; + // The full replay sequence flows through — including events (Popped) that + // are not initial-entry vocabulary — with original ids. + expect(args.initialEvents.map((e) => ({ name: e.name, id: e.id }))).toEqual([ + { name: "Pushed", id: "e1" }, + { name: "Pushed", id: "e2" }, + { name: "Popped", id: "e3" }, + ]); + // The same one-shot record shape onInit receives. + expect(args.initInfo).toEqual({ kind: "load" }); + // An identity return reconstructs the default faithful result: the Popped + // still applies, so a1 is back on top. + const top = actions.getStack().activities.find((x) => x.isTop); + expect(top?.id).toEqual("a1"); +}); + +test("load - 체인의 반환이 재생열로 채택됩니다 — 이벤트를 걸러내면 탐색 기록이 그에 맞게 재구성됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter((e) => e.name !== "Popped"), + }, + ), + ], + }); + + // With the Popped dropped from the replay sequence, b1 is alive and on top. + const b = actions.getStack().activities.find((x) => x.id === "b1"); + expect(b?.transitionState).toEqual("enter-done"); + expect(b?.isTop).toBe(true); +}); + +test("load - 체인이 추가한 이벤트도 배열 순서대로 재기저되어 정착 상태로 복원됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "C"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => [ + ...initialEvents, + // Appended with a fresh (now) eventDate — the rebase must still + // settle it inside the window, ordered after the snapshot events. + makeEvent("Pushed", { + activityId: "c1", + activityName: "C", + activityParams: {}, + }), + ], + }, + ), + ], + }); + + const stack = actions.getStack(); + const c = stack.activities.find((x) => x.id === "c1"); + expect(c?.transitionState).toEqual("enter-done"); + expect(c?.isTop).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - 낡은 스냅샷을 체인이 수선하면 검증은 수선된 재생열에 적용되어 load가 성공합니다", () => { + // The snapshot materializes unregistered "B" — on its own this fails as + // incompatible-events. A plugin that strips the dead activity's events + // repairs the sequence: validation applies to what actually replays. + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter( + (e) => !("activityName" in e && e.activityName === "B"), + ), + }, + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + expect(a?.transitionState).toEqual("enter-done"); + expect(a?.isTop).toBe(true); +}); + +test("load - 체인 반환이 미등록 activity를 물화하면 incompatible-events로 실패합니다", () => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => [ + ...initialEvents, + makeEvent("Pushed", { + activityId: "x1", + activityName: "X", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-events", + ); +}); + +test("load - 체인이 재생열을 비우면 empty-stack으로 실패하고 공급자의 onLoadError로 라우팅됩니다", () => { + const onLoadError = jest.fn( + (_args: { error: SnapshotLoadError; initialContext: any }) => ({ + recover: "create" as const, + }), + ); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home", "A"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onLoadError }, + ), + () => ({ + key: "emptier", + // A load-only policy — the create run after recovery passes through. + overrideInitialEvents: ({ initialEvents, initInfo }) => + initInfo.kind === "load" ? [] : initialEvents, + }), + ], + }); + + expect(onLoadError).toHaveBeenCalledTimes(1); + expect(onLoadError.mock.calls[0][0].error.cause.kind).toEqual("empty-stack"); + // Recovery resumed the create path with the option's initial entry. + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); +}); + +test("load - 체인 훅이 throw하면 그 에러가 그대로 전파되고 onLoadError는 호출되지 않습니다", () => { + // Characterization, not contract: a throwing hook is a plugin bug, not a + // snapshot defect — it is not dressed up as a SnapshotLoadError nor routed + // to the provider. Same rationale as the provideSnapshot/onLoadError throw + // pins below. + const hookFailure = new Error("override hook failed"); + const onLoadError = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onLoadError }, + ), + () => ({ + key: "thrower", + overrideInitialEvents: () => { + throw hookFailure; + }, + }), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(hookFailure); + expect(onLoadError).toHaveBeenCalledTimes(0); }); test("load - 초기 activity 핸들러를 호출하지 않습니다", () => { @@ -721,8 +981,9 @@ test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial- const onInitialActivityIgnored = jest.fn(); const overrideInitialEvents = jest.fn( (_args: { - initialEvents: (PushedEvent | StepPushedEvent)[]; + initialEvents: NavigationEvent[]; initialContext: any; + initInfo: StackInitInfo; }) => [ makeEvent("Pushed", { activityId: "redirect1", @@ -755,10 +1016,15 @@ test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial- store.init(); // The chain ran, over the option's initial events (not snapshot leftovers). + // It ran only once: the load attempt failed at the structure check, before + // the chain — so this single call is the recovery's create run. expect(overrideInitialEvents).toHaveBeenCalledTimes(1); expect(overrideInitialEvents.mock.calls[0][0].initialEvents).toMatchObject([ { name: "Pushed", activityId: "home1" }, ]); + expect(overrideInitialEvents.mock.calls[0][0].initInfo).toEqual({ + kind: "create", + }); // The chain's substitution is what the stack is built from... expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ "redirect1", diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 8f8cf8914..499e85943 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -10,13 +10,25 @@ import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; * events through the existing aggregate machinery. Static information * (transitionDuration, the registered-activity set) is re-derived from the * current config's static events, never from the snapshot. + * + * `overrideNavigationEvents` is the plugins' `overrideInitialEvents` chain: + * its return is adopted as the replay sequence. It runs after the structure + * check (hooks never see an unrecognizable value) and before every other + * step, so validation and rebasing apply to the sequence that actually + * replays — whether it came straight from the snapshot or was reshaped by a + * plugin. An error thrown by the chain itself is a plugin bug, not a snapshot + * defect, and propagates raw instead of becoming a `SnapshotLoadError`. */ export function loadSnapshot( snapshot: StackSnapshot, staticEvents: DomainEvent[], + overrideNavigationEvents?: (events: NavigationEvent[]) => NavigationEvent[], ): { events: DomainEvent[]; stack: Stack } { assertSnapshotStructure(snapshot); + const navigationEvents = + overrideNavigationEvents?.(snapshot.events) ?? snapshot.events; + const registeredActivityNames = new Set( filterEvents(staticEvents, "ActivityRegistered").map( (event) => event.activityName, @@ -28,7 +40,7 @@ export function loadSnapshot( // only Pushed — this also covers Replaced, since Replaced materializes an // activity by name too. An unregistered name here means the config changed // out from under a stale snapshot: fail loudly rather than resurrect it. - for (const event of snapshot.events) { + for (const event of navigationEvents) { if ( (event.name === "Pushed" || event.name === "Replaced") && !registeredActivityNames.has(event.activityName) @@ -44,7 +56,7 @@ export function loadSnapshot( filterEvents(staticEvents, "Initialized")[0]?.transitionDuration ?? 0; const now = Date.now(); - const rebasedEvents = rebaseNavigationEvents(snapshot.events, { + const rebasedEvents = rebaseNavigationEvents(navigationEvents, { now, transitionDuration, latestStaticEventDate: staticEvents.reduce( diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 7318feb00..2fab1e172 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,13 +1,17 @@ import isEqual from "react-fast-compare"; import { aggregate } from "./aggregate"; -import type { DomainEvent, PushedEvent, StepPushedEvent } from "./event-types"; +import type { DomainEvent } from "./event-types"; import { isNavigationEvent, makeEvent } from "./event-utils"; -import type { StackflowActions, StackflowPlugin } from "./interfaces"; +import type { + StackflowActions, + StackflowPlugin, + StackInitInfo, +} from "./interfaces"; import { loadSnapshot } from "./loadSnapshot"; import { produceEffects } from "./produceEffects"; import { SnapshotLoadError } from "./SnapshotLoadError"; import type { Stack } from "./Stack"; -import type { StackSnapshot } from "./StackSnapshot"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; import { divideBy, once, uniqBy } from "./utils"; import { makeActions } from "./utils/makeActions"; import { triggerPostEffectHooks } from "./utils/triggerPostEffectHooks"; @@ -23,7 +27,7 @@ export type MakeCoreStoreOptions = { plugins: StackflowPlugin[]; handlers?: { onInitialActivityIgnored?: ( - initialPushedEvents: (PushedEvent | StepPushedEvent)[], + initialPushedEvents: NavigationEvent[], ) => void; onInitialActivityNotFound?: () => void; }; @@ -63,18 +67,32 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { value: [], }; + // One chain for both paths: each plugin sees the previous plugin's return, + // with initInfo telling which path is running. On load the return is the + // replay sequence, so it goes back through the load validation afterwards. + const overrideInitialEvents = ( + initialEvents: NavigationEvent[], + initInfo: StackInitInfo, + ): NavigationEvent[] => + pluginInstances.reduce( + (events, pluginInstance) => + pluginInstance.overrideInitialEvents?.({ + initialEvents: events, + initialContext, + initInfo, + }) ?? events, + initialEvents, + ); + /** - * The create path is unchanged from the pre-snapshot behavior — a store - * with no snapshot provider is observably identical to before. + * The create path keeps the pre-snapshot pipeline — with no snapshot + * provider the store is built exactly as before; the only addition the + * chain sees is the initInfo signal. */ const createStack = (): Stack => { - const initialPushedEvents = pluginInstances.reduce( - (initialEvents, pluginInstance) => - pluginInstance.overrideInitialEvents?.({ - initialEvents, - initialContext, - }) ?? initialEvents, + const initialPushedEvents = overrideInitialEvents( initialPushedEventsByOption, + { kind: "create" }, ); const isInitialActivityIgnored = @@ -129,7 +147,9 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { const { pluginInstance, snapshot } = suppliedSnapshots[0]; try { - const loaded = loadSnapshot(snapshot, initialRemainingEvents); + const loaded = loadSnapshot(snapshot, initialRemainingEvents, (events) => + overrideInitialEvents(events, { kind: "load" }), + ); events.value = loaded.events; stackValue = loaded.stack; initInfo = { kind: "load" }; diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 66a1f6b03..f30e6c565 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -1,9 +1,8 @@ import type { CoreStore, - PushedEvent, + NavigationEvent, Stack, StackflowPlugin, - StepPushedEvent, } from "@stackflow/core"; import { makeCoreStore, makeEvent } from "@stackflow/core"; import type { Location, MemoryHistory } from "history"; @@ -70,13 +69,12 @@ const stackflow = ({ * `@stackflow/react`에서 복사됨 */ const pluginInstances = plugins.map((plugin) => plugin()); - const initialPushedEvents = pluginInstances.reduce< - (PushedEvent | StepPushedEvent)[] - >( + const initialPushedEvents = pluginInstances.reduce( (initialEvents, pluginInstance) => pluginInstance.overrideInitialEvents?.({ initialEvents, initialContext: {}, + initInfo: { kind: "create" }, }) ?? initialEvents, [], ); @@ -214,6 +212,7 @@ describe("historySyncPlugin", () => { pluginInstance.overrideInitialEvents?.({ initialEvents: [], initialContext: {}, + initInfo: { kind: "create" }, }); expect(fallbackActivity).toHaveBeenCalledTimes(1); From 1ff0b48b2f1ac36b0dead9d3210f49f5d45a98ce Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 23:40:11 +0900 Subject: [PATCH 22/28] refactor(core): drop the dead setPrototypeOf workaround in SnapshotLoadError (review: dead prototype workaround) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object.setPrototypeOf(this, SnapshotLoadError.prototype) restores the prototype chain that breaks only when `class extends Error` is down-level transpiled to ES5. This package never targets that low: the JS build (esbuild) is es2015 and the type build (tsc) is ESNext, both of which emit a native `class` whose native `extends Error` keeps the prototype chain — so `instanceof SnapshotLoadError` (makeCoreStore's load recovery branches) already holds without it. The workaround guards against an emission mode this build never produces. Co-Authored-By: Claude Fable 5 --- core/src/SnapshotLoadError.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/src/SnapshotLoadError.ts b/core/src/SnapshotLoadError.ts index 5ef3d523b..6965c6707 100644 --- a/core/src/SnapshotLoadError.ts +++ b/core/src/SnapshotLoadError.ts @@ -32,9 +32,5 @@ export class SnapshotLoadError extends Error { super(message ?? `failed to load snapshot: ${cause.kind}`); this.name = "SnapshotLoadError"; this.cause = cause; - - // Restore the prototype chain so `instanceof` holds after down-level - // transpilation of a built-in subclass. - Object.setPrototypeOf(this, SnapshotLoadError.prototype); } } From 7929873c3521c42d6100a843c953282768df5633 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 23:42:17 +0900 Subject: [PATCH 23/28] refactor(core): fold the load registration check into validateEvents (review: unify registration check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadSnapshot ran its own Pushed/Replaced registration check before replay. The Pushed half was already redundant — aggregate calls validateEvents, whose throw loadSnapshot wraps into incompatible-events — and validateEvents only omitted Replaced, though Replaced materializes an activity by name exactly as Pushed does. Add the Replaced arm to validateEvents (correcting that asymmetry at the source) and delete loadSnapshot's dedicated block. rebase touches only eventDate, and validateEvents is order-independent, so moving the check from pre-rebase to the post-rebase aggregate leaves the result identical; both routes surface as incompatible-events, so the observable failure is unchanged. The Replaced check is a missing-check correction, not a contract change: config-first replace only targets registered names, so it does not fire on the live path. Co-Authored-By: Claude Fable 5 --- core/src/event-utils/validateEvents.spec.ts | 18 ++++++++++++++++ core/src/event-utils/validateEvents.ts | 10 +++++++-- core/src/loadSnapshot.ts | 23 --------------------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/core/src/event-utils/validateEvents.spec.ts b/core/src/event-utils/validateEvents.spec.ts index f611ecf4a..4ab039cb8 100644 --- a/core/src/event-utils/validateEvents.spec.ts +++ b/core/src/event-utils/validateEvents.spec.ts @@ -48,3 +48,21 @@ test("validateEvents - 푸시했는데 해당 액티비티가 없는 경우 thro ]); }).toThrow(); }); + +test("validateEvents - Replace했는데 해당 액티비티가 없는 경우 throw 합니다", () => { + expect(() => { + validateEvents([ + initializedEvent({ + transitionDuration: 300, + }), + registeredEvent({ + activityName: "home", + }), + makeEvent("Replaced", { + activityId: "a1", + activityName: "sample", + activityParams: {}, + }), + ]); + }).toThrow(); +}); diff --git a/core/src/event-utils/validateEvents.ts b/core/src/event-utils/validateEvents.ts index e86243cb1..da04e96de 100644 --- a/core/src/event-utils/validateEvents.ts +++ b/core/src/event-utils/validateEvents.ts @@ -18,9 +18,15 @@ export function validateEvents(events: DomainEvent[]) { activityRegisteredEvents.map((e) => e.activityName), ); - const pushedEvents = filterEvents(events, "Pushed"); + // Both Pushed and Replaced materialize an activity by name, so both must + // name a registered activity — checking only Pushed left Replaced an + // asymmetric gap. + const materializingEvents = [ + ...filterEvents(events, "Pushed"), + ...filterEvents(events, "Replaced"), + ]; - if (pushedEvents.some((e) => !registeredActivityNames.has(e.activityName))) { + if (materializingEvents.some((e) => !registeredActivityNames.has(e.activityName))) { throw new Error("the corresponding activity does not exist"); } } diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 499e85943..3795968e4 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -29,29 +29,6 @@ export function loadSnapshot( const navigationEvents = overrideNavigationEvents?.(snapshot.events) ?? snapshot.events; - const registeredActivityNames = new Set( - filterEvents(staticEvents, "ActivityRegistered").map( - (event) => event.activityName, - ), - ); - - // Registration check (L6). The current config is the source of truth for - // which activities exist. Unlike the runtime `validateEvents` — which checks - // only Pushed — this also covers Replaced, since Replaced materializes an - // activity by name too. An unregistered name here means the config changed - // out from under a stale snapshot: fail loudly rather than resurrect it. - for (const event of navigationEvents) { - if ( - (event.name === "Pushed" || event.name === "Replaced") && - !registeredActivityNames.has(event.activityName) - ) { - throw new SnapshotLoadError({ - kind: "incompatible-events", - detail: `activity "${event.activityName}" is not registered in the current config`, - }); - } - } - const transitionDuration = filterEvents(staticEvents, "Initialized")[0]?.transitionDuration ?? 0; From 7f2c7b296f4d9a11ab9b00380a65316695336c7b Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 23:45:37 +0900 Subject: [PATCH 24/28] refactor(core): rebase static events with the navigation events and drop the window math (review: rebase static together) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load rebase carried a latestStaticEventDate reduce, a static→settled window, fractional spacing, and a degenerate-window fallback for one reason: the self-imposed rule that static events (Initialized, ActivityRegistered) keep their original eventDate. But static events are navigation-inert — aggregate reads their eventDate only as a sort key, transitionDuration is a payload field rather than that event's date, and captureSnapshot never persists static events — so they need only stay ordered before the navigation events. Re-date static and navigation events in a single backward walk from now − transitionDuration, one step per event, and the window machinery falls away. Static-before-navigation becomes structural at any history length. It also fixes a latent td=0 defect: the react integration backdates static by 2·td, so at td=0 static and freshly re-dated navigation collided and the degenerate fallback sorted navigation ahead of static — surviving only because the reducer tolerates event order. The shared re-dating removes that reliance. Co-Authored-By: Claude Fable 5 --- core/src/loadSnapshot.ts | 48 +++++++++++--------------- core/src/rebase.spec.ts | 73 ++++++++++++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts index 3795968e4..3d489373a 100644 --- a/core/src/loadSnapshot.ts +++ b/core/src/loadSnapshot.ts @@ -33,17 +33,11 @@ export function loadSnapshot( filterEvents(staticEvents, "Initialized")[0]?.transitionDuration ?? 0; const now = Date.now(); - const rebasedEvents = rebaseNavigationEvents(navigationEvents, { + const events = rebaseEvents([...staticEvents, ...navigationEvents], { now, transitionDuration, - latestStaticEventDate: staticEvents.reduce( - (latest, event) => Math.max(latest, event.eventDate), - Number.NEGATIVE_INFINITY, - ), }); - const events = [...staticEvents, ...rebasedEvents]; - let stack: Stack; try { stack = aggregate(events, now); @@ -110,36 +104,32 @@ function assertSnapshotStructure(snapshot: StackSnapshot): void { } /** - * Re-date snapshot events so replay settles deterministically (RB1–RB5): - * assign strictly increasing dates in array order (array order is the replay - * order), placed inside the window after the static events and far enough in - * the past (`≤ now − transitionDuration`) that every reducer folds to a - * settled state. Fractional spacing fits any number of events inside the - * window, so snapshot events fold after the static events regardless of - * history length. Every other field is preserved byte-for-byte - * (id/activityId/stepId included). Basing the new dates on capture order - * rather than the original values keeps a clock skew between the capture and - * load sessions from disturbing intra-snapshot order, and makes post-load - * navigation (dispatched at the current time) always sort after the restored - * events. + * Re-date the load events so replay settles deterministically: assign strictly + * increasing dates in array order (array order is the replay order), every one + * at or before `now − transitionDuration` so every reducer folds to a settled + * state. Static events lead the array, so this single backward walk keeps them + * ahead of the navigation events without dating them specially — static events + * are navigation-inert (aggregate reads their `eventDate` only as a sort key, + * and a re-captured snapshot never persists them), so they need only stay + * ordered before navigation, which the shared re-dating guarantees at any + * `transitionDuration` (td=0 included, where static and navigation would + * otherwise share a timestamp). Every other field is preserved byte-for-byte + * (id/activityId/stepId included). Dating by replay order rather than the + * original values keeps a capture/load clock skew from disturbing order, and + * makes post-load navigation (dispatched at the current time) sort after the + * restored events. */ -function rebaseNavigationEvents( - events: NavigationEvent[], +function rebaseEvents( + events: DomainEvent[], context: { now: number; transitionDuration: number; - latestStaticEventDate: number; }, -): NavigationEvent[] { +): DomainEvent[] { const settledUpperBound = context.now - context.transitionDuration; - const window = settledUpperBound - context.latestStaticEventDate; - // A degenerate window (static events dated at or past the settled bound — - // e.g. a direct core embedding dating them near creation time) falls back - // to settledness alone: fold semantics are safe without the placement. - const spacing = window > 0 ? Math.min(1, window / (events.length + 1)) : 1; return events.map((event, index) => ({ ...event, - eventDate: settledUpperBound - (events.length - index) * spacing, + eventDate: settledUpperBound - (events.length - index), })); } diff --git a/core/src/rebase.spec.ts b/core/src/rebase.spec.ts index 07d4f9634..f1d22dc2b 100644 --- a/core/src/rebase.spec.ts +++ b/core/src/rebase.spec.ts @@ -189,22 +189,19 @@ test("load - 원본 이벤트의 id·activityId·stepId를 바이트 보존하 expect(a?.steps[1].enteredBy.eventDate).not.toEqual(originalStepDate); }); -test("load - 이벤트 수가 transitionDuration(ms)을 넘어도 재기저 date가 정적 이벤트 뒤의 창 안에 배치됩니다", () => { - // The react integration backdates static events by only 2×transitionDuration, - // leaving a window of transitionDuration ms after them. A fixed 1ms integer - // spacing would push ≥350 events out of that window, folding the earliest - // snapshot events before Initialized/ActivityRegistered — fractional - // spacing must fit any history length inside the window. +test("load - 정적 이벤트와 복원 이벤트를 함께 재기저해, 이벤트 수와 무관하게 정적 이벤트가 복원 이벤트보다 앞에 정렬되고 전부 정착합니다", () => { + // Static and navigation events are re-dated in one backward walk from + // now − transitionDuration, so static-before-navigation is structural for + // any history length — no window to overflow, whatever the count. const transitionDuration = 350; - const staticBackdate = Date.now() - 2 * transitionDuration; const staticEvents: DomainEvent[] = [ makeEvent("Initialized", { transitionDuration, - eventDate: staticBackdate, + eventDate: enoughPastTime(), }), makeEvent("ActivityRegistered", { activityName: "A", - eventDate: staticBackdate + 1, + eventDate: enoughPastTime(), }), ]; const snapshotEvents = Array.from({ length: 400 }, (_, index) => @@ -230,7 +227,8 @@ test("load - 이벤트 수가 transitionDuration(ms)을 넘어도 재기저 date .map((e) => e.eventDate); expect(rebasedDates).toHaveLength(400); - // Every rebased date lies inside the window: after every static event... + // Every navigation date sorts strictly after every (also re-dated) static + // event... expect(Math.min(...rebasedDates)).toBeGreaterThan(Math.max(...staticDates)); // ...and at or before creation time − transitionDuration (settled). expect(Math.max(...rebasedDates)).toBeLessThanOrEqual( @@ -251,6 +249,61 @@ test("load - 이벤트 수가 transitionDuration(ms)을 넘어도 재기저 date expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a399"); }); +test("load - transitionDuration이 0이어도 정적 이벤트가 복원 이벤트보다 앞에 정렬됩니다", () => { + // With td=0 the react integration backdates static by 2·td = 0, colliding + // with freshly created navigation dates; the old window degenerated and + // fell back to placement that ignored the static lower bound, sorting + // navigation before static. Re-dating static and navigation together makes + // the ordering hold structurally at td=0 too. + const staticEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration: 0, + eventDate: Date.now(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: Date.now(), + }), + ]; + const snapshotEvents = [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a2", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]; + + const store = makeCoreStore({ + initialEvents: staticEvents, + plugins: [provideSnapshotPlugin(snapshotEvents)], + }); + + const log = store.pullEvents(); + const staticDates = log + .filter((e) => e.name === "Initialized" || e.name === "ActivityRegistered") + .map((e) => e.eventDate); + const rebasedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + // Navigation still sorts strictly after static, with no window to lean on. + expect(Math.min(...rebasedDates)).toBeGreaterThan(Math.max(...staticDates)); + + const stack = store.actions.getStack(); + expect(stack.globalTransitionState).toEqual("idle"); + expect( + stack.activities.every((x) => x.transitionState === "enter-done"), + ).toBe(true); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a2"); +}); + test("load - load 후 pop이 복원 최상단을 exit 전환시키고 아래 복원 activity를 재노출합니다", () => { const { actions } = makeCoreStore({ initialEvents: config(["A", "B"], 350), From c382340d08631adf47ca97375a74590725bee9cf Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 9 Jul 2026 23:54:40 +0900 Subject: [PATCH 25/28] fix(core): capture only committed navigation in captureSnapshot (review: per-event settled capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit captureSnapshot took every navigation event from the raw log, including events whose own transition had not settled. Load is effect-silent — it assigns the reconstructed stack directly, replaying no PUSHED/onChanged effect — and the rebase drives every restored event to done, so a mid-transition or pause-queued event, once captured, reappeared on reload as a settled state the live session never committed and never fired its done-effect for. A push queued behind a pause that never resumed came back as a done activity that was never shown. Re-aggregate the log at capture time (flooring now at the latest event date, as dispatchEvent does, so a just-committed event reads as settled) and drop any event whose transition is still in flight: the entering event of an enter-active activity (its activity has not committed) and the exiting event of an exit-active one (its last committed state is preserved), plus everything queued in an unresumed pause. The predicate is per-event, not per-activity — a settled Pushed and an unsettled Popped can share a target, and only the Popped drops. This reverses two capture behaviors: a pause-queued push is no longer carried into the snapshot (previously it re-materialized on load), and a mid-transition event is no longer captured until it commits. The snapshot now equals the last committed navigation history. Co-Authored-By: Claude Fable 5 --- core/src/captureSnapshot.spec.ts | 123 ++++++++++++++++++++++++++-- core/src/makeCoreStore.ts | 47 ++++++++++- core/src/persisterRoundtrip.spec.ts | 14 +++- core/src/snapshotRoundtrip.spec.ts | 26 +++--- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts index 70cce63ae..40551d6e3 100644 --- a/core/src/captureSnapshot.spec.ts +++ b/core/src/captureSnapshot.spec.ts @@ -113,8 +113,10 @@ test("captureSnapshot - 스냅샷 events에서 Paused·Resumed를 제외합니 test("captureSnapshot - 6종 탐색 이벤트를 모두 보존합니다", () => { const { actions } = makeCoreStore({ initialEvents: [ + // transitionDuration 0 so each dispatched event commits (settles) + // instantly — the capture predicate keeps only committed navigation. makeEvent("Initialized", { - transitionDuration: 350, + transitionDuration: 0, eventDate: enoughPastTime(), }), makeEvent("ActivityRegistered", { @@ -231,11 +233,13 @@ test("captureSnapshot - 동일 id 이벤트를 중복 제거합니다", () => { ); }); -test("captureSnapshot - 생성 이후 디스패치된 탐색 이벤트를 포함합니다", () => { +test("captureSnapshot - 생성 이후 커밋된 탐색 이벤트를 포함합니다", () => { const { actions } = makeCoreStore({ initialEvents: [ + // transitionDuration 0 so the post-create push commits instantly and is + // captured — capture keeps committed navigation, not mid-transition. makeEvent("Initialized", { - transitionDuration: 350, + transitionDuration: 0, eventDate: enoughPastTime(), }), makeEvent("ActivityRegistered", { @@ -263,7 +267,7 @@ test("captureSnapshot - 생성 이후 디스패치된 탐색 이벤트를 포함 expect(pushedIds).toContain("a2"); }); -test("captureSnapshot - pause 중 디스패치된 탐색 이벤트를 포함하되 Paused는 제외합니다", () => { +test("captureSnapshot - pause 중 큐잉되어 resume되지 않은 탐색 이벤트는 제외합니다", () => { const { actions } = makeCoreStore({ initialEvents: [ makeEvent("Initialized", { @@ -287,8 +291,8 @@ test("captureSnapshot - pause 중 디스패치된 탐색 이벤트를 포함하 actions.pause(); actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); - // While paused, the pushed event is queued, so it is not yet a visible - // activity — capturing from aggregated state would silently miss it. + // Queued behind the pause, a2 never became a visible activity — the live + // session never committed it, so the snapshot must not resurrect it. expect( actions.getStack().activities.some((a) => a.id === "a2"), ).toBe(false); @@ -301,6 +305,12 @@ test("captureSnapshot - pause 중 디스패치된 탐색 이벤트를 포함하 snapshot.events.some( (e) => e.name === "Pushed" && e.activityId === "a2", ), + ).toBe(false); + // a1, committed before the pause, is still captured. + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a1", + ), ).toBe(true); expect(names).not.toContain("Paused"); } finally { @@ -310,7 +320,7 @@ test("captureSnapshot - pause 중 디스패치된 탐색 이벤트를 포함하 } }); -test("captureSnapshot - 전환 진행 중 캡처한 스냅샷을 load하면 정착 상태로 복원됩니다", () => { +test("captureSnapshot - 전환이 진행 중인(미정착) 탐색 이벤트는 제외되어, load 시 직전 커밋 상태로 복원됩니다", () => { const source = makeCoreStore({ initialEvents: [ makeEvent("Initialized", { @@ -341,6 +351,14 @@ test("captureSnapshot - 전환 진행 중 캡처한 스냅샷을 load하면 정 const snapshot = source.actions.captureSnapshot(); + // The mid-transition push is uncommitted, so it is not captured... + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a2"), + ).toBe(false); + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a1"), + ).toBe(true); + const restored = makeCoreStore({ initialEvents: [ makeEvent("Initialized", { @@ -356,8 +374,95 @@ test("captureSnapshot - 전환 진행 중 캡처한 스냅샷을 load하면 정 }); const restoredStack = restored.actions.getStack(); - const topActivity = restoredStack.activities.find((a) => a.isTop); - expect(topActivity?.transitionState).toEqual("enter-done"); + // ...so the restore reflects the last committed state: a1 alone, settled. + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + expect(restoredStack.activities.find((a) => a.isTop)?.transitionState).toEqual( + "enter-done", + ); expect(restoredStack.globalTransitionState).toEqual("idle"); }); + +test("captureSnapshot - 같은 액티비티의 정착 Pushed는 남기고 미정착 Popped만 제외합니다", () => { + // A and B restored settled (load rebases them into the past); a pop at the + // current time is mid-transition. The pop targets B, whose Pushed already + // committed — so capture keeps both Pushes and drops only the unsettled + // Popped. Per-event, not per-activity: a "B is exiting" rule would wrongly + // drop B entirely. + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + // Both restored settled; pop B so its exit is mid-transition. + store.actions.pop(); + expect( + store.actions.getStack().activities.find((a) => a.id === "b1") + ?.transitionState, + ).toEqual("exit-active"); + + const snapshot = store.actions.captureSnapshot(); + + // The unsettled Popped is dropped; both settled Pushes remain. + expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed", "Pushed"]); + + // Reloading restores A and B both settled — the uncommitted pop is undone. + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + const restoredStack = restored.actions.getStack(); + + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1", "b1"]); + expect( + restoredStack.activities.find((a) => a.id === "a1")?.transitionState, + ).toEqual("enter-done"); + expect( + restoredStack.activities.find((a) => a.id === "b1")?.transitionState, + ).toEqual("enter-done"); + expect(restoredStack.activities.find((a) => a.isTop)?.id).toEqual("b1"); +}); diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 2fab1e172..40e0a2b60 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -189,14 +189,53 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { return stack.value; }, captureSnapshot() { - // Read the raw event log (not aggregated state, so pause-queued events - // are still included) and normalize it the way aggregate pre-processes: - // sort by eventDate ascending, dedupe by id, then keep only navigation + // A snapshot is the last committed navigation history: it carries only + // events whose own transition has settled. Load is effect-silent (it + // assigns the reconstructed stack directly and replays no + // PUSHED/onChanged effect) and the rebase drives every restored event to + // done — so an event still mid-transition, or queued behind a pause that + // never resumed, would reappear on reload as a settled state the live + // session never committed and never fired its done-effect for. Re- + // aggregate the raw log to read each event's committed state, flooring + // now at the latest event date the way dispatchEvent does so a + // just-committed event is read as settled rather than future-dated. + const now = events.value.reduce( + (latest, event) => Math.max(latest, event.eventDate), + Date.now(), + ); + const stack = aggregate(events.value, now); + + const uncommittedEventIds = new Set(); + for (const activity of stack.activities) { + // Drop the entering event of an activity still mid-enter (the activity + // has not committed) and the exiting event of one still mid-exit (its + // last committed state is preserved). Per-event, not per-activity: a + // settled Pushed and an unsettled Popped can point at the same + // activity, and only the Popped drops. + if (activity.transitionState === "enter-active") { + uncommittedEventIds.add(activity.enteredBy.id); + } else if ( + activity.transitionState === "exit-active" && + activity.exitedBy + ) { + uncommittedEventIds.add(activity.exitedBy.id); + } + } + for (const pausedEvent of stack.pausedEvents ?? []) { + // Queued behind a pause that never resumed — never applied, so never + // committed. + uncommittedEventIds.add(pausedEvent.id); + } + + // Normalize the raw log the way aggregate pre-processes — sort by + // eventDate ascending, dedupe by id — keep only committed navigation // events. The resulting array order is the replay order. const navigationEvents = uniqBy( [...events.value].sort((a, b) => a.eventDate - b.eventDate), (e) => e.id, - ).filter(isNavigationEvent); + ) + .filter(isNavigationEvent) + .filter((event) => !uncommittedEventIds.has(event.id)); return { $schema: "stackflow.snapshot.v1", diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts index 11ef6fa66..b772c85b3 100644 --- a/core/src/persisterRoundtrip.spec.ts +++ b/core/src/persisterRoundtrip.spec.ts @@ -14,9 +14,12 @@ const enoughPastTime = () => { return new Date(Date.now() - MINUTE).getTime() + dt; }; -const config = (activityNames: string[]): DomainEvent[] => [ +const config = ( + activityNames: string[], + transitionDuration = 350, +): DomainEvent[] => [ makeEvent("Initialized", { - transitionDuration: 350, + transitionDuration, eventDate: enoughPastTime(), }), ...activityNames.map((activityName) => @@ -73,9 +76,12 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov }); }; + // transitionDuration 0 so the Article push commits instantly and the + // onChanged capture persists it — a snapshot carries only committed + // navigation, so a mid-transition push would persist once it settles. // Session 1: create Home, then navigate to Article (each change persists). const session1 = makeCoreStore({ - initialEvents: [...config(["Home", "Article"]), initialHome()], + initialEvents: [...config(["Home", "Article"], 0), initialHome()], plugins: [persister(() => {})], }); session1.actions.push({ @@ -87,7 +93,7 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov // Session 2: same config + plugin; storage now holds the snapshot. const onInit2 = jest.fn(); const session2 = makeCoreStore({ - initialEvents: [...config(["Home", "Article"]), initialHome()], + initialEvents: [...config(["Home", "Article"], 0), initialHome()], plugins: [persister(onInit2)], }); session2.init(); diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts index e804fdd30..fd6b8afd7 100644 --- a/core/src/snapshotRoundtrip.spec.ts +++ b/core/src/snapshotRoundtrip.spec.ts @@ -86,7 +86,7 @@ test("load - load 직후 captureSnapshot이 같은 탐색 기록을 재구성하 expect(b?.steps[1].params.step).toEqual("2"); }); -test("load - pause 중 캡처한 스냅샷은 큐잉됐던 항해가 전부 적용된 정착 상태로 복원됩니다", () => { +test("load - pause 중 큐잉되어 resume되지 않은 항해는 스냅샷에서 제외되어 복원되지 않습니다", () => { const source = makeCoreStore({ initialEvents: [ ...config(["A", "B"]), @@ -107,9 +107,9 @@ test("load - pause 중 캡처한 스냅샷은 큐잉됐던 항해가 전부 적 activityParams: {}, }); - // At capture time the queued push is not yet a visible activity — the - // contract is that replay applies queued navigation as if the pause never - // happened, so the restore below must nonetheless materialize it. + // Queued behind the pause, b1 never became a visible activity — the live + // session never committed it, and a reload must reflect what the session + // showed, not resurrect the pending push as a settled activity. expect(source.actions.getStack().activities.some((x) => x.id === "b1")).toBe( false, ); @@ -123,20 +123,22 @@ test("load - pause 중 캡처한 스냅샷은 큐잉됐던 항해가 전부 적 source.actions.resume(); } + // The uncommitted queued push is excluded from the snapshot. + expect( + captured.events.some((e) => e.name === "Pushed" && e.activityId === "b1"), + ).toBe(false); + const restored = makeCoreStore({ initialEvents: config(["A", "B"]), plugins: [provideSnapshotPlugin(captured)], }); const stack = restored.actions.getStack(); - const a = stack.activities.find((x) => x.id === "a1"); - const b = stack.activities.find((x) => x.id === "b1"); - // The queued activity materialized, settled, on top of the pre-pause one. - expect(b?.transitionState).toEqual("enter-done"); - expect(b?.isTop).toBe(true); - expect(a?.transitionState).toEqual("enter-done"); - expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); - // No pause survives the round-trip: the restored stack is idle. + // Only the pre-pause activity is restored; b1 is not resurrected. + expect(stack.activities.map((x) => x.id)).toEqual(["a1"]); + expect(stack.activities.find((x) => x.id === "a1")?.transitionState).toEqual( + "enter-done", + ); expect(stack.globalTransitionState).toEqual("idle"); }); From c03e3ee3ccd6295ee7caf10b04fc49e7e90f1808 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 10 Jul 2026 00:31:43 +0900 Subject: [PATCH 26/28] fix(core): drop a whole uncommitted activity's events when capturing, not just its entering event (review B1: capture dependency closure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed-navigation capture predicate collected uncommitted events per element — the entering event of an enter-active activity, the exiting event of an exit-active one, and pause-queued events. But a step event settles the instant it applies (Step* reducers have no transition gate), so a step dispatched onto an activity still mid-enter stayed "committed" and survived into the snapshot even as its parent's Pushed was dropped. On reload that step is an orphan: core's stepPush leaves targetActivityId unset, so findTargetActivityIndices falls back to the latest active activity and grafts the step onto a different, committed activity below — silent corruption, no error. A persister capturing on every onChanged reaches this through any step taken inside an enter transition. Bind the drop to the activity, not the element. Fold the log once (as aggregate does) and attribute every event to the activity it enters (Pushed/Replaced) or targets (Popped/Step*), resolving step and pop targets against the running stack the same way the reducer does — so a step's parent is known even after the step is superseded. An event is kept only if its activity committed (entry settled), which drops an uncommitted activity's entire span — its steps, surviving or superseded, included — alongside the exit-in-flight Popped and pause-queued events already handled. No step outlives its parent, so no orphan can graft. Co-Authored-By: Claude Fable 5 --- core/src/captureSnapshot.spec.ts | 147 +++++++++++++++++++++++++++++++ core/src/makeCoreStore.ts | 143 +++++++++++++++++++----------- 2 files changed, 241 insertions(+), 49 deletions(-) diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts index 40551d6e3..451b92f60 100644 --- a/core/src/captureSnapshot.spec.ts +++ b/core/src/captureSnapshot.spec.ts @@ -466,3 +466,150 @@ test("captureSnapshot - 같은 액티비티의 정착 Pushed는 남기고 미정 ).toEqual("enter-done"); expect(restoredStack.activities.find((a) => a.isTop)?.id).toEqual("b1"); }); + +test("captureSnapshot - 미커밋(enter-active) 액티비티에 얹힌 step은 부모와 함께 제외되어 고아가 되지 않습니다", () => { + // a1 restored settled; push a2 mid-enter, then step onto a2. The step's own + // transition settles instantly, but its parent a2 has not committed — so the + // step must drop with a2. Otherwise reload replays an orphan step that, + // lacking a target activity, grafts onto the committed activity below. + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + store.actions.push({ activityId: "a2", activityName: "B", activityParams: {} }); + store.actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); + expect( + store.actions.getStack().activities.find((a) => a.id === "a2") + ?.transitionState, + ).toEqual("enter-active"); + + const snapshot = store.actions.captureSnapshot(); + + // a2's push and its step both drop — no orphan step survives. + expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + const restoredStack = restored.actions.getStack(); + + // The committed activity below is untouched — no grafted step, a2 absent. + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + expect( + restoredStack.activities.find((a) => a.id === "a1")?.steps.map((s) => s.id), + ).toEqual(["a1"]); +}); + +test("captureSnapshot - 미커밋 액티비티에서 supersede된 step 이벤트도 부모와 함께 제외됩니다", () => { + // A subtler orphan: a step on the mid-enter a2 is replaced, so the original + // StepPushed no longer survives in a2's steps. A predicate that dropped only + // an activity's *surviving* steps would leave that superseded StepPushed + // behind. Attribution binds every step to its parent, so it drops too. + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + store.actions.push({ activityId: "a2", activityName: "B", activityParams: {} }); + store.actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); + store.actions.stepReplace({ stepId: "s1b", stepParams: { step: "1b" } }); + expect( + store.actions.getStack().activities.find((a) => a.id === "a2") + ?.transitionState, + ).toEqual("enter-active"); + + const snapshot = store.actions.captureSnapshot(); + + // Nothing from a2 survives — not its push, not the surviving step, not the + // superseded one. + expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + const restoredStack = restored.actions.getStack(); + + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + expect( + restoredStack.activities.find((a) => a.id === "a1")?.steps.map((s) => s.id), + ).toEqual(["a1"]); +}); diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 40e0a2b60..012f460d3 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,4 +1,6 @@ import isEqual from "react-fast-compare"; +import { findTargetActivityIndices } from "./activity-utils/findTargetActivityIndices"; +import { makeStackReducer } from "./activity-utils/makeStackReducer"; import { aggregate } from "./aggregate"; import type { DomainEvent } from "./event-types"; import { isNavigationEvent, makeEvent } from "./event-utils"; @@ -41,6 +43,97 @@ export type CoreStore = { pluginInstances: ReturnType[]; }; +/** + * The navigation events a snapshot should carry: those whose transition has + * committed. A snapshot is the last committed navigation history — load is + * effect-silent (it assigns the reconstructed stack directly, replaying no + * PUSHED/onChanged effect) and the rebase drives every restored event to done, + * so carrying an uncommitted event would resurrect on reload a state the live + * session never committed and never fired its done-effect for. + * + * Fold the log once (the way aggregate does), attributing each event to the + * activity it enters (Pushed/Replaced) or targets (Popped/Step*) — resolving + * step and pop targets against the running stack exactly as the reducer does, + * so a step's parent is known even after the step is superseded. An event is + * then dropped iff the activity it belongs to did not commit: the whole span of + * an activity still mid-enter (its steps included, so no step outlives its + * parent and grafts onto another activity on reload), the Popped of an exit + * still in flight (the activity's last committed state is preserved), and + * anything queued behind a pause that never resumed. + */ +function collectCommittedNavigationEvents( + log: DomainEvent[], +): NavigationEvent[] { + // Floor now at the latest event date the way dispatchEvent does, so a + // just-committed event reads as settled rather than future-dated. + const now = log.reduce( + (latest, event) => Math.max(latest, event.eventDate), + Date.now(), + ); + + const normalized = uniqBy( + [...log].sort((a, b) => a.eventDate - b.eventDate), + (event) => event.id, + ); + + const attributedActivityId = new Map(); + const reducer = makeStackReducer({ now }); + let stack: Stack = { + activities: [], + globalTransitionState: "idle", + registeredActivities: [], + transitionDuration: 0, + events: [], + }; + + for (const event of normalized) { + if (event.name === "Pushed" || event.name === "Replaced") { + attributedActivityId.set(event.id, event.activityId); + } else if ( + event.name === "Popped" || + event.name === "StepPushed" || + event.name === "StepReplaced" || + event.name === "StepPopped" + ) { + const [targetIndex] = findTargetActivityIndices(stack.activities, event, { + now, + transitionDuration: stack.transitionDuration, + }); + const target = + targetIndex === undefined ? undefined : stack.activities[targetIndex]; + + if (target) { + attributedActivityId.set(event.id, target.id); + } + } + + stack = reducer(stack, event); + } + + const committedActivityIds = new Set( + stack.activities + .filter((activity) => activity.transitionState !== "enter-active") + .map((activity) => activity.id), + ); + const rolledBackExitEventIds = new Set(); + for (const activity of stack.activities) { + if (activity.transitionState === "exit-active" && activity.exitedBy) { + rolledBackExitEventIds.add(activity.exitedBy.id); + } + } + const pausedEventIds = new Set( + (stack.pausedEvents ?? []).map((event) => event.id), + ); + + return normalized.filter(isNavigationEvent).filter((event) => { + if (pausedEventIds.has(event.id) || rolledBackExitEventIds.has(event.id)) { + return false; + } + const activityId = attributedActivityId.get(event.id); + return activityId !== undefined && committedActivityIds.has(activityId); + }); +} + export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { const storeListeners: Array<() => void> = []; @@ -189,57 +282,9 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { return stack.value; }, captureSnapshot() { - // A snapshot is the last committed navigation history: it carries only - // events whose own transition has settled. Load is effect-silent (it - // assigns the reconstructed stack directly and replays no - // PUSHED/onChanged effect) and the rebase drives every restored event to - // done — so an event still mid-transition, or queued behind a pause that - // never resumed, would reappear on reload as a settled state the live - // session never committed and never fired its done-effect for. Re- - // aggregate the raw log to read each event's committed state, flooring - // now at the latest event date the way dispatchEvent does so a - // just-committed event is read as settled rather than future-dated. - const now = events.value.reduce( - (latest, event) => Math.max(latest, event.eventDate), - Date.now(), - ); - const stack = aggregate(events.value, now); - - const uncommittedEventIds = new Set(); - for (const activity of stack.activities) { - // Drop the entering event of an activity still mid-enter (the activity - // has not committed) and the exiting event of one still mid-exit (its - // last committed state is preserved). Per-event, not per-activity: a - // settled Pushed and an unsettled Popped can point at the same - // activity, and only the Popped drops. - if (activity.transitionState === "enter-active") { - uncommittedEventIds.add(activity.enteredBy.id); - } else if ( - activity.transitionState === "exit-active" && - activity.exitedBy - ) { - uncommittedEventIds.add(activity.exitedBy.id); - } - } - for (const pausedEvent of stack.pausedEvents ?? []) { - // Queued behind a pause that never resumed — never applied, so never - // committed. - uncommittedEventIds.add(pausedEvent.id); - } - - // Normalize the raw log the way aggregate pre-processes — sort by - // eventDate ascending, dedupe by id — keep only committed navigation - // events. The resulting array order is the replay order. - const navigationEvents = uniqBy( - [...events.value].sort((a, b) => a.eventDate - b.eventDate), - (e) => e.id, - ) - .filter(isNavigationEvent) - .filter((event) => !uncommittedEventIds.has(event.id)); - return { $schema: "stackflow.snapshot.v1", - events: navigationEvents, + events: collectCommittedNavigationEvents(events.value), }; }, dispatchEvent(name, params) { From 4c11f321168e929bc0d616ff8fcfea73ccc86be3 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 10 Jul 2026 00:32:27 +0900 Subject: [PATCH 27/28] docs(core): note the unified registration check now covers Replaced (review A1: validateEvents Replaced note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record in the changeset that folding the snapshot load's registration check into validateEvents also makes aggregate reject an unregistered Replaced on every path, matching its existing Pushed check — dormant in config-first usage where a replace only targets registered activities. Co-Authored-By: Claude Fable 5 --- .changeset/fep-2548-create-load-distinction.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md index a0f65f7b4..0c8173003 100644 --- a/.changeset/fep-2548-create-load-distinction.md +++ b/.changeset/fep-2548-create-load-distinction.md @@ -29,3 +29,5 @@ const persisterPlugin = ({ storage, codec }) => () => ({ ``` A load reconstructs the stack by replaying the snapshot's navigation events — as passed through the plugins' `overrideInitialEvents` chain — through the existing aggregate machinery: it re-derives static information (transition duration, the registered-activity set) from the current config, re-dates the events so the restored stack settles synchronously, and preserves each event's `id`/`activityId`/`stepId` byte-for-byte. No new domain events or stack state properties are introduced. + +The snapshot load's registration check is unified into `validateEvents` (run by `aggregate` on every path), which now rejects a `Replaced` that materializes an unregistered activity, matching its long-standing check for `Pushed`. In config-first usage a replace only targets registered activities, so this added check does not fire on the live path. From 606810796ae536d0a663bcfb55322cc7f77cf6fb Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 10 Jul 2026 12:57:08 +0900 Subject: [PATCH 28/28] fix(core): capture every recorded navigation event, excluding only unresumed-paused (review: capture records, not settlement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier revision restricted captureSnapshot to committed (settled) navigation, excluding mid-transition events and adding activity-level attribution so a dropped activity's steps did not orphan. Revisit the model: a transition is only a visual effect, and a navigation event's effect hook (onPushed/onPopped) already fires in the active state — so reconstructing an unsettled event as state on load loses no effect and produces no state/effect mismatch. What makes an event part of the history is that it entered the aggregate, not that its transition settled. Capture every navigation event the log records, excluding only events queued behind a pause that never resumed — those are quarantined out of the aggregate (never applied), so they were never part of the recorded history. This drops the settlement predicate and the attribution machinery that existed only to keep steps from outliving an excluded parent: with mid-transition parents now included, no step is orphaned. Snapshot tests move to the new contract — mid-transition push and mid-transition pop both round-trip, and only an unresumed-paused event is excluded. Co-Authored-By: Claude Fable 5 --- core/src/captureSnapshot.spec.ts | 212 ++++------------------------ core/src/makeCoreStore.ts | 116 +++------------ core/src/persisterRoundtrip.spec.ts | 14 +- core/src/snapshotRoundtrip.spec.ts | 6 +- 4 files changed, 57 insertions(+), 291 deletions(-) diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts index 451b92f60..210a1806f 100644 --- a/core/src/captureSnapshot.spec.ts +++ b/core/src/captureSnapshot.spec.ts @@ -113,10 +113,8 @@ test("captureSnapshot - 스냅샷 events에서 Paused·Resumed를 제외합니 test("captureSnapshot - 6종 탐색 이벤트를 모두 보존합니다", () => { const { actions } = makeCoreStore({ initialEvents: [ - // transitionDuration 0 so each dispatched event commits (settles) - // instantly — the capture predicate keeps only committed navigation. makeEvent("Initialized", { - transitionDuration: 0, + transitionDuration: 350, eventDate: enoughPastTime(), }), makeEvent("ActivityRegistered", { @@ -233,13 +231,11 @@ test("captureSnapshot - 동일 id 이벤트를 중복 제거합니다", () => { ); }); -test("captureSnapshot - 생성 이후 커밋된 탐색 이벤트를 포함합니다", () => { +test("captureSnapshot - 생성 이후 디스패치된 탐색 이벤트를 포함합니다", () => { const { actions } = makeCoreStore({ initialEvents: [ - // transitionDuration 0 so the post-create push commits instantly and is - // captured — capture keeps committed navigation, not mid-transition. makeEvent("Initialized", { - transitionDuration: 0, + transitionDuration: 350, eventDate: enoughPastTime(), }), makeEvent("ActivityRegistered", { @@ -291,8 +287,9 @@ test("captureSnapshot - pause 중 큐잉되어 resume되지 않은 탐색 이벤 actions.pause(); actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); - // Queued behind the pause, a2 never became a visible activity — the live - // session never committed it, so the snapshot must not resurrect it. + // Queued behind the pause and never resumed, a2 is quarantined out of the + // aggregate — not part of the recorded history, so it is the one navigation + // event capture excludes. expect( actions.getStack().activities.some((a) => a.id === "a2"), ).toBe(false); @@ -306,7 +303,7 @@ test("captureSnapshot - pause 중 큐잉되어 resume되지 않은 탐색 이벤 (e) => e.name === "Pushed" && e.activityId === "a2", ), ).toBe(false); - // a1, committed before the pause, is still captured. + // a1, recorded before the pause, is still captured. expect( snapshot.events.some( (e) => e.name === "Pushed" && e.activityId === "a1", @@ -320,7 +317,7 @@ test("captureSnapshot - pause 중 큐잉되어 resume되지 않은 탐색 이벤 } }); -test("captureSnapshot - 전환이 진행 중인(미정착) 탐색 이벤트는 제외되어, load 시 직전 커밋 상태로 복원됩니다", () => { +test("captureSnapshot - 전환이 진행 중인 탐색 이벤트도 포함되어 load 시 그대로 재생됩니다", () => { const source = makeCoreStore({ initialEvents: [ makeEvent("Initialized", { @@ -351,10 +348,11 @@ test("captureSnapshot - 전환이 진행 중인(미정착) 탐색 이벤트는 const snapshot = source.actions.captureSnapshot(); - // The mid-transition push is uncommitted, so it is not captured... + // A transition is only visual — a2 is recorded in the stack, so it is + // captured despite being mid-transition. expect( snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a2"), - ).toBe(false); + ).toBe(true); expect( snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a1"), ).toBe(true); @@ -375,20 +373,19 @@ test("captureSnapshot - 전환이 진행 중인(미정착) 탐색 이벤트는 const restoredStack = restored.actions.getStack(); - // ...so the restore reflects the last committed state: a1 alone, settled. - expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + // Both restore; the rebase settles the once-mid-transition push. + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1", "a2"]); + expect(restoredStack.activities.find((a) => a.isTop)?.id).toEqual("a2"); expect(restoredStack.activities.find((a) => a.isTop)?.transitionState).toEqual( "enter-done", ); expect(restoredStack.globalTransitionState).toEqual("idle"); }); -test("captureSnapshot - 같은 액티비티의 정착 Pushed는 남기고 미정착 Popped만 제외합니다", () => { - // A and B restored settled (load rebases them into the past); a pop at the - // current time is mid-transition. The pop targets B, whose Pushed already - // committed — so capture keeps both Pushes and drops only the unsettled - // Popped. Per-event, not per-activity: a "B is exiting" rule would wrongly - // drop B entirely. +test("captureSnapshot - 전환 중 pop한 이벤트도 Pushed·Popped가 모두 담겨 load 시 pop이 반영됩니다", () => { + // a1 restored settled; push b1, then pop it while both transitions are in + // flight. The push and the pop are both recorded, so both are captured, and + // the reload replays them — the pop takes effect (b1 exits, a1 active again). const store = makeCoreStore({ initialEvents: [ makeEvent("Initialized", { @@ -414,18 +411,12 @@ test("captureSnapshot - 같은 액티비티의 정착 Pushed는 남기고 미정 activityParams: {}, eventDate: enoughPastTime(), }), - makeEvent("Pushed", { - activityId: "b1", - activityName: "B", - activityParams: {}, - eventDate: enoughPastTime(), - }), ], }), ], }); - // Both restored settled; pop B so its exit is mid-transition. + store.actions.push({ activityId: "b1", activityName: "B", activityParams: {} }); store.actions.pop(); expect( store.actions.getStack().activities.find((a) => a.id === "b1") @@ -434,160 +425,11 @@ test("captureSnapshot - 같은 액티비티의 정착 Pushed는 남기고 미정 const snapshot = store.actions.captureSnapshot(); - // The unsettled Popped is dropped; both settled Pushes remain. - expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed", "Pushed"]); - - // Reloading restores A and B both settled — the uncommitted pop is undone. - const restored = makeCoreStore({ - initialEvents: [ - makeEvent("Initialized", { - transitionDuration: 350, - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "A", - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "B", - eventDate: enoughPastTime(), - }), - ], - plugins: [provideSnapshot(snapshot)], - }); - const restoredStack = restored.actions.getStack(); - - expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1", "b1"]); - expect( - restoredStack.activities.find((a) => a.id === "a1")?.transitionState, - ).toEqual("enter-done"); - expect( - restoredStack.activities.find((a) => a.id === "b1")?.transitionState, - ).toEqual("enter-done"); - expect(restoredStack.activities.find((a) => a.isTop)?.id).toEqual("b1"); -}); - -test("captureSnapshot - 미커밋(enter-active) 액티비티에 얹힌 step은 부모와 함께 제외되어 고아가 되지 않습니다", () => { - // a1 restored settled; push a2 mid-enter, then step onto a2. The step's own - // transition settles instantly, but its parent a2 has not committed — so the - // step must drop with a2. Otherwise reload replays an orphan step that, - // lacking a target activity, grafts onto the committed activity below. - const store = makeCoreStore({ - initialEvents: [ - makeEvent("Initialized", { - transitionDuration: 350, - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "A", - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "B", - eventDate: enoughPastTime(), - }), - ], - plugins: [ - provideSnapshot({ - $schema: "stackflow.snapshot.v1", - events: [ - makeEvent("Pushed", { - activityId: "a1", - activityName: "A", - activityParams: {}, - eventDate: enoughPastTime(), - }), - ], - }), - ], - }); - - store.actions.push({ activityId: "a2", activityName: "B", activityParams: {} }); - store.actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); - expect( - store.actions.getStack().activities.find((a) => a.id === "a2") - ?.transitionState, - ).toEqual("enter-active"); - - const snapshot = store.actions.captureSnapshot(); - - // a2's push and its step both drop — no orphan step survives. - expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); - - const restored = makeCoreStore({ - initialEvents: [ - makeEvent("Initialized", { - transitionDuration: 350, - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "A", - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "B", - eventDate: enoughPastTime(), - }), - ], - plugins: [provideSnapshot(snapshot)], - }); - const restoredStack = restored.actions.getStack(); - - // The committed activity below is untouched — no grafted step, a2 absent. - expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + // Both the mid-transition push and the mid-transition pop are captured. expect( - restoredStack.activities.find((a) => a.id === "a1")?.steps.map((s) => s.id), - ).toEqual(["a1"]); -}); - -test("captureSnapshot - 미커밋 액티비티에서 supersede된 step 이벤트도 부모와 함께 제외됩니다", () => { - // A subtler orphan: a step on the mid-enter a2 is replaced, so the original - // StepPushed no longer survives in a2's steps. A predicate that dropped only - // an activity's *surviving* steps would leave that superseded StepPushed - // behind. Attribution binds every step to its parent, so it drops too. - const store = makeCoreStore({ - initialEvents: [ - makeEvent("Initialized", { - transitionDuration: 350, - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "A", - eventDate: enoughPastTime(), - }), - makeEvent("ActivityRegistered", { - activityName: "B", - eventDate: enoughPastTime(), - }), - ], - plugins: [ - provideSnapshot({ - $schema: "stackflow.snapshot.v1", - events: [ - makeEvent("Pushed", { - activityId: "a1", - activityName: "A", - activityParams: {}, - eventDate: enoughPastTime(), - }), - ], - }), - ], - }); - - store.actions.push({ activityId: "a2", activityName: "B", activityParams: {} }); - store.actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); - store.actions.stepReplace({ stepId: "s1b", stepParams: { step: "1b" } }); - expect( - store.actions.getStack().activities.find((a) => a.id === "a2") - ?.transitionState, - ).toEqual("enter-active"); - - const snapshot = store.actions.captureSnapshot(); - - // Nothing from a2 survives — not its push, not the surviving step, not the - // superseded one. - expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "b1"), + ).toBe(true); + expect(snapshot.events.some((e) => e.name === "Popped")).toBe(true); const restored = makeCoreStore({ initialEvents: [ @@ -608,8 +450,10 @@ test("captureSnapshot - 미커밋 액티비티에서 supersede된 step 이벤트 }); const restoredStack = restored.actions.getStack(); - expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1"]); + // The pop is reflected: b1 exited, a1 is the active activity again. + expect(restoredStack.activities.find((a) => a.isActive)?.id).toEqual("a1"); expect( - restoredStack.activities.find((a) => a.id === "a1")?.steps.map((s) => s.id), - ).toEqual(["a1"]); + restoredStack.activities.find((a) => a.id === "b1")?.transitionState, + ).toEqual("exit-done"); + expect(restoredStack.globalTransitionState).toEqual("idle"); }); diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 012f460d3..bae16d658 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,6 +1,4 @@ import isEqual from "react-fast-compare"; -import { findTargetActivityIndices } from "./activity-utils/findTargetActivityIndices"; -import { makeStackReducer } from "./activity-utils/makeStackReducer"; import { aggregate } from "./aggregate"; import type { DomainEvent } from "./event-types"; import { isNavigationEvent, makeEvent } from "./event-utils"; @@ -43,97 +41,6 @@ export type CoreStore = { pluginInstances: ReturnType[]; }; -/** - * The navigation events a snapshot should carry: those whose transition has - * committed. A snapshot is the last committed navigation history — load is - * effect-silent (it assigns the reconstructed stack directly, replaying no - * PUSHED/onChanged effect) and the rebase drives every restored event to done, - * so carrying an uncommitted event would resurrect on reload a state the live - * session never committed and never fired its done-effect for. - * - * Fold the log once (the way aggregate does), attributing each event to the - * activity it enters (Pushed/Replaced) or targets (Popped/Step*) — resolving - * step and pop targets against the running stack exactly as the reducer does, - * so a step's parent is known even after the step is superseded. An event is - * then dropped iff the activity it belongs to did not commit: the whole span of - * an activity still mid-enter (its steps included, so no step outlives its - * parent and grafts onto another activity on reload), the Popped of an exit - * still in flight (the activity's last committed state is preserved), and - * anything queued behind a pause that never resumed. - */ -function collectCommittedNavigationEvents( - log: DomainEvent[], -): NavigationEvent[] { - // Floor now at the latest event date the way dispatchEvent does, so a - // just-committed event reads as settled rather than future-dated. - const now = log.reduce( - (latest, event) => Math.max(latest, event.eventDate), - Date.now(), - ); - - const normalized = uniqBy( - [...log].sort((a, b) => a.eventDate - b.eventDate), - (event) => event.id, - ); - - const attributedActivityId = new Map(); - const reducer = makeStackReducer({ now }); - let stack: Stack = { - activities: [], - globalTransitionState: "idle", - registeredActivities: [], - transitionDuration: 0, - events: [], - }; - - for (const event of normalized) { - if (event.name === "Pushed" || event.name === "Replaced") { - attributedActivityId.set(event.id, event.activityId); - } else if ( - event.name === "Popped" || - event.name === "StepPushed" || - event.name === "StepReplaced" || - event.name === "StepPopped" - ) { - const [targetIndex] = findTargetActivityIndices(stack.activities, event, { - now, - transitionDuration: stack.transitionDuration, - }); - const target = - targetIndex === undefined ? undefined : stack.activities[targetIndex]; - - if (target) { - attributedActivityId.set(event.id, target.id); - } - } - - stack = reducer(stack, event); - } - - const committedActivityIds = new Set( - stack.activities - .filter((activity) => activity.transitionState !== "enter-active") - .map((activity) => activity.id), - ); - const rolledBackExitEventIds = new Set(); - for (const activity of stack.activities) { - if (activity.transitionState === "exit-active" && activity.exitedBy) { - rolledBackExitEventIds.add(activity.exitedBy.id); - } - } - const pausedEventIds = new Set( - (stack.pausedEvents ?? []).map((event) => event.id), - ); - - return normalized.filter(isNavigationEvent).filter((event) => { - if (pausedEventIds.has(event.id) || rolledBackExitEventIds.has(event.id)) { - return false; - } - const activityId = attributedActivityId.get(event.id); - return activityId !== undefined && committedActivityIds.has(activityId); - }); -} - export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { const storeListeners: Array<() => void> = []; @@ -282,9 +189,30 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { return stack.value; }, captureSnapshot() { + // A snapshot carries every navigation event the stack has recorded. A + // transition is only a visual effect and its navigation effect hook fires + // in the active state already, so an event counts as taken the moment it + // enters the aggregate — settled or not. The sole exception is an event + // queued behind a pause that never resumed: it is quarantined out of the + // aggregate (never applied), so it is not part of the recorded history. + const stack = aggregate(events.value, Date.now()); + const unresumedPausedEventIds = new Set( + (stack.pausedEvents ?? []).map((event) => event.id), + ); + + // Normalize the raw log the way aggregate pre-processes — sort by + // eventDate ascending, dedupe by id. The resulting array order is the + // replay order. + const navigationEvents = uniqBy( + [...events.value].sort((a, b) => a.eventDate - b.eventDate), + (event) => event.id, + ) + .filter(isNavigationEvent) + .filter((event) => !unresumedPausedEventIds.has(event.id)); + return { $schema: "stackflow.snapshot.v1", - events: collectCommittedNavigationEvents(events.value), + events: navigationEvents, }; }, dispatchEvent(name, params) { diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts index b772c85b3..11ef6fa66 100644 --- a/core/src/persisterRoundtrip.spec.ts +++ b/core/src/persisterRoundtrip.spec.ts @@ -14,12 +14,9 @@ const enoughPastTime = () => { return new Date(Date.now() - MINUTE).getTime() + dt; }; -const config = ( - activityNames: string[], - transitionDuration = 350, -): DomainEvent[] => [ +const config = (activityNames: string[]): DomainEvent[] => [ makeEvent("Initialized", { - transitionDuration, + transitionDuration: 350, eventDate: enoughPastTime(), }), ...activityNames.map((activityName) => @@ -76,12 +73,9 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov }); }; - // transitionDuration 0 so the Article push commits instantly and the - // onChanged capture persists it — a snapshot carries only committed - // navigation, so a mid-transition push would persist once it settles. // Session 1: create Home, then navigate to Article (each change persists). const session1 = makeCoreStore({ - initialEvents: [...config(["Home", "Article"], 0), initialHome()], + initialEvents: [...config(["Home", "Article"]), initialHome()], plugins: [persister(() => {})], }); session1.actions.push({ @@ -93,7 +87,7 @@ test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 prov // Session 2: same config + plugin; storage now holds the snapshot. const onInit2 = jest.fn(); const session2 = makeCoreStore({ - initialEvents: [...config(["Home", "Article"], 0), initialHome()], + initialEvents: [...config(["Home", "Article"]), initialHome()], plugins: [persister(onInit2)], }); session2.init(); diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts index fd6b8afd7..eb7173e5c 100644 --- a/core/src/snapshotRoundtrip.spec.ts +++ b/core/src/snapshotRoundtrip.spec.ts @@ -107,9 +107,9 @@ test("load - pause 중 큐잉되어 resume되지 않은 항해는 스냅샷에 activityParams: {}, }); - // Queued behind the pause, b1 never became a visible activity — the live - // session never committed it, and a reload must reflect what the session - // showed, not resurrect the pending push as a settled activity. + // Queued behind the pause and never resumed, b1 is quarantined out of the + // aggregate — not part of the recorded history, so a reload must not + // resurrect the pending push as a settled activity. expect(source.actions.getStack().activities.some((x) => x.id === "b1")).toBe( false, );