diff --git a/README.md b/README.md index f23f702..fcae117 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001 vendor providers or add framework-specific magic. See `docs/recipes/observability-flags.md` and `docs/recipes/deploy-versioning.md`. - **PWA/offline/sync:** optional adapters may reuse generated targets/effects, but core hemx must not gain a mandatory client state graph or local app - runtime. See `docs/recipes/pwa-offline.md`. req: canonical_authoring/008 + runtime. Local truth is commands/events/projections, not stored DOM patches or + stored `EffectBatch` payloads. See `docs/recipes/pwa-offline.md` and + `docs/recipes/local-command-log.md`. req: canonical_authoring/008 req: local/001 req: local/002 - **Host capabilities:** browser, PWA, WebView, and native-shell capabilities use `hemx-host` manifests/calls/events. Adapters return host facts to app code; UI still changes through normal hemx effects. See diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d56d22e..d69a47a 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -572,6 +572,22 @@ what a valid business email is. --- +## local + +### req: local/001 +001 Local/offline behavior is represented as app commands, domain events, and projections. Stored DOM patches or stored EffectBatch payloads are not the source of truth. + +### req: local/002 +002 Local command logs are app or integration territory until a reusable hemx contract proves common semantics across multiple apps. hemx core must not gain a mandatory browser database, client store, sync engine, or conflict policy. + +### req: local/003 +003 Replaying local work back to a server or peer sync target is explicit app/integration policy. A local projection may render immediate feedback, but server acceptance, rejection, reconciliation, export, and deletion rules remain visible product decisions. + +### req: local/004 +004 A local-first exemplar must show a command becoming a domain event and projection before hemx UI effects are produced, so the UI effect remains output of app state rather than persisted truth. + +--- + ## interop ### req: interop/001 diff --git a/docs/recipes/local-command-log.md b/docs/recipes/local-command-log.md new file mode 100644 index 0000000..72a1654 --- /dev/null +++ b/docs/recipes/local-command-log.md @@ -0,0 +1,38 @@ +# Recipe: local command log + +A hemx app may feel local-first without making hemx core a client database or +sync framework. The local artifact is an app-owned command/event log plus a +projection; hemx effects are rendered output, not stored truth. req: local/001 +req: local/002 + +## Shape + +```text +user intent +→ LocalCommand +→ domain validation +→ LocalEvent +→ Projection +→ hemx EffectBatch +``` + +The log may live in memory, IndexedDB, SQLite, a native host store, or another +app-chosen persistence layer. That storage choice is not hemx core. req: local/002 + +## Replay and sync + +Replaying local work to a server, remote AI/STT gateway, backup target, or peer +sync engine is explicit product policy. The app decides what can be queued, +exported, deleted, reconciled, retried, rejected, or redacted. A local projection +can render immediate feedback while those decisions remain pending. req: local/003 + +## Boundary + +Do not persist DOM patches as truth. Do not persist `EffectBatch` payloads as the +local application log. Those are render instructions produced after app/domain +code accepts commands and projects events. req: local/001 req: local/004 + +Use `hemx-host` only when the local log needs device or shell capabilities such +as secure storage, files, haptics, microphone, or notifications. The host still +returns facts; app code still owns the command/event/projection policy. req: +host/002 req: local/003 diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 145930a..7175969 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -19,6 +19,7 @@ use hemx_techdemo::ui; use hemx_techdemo::ui::control_center::{self as control, classes}; use hemx_techdemo::ui::{ host_panel as host_control, issue_card as card_control, issue_lane as lane_control, + local_panel as local_control, }; use std::collections::{BTreeMap, VecDeque}; use std::convert::Infallible; @@ -68,6 +69,78 @@ impl Stage { } } +#[derive(Clone)] +enum LocalCommand { + CompleteSet { set_id: u64, reps: u8 }, +} + +#[derive(Clone)] +enum LocalEvent { + SetCompleted { set_id: u64, reps: u8 }, +} + +#[derive(Clone)] +struct LocalProjection { + completed_sets: usize, + summary: String, +} + +#[derive(Clone)] +struct LocalJournal { + commands: Vec, + events: Vec, + projection: LocalProjection, +} + +impl Default for LocalJournal { + fn default() -> Self { + Self { + commands: Vec::new(), + events: Vec::new(), + projection: LocalProjection { + completed_sets: 0, + summary: "No local commands queued".into(), + }, + } + } +} + +impl LocalJournal { + fn accept(&mut self, command: LocalCommand) { + // req: local/001 req: local/004 + let event = match &command { + LocalCommand::CompleteSet { set_id, reps } => LocalEvent::SetCompleted { + set_id: *set_id, + reps: *reps, + }, + }; + self.commands.push(command); + self.events.push(event.clone()); + self.project(&event); + } + + fn project(&mut self, event: &LocalEvent) { + // req: local/001 req: local/004 + match event { + LocalEvent::SetCompleted { set_id, reps } => { + self.projection.completed_sets += 1; + self.projection.summary = format!( + "Projected set {set_id} with {reps} reps from commands/events; no DOM patch or EffectBatch was stored" + ); + } + } + } + + fn status(&self) -> String { + format!( + "{} commands, {} events, {} projected sets", + self.commands.len(), + self.events.len(), + self.projection.completed_sets + ) + } +} + #[derive(Clone)] struct DemoState { next_id: u64, @@ -76,6 +149,7 @@ struct DemoState { spotlight: String, selected_id: Option, host_status: String, + local_journal: LocalJournal, } impl Default for DemoState { @@ -109,6 +183,7 @@ impl Default for DemoState { spotlight: "No selectors. Generated resources address every target.".into(), selected_id: Some(2), host_status: "Host calls are typed facts until app code accepts a result.".into(), + local_journal: LocalJournal::default(), }; state.log("Demo booted from server-rendered HTML"); state.log("Runtime attached one delegated listener per root"); @@ -162,6 +237,7 @@ struct ControlCenter { inspector: Html, activity: Html, host: Html, + local: Html, island_snapshot: String, } @@ -210,6 +286,14 @@ struct HostPanel { boundary: &'static str, } +#[derive(Hemplate)] +#[hemplate = "partials"] +struct LocalPanel { + status: String, + projection: String, + boundary: &'static str, +} + #[derive(Hemplate)] #[hemplate = "partials"] struct ArchitectureActivity; @@ -278,6 +362,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse { inspector: architecture_inspector(), activity: architecture_activity(), host: render_host_panel(&DemoState::default()), + local: render_local_panel(&DemoState::default()), island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(), }); request @@ -581,6 +666,20 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Native host result accepted by app code") } }) + .on(local_control::queue_local_set, { + let shared = shared.clone(); + move |_| { + // req: local/001 req: local/004 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let set_id = demo.local_journal.commands.len() as u64 + 1; + demo.local_journal + .accept(LocalCommand::CompleteSet { set_id, reps: 8 }); + demo.log(format!( + "Local command #{set_id} became event and projection before UI effects" + )); + demo_effects(&demo, "Local command accepted · projection rendered") + } + }) .on(control::simulate_push, { let shared = shared.clone(); move |_| { @@ -627,6 +726,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { control::activity.put(&activity_view(demo)), control::inspector.put(&inspector_view(demo)), control::host_panel.put(&host_panel(demo)), + control::local_panel.put(&local_panel(demo)), control::notice.text(notice), control::launch_work_form.clear(), ISLAND_ORBIT.emit(island_snapshot(demo)), @@ -674,6 +774,7 @@ fn page_html(demo: &DemoState) -> Html { inspector: render_inspector(demo), activity: render_activity(demo), host: render_host_panel(demo), + local: render_local_panel(demo), island_snapshot: island_snapshot(demo), }) } @@ -700,7 +801,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics { .count(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); HeroMetrics { - resource_count: 18, + resource_count: 20, active_count: active, shipped_count: shipped, impact_score: impact, @@ -769,6 +870,20 @@ fn render_host_panel(demo: &DemoState) -> Html { ui::render(&host_panel(demo)) } +fn local_panel(demo: &DemoState) -> LocalPanel { + // req: local/001 req: local/004 + LocalPanel { + status: demo.local_journal.status(), + projection: demo.local_journal.projection.summary.clone(), + boundary: "LocalCommand → LocalEvent → Projection → EffectBatch", + } +} + +fn render_local_panel(demo: &DemoState) -> Html { + // req: local/001 req: local/004 + ui::render(&local_panel(demo)) +} + fn inspector_view(demo: &DemoState) -> InspectorPanel { // req: html_safety/002 req: view/001 let selected = demo diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml index cd2b8ea..4b3ba98 100644 --- a/examples/techdemo/templates/control_center.heml +++ b/examples/techdemo/templates/control_center.heml @@ -33,6 +33,7 @@
{+= self.host =+}
+
{+= self.local =+}

