feat(local): model local command log boundary
Define local/offline truth as commands, domain events, and projections rather than stored DOM patches or EffectBatch payloads, and wire the techdemo through a local command-to-projection-to-effect flow. req: local/001 req: local/002 req: local/003 req: local/004 req: examples/001
This commit is contained in:
@@ -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`.
|
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,
|
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
|
||||||
but core hemx must not gain a mandatory client state graph or local app
|
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
|
- **Host capabilities:** browser, PWA, WebView, and native-shell capabilities use
|
||||||
`hemx-host` manifests/calls/events. Adapters return host facts to app code;
|
`hemx-host` manifests/calls/events. Adapters return host facts to app code;
|
||||||
UI still changes through normal hemx effects. See
|
UI still changes through normal hemx effects. See
|
||||||
|
|||||||
@@ -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
|
## interop
|
||||||
|
|
||||||
### req: interop/001
|
### req: interop/001
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,6 +19,7 @@ use hemx_techdemo::ui;
|
|||||||
use hemx_techdemo::ui::control_center::{self as control, classes};
|
use hemx_techdemo::ui::control_center::{self as control, classes};
|
||||||
use hemx_techdemo::ui::{
|
use hemx_techdemo::ui::{
|
||||||
host_panel as host_control, issue_card as card_control, issue_lane as lane_control,
|
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::collections::{BTreeMap, VecDeque};
|
||||||
use std::convert::Infallible;
|
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<LocalCommand>,
|
||||||
|
events: Vec<LocalEvent>,
|
||||||
|
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)]
|
#[derive(Clone)]
|
||||||
struct DemoState {
|
struct DemoState {
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
@@ -76,6 +149,7 @@ struct DemoState {
|
|||||||
spotlight: String,
|
spotlight: String,
|
||||||
selected_id: Option<u64>,
|
selected_id: Option<u64>,
|
||||||
host_status: String,
|
host_status: String,
|
||||||
|
local_journal: LocalJournal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DemoState {
|
impl Default for DemoState {
|
||||||
@@ -109,6 +183,7 @@ impl Default for DemoState {
|
|||||||
spotlight: "No selectors. Generated resources address every target.".into(),
|
spotlight: "No selectors. Generated resources address every target.".into(),
|
||||||
selected_id: Some(2),
|
selected_id: Some(2),
|
||||||
host_status: "Host calls are typed facts until app code accepts a result.".into(),
|
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("Demo booted from server-rendered HTML");
|
||||||
state.log("Runtime attached one delegated listener per root");
|
state.log("Runtime attached one delegated listener per root");
|
||||||
@@ -162,6 +237,7 @@ struct ControlCenter {
|
|||||||
inspector: Html,
|
inspector: Html,
|
||||||
activity: Html,
|
activity: Html,
|
||||||
host: Html,
|
host: Html,
|
||||||
|
local: Html,
|
||||||
island_snapshot: String,
|
island_snapshot: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +286,14 @@ struct HostPanel {
|
|||||||
boundary: &'static str,
|
boundary: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
#[hemplate = "partials"]
|
||||||
|
struct LocalPanel {
|
||||||
|
status: String,
|
||||||
|
projection: String,
|
||||||
|
boundary: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct ArchitectureActivity;
|
struct ArchitectureActivity;
|
||||||
@@ -278,6 +362,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse {
|
|||||||
inspector: architecture_inspector(),
|
inspector: architecture_inspector(),
|
||||||
activity: architecture_activity(),
|
activity: architecture_activity(),
|
||||||
host: render_host_panel(&DemoState::default()),
|
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(),
|
island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(),
|
||||||
});
|
});
|
||||||
request
|
request
|
||||||
@@ -581,6 +666,20 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Native host result accepted by app code")
|
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, {
|
.on(control::simulate_push, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
@@ -627,6 +726,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
|
|||||||
control::activity.put(&activity_view(demo)),
|
control::activity.put(&activity_view(demo)),
|
||||||
control::inspector.put(&inspector_view(demo)),
|
control::inspector.put(&inspector_view(demo)),
|
||||||
control::host_panel.put(&host_panel(demo)),
|
control::host_panel.put(&host_panel(demo)),
|
||||||
|
control::local_panel.put(&local_panel(demo)),
|
||||||
control::notice.text(notice),
|
control::notice.text(notice),
|
||||||
control::launch_work_form.clear(),
|
control::launch_work_form.clear(),
|
||||||
ISLAND_ORBIT.emit(island_snapshot(demo)),
|
ISLAND_ORBIT.emit(island_snapshot(demo)),
|
||||||
@@ -674,6 +774,7 @@ fn page_html(demo: &DemoState) -> Html {
|
|||||||
inspector: render_inspector(demo),
|
inspector: render_inspector(demo),
|
||||||
activity: render_activity(demo),
|
activity: render_activity(demo),
|
||||||
host: render_host_panel(demo),
|
host: render_host_panel(demo),
|
||||||
|
local: render_local_panel(demo),
|
||||||
island_snapshot: island_snapshot(demo),
|
island_snapshot: island_snapshot(demo),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -700,7 +801,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics {
|
|||||||
.count();
|
.count();
|
||||||
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
||||||
HeroMetrics {
|
HeroMetrics {
|
||||||
resource_count: 18,
|
resource_count: 20,
|
||||||
active_count: active,
|
active_count: active,
|
||||||
shipped_count: shipped,
|
shipped_count: shipped,
|
||||||
impact_score: impact,
|
impact_score: impact,
|
||||||
@@ -769,6 +870,20 @@ fn render_host_panel(demo: &DemoState) -> Html {
|
|||||||
ui::render(&host_panel(demo))
|
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 {
|
fn inspector_view(demo: &DemoState) -> InspectorPanel {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
let selected = demo
|
let selected = demo
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<button type="button" data-hemx-handle="reset_demo">Reset demo</button>
|
<button type="button" data-hemx-handle="reset_demo">Reset demo</button>
|
||||||
</div>
|
</div>
|
||||||
<div data-hemx-slot="host_panel">{+= self.host =+}</div>
|
<div data-hemx-slot="host_panel">{+= self.host =+}</div>
|
||||||
|
<div data-hemx-slot="local_panel">{+= self.local =+}</div>
|
||||||
<p data-hemx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
|
<p data-hemx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="local-panel">
|
||||||
|
<p><strong>Local command log</strong><br>{+ self.status +}</p>
|
||||||
|
<p>{+ self.projection +}</p>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button type="button" data-hemx-handle="queue_local_set">Queue local set completion</button>
|
||||||
|
</div>
|
||||||
|
<code>{+ self.boundary +}</code>
|
||||||
|
</div>
|
||||||
@@ -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_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::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_techdemo::ui::BUILD_FINGERPRINT;
|
||||||
use hemx_test::{
|
use hemx_test::{
|
||||||
class_descendant_selector, class_selector, handle_form_body, inspect_wire,
|
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",
|
"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")]));
|
let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")]));
|
||||||
assert_effect_response(&delete_missing);
|
assert_effect_response(&delete_missing);
|
||||||
let delete_missing_batch = delete_missing.effects();
|
let delete_missing_batch = delete_missing.effects();
|
||||||
|
|||||||
Reference in New Issue
Block a user