diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md new file mode 100644 index 000000000..4ede2db65 --- /dev/null +++ b/.changeset/fep-2548-create-load-distinction.md @@ -0,0 +1,33 @@ +--- +"@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`), and `overrideInitialEvents` implementations that explicitly annotate their parameter with the previous `(PushedEvent | StepPushedEvent)[]` shape must widen it to `SnapshotEvent[]` (implementations with inferred parameters are unaffected). + +- `StackSnapshot` (and the `SnapshotEvent` union) — a plain-data value, owned by core, that carries every event the stack recorded at runtime — the six navigation events plus `Paused`/`Resumed` — excluding only the static events (`Initialized`, `ActivityRegistered`), which are config-grade information the current config re-derives at load time. Encoding to a persistence medium (codec) is the consumer's responsibility. +- `actions.captureSnapshot()` — export the recorded event log as-is (statics excluded); callable from any hook, at any time. Core holds no opinion about when a snapshot is meaningful: capturing a paused stack yields a snapshot that restores a paused stack, and whether to capture at such a moment is the caller's timing choice. +- `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 `SnapshotEvent[]` 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 (`Paused`/`Resumed` included when the snapshot recorded them), and its return is adopted as the replay sequence with its event dates preserved — core never re-dates it, so a guarantee like "every restored activity is settled" is this hook's to provide by re-dating the events itself. The return then runs 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 }) => () => ({ + 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 events — as passed through the plugins' `overrideInitialEvents` chain — through the existing aggregate machinery, preserving them byte-for-byte, `eventDate` included: the recorded dates are the replay truth, so a stack captured mid-transition restores mid-transition and a paused stack restores paused, and capture∘load is an identity on the snapshot events. Static information (transition duration, the registered-activity set) is re-derived from the current config, and only those static events are re-dated — to just before the earliest replayed event, so registration and transition duration are in place before any snapshot event applies. Core imposes no settling or date normalization on the replay; a supplier or plugin that wants a fully-settled restore (or protection from a capture clock that ran ahead) re-dates the sequence in `overrideInitialEvents`. 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. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..124eb99bb --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,71 @@ +# Stackflow — Ubiquitous Language + +## Terms + +### 생성 (Create) + +새 Stack을 처음부터(from scratch) 만들어내는 진입 경로. 보존된 탐색 맥락이 없는 상태에서 +시작한다. defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 의미상 push다 — +따라서 activity 진입 가드의 적용 대상이다. + +### 복원 (Load) + +보존된 탐색 맥락(스냅샷)으로부터 Stack을 재구성하는 모든 진입 경로. 저장 매체와 +무관하다 — persister의 storage 스냅샷이든 `history.state` 복원이든, 스냅샷에서 +스택을 되살리면 load다. load로 만들어진 스택은 보존 시점에 이미 검증된 것으로 +간주하므로 activity 진입 가드를 건너뛴다. + +### 진입 (Entry) + +Activity가 Stack에 나타나게 되는 사건. Create 경로의 최초 진입은 의미상 push이며, +플러그인이 가로챌(검사·차단·변경) 수 있어야 한다. 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) + +Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plugins +(+ react integration) 조합의 동작으로 만들어질 수 있는 상태는 모두 정상 상태다. +활성 activity 개수(1개 이상 무엇이든), exit 상태 activity의 존재, 전환 진행 여부와 +무관하다 — 도달 가능성(reachability)이 기준이다. Load의 사후조건은 "스냅샷이 +보존한 정상 상태의 충실한 재구성"이며, 그 충실성의 필수 범위는 탐색 기록이다. + +## Relationships + +- Stack은 Create 또는 Load 중 정확히 하나의 경로로 만들어진다. +- Create ↔ Load 구분은 core가 표현한다 — 플러그인(guard, persister, history-sync)이 + 진입 경로별 정책을 세우는 근거가 된다. +- Load는 Stack 생성 시점에만 일어난다. 스냅샷은 생성 시점에 동기적으로 주어져야 + 하며, 생성된 Stack은 그 순간부터 항상 정상 상태다 — "복원 대기 중" 같은 중간 + 상태는 존재하지 않는다. 비동기 스냅샷 소스의 수용은 스택 생성을 지연하는 상위 + 레이어의 부트스트랩 문제다. +- Load 실패(손상된 스냅샷, 등록 해제된 activity 등)는 조용한 폴백이 아니라 + 명시적 에러다. 유효하지 않은 스냅샷으로부터 정상 상태 Stack이 몰래 만들어지는 + 일은 없다. +- Load 실패 에러의 1차 처리 책임은 스냅샷 공급자(persister 등)에게 있다. + 공급자가 복구 정책(스냅샷 폐기, create 재시도 등)을 결정하며, 앱 개발자는 + 기본적으로 이 에러를 다루지 않는다. +- Create ↔ Load 구분은 Stack 생성 시점의 일회성 신호다. 구분이 Stack 상태에 지속 + 속성으로 남지는 않는다 — 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 + 한다. +- Stack 생성은 스냅샷을 최대 하나만 받는다. 복수 복원 후보의 선택·조정은 Stack + 생성 이전 계층(공급자들)의 책임이며, core는 조정자 역할을 맡지 않는다. diff --git a/core/src/SnapshotLoadError.ts b/core/src/SnapshotLoadError.ts new file mode 100644 index 000000000..6965c6707 --- /dev/null +++ b/core/src/SnapshotLoadError.ts @@ -0,0 +1,36 @@ +/** + * 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: "unrecognized-snapshot"; detail: string } + | { kind: "incompatible-events"; detail: unknown } + | { kind: "empty-stack" }; + +/** + * 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; + } +} diff --git a/core/src/StackSnapshot.ts b/core/src/StackSnapshot.ts new file mode 100644 index 000000000..044a61d88 --- /dev/null +++ b/core/src/StackSnapshot.ts @@ -0,0 +1,52 @@ +import type { + PausedEvent, + PoppedEvent, + PushedEvent, + ReplacedEvent, + ResumedEvent, + StepPoppedEvent, + StepPushedEvent, + StepReplacedEvent, +} from "./event-types"; + +/** + * The six navigation events — a subset union of the existing domain event + * types (no new vocabulary is introduced). + */ +export type NavigationEvent = + | PushedEvent + | ReplacedEvent + | PoppedEvent + | StepPushedEvent + | StepReplacedEvent + | StepPoppedEvent; + +/** + * The events a snapshot carries: every domain event except the static ones + * (`Initialized`, `ActivityRegistered`). Statics are config/source-grade + * information — they may legitimately differ after a reload, so the current + * config re-derives them at load time instead of trusting the snapshot. + * Everything the stack recorded at runtime, `Paused`/`Resumed` included, is + * exported as-is. + */ +export type SnapshotEvent = NavigationEvent | PausedEvent | ResumedEvent; + +/** + * 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"; + + /** + * The event log as recorded (normalized to replay order), minus the static + * events the current config re-derives at load time. Whether to capture a + * paused stack is the caller's choice — core exports the stack it is asked + * about, pause state and all. + */ + events: SnapshotEvent[]; +}; diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts new file mode 100644 index 000000000..d7e028867 --- /dev/null +++ b/core/src/captureSnapshot.spec.ts @@ -0,0 +1,454 @@ +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 - Paused·Resumed도 기록된 그대로 스냅샷 events에 포함합니다", () => { + 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(); + + // The pause markers are runtime history like any other event — replaying + // them reproduces the same pause/resume sequence, so they are carried as-is. + const names = snapshot.events.map((e) => e.name); + expect(names).toEqual(["Pushed", "Paused", "Pushed", "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: {} }); + + // Queued behind the pause, a2 is not applied to the live stack — and the + // snapshot records exactly that: the queued push together with the Paused + // marker that quarantines it, so a reload reproduces the same paused stack. + 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(names).toEqual(["Pushed", "Paused", "Pushed"]); + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a2", + ), + ).toBe(true); + } 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(); + + // 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(true); + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a1"), + ).toBe(true); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + + const restoredStack = restored.actions.getStack(); + + // Both restore, replayed at their recorded dates — the push captured + // mid-transition is still mid-transition on load, exactly as captured. + 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-active", + ); + expect(restoredStack.globalTransitionState).toEqual("loading"); +}); + +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", { + 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: "b1", activityName: "B", activityParams: {} }); + store.actions.pop(); + expect( + store.actions.getStack().activities.find((a) => a.id === "b1") + ?.transitionState, + ).toEqual("exit-active"); + + const snapshot = store.actions.captureSnapshot(); + + // Both the mid-transition push and the mid-transition pop are captured. + expect( + 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: [ + 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 pop is reflected mid-flight, as captured: b1 is exiting, and a1 is + // the active activity again. + expect(restoredStack.activities.find((a) => a.isActive)?.id).toEqual("a1"); + expect( + restoredStack.activities.find((a) => a.id === "b1")?.transitionState, + ).toEqual("exit-active"); + expect(restoredStack.globalTransitionState).toEqual("loading"); +}); diff --git a/core/src/createPath.spec.ts b/core/src/createPath.spec.ts new file mode 100644 index 000000000..96bac7e51 --- /dev/null +++ b/core/src/createPath.spec.ts @@ -0,0 +1,318 @@ +import type { PushedEvent, StepPushedEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { SnapshotEvent, 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 경로를 타고 initInfo.kind가 "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].initInfo).toEqual({ kind: "create" }); +}); + +test('create - overrideInitialEvents가 onInit과 동일한 형태의 initInfo { kind: "create" }를 전달받습니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: SnapshotEvent[]; + 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(); + + 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/event-utils/index.ts b/core/src/event-utils/index.ts index 6ca98c51a..366804099 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 "./isSnapshotEvent"; export * from "./makeEvent"; export * from "./validateEvents"; diff --git a/core/src/event-utils/isSnapshotEvent.ts b/core/src/event-utils/isSnapshotEvent.ts new file mode 100644 index 000000000..fd49c2508 --- /dev/null +++ b/core/src/event-utils/isSnapshotEvent.ts @@ -0,0 +1,32 @@ +import type { DomainEvent } from "../event-types"; +import type { SnapshotEvent } from "../StackSnapshot"; + +/** + * The events a snapshot carries — every domain event except the static ones + * (`Initialized`, `ActivityRegistered`), which the current config re-derives + * at load time. Single source of truth for the capture-side filter and the + * load-side structure check. + */ +const SNAPSHOT_EVENT_NAMES: ReadonlySet = new Set([ + "Pushed", + "Replaced", + "Popped", + "StepPushed", + "StepReplaced", + "StepPopped", + "Paused", + "Resumed", +]); + +/** Whether an event is one a snapshot carries (i.e. not a static event). */ +export function isSnapshotEvent(event: DomainEvent): event is SnapshotEvent { + return SNAPSHOT_EVENT_NAMES.has(event.name); +} + +/** Whether a value is the name of an event a snapshot carries. */ +export function isSnapshotEventName(name: unknown): boolean { + return ( + typeof name === "string" && + SNAPSHOT_EVENT_NAMES.has(name as DomainEvent["name"]) + ); +} 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/index.ts b/core/src/index.ts index a5c440d5d..dd3770f80 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,9 @@ export { RegisteredActivity, Stack, } from "./Stack"; +export { + NavigationEvent, + SnapshotEvent, + StackSnapshot, +} from "./StackSnapshot"; export { id } from "./utils"; diff --git a/core/src/initInfo.spec.ts b/core/src/initInfo.spec.ts new file mode 100644 index 000000000..450d9f807 --- /dev/null +++ b/core/src/initInfo.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('initInfo - create 경로에서 onInit의 initInfo.kind가 "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].initInfo).toEqual({ kind: "create" }); +}); + +test("initInfo - 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..01f3be7cd 100644 --- a/core/src/interfaces/StackflowPlugin.ts +++ b/core/src/interfaces/StackflowPlugin.ts @@ -9,10 +9,13 @@ import type { StepReplacedEvent, } from "../event-types"; import type { BaseDomainEvent } from "../event-types/_base"; +import type { SnapshotLoadError } from "../SnapshotLoadError"; +import type { SnapshotEvent, StackSnapshot } from "../StackSnapshot"; import type { StackflowPluginHook, StackflowPluginPostEffectHook, StackflowPluginPreEffectHook, + StackInitInfo, } from "./StackflowPluginHook"; export type StackflowPlugin = () => { @@ -128,10 +131,54 @@ export type StackflowPlugin = () => { onChanged?: StackflowPluginPostEffectHook<"%SOMETHING_CHANGED%">; /** - * Specifies the first `PushedEvent`, `StepPushedEvent` (Overrides the `initialActivity` option specified in the `stackflow()` function) + * Intercept the 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) — + * `Paused`/`Resumed` included when the snapshot recorded them. The + * return is adopted as the replay sequence with its event dates + * preserved: core never re-dates it, so replay order follows the + * recorded dates and a guarantee like "every restored activity is + * settled" is this hook's to provide, by re-dating the events itself. + * The return then runs 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: SnapshotEvent[]; initialContext: any; - }) => (PushedEvent | StepPushedEvent)[]; + initInfo: StackInitInfo; + }) => SnapshotEvent[]; + + /** + * 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; + // 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 + * 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..d45504959 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -1,7 +1,19 @@ import type { Effect } from "../Effect"; import type { StackflowActions } from "./StackflowActions"; -export type StackflowPluginHook = (args: { actions: StackflowActions }) => void; +/** + * 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; + initInfo: StackInitInfo; +}) => 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..a63d1bf95 --- /dev/null +++ b/core/src/loadPath.spec.ts @@ -0,0 +1,1254 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { + NavigationEvent, + SnapshotEvent, + 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를 충실히 재구성합니다", () => { + // Dates follow the recorded order (a snapshot's dates are its replay + // order): the push precedes the step it hosts. + const originalPushDate = enoughPastTime(); + const originalStepDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: originalPushDate, + }), + 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"); + // Snapshot events replay byte-for-byte — eventDate included. + expect(a?.steps[1].enteredBy.eventDate).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); +}); + +// --------------------------------------------------------------------------- +// load interception — the override chain runs on the replay sequence +// --------------------------------------------------------------------------- + +test('load - overrideInitialEvents 체인이 스냅샷 재생열 전체와 initInfo { kind: "load" }로 호출됩니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: SnapshotEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => args.initialEvents, + ); + + const { actions } = makeCoreStore({ + 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(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 - 체인이 추가한 이벤트도 재기저 없이 그 date 그대로 재생됩니다(fresh date면 전환 진행 중으로 복원)", () => { + 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 chain's return + // replays as-is, so the fresh push restores mid-transition; a + // plugin wanting a settled entry dates the event itself. + 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-active"); + expect(c?.isTop).toBe(true); + expect(stack.globalTransitionState).toEqual("loading"); +}); + +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 핸들러를 호출하지 않습니다", () => { + 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회 initInfo { kind: "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].initInfo).toEqual({ kind: "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 반환 시점에 복원 스택이 동기적으로 구성돼 있습니다(과거 date 스냅샷은 정착 상태)", () => { + 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 → unrecognized-snapshot +// --------------------------------------------------------------------------- + +test("load - $schema 불일치 스냅샷은 SnapshotLoadError{unrecognized-snapshot}로 실패합니다", () => { + 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).toEqual({ + kind: "unrecognized-snapshot", + detail: "$schema mismatch", + }); +}); + +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).toEqual({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); +}); + +test("load - events에 스냅샷이 나를 수 없는 항목(static event)이 있으면 unrecognized-snapshot로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + // A static event embedded in the snapshot. Statics are re-derived from + // the current config at load time — 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); + // The offending item's position is named for diagnosis (the valid Pushed at + // index 0 passes; the static item sits at index 1). + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 1 is not a snapshot event", + }); +}); + +test("load - events 항목에 id가 결손되면 unrecognized-snapshot로 실패합니다", () => { + 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).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a snapshot event", + }); +}); + +test("load - events 항목에 name이 결손되면 unrecognized-snapshot로 실패합니다", () => { + 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).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a snapshot event", + }); +}); + +// --------------------------------------------------------------------------- +// registration check → incompatible-events · L6 +// --------------------------------------------------------------------------- + +test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{incompatible-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("incompatible-events"); + if (cause.kind === "incompatible-events") { + expect(cause.detail).toBeDefined(); + } +}); + +test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{incompatible-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("incompatible-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-stack · L3 +// --------------------------------------------------------------------------- + +test("load - events가 빈 스냅샷은 SnapshotLoadError{empty-stack}로 실패합니다", () => { + const caught = catchLoad(snapshot([]), config(["A"])); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); +}); + +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.) + const caught = catchLoad( + snapshot([ + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); +}); + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +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].initInfo).toEqual({ kind: "create" }); +}); + +test('load - recover:"create" 재개가 overrideInitialEvents 체인과 initial-activity 핸들러를 포함한 create 파이프라인을 온전히 태웁니다', () => { + const onInit = jest.fn(); + const onInitialActivityIgnored = jest.fn(); + const overrideInitialEvents = jest.fn( + (_args: { + initialEvents: SnapshotEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => [ + 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). + // 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", + ]); + // ...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].initInfo).toEqual({ kind: "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가 {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: [] }), + 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" } }), + ); +}); + +// --------------------------------------------------------------------------- +// 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/loadSnapshot.ts b/core/src/loadSnapshot.ts new file mode 100644 index 000000000..71439e514 --- /dev/null +++ b/core/src/loadSnapshot.ts @@ -0,0 +1,137 @@ +import { aggregate } from "./aggregate"; +import type { DomainEvent } from "./event-types"; +import { isSnapshotEventName } from "./event-utils"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { Stack } from "./Stack"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; + +/** + * Reconstruct a stack from a provided snapshot by replaying its events + * through the existing aggregate machinery. The snapshot's events replay + * as-is — their recorded `eventDate`s are the replay truth (replay order + * follows the dates), so a stack captured mid-transition restores + * mid-transition and a paused stack restores paused. Core imposes no + * settling or normalization on the replay; a plugin that wants a stronger + * guarantee (e.g. a fully-settled restore) re-dates the sequence in + * `overrideInitialEvents`. Static information (transitionDuration, the + * registered-activity set) is re-derived from the current config's static + * events, never from the snapshot; only those static events are re-dated + * (see `backdateStaticEvents`). + * + * `overrideSnapshotEvents` 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 applies 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[], + overrideSnapshotEvents?: (events: SnapshotEvent[]) => SnapshotEvent[], +): { events: DomainEvent[]; stack: Stack } { + assertSnapshotStructure(snapshot); + + const snapshotEvents = + overrideSnapshotEvents?.(snapshot.events) ?? snapshot.events; + + const events: DomainEvent[] = [ + ...backdateStaticEvents(staticEvents, snapshotEvents), + ...snapshotEvents, + ]; + + let stack: Stack; + try { + stack = aggregate(events, Date.now()); + } catch (error) { + // A structurally-valid event sequence that the replay machinery rejects + // (e.g. `validateEvents`) is an incompatible-events failure, not a crash. + throw new SnapshotLoadError({ + kind: "incompatible-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 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 }; +} + +/** + * A value that is not a core-known v1 snapshot must fail loudly before any + * 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: "unrecognized-snapshot", + detail: "$schema mismatch", + }); + } + + const events: unknown = snapshot.events; + + if (!Array.isArray(events)) { + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); + } + + for (const [index, event] of events.entries()) { + if ( + !event || + typeof event !== "object" || + typeof (event as { id?: unknown }).id !== "string" || + !isSnapshotEventName((event as { name?: unknown }).name) + ) { + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: `event item at index ${index} is not a snapshot event`, + }); + } + } +} + +/** + * Date the static events to strictly increasing values just before the + * earliest replayed event, preserving their relative order. Statics must + * apply first: `Initialized` seeds `transitionDuration` for every later + * reducer step, and a snapshot whose tail is an unresumed `Paused` would + * quarantine statics sorted after it. Their natural dates cannot be trusted + * for that ordering — the current config's statics are dated "now", which + * falls after a past-dated snapshot — so they are pinned relative to the + * replay sequence instead of the clock. The replayed events themselves are + * never re-dated. + */ +function backdateStaticEvents( + staticEvents: DomainEvent[], + snapshotEvents: SnapshotEvent[], +): DomainEvent[] { + if (snapshotEvents.length === 0) { + return staticEvents; + } + + const earliestReplayDate = snapshotEvents.reduce( + (earliest, event) => Math.min(earliest, event.eventDate), + Number.POSITIVE_INFINITY, + ); + + return staticEvents.map((event, index) => ({ + ...event, + eventDate: earliestReplayDate - (staticEvents.length - index), + })); +} diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 297ac948c..9cc877ee1 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,11 +1,18 @@ import isEqual from "react-fast-compare"; import { aggregate } from "./aggregate"; -import type { DomainEvent, PushedEvent, StepPushedEvent } from "./event-types"; -import { makeEvent } from "./event-utils"; -import type { StackflowActions, StackflowPlugin } from "./interfaces"; +import type { DomainEvent } from "./event-types"; +import { isSnapshotEvent, makeEvent } from "./event-utils"; +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 { divideBy, once } from "./utils"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; +import { divideBy, once, uniqBy } from "./utils"; import { makeActions } from "./utils/makeActions"; import { triggerPostEffectHooks } from "./utils/triggerPostEffectHooks"; @@ -20,7 +27,7 @@ export type MakeCoreStoreOptions = { plugins: StackflowPlugin[]; handlers?: { onInitialActivityIgnored?: ( - initialPushedEvents: (PushedEvent | StepPushedEvent)[], + initialPushedEvents: SnapshotEvent[], ) => void; onInitialActivityNotFound?: () => void; }; @@ -49,39 +56,130 @@ 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; + // 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: SnapshotEvent[], + initInfo: StackInitInfo, + ): SnapshotEvent[] => + pluginInstances.reduce( + (events, pluginInstance) => + pluginInstance.overrideInitialEvents?.({ + initialEvents: events, + initialContext, + initInfo, + }) ?? events, + initialEvents, + ); - if (isInitialActivityIgnored) { - options.handlers?.onInitialActivityIgnored?.(initialPushedEvents); - } + /** + * 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 = overrideInitialEvents( + initialPushedEventsByOption, + { kind: "create" }, + ); - if (initialPushedEvents.length === 0) { - options.handlers?.onInitialActivityNotFound?.(); - } + const isInitialActivityIgnored = + initialPushedEvents.length > 0 && + initialPushedEventsByOption.length > 0 && + initialPushedEvents !== initialPushedEventsByOption; - const events: { value: DomainEvent[] } = { - value: [...initialRemainingEvents, ...initialPushedEvents], + if (isInitialActivityIgnored) { + options.handlers?.onInitialActivityIgnored?.(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 initInfo: { kind: "create" | "load" }; + let stackValue: Stack; + + if (suppliedSnapshots.length === 1) { + const { pluginInstance, snapshot } = suppliedSnapshots[0]; + + try { + const loaded = loadSnapshot(snapshot, initialRemainingEvents, (events) => + overrideInitialEvents(events, { kind: "load" }), + ); + events.value = loaded.events; + stackValue = loaded.stack; + initInfo = { kind: "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(); + initInfo = { kind: "create" }; + } + } else { + stackValue = createStack(); + initInfo = { kind: "create" }; + } + const stack = { - value: aggregate(events.value, new Date().getTime()), + value: stackValue, }; let currentInterval: ReturnType | null = null; @@ -90,6 +188,27 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { getStack() { return stack.value; }, + captureSnapshot() { + // A snapshot is the recorded event log as-is, minus the static events + // (`Initialized`/`ActivityRegistered`) the current config re-derives at + // load time. Core holds no opinion beyond that vocabulary split: + // `Paused`/`Resumed` and events queued behind a pause are exported + // exactly as recorded, so a paused stack round-trips as a paused stack. + // Whether to capture at such a moment is the caller's timing choice. + // + // Normalize the log the way aggregate pre-processes — sort by eventDate + // ascending, dedupe by id. Dates are preserved, so this changes no + // replay semantics; it only makes the array order the replay order. + const snapshotEvents = uniqBy( + [...events.value].sort((a, b) => a.eventDate - b.eventDate), + (event) => event.id, + ).filter(isSnapshotEvent); + + return { + $schema: "stackflow.snapshot.v1", + events: snapshotEvents, + }; + }, dispatchEvent(name, params) { const newEvent = makeEvent(name, params); @@ -153,6 +272,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { pluginInstances.forEach((pluginInstance) => { pluginInstance.onInit?.({ actions, + initInfo, }); }); }), diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts new file mode 100644 index 000000000..180e1739f --- /dev/null +++ b/core/src/persisterRoundtrip.spec.ts @@ -0,0 +1,176 @@ +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(), + }); + +/** + * 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 }; + + const persister = ( + onInit: ReturnType["onInit"], + ): StackflowPlugin => { + return () => ({ + key: "persister", + onChanged({ actions }) { + memory.value = JSON.stringify(actions.captureSnapshot()); + }, + provideSnapshot: () => decodeOrDiscard(memory), + 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"); + // The article was pushed (and captured) moments ago, so its as-is replay + // is still mid-transition — the restored stack picks up exactly where the + // captured one left off and settles on its own clock. + expect(article?.transitionState).toEqual("enter-active"); + expect(article?.isTop).toBe(true); + expect((home?.zIndex ?? -1) < (article?.zIndex ?? -1)).toBe(true); + expect(onInit2.mock.calls[0][0].initInfo).toEqual({ kind: "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: () => decodeOrDiscard(memory), + 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].initInfo).toEqual({ kind: "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].initInfo).toEqual({ kind: "create" }); +}); diff --git a/core/src/provideSnapshot.spec.ts b/core/src/provideSnapshot.spec.ts new file mode 100644 index 000000000..475b940ab --- /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", + // The contract allows returning `undefined` (a bare `return;`) — core + // must treat it like `null`: nothing to provide. + provideSnapshot: () => undefined, + }); + + 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..75a5cc7e7 --- /dev/null +++ b/core/src/rebase.spec.ts @@ -0,0 +1,441 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { SnapshotEvent, 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: SnapshotEvent[]): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: (): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, + }), + }); + +test("load - 스냅샷 이벤트의 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"); + + // The snapshot events are preserved byte-for-byte — eventDate included. + expect(a?.id).toEqual("a1"); + expect(a?.enteredBy.id).toEqual("e1"); + expect(a?.enteredBy.eventDate).toEqual(originalPushDate); + expect(a?.steps[1].id).toEqual("s2"); + expect(a?.steps[1].enteredBy.id).toEqual("e2"); + expect(a?.steps[1].enteredBy.eventDate).toEqual(originalStepDate); +}); + +test("load - 재생 순서는 기록된 eventDate 순서입니다(배열 순서 아님)", () => { + // Core-captured snapshots normalize array order to date order, so the two + // never disagree in practice; for a hand-crafted snapshot that disagrees, + // the dates are the replay truth (aggregate sorts by eventDate). + const earlierDate = enoughPastTime(); + const laterDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin([ + // Array order [A, B] disagrees with date order (A is dated later). + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: laterDate, + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: earlierDate, + }), + ]), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + const b = actions.getStack().activities.find((x) => x.id === "b1"); + + // Date order wins: B (earlier) below, A (later) on top. + expect((b?.zIndex ?? -1) < (a?.zIndex ?? -1)).toBe(true); + expect(a?.isTop).toBe(true); +}); + +test("load - 과거에 기록된 스냅샷은 정착 상태(enter-done·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(), + }), + ]), + ], + }); + + // The recorded dates predate now − transitionDuration, so the as-is replay + // itself folds to a settled stack — no re-dating involved. + 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 - 미래 date 스냅샷(캡처 세션 시계 선행)도 그대로 재생되며 core는 정규화하지 않습니다", () => { + // As-is replay accepts the recorded dates as truth even when the capture + // session's clock ran ahead — the restored activities stay unsettled until + // the local clock catches up. A supplier or plugin that wants to rule this + // out normalizes the dates in overrideInitialEvents. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: futureTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: futureTime(), + }), + ]), + ], + }); + + const stack = actions.getStack(); + expect(stack.activities.find((x) => x.id === "a1")?.transitionState).toEqual( + "enter-active", + ); + expect(stack.activities.find((x) => x.id === "b1")?.transitionState).toEqual( + "enter-active", + ); + expect(stack.globalTransitionState).toEqual("loading"); +}); + +test("load - 플러그인이 overrideInitialEvents에서 재기저하면 정착 복원을 스스로 보장할 수 있습니다", () => { + // The escape hatch for the test above: a plugin re-dates the replay + // sequence into the settled past, restoring a fully-settled stack from the + // same future-dated snapshot. + const settlePlugin: StackflowPlugin = () => ({ + key: "settler", + overrideInitialEvents: ({ initialEvents, initInfo }) => + initInfo.kind === "load" + ? initialEvents.map((event, index) => ({ + ...event, + eventDate: Date.now() - MINUTE + index, + })) + : initialEvents, + }); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: futureTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: futureTime(), + }), + ]), + settlePlugin, + ], + }); + + const stack = actions.getStack(); + expect( + stack.activities.every((x) => x.transitionState === "enter-done"), + ).toBe(true); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("b1"); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - 이벤트 수와 무관하게 정적 이벤트가 모든 스냅샷 이벤트보다 앞 시점으로 재기저됩니다", () => { + // Only the statics are re-dated: pinned just before the earliest snapshot + // event so registration and transitionDuration apply before any replayed + // event, for any history length. + const transitionDuration = 350; + const staticEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + ]; + const snapshotEvents = Array.from({ length: 400 }, (_, index) => + makeEvent("Pushed", { + activityId: `a${index}`, + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ); + const originalDates = snapshotEvents.map((e) => e.eventDate); + + 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 replayedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + // Statics sort strictly before every snapshot event; the snapshot events + // themselves keep their recorded dates untouched. + expect(Math.max(...staticDates)).toBeLessThan(Math.min(...replayedDates)); + expect(replayedDates).toEqual(originalDates); + + const stack = store.actions.getStack(); + expect(stack.globalTransitionState).toEqual("idle"); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a399"); +}); + +test("load - transitionDuration이 0이어도 정적 이벤트가 스냅샷 이벤트보다 앞에 정렬됩니다", () => { + // Static backdating is pinned to the earliest snapshot event, not to the + // clock or the transition duration, so td=0 (where fresh static dates and + // fresh navigation dates would collide) plays no role in the ordering. + 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 replayedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + expect(Math.max(...staticDates)).toBeLessThan(Math.min(...replayedDates)); + + 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 - 스냅샷 꼬리가 unresumed Paused여도 정적 이벤트는 그보다 앞에 적용되어 격리되지 않습니다", () => { + // Statics sorted after an unresumed Paused would be quarantined with the + // queued events — leaving the restored stack with transitionDuration 0 and + // no registered activities. Backdating statics ahead of the whole replay + // sequence rules that out structurally. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Paused", { + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + const stack = actions.getStack(); + // The statics applied before the pause: config-derived state is intact. + expect(stack.transitionDuration).toEqual(350); + expect(stack.registeredActivities.map((x) => x.name)).toEqual(["A", "B"]); + // The pause itself round-tripped: b1 stays quarantined behind it. + expect(stack.globalTransitionState).toEqual("paused"); + expect(stack.activities.map((x) => x.id)).toEqual(["a1"]); +}); + +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, sorts after the past-dated + // restored 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 lands on the restored top because the preserved past dates keep 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"]); +}); diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts new file mode 100644 index 000000000..0e0d9fa4b --- /dev/null +++ b/core/src/snapshotRoundtrip.spec.ts @@ -0,0 +1,155 @@ +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(); + + // The replay preserves snapshot events byte-for-byte (eventDate included) + // and capture exports them back as-is, so capture∘load is an identity on + // the snapshot events. + expect(recaptured.events).toEqual(original.events); + + 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"); +}); + +test("load - pause 중 캡처한 스냅샷은 paused 스택으로 복원되고 resume하면 큐잉된 항해가 적용됩니다", () => { + 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: {}, + }); + + // Queued behind the pause, b1 is not applied to the live stack. + 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(); + } + + // The snapshot records the paused stack as-is: the Paused marker together + // with the queued push. + expect(captured.events.map((e) => e.name)).toEqual([ + "Pushed", + "Paused", + "Pushed", + ]); + + const restored = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(captured)], + }); + + // The paused stack round-trips as a paused stack: b1 stays quarantined, so + // the restored visible state matches what was visible at capture time. + const stack = restored.actions.getStack(); + expect(stack.globalTransitionState).toEqual("paused"); + expect(stack.activities.map((x) => x.id)).toEqual(["a1"]); + + // Resuming the restored session applies the queued navigation — the pending + // push survived the reload instead of being lost or force-applied. The + // previous session's pauser is gone, so resuming is the new session's call. + restored.actions.resume(); + expect( + restored.actions.getStack().activities.map((x) => x.id), + ).toEqual(["a1", "b1"]); +}); 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( diff --git a/design-fep-2548-init-load-mechanism.md b/design-fep-2548-init-load-mechanism.md new file mode 100644 index 000000000..74aecf253 --- /dev/null +++ b/design-fep-2548-init-load-mechanism.md @@ -0,0 +1,841 @@ +# Stackflow core — Stack 초기화의 create/load 구분 메커니즘 설계 (FEP-2548) + +> 이 문서는 stackflow core에 Stack 초기화(bootstrap) 시 생성(create)과 복원(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차 처리 (공급자 전용) | + +여기에 기존 `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). + +--- + +## 1. 문제와 요구사항 + +### 1.1 문제 + +core에는 현재 "Stack이 어떻게 태어났는가"의 어휘가 없다. 모든 생성은 `makeCoreStore(initialEvents)` +한 경로이며, 초기 진입은 플러그인의 `overrideInitialEvents`로만 변형할 수 있다(§10-S3). +그 결과: + +- **persister(FEP-2546)**: 탐색 맥락을 보존했다가 되살리는 왕복(캡처→보존→load)을 core 계약만으로 + 구성할 방법이 없다. 스냅샷의 형식·캡처·복원이 전부 미정의다. +- **activity guard(FEP-2521)**: 런타임 push는 `onBeforePush`로 가로챌 수 있지만, 생성 시점의 + 최초 진입은 액션 파이프라인을 타지 않아(§10-S4) 가로챌 표면이 없다. 또한 "복원된 스택은 보존 + 시점에 이미 검증되었으므로 가드를 건너뛴다"는 정책을 세울 근거(create/load 구분)가 없다. +- **history-sync(FEP-2001)**: "스택을 진실의 원천으로 삼아 브라우저 히스토리를 동기화"하는 방향으로 + 개정하려면, 자신이 URL을 해석해 스택을 만드는 경우(create)와 보존된 스택을 되살려 히스토리를 맞추는 + 경우(load)를 구분할 신호가 필요하다. + +### 1.2 확정 요구사항 (정본: Linear FEP-2548, `CONTEXT.md`) + +- **R1** load 경계 소스 불문: 스냅샷 복원은 저장 매체 무관 전부 load +- **R2** 이진 분류: core 어휘는 create/load 둘뿐 (deep link 세분은 core 어휘 아님) +- **R3** 생성 시점 동기 load만: "복원 대기 중" 중간 상태 없음, 비동기 소스는 상위 레이어 부트스트랩 문제 +- **R4** load 실패 = 명시적 에러 (조용한 폴백 금지) +- **R5** 에러 1차 처리자 = 스냅샷 공급자 (앱 개발자는 기본 무관여) +- **R6** create 진입은 가로채기 가능(의미상 push), load 진입은 가로채기 대상 아님 +- **R7** create/load 구분은 생성 시점 일회성 신호 (지속 속성 아님) +- **R8** non-breaking: `overrideInitialEvents` 유지, 그 결과는 create 취급 +- **R9** 단일 스냅샷 자리: 생성은 스냅샷 최대 1개, 경합 조정은 core 위 계층 +- **R10** 스냅샷 왕복(캡처→보존→load)은 core 계약만으로 닫힘 +- **R11** load 사후조건: 스냅샷이 보존한 정상 상태(불변식 충족 = 도달 가능 상태)의 충실한 재구성 +- **R12** 복원 범위: 탐색 기록(`stack.activities`)이 필수, 나머지(transitionDuration· + globalTransitionState·pausedEvents·registeredActivities)는 부가·복원 비필수. 전환 정보 폐기는 열린 선택 +- **R13** 직렬화는 core 밖: codec은 스냅샷 사용자 책임. core는 `activityParams`·`activityContext`에 + 어떤 값이 와도 동작을 전제하지 않음. "형식 소유"는 구조의 소유이며 보존 매체 인코딩은 제외 + +**비목표**: late load / create 하위 세분화의 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 = "복원할 것 없음" → create + }, + + // [에러 1차 처리] 손상 스냅샷은 내가 치우고 create 재시도를 지시한다. + onLoadError({ error }) { + storage.remove(); + report(error); + return { recover: "create" }; // 명시적 결정 — 조용한 폴백이 아님 + }, +}); +``` + +### 2.2 activity guard (FEP-2521) — create 가로채기·load 스킵 + +```ts +// 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(); + } + }, + + // [검증 벨트] 순서 오배치로 인한 침묵 우회를 개발 시점의 큰 소리로 전환한다. + // 집행이 아니라 검증이다(사후 축출은 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 +// 미래의 history-sync: history.state에 스냅샷을 실어 두고 복원 시 공급자가 된다. +provideSnapshot() { + return decodeSnapshotFromHistoryState(history.location.state); // 없으면 null +}, +onInit({ actions, initializedBy }) { + if (initializedBy === "load") { + rewriteBrowserHistoryToMatch(actions.getStack()); // 스택이 진실 — history를 맞춘다 + } else { + /* 현행 create 동작 그대로 */ + } +}, +``` + +역산 결과가 §0의 네 표면 + `onInit` 인자 1개다. 특히 create 진입 가로채기는 새 표면이 아니라 기존 +`overrideInitialEvents`의 용법으로 성립한다(§2.2). 이 외의 표면(신규 도메인 이벤트, Stack 상태 속성, +react 표면, `makeCoreStore` 옵션, create 전용 가로채기 훅)은 어떤 소비자의 사용 코드에도 등장하지 +않으므로 만들지 않는다. + +--- + +## 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로 스스로 강등하는 + 것과 같은 역할 분담이다. 이 분담의 귀결로 서버는 create·클라이언트는 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: "create" } 반환 → core는 create 경로로 생성을 계속한다(공급자의 명시적 결정). + * void 반환 또는 핸들러 부재 → SnapshotLoadError가 makeCoreStore 밖으로 던져진다(R4). */ + onLoadError?: (args: { + error: SnapshotLoadError; + initialContext: any; + }) => { recover: "create" } | 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: "create" }`는 "조용한 폴백 + 금지(R4)"와 "공급자가 복구 정책 결정(R5)"을 한 지점에서 화해시킨다 — 폴백이 일어나되, 스냅샷을 + 폐기하고 로그를 남긴 공급자의 명시적 서명이 있는 폴백이다. 핸들러 부재·void 반환 시 throw가 + 기본값인 것은 R4의 안전측이다(에러를 다루지 않는 공급자의 스냅샷 실패는 시끄럽게 죽는다). +- **복구는 재폴링하지 않는다**: `{ recover: "create" }`는 create 경로의 초기 이벤트 파이프라인부터 + 재개하며 `provideSnapshot`을 다시 폴링하지 않는다 — 무한 루프를 차단하고 load 시도를 생성당 + 1회로 고정한다(R3). +- 에러 채널은 동기다: load가 생성 시점 동기(R3)이므로 복구 결정도 동기여야 한다. 비동기 채널 + (이벤트·프라미스)은 "복원 대기 중" 중간 상태를 재도입하므로 두지 않는다. + +### 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). 초기 진입이 + `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은 확률이 아니라 보장이어야 한다. + +그래서 create 가로채기는 액션 파이프라인이 아니라 `overrideInitialEvents` 체인(pre-aggregate 데이터 +변형)에 둔다 — 이 위치가 위 두 오발화를 구조적으로 차단한다. + +기각한 다른 대안: + +- **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) + +```ts +/** onInit 인자 확장(순수 additive) — Stack 상태에는 어떤 흔적도 남지 않는다. */ +onInit?: (args: { + actions: StackflowActions; + initializedBy: "create" | "load"; +}) => void; +``` + +- 신호는 `onInit` 인자로만 존재한다. Stack에 조회 가능한 지속 속성이 없고, 신규 도메인 이벤트도 + 없으므로 이벤트 로그에도 구분의 흔적이 없다 — R7과 비목표("구분의 지속 속성화")가 by construction + 성립한다. 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야 한다는 `CONTEXT.md`의 관계 + 정의 그대로다. +- 이름이 `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`에서 `initializedBy === "load"`를 + 본 소비자는 `getStack()`의 모든 activity가 복원물임을 이미 알고, 이후 push되는 activity는 정의상 + 신규다. 세 소비자의 사용 코드(§2) 어디에도 per-activity 출처 조회가 등장하지 않는다. 재생된 + activity의 `enteredBy`는 스냅샷 속 원본 Pushed/Replaced 이벤트로 유지되어(§10-S9), 생성 이후의 + 세계는 create 출신과 구별 불가능하게 균질하다. 기각 대안 — `enteredBy`에 Loaded류 마킹이나 신규 + 이벤트 어휘: 새 도메인 이벤트 + 지속 속성 + 소비자 부재의 삼중 지불. 관측 요구(devtools 등)가 + 실증되면 `onInit` 신호를 구독해 자체 기록하는 비침습 경로가 있다. + +--- + +## 4. 생성 시퀀스 + +### 4.1 create 경로 — 스냅샷 공급자 없음 (전원 null) + +``` +makeCoreStore(options) + 1. 플러그인 인스턴스화 (현행 그대로) + 2. provideSnapshot 전원 폴링 → 전부 null → create 경로 확정 + 3. options.initialEvents를 [Pushed/StepPushed | 나머지(정적)]로 분리 (현행 §10-S3) + 4. overrideInitialEvents 체인 (현행 그대로 — 무변경 유지, R8) + — 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가 초기 진입을 전부 strip하면(overrideInitialEvents가 빈 배열 반환) 5에서 +`onInitialActivityNotFound`가 발화하고 activity 0개 스택으로 생성된다 — "초기 activity 없음"은 오늘도 +정의된 상태이며(§10-S3의 빈 배열 경로) 같은 상태에 착지한다. 이 판정은 `overrideInitialEvents`가 +무변경이므로 현행과 동일하다(가로채기 파이프라인 신설이 없어 Ignored 의미 확장도 불필요하다). + +### 4.2 load 경로 — 정확히 하나가 non-null + +``` +makeCoreStore(options) + 1. 플러그인 인스턴스화 + 2. provideSnapshot 전원 폴링 + — non-null 0개 → create 경로(§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)는 폐기: 스냅샷이 곧 탐색 기록이며, + "무엇으로 시작하는가"는 create 경로의 어휘다. 복원된 탐색 위에 initialActivity를 겹치면 + 보존 시점에 없던 activity가 생겨 R11(충실한 재구성)을 깬다 + — 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: "create" } 반환 → §4.1의 3부터 재개 (provideSnapshot 재폴링 없음) + — void/핸들러 부재 → makeCoreStore 밖으로 throw + 8. (성공 시) store 완성 + 9. store.init() → onInit({ actions, initializedBy: "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`는 시그니처·호출 시점·의미 전부 무변경이다. 그 결과는 create 취급이며(R8), + create 가로채기 소비자(guard)는 바로 이 `overrideInitialEvents` 체인 안에서 초기 이벤트를 검사· + strip·치환한다(§3.5) — deep link(URL 해석)도 create이고 guard의 적용 대상이라는 `CONTEXT.md` + 정의와 정확히 맞물린다. +- `store.init()`/`onInit`의 호출 주체·타이밍(react 통합이 브라우저에서 1회 호출, §10-S13·S10)도 + 무변경이다. load 경로에서도 같은 지점에서 발화하며 신호만 다르다. + +--- + +## 5. 불변식 + +**경로 공통** + +- **C1 (경로 배타)**: 한 번의 생성은 정확히 create 또는 load 하나를 탄다. load 실패 복구는 공급자의 + 명시적 `{ recover: "create" }` 결정으로만 create에 재진입하며, 재진입 후 다시 load로 돌아올 수 없다 + (재폴링 없음). +- **C2 (자리 하나)**: non-null 공급 2개 이상 = 생성 에러. core는 조정하지 않는다 — 조정자가 아니라 + 강제자다(R9). +- **C3 (신호 일회성)**: `initializedBy`는 생성 시점 훅 인자로만 존재하고 Stack 상태·이벤트 로그 어디에도 + 남지 않는다(R7). +- **C4 (생성 중 무발화)**: 생성 완료 전에는 어떤 post-effect 훅도 발화하지 않는다(§10-S2). + +**create 경로** + +- **N1 (현행 동일성)**: 스냅샷 공급자가 없으면(`provideSnapshot` 전원 null) create 시퀀스는 오늘의 + 코드 경로와 관찰상 동일하다. create 경로에 신규 step이 없고 `overrideInitialEvents`가 무변경이므로, + guard 등 create 가로채기 소비자의 설치 여부와 무관하게 성립한다(R8의 구조적 근거 — §6 R8). +- **N2 (핸들러 보존)**: initial activity 핸들러(ignored/notFound)는 `overrideInitialEvents` 체인 + 결과에 대해 현행과 같은 판정으로 발화한다(§10-S13). + +**load 경로** + +- **L1 (무가로채기)**: `overrideInitialEvents` 체인이 호출되지 않는다(R6) — load 분기가 초기 이벤트 + 파이프라인 자체를 건너뛰므로 규약이 아니라 구조의 귀결이다. 그 체인에서 create 진입을 가로채는 + guard도 load 경로에서는 구조적으로 작동하지 않는다. +- **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 어휘는 `initializedBy: "create" \| "load"`뿐. deep link는 create의 내부 사정(`overrideInitialEvents`)으로 남음 | +| R3 동기 load | `provideSnapshot`은 생성 중 동기 호출·동기 반환. 비동기 소스는 생성 지연(상위 부트스트랩) + 인라인 공급 플러그인. 에러 복구 결정(`onLoadError`)도 동기 | +| R4 명시적 에러 | `SnapshotLoadError` 3분류 + 기본 throw. 폴백은 공급자의 명시적 `{ recover: "create" }`로만. `empty-navigation`도 에러로 승격. 미등록 activity 물화(Pushed·Replaced 불문)는 load 등록 검사가 잡음(L6) | +| R5 공급자 1차 처리 | `onLoadError`는 스냅샷을 공급한 플러그인에게만 호출. 앱 개발자는 잘 만든 공급자 뒤에서 무관여 | +| 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의 설계 수준 증명) | +| 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다**: 옵셔널 훅 2개(`provideSnapshot`·`onLoadError`) + actions + 메서드 1개 + `onInit` 인자 필드 1개 + 타입 2개. 기존 시그니처 변경 0, 기존 훅 제거 0, + `makeCoreStore` 옵션 변경 0, `aggregate`/`validateEvents`/리듀서 변경 0, `overrideInitialEvents` + 변경 0. +2. **스냅샷 미공급 시 코드 경로가 오늘과 동일하다(N1)**: 공급자가 없으면 폴링은 전부 null이고 + 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. **create 진입을 액션 파이프라인에 태우지 않는 것 자체가 R8 장치다**: §3.5의 비통일 논증 — + post-effect 발화(history-sync `pushState` 중복)와 pre-effect 오발화(loader plugin의 생성 중 + `pause()`)를 구조적으로 차단한다. create 가로채기를 `overrideInitialEvents`(pre-aggregate 데이터 + 변형)에 두는 것이 이 차단의 위치다. + +**비목표 정합**: late load 없음(생성 시점 전용, 재폴링 없음) / create 세분화 없음(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이면 create. +4. **재구성**: core가 구조·등록 검사→재기저→재생→사후조건(§4.2-3~6). 성공 시 `onInit({ initializedBy: "load" })`. + 결과 스택의 탐색 기록은 캡처 시점과 일치(L3)하고, 이후 항해는 오늘과 동일한 액션 경로. +5. **실패 처리**: 손상 스냅샷이면 core가 이 공급자의 `onLoadError`만 호출(§3.4) — 공급자는 스냅샷을 + 폐기하고 `{ recover: "create" }` 반환 → 앱은 초기 화면으로 정상 기동. 앱 개발자 코드는 어디에도 + 등장하지 않는다(R5). +6. **왕복 재개**: load 직후 `captureSnapshot()`이 같은 탐색 기록을 재구성하는 스냅샷을 반환(L5)하므로 + 주기적 persist가 자연스럽다. + +1–6에 등장한 표면은 `captureSnapshot`·`StackSnapshot`·`provideSnapshot`·`onLoadError`·`onInit(initializedBy)` +— 전부 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) — create 가로채기·load 스킵 + +- **런타임 push**: 기존 `onBeforePush`(현행 파이프라인, §10-S5) — 무변경. +- **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 미개정)**: 공급자가 없으므로 모든 생성이 create + 경로다. history-sync의 `overrideInitialEvents`(history.state/URL 해석)·`onInit`(히스토리 정렬)· + `onPushed`(pushState) 전부 오늘과 동일하게 발화한다(R8). `overrideInitialEvents`가 만든 초기 + 이벤트는 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`가 비어 있으면(콜드 스타트) + 현행 로직이 복원된 스택 전체를 브라우저 히스토리에 정렬한다(§10-S18) — 이미 올바른 방향이다. + state가 있으면(리로드) 정렬을 건너뛰는데, 원본 activity id 보존(RB5) 덕에 같은 세션의 state와는 + id가 일치해 popstate 방향 판정이 동작하나, 스냅샷과 브라우저 히스토리가 어긋난 경우의 정합은 + 미보장이다 — **이 간극을 닫는 것이 FEP-2001의 범위**이며, 그때까지 persister 문서가 이 조합의 + 한계를 명시해야 한다. +- **국면 3 — 목표 (FEP-2001 개정 후)**: history-sync 자신이 공급자가 된다 — `provideSnapshot`으로 + history.state에서 스냅샷을 공급하고(§2.3), `onInit`에서 `initializedBy === "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 | 초기 이벤트는 액션 파이프라인을 타지 않고 직접 집계 — 현행 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` | +| 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 검증) | +| 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` | + +--- + +## 11. 트레이드오프 + +**지불하는 것** + +- `makeCoreStore` 생성부의 분기 증가(폴링 → load 시도 → 실패 복구 → create 재개). 생성 시퀀스의 + 상태도가 오늘의 직선 코드보다 하나 늘어난다. +- **스냅샷-이벤트 어휘 결합**: 스냅샷 호환성이 core 이벤트 스키마의 안정성에 묶인다. 이벤트 필드가 + 비호환으로 바뀌면 옛 스냅샷은 load 실패한다. "비호환 = load 실패"가 비목표로 승인돼 있어 계약 + 위반은 아니지만, 이벤트 스키마 변경의 비용을 키운다는 사실은 남는다 — `$schema` 태그가 그 비용을 + 조용한 오동작이 아니라 명시적 에러로 바꾼다. +- guard는 두 어댑터를 유지한다 — 런타임(`onBeforePush`+preventDefault)과 create(`overrideInitialEvents` + 배열 필터/치환)의 모양이 달라 정책 함수를 두 어댑터에 물린다. 여기에 순서 규율(guard를 초기 이벤트 + 생성자 뒤 배치)과 검증 벨트(`onInit`) 구현이 더해진다. 이 이원화·규율·벨트의 지불 주체는 guard + 플러그인 저자이지 앱 개발자가 아니며, 전용 create 훅을 두지 않은 대가다 — 그 대신 공개 표면이 하나 + 줄고, breaking 없이 후에 전용 훅을 additive로 증분할 옵션이 보존된다(전용 훅은 순서 규율·벨트 없이 + 구조적 집행을 보장했을 것이나, 지금 지불할 표면이 아니다 — §3.5). +- load 등록 검사 1건 — 등록 술어가 두 곳(런타임 `validateEvents`의 Pushed 검사, load의 + activity 도입 이벤트 전수 검사)에서 적용된다. 술어 자체는 동형이지만 규칙 변경 시 함께 고칠 + 지점이 둘이 되는 소폭의 긴장. 런타임 `validateEvents`의 전역 확장(독립적 core 수정 후보 — + §3.4)이 채택되면 한 곳으로 합쳐진다. +- 이벤트 이력은 세션 길이에 비례해 자란다 — compaction 로드맵(§9)으로 계약 무변경 완화가 예약되어 + 있으나 v1에서는 장수 세션의 스냅샷이 비대해질 수 있다. + +**획득하는 것** + +- **표면 최소**: 신규 개념은 타입 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는 기존 + `overrideInitialEvents`의 strip/치환 + 순서 규율 + `onInit` 검증 벨트로 성립하며, history-sync는 + 신호 분기 하나로 개정 경로가 열린다. +- **왕복 안정(L5)**: load 직후 재캡처가 안정적이라 주기적 persist가 자연스럽다. diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 66a1f6b03..b77ca12e3 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, + SnapshotEvent, 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); 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-impl.md b/run-plan-fep-2548-impl.md new file mode 100644 index 000000000..a32428759 --- /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 | 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 | 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 | 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. 인라인 자산 정의 + +해당 없음 (자산 워크플로우·자산 role·자산 lens만 사용). + +## 5. 설계 메타 + +- **사용자 명시(디폴트보다 우선)**: ① 전 Claude 세션 모델 opus-4.8[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에는 + 비반영. 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에는 비반영. 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 재실행).