Every control posts through a generated handle and receives typed updates.

diff --git a/examples/techdemo/templates/partials/local_panel.heml b/examples/techdemo/templates/partials/local_panel.heml new file mode 100644 index 0000000..9aff4c9 --- /dev/null +++ b/examples/techdemo/templates/partials/local_panel.heml @@ -0,0 +1,8 @@ +
+

Local command log
{+ self.status +}

+

{+ self.projection +}

+
+ +
+ {+ self.boundary +} +
diff --git a/examples/techdemo/tests/e2e.rs b/examples/techdemo/tests/e2e.rs index 5fa5ecc..0f596b5 100644 --- a/examples/techdemo/tests/e2e.rs +++ b/examples/techdemo/tests/e2e.rs @@ -5,6 +5,7 @@ use hemx_techdemo::ui::host_panel::{ }; use hemx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work}; use hemx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle; +use hemx_techdemo::ui::local_panel::queue_local_set; use hemx_techdemo::ui::BUILD_FINGERPRINT; use hemx_test::{ class_descendant_selector, class_selector, handle_form_body, inspect_wire, @@ -306,6 +307,20 @@ fn product_is_e2e_working_over_http() { "Native haptic acknowledgment accepted by app code", ); + let local_command = post("/", &handle_form_body(queue_local_set, &[])); + assert_effect_response(&local_command); + let local_batch = local_command.effects(); + assert_payload_contains(&local_batch, "Local command accepted · projection rendered"); + assert_payload_contains(&local_batch, "1 commands, 1 events, 1 projected sets"); + assert_payload_contains( + &local_batch, + "Projected set 1 with 8 reps from commands/events; no DOM patch or EffectBatch was stored", + ); + assert_payload_contains( + &local_batch, + "Local command #1 became event and projection before UI effects", + ); + let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")])); assert_effect_response(&delete_missing); let delete_missing_batch = delete_missing.effects();