Skip to content

Commit a9dc2c6

Browse files
committed
add explicit replay store recovery
1 parent 652fa11 commit a9dc2c6

10 files changed

Lines changed: 381 additions & 65 deletions

File tree

Agents.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,10 @@ chrome-devtools take_screenshot --fullPage --filePath .tmp-devtools-full.png
144144
- UI 事件优先用 `on_store_click(name, action = ..., dispatch = ...)` / `on_store_input(...)` 发送 typed action,不直接操作 state tree。
145145
- domain action 事件优先用 `on_action_click(name, action = ..., dispatch = ...)` / `on_action_input(...)` / `on_action_enter(...)`,不要在每个 element 内重复 forwarding closure。
146146
- 只有包含额外分支、组合更新或直接 model 输入的 handler 才使用 `on_local_*`
147-
- 一个 store 把 state 类型、action 类型、纯 reducer 和 versioned codecs 定义在一起;codec 只在 store 定义处出现,不传进组件调用。
148-
- `Store_spec(...)``State_codec(...)``Action_codec(...)` 的定义统一使用 labelled fields,显式写出 `name/schema/version/decode/encode/reduce`,不要依赖难以辨认的位置参数顺序。
147+
- 一个 store 把 state 类型、action 类型、纯 reducer、action codec 和显式 recovery policy 定义在一起;codec 只在 store 定义处出现,不传进组件调用。
148+
- store 统一通过 labelled `snapshot_store(...)` / `replay_store(...)` 构造,不让业务模块直接依赖 `Store_spec(...)` 的内部字段顺序。`State_codec(...)``Action_codec(...)` 仍显式写出 `schema/version/decode/encode`
149+
- `snapshot_store(...)` 适合累积、toggle 或无法安全压缩的状态;`replay_store(...)` 只适合有明确 session 边界的纯 component actions,并使用 `Replay_start` / `Replay_replace(slot)` / `Replay_reset` 声明恢复语义。
150+
- replay slot 必须是有限、稳定的语义名称;不要用动态业务 id/用户输入制造 slot,也不要通过丢弃最早 action 强行限制日志。无法安全压缩时回到 snapshot store。
149151
- scope/path/slot/tree 属于 framework/runtime 细节;业务 view 只声明 keyed boundary,不手工拼 path。
150152
- 单个 keyed boundary 优先写成 `component(group, key) { ... }` / `feature_root(group, key) { ... }`,用 Koka trailing-lambda 语法让 lifecycle 边界有鲜明特征;列表仍用 labelled `components(...)` 保持 key/render 映射清楚。
151153
- `component(...)` / `components(...)` 表示 ordinary child:当前 feature 仍 render、但 child 不再出现时,其 local store 与 effect metadata 会自动清理。filter/条件分支隐藏 child 等同 unmount。
@@ -193,7 +195,7 @@ button(
193195
- component store 由 `use_store(...)` 返回的 dispatch 自动发 observation,业务 view 不重复埋点。
194196
- app/runtime 边界通过 `run_runtime_action_observed(...)` 获取有序 action 列表;不需要观察的测试或内部调用使用 `run_runtime_action(...)`
195197
- observation 表示“已发送 intent”,不表示 reducer 成功或外部 effect 已提交。confirm 拒绝的 action 仍可被观察。
196-
- 未建立权限、effect response 和幂等策略前,不自动 replay,也不把 action log 混入 component-state snapshot
198+
- `replay_store(...)` 的 scoped pure component-action log 可以进入 component snapshot,但与统一 observation stream 分开;未建立权限、effect response 和幂等策略前,不自动 replay domain/external-effect actions
197199
- 新增 domain action 时,codec 与 action/reducer 放在同一 feature 模块,并覆盖 schema/version/payload 的 round-trip 测试。
198200

199201
## Element 调用约定
@@ -219,6 +221,7 @@ div([
219221
- scheduled effects 按组件求值顺序收集;不要把跨组件的 effect 顺序当作数据依赖。
220222
- snapshot entry 必须保留稳定 path、schema、version、payload;decoder 对 malformed payload、schema/version 不匹配安全回退。
221223
- `respo/component-scope` 是 runtime-owned lifecycle marker,会进入 snapshot;业务模块不得读取、构造或修改。ordinary child sweep 由 framework visitation 驱动,不在 reducer 中重建 path。
224+
- replay entry 使用 `respo/replay:<action-schema>`,只由 `replay_store(...)` 读写;业务 view 仍只使用 `(state, dispatch) = use_store(...)`。恢复策略与选择标准见 `docs/store-recovery.md`
222225
- `src/main.js` 负责 localStorage 与 Vite HMR hand-off。修改浏览器桥时要验证 replacement 前 flush、dispose 和 `pagehide` 三条路径。
223226
- snapshot 只是组件临时状态恢复机制,不替代业务数据持久化。
224227
- lifecycle 规则和旧 snapshot 兼容限制见 `docs/component-lifecycle.md`

PLAN.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@
4747

4848
### Typed component store
4949

50-
- `store_spec<s,a>` 将 state codec、action codec 和纯 reducer 组合在一起;
51-
- store 与 codec 定义使用完整 labelled fields,使 runtime identity、snapshot schema、action schema 与 reducer 职责在声明处清晰分组;
50+
- `store_spec<s,a>` 将 action codec、纯 reducer 和显式 recovery policy 组合在一起;
51+
- `snapshot_store(...)` 保存完整 typed state,适合累积、toggle 或不能安全压缩的 reducer;
52+
- `replay_store(...)` 保存当前 session 内压缩后的 typed component actions,不要求 state codec;
53+
- replay policy 只提供 `Replay_start` / `Replay_replace(slot)` / `Replay_reset`,框架不通过截断任意 action 历史换取有界日志;
54+
- Todo editor 使用 replay recovery,Lab incident 继续使用 snapshot recovery,组件调用保持同一个 `use_store(...)` reducer pair;
55+
- store 与 codec 定义使用完整 labelled fields,使 runtime identity、recovery、action schema 与 reducer 职责在声明处清晰分组;
5256
- 组件调用收敛为 `(state, dispatch) = use_store(spec, initial = ...)`,对齐 React `useReducer`
5357
- `on_store_click(...)` / `on_store_input(...)` 使用 labelled `action + dispatch` 直接发 typed action;
5458
- binding record 与 `.current` / `.send` 不再暴露给业务 view;
@@ -80,6 +84,7 @@
8084
- snapshot 使用 path + schema + version + payload;
8185
- ordinary child ownership marker 会进入 snapshot;feature 恢复后可继续判断 stale child,旧版无 marker 的孤立 entry 不做不安全的 path 推断;
8286
- malformed snapshot、unknown schema 和 version mismatch 会安全回退;
87+
- replay store 使用 `respo/replay:<action-schema>` entry,从当前 `initial` 和 decoded component actions 恢复;
8388
- key segment 使用无碰撞 canonical encoding;现有 slug/数字路径保持不变,旧版空串、下划线开头或保留字符 key 的 snapshot 允许一次性回退 initial state;
8489
- `src/main.js` 在事件后合并保存,并在 HMR replacement、dispose、`pagehide` 前 flush;
8590
- 同一 snapshot 同时支持开发时 JS 替换和普通页面的 localStorage 恢复。
@@ -108,6 +113,8 @@ feature_marker(name)
108113
component(group, key) { ... }
109114
components(items, group = ..., key = ..., render = ...)
110115
116+
snapshot_store(name = ..., state_codec = ..., action_codec = ..., reduce = ...)
117+
replay_store(name = ..., action_codec = ..., replay = ..., reduce = ...)
111118
use_store(spec, initial = ...)
112119
state_effect(name = ..., deps = ..., action = ...)
113120
on_store_click(name, action = ..., dispatch = ...)
@@ -150,12 +157,13 @@ feature_dom_marker(group = ..., key = ..., name = ...)
150157
- 评估是否提供框架级 codec combinators,减少 feature 手写 encode/decode;
151158
- 保持 decoder 失败时回退 initial state,不让单个坏 entry 阻断 boot。
152159

153-
### 2. 定义 replay 与权限策略
160+
### 2. 评估 replay authoring 与权限策略
154161

155-
- 为可安全 replay 的纯 action 增加显式 metadata,不从 codec 存在性推断;
162+
- Todo editor 已用显式 start/replace/reset policy 验证 session 型 replay;下一步先按 #8 的使用者标准评估定义规模与 HMR 行为,再决定是否推广;
163+
- 只有天然有界 session 使用 component replay,不能安全压缩的 store 保持 snapshot;
156164
- 外部 effect action 默认只允许 inspect,replay 需要 capability/permission;
157165
- 定义 confirmation、request、timer 等 effect 的 recorded response 与去重策略;
158-
- action log persistence 与 component-state snapshot 分开版本和保留周期
166+
- agent/domain action log persistence 与 scoped component replay entry 分开版本、权限和保留周期
159167
- devtools/agents 只能通过已注册 codec 解码和投递,不能写 raw state tree。
160168

161169
### 3. 拆分 component facade 与 runtime/testing API
@@ -186,8 +194,10 @@ feature_dom_marker(group = ..., key = ..., name = ...)
186194
## GitHub 跟踪
187195

188196
- [#7 Hide feature identity and remove cross-component store path coordination](https://github.com/Respo/explore-react.koka/issues/7):已由 PR #10 合并;
189-
- [#8 Prototype action-replay component stores for HMR recovery](https://github.com/Respo/explore-react.koka/issues/8):后续独立实验,不把外部 effect replay 混入本轮重构。
190-
- [#9 Define lifecycle cleanup for unreachable child component stores](https://github.com/Respo/explore-react.koka/issues/9):当前实现批次;由 framework visitation 处理永久移除的 keyed child。
197+
- [#8 Reduce typed store boilerplate with explicit replay recovery](https://github.com/Respo/explore-react.koka/issues/8):当前实现批次;以 Todo editor 的定义成本与恢复体验作为是否推广的标准;
198+
- [#9 Define lifecycle cleanup for unreachable child component stores](https://github.com/Respo/explore-react.koka/issues/9):已由 PR #11 合并;
199+
- [#12 Simplify domain and local-store transitions in component events](https://github.com/Respo/explore-react.koka/issues/12):等待 #8 明确 session 完成语义后再评估公共 abstraction;
200+
- [#13 Publish a progressive-disclosure component authoring surface](https://github.com/Respo/explore-react.koka/issues/13):在 recovery API 稳定后整理 quick start、authoring API 与 module 边界。
191201

192202
## 验证标准
193203

@@ -201,7 +211,8 @@ feature_dom_marker(group = ..., key = ..., name = ...)
201211
6. HMR replacement 后 component store 能从最新 snapshot 恢复;
202212
7. malformed/旧版本 snapshot 不导致 boot 失败;
203213
8. listener registry 没有 duplicate id 或 semantic drift warning;
204-
9. ordinary child unmount 会清理 store/effect/marker,整个 feature unmount 仍保留 snapshot。
214+
9. ordinary child unmount 会清理 store/effect/marker,整个 feature unmount 仍保留 snapshot;
215+
10. replay editor 的重复 input 只保留最新 draft,finish/cancel 回到最新 `initial`,malformed/version mismatch 安全回退。
205216

206217
## 非目标
207218

README.md

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ setters. A store combines:
205205
- a state type;
206206
- a serializable action type;
207207
- one pure update function;
208-
- versioned codecs used by the runtime and snapshot boundary.
208+
- one explicit recovery choice at the store definition.
209209

210210
```koka
211211
pub struct task_editor_state(editing : bool, draft : string)
@@ -223,26 +223,34 @@ fun reduce_task_editor(current : task_editor_state, action : task_editor_action)
223223
Finish_edit -> current(editing = False)
224224
Cancel_edit(title) -> Task_editor_state(False, title)
225225
226-
pub val task_editor_store : store_spec<task_editor_state,task_editor_action> = Store_spec(
226+
pub val task_editor_store : store_spec<task_editor_state,task_editor_action> = replay_store(
227227
name = "editor",
228-
state_codec = State_codec(
229-
schema = "todo/task-editor",
230-
version = 1,
231-
decode = decode_task_editor,
232-
encode = encode_task_editor),
233228
action_codec = Action_codec(
234229
schema = "todo/task-editor-action",
235230
version = 1,
236231
decode = decode_task_editor_action,
237232
encode = encode_task_editor_action),
233+
replay = fn(action) {
234+
match action
235+
Begin_edit(_) -> Replay_start
236+
Change_draft(_) -> Replay_replace("draft")
237+
Finish_edit -> Replay_reset
238+
Cancel_edit(_) -> Replay_reset
239+
},
238240
reduce = reduce_task_editor)
239241
```
240242

241243
Store definitions use labelled fields deliberately. `name` owns runtime
242-
identity, `state_codec` owns snapshot compatibility, `action_codec` owns the
243-
observable wire action, and `reduce` is the pure transition. Avoid positional
244-
`Store_spec(...)`, `State_codec(...)`, and `Action_codec(...)` calls: they are
245-
shorter but make schema/version and encode/decode order too easy to misread.
244+
identity, `action_codec` owns the observable wire action, and `reduce` is the
245+
pure transition. `replay` declares a bounded editor session: begin replaces an
246+
old session, repeated draft changes replace the same stable slot, and
247+
finish/cancel return to the call site's latest `initial` value.
248+
249+
Use `snapshot_store(...)` when state cannot be safely represented by
250+
`Replay_start` / `Replay_replace(slot)` / `Replay_reset`. Counters, arbitrary
251+
toggle history, and accumulative collections normally keep an explicit state
252+
codec. The runtime never truncates arbitrary actions because doing so can
253+
change reducer semantics.
246254

247255
The component-facing call mirrors React's reducer pair:
248256

@@ -271,9 +279,11 @@ button(
271279
dispatch = dispatch))
272280
```
273281

274-
The codecs are defined once beside the store. They are not passed through every
275-
component call. Explicit scope/path/tree access is reserved for framework and
276-
testing code.
282+
The required codecs are defined once beside the store. They are not passed
283+
through every component call. Explicit scope/path/tree access is reserved for
284+
framework and testing code. See
285+
[`docs/store-recovery.md`](docs/store-recovery.md) for the recovery decision,
286+
replay modes, migration behavior, and complete examples.
277287

278288
Feature render and panel APIs return only `vnode`. The app boundary owns the
279289
runtime tree through `runtime_frame`, and one `run_component(...)` pass collects
@@ -364,12 +374,13 @@ logger. The browser runtime uses `run_runtime_action_observed(...)` to log the
364374
same stream, while `run_runtime_action(...)` deliberately handles and discards
365375
it for callers that do not need observation.
366376

367-
This is an intent log, not a replay engine. Dispatch is observed before the
368-
reducer/workflow runs, so an action remains visible even when confirmation
369-
rejects it. Replaying actions that invoke browser or service effects needs a
370-
separate policy for permissions, deduplication, and recorded responses. Until
371-
that policy exists, the runtime does not automatically persist or replay the
372-
action stream.
377+
This observation stream is an intent log, not a general replay engine. Dispatch
378+
is observed before the reducer/workflow runs, so an action remains visible even
379+
when confirmation rejects it. A component may explicitly choose
380+
`replay_store(...)`; that store persists only its own scoped pure actions and
381+
does not consume the observation stream. Replaying actions that invoke browser
382+
or service effects still needs a separate policy for permissions,
383+
deduplication, and recorded responses.
373384

374385
## State snapshots, HMR, and reloads
375386

@@ -382,6 +393,8 @@ defensive:
382393
- malformed snapshot data is ignored instead of reaching a component decoder;
383394
- component state is restored only when its keyed scope and store schema still
384395
match;
396+
- replay stores use `respo/replay:<action-schema>` entries and rebuild state
397+
from the current `initial` plus decoded component actions;
385398
- `respo/component-scope` metadata preserves ordinary-child ownership across
386399
HMR/reload so stale child branches can be swept on the next feature render.
387400

demo/lab/state.kk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ fun decode_incident_local_action(version : int, payload : string) : maybe<incide
3939
else if payload.head == "d" then Just(Change_incident_draft(payload.tail))
4040
else Nothing
4141

42-
pub val incident_local_store : store_spec<incident_local_state,incident_local_action> = Store_spec(
42+
pub val incident_local_store : store_spec<incident_local_state,incident_local_action> = snapshot_store(
4343
name = "incident",
4444
state_codec = State_codec(
4545
schema = "lab/incident-local",

demo/tests.kk

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ pub fun demo_test_results() : <div> list<test_result>
2727
ordinary_component_lifecycle_test(),
2828
persistent_feature_lifecycle_test(),
2929
generic_state_codec_test(),
30+
replay_empty_payload_test(),
3031
malformed_store_payload_test(),
3132
auto_hook_scope_test(),
3233
listener_registry_guard_test(),
3334
typed_action_listener_test(),
3435
todo_editor_store_protocol_test(),
36+
todo_editor_replay_session_test(),
3537
unified_action_observation_test(),
3638
vnode_click_payload_matches_route_test(),
3739
harness_cancel_edit_test(),

demo/tests/statecases.kk

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,42 @@ pub fun generic_state_codec_test() : <div> test_result
143143
val open_after = read_named_local_state(next_tree, scope_name, "open", False)
144144
Test_result("Implicit use_state codec", "draft=" ++ draft ++ ", open-before=" ++ open_before.show ++ ", open-after=" ++ open_after.show, draft == "hello" && not(open_before) && draft_after == "hello world" && open_after)
145145

146+
pub fun replay_empty_payload_test() : <div> test_result
147+
val scope_name = component_local_path("tests", "empty-replay-payload")
148+
val empty_codec = Action_codec(
149+
schema = "tests/empty-action",
150+
version = 1,
151+
decode = fn(version, payload) if version == 1 then Just(payload) else Nothing,
152+
encode = fn(value : string) value)
153+
val empty_store : store_spec<string,string> = replay_store(
154+
name = "empty",
155+
action_codec = empty_codec,
156+
replay = fn(_action) Replay_start,
157+
reduce = fn(_current, action) action)
158+
val (run, _observed) = capture_actions(fn() run_local_state(Nil, fn() feature_root("tests", "empty-replay-payload") {
159+
val (_current, dispatch) = use_store(empty_store, initial = "fallback")
160+
dispatch("")
161+
}))
162+
val (_unit, tree, _effects) = run
163+
val restored = read_store_state(
164+
decode_state_snapshot(encode_state_snapshot(tree)),
165+
scope_name,
166+
empty_store,
167+
"fallback")
168+
val framed = match find(tree, fn(entry) entry.path == named_local_state_path(scope_name, "empty"))
169+
Just(entry) -> entry.payload == "a"
170+
Nothing -> False
171+
Test_result("Replay framing preserves empty action payload", "restored='" ++ restored ++ "', framed=" ++ framed.show, restored == "" && framed)
172+
146173
pub fun malformed_store_payload_test() : <div> test_result
147174
val todo_scope = "tests/malformed-todo"
148175
val todo_initial = Task_editor_state(False, "todo fallback")
149-
val todo_tree = [State_entry(named_local_state_path(todo_scope, "editor"), "todo/task-editor", 1, "")]
176+
val todo_tree = [State_entry(named_local_state_path(todo_scope, "editor"), "respo/replay:todo/task-editor-action", 1, "%")]
150177
val todo_state = read_store_state(todo_tree, todo_scope, task_editor_store, todo_initial)
178+
val todo_version_tree = [State_entry(named_local_state_path(todo_scope, "editor"), "respo/replay:todo/task-editor-action", 9, "bOld")]
179+
val todo_version_state = read_store_state(todo_version_tree, todo_scope, task_editor_store, todo_initial)
180+
val todo_legacy_tree = [State_entry(named_local_state_path(todo_scope, "editor"), "todo/task-editor", 1, "1Old")]
181+
val todo_legacy_state = read_store_state(todo_legacy_tree, todo_scope, task_editor_store, todo_initial)
151182
val todo_action_safe = match decode_store_action(task_editor_store, Action_envelope(source = "component", target = todo_scope, schema = "todo/task-editor-action", version = 1, payload = ""))
152183
Nothing -> True
153184
_ -> False
@@ -160,7 +191,7 @@ pub fun malformed_store_payload_test() : <div> test_result
160191
_ -> False
161192
val malformed_snapshot_safe = is-empty(decode_state_snapshot("path|schema|1|bad%"))
162193
val empty_snapshot_payload_valid = length(decode_state_snapshot("path|schema|1|")) == 1
163-
val passed = todo_state.draft == "todo fallback" && lab_state.draft == "lab fallback" && todo_action_safe && lab_action_safe && malformed_snapshot_safe && empty_snapshot_payload_valid
194+
val passed = todo_state.draft == "todo fallback" && todo_version_state.draft == "todo fallback" && todo_legacy_state.draft == "todo fallback" && lab_state.draft == "lab fallback" && todo_action_safe && lab_action_safe && malformed_snapshot_safe && empty_snapshot_payload_valid
164195
Test_result("Malformed store payloads are rejected", "todo=" ++ todo_action_safe.show ++ ", lab=" ++ lab_action_safe.show ++ ", snapshot=" ++ malformed_snapshot_safe.show, passed)
165196

166197
pub fun auto_hook_scope_test() : <div> test_result

0 commit comments

Comments
 (0)