feat(kanban): restore commands offline
req: local/001\nreq: local/002\nreq: local/003\nreq: local/004
This commit is contained in:
@@ -33,11 +33,11 @@ encryption, retention, backup, and deployment policy remain host concerns.
|
|||||||
## Slice 3 — durable offline command log
|
## Slice 3 — durable offline command log
|
||||||
|
|
||||||
- [ ] **User value:** an opted-in Kanban mutation remains available after network loss and browser reload without storing DOM patches as truth.
|
- [ ] **User value:** an opted-in Kanban mutation remains available after network loss and browser reload without storing DOM patches as truth.
|
||||||
- **State:** In progress — the first app-owned `reorder_card` command is transactionally persisted with schema, actor, session, causal id, and app payload before projection; reload restores the projection from commands, while unknown schemas stop with an explicit diagnostic. Offline shell reload, export/delete/reset, quota/corruption recovery, bounded replay, and performance proof remain.
|
- **State:** In progress — the first app-owned `reorder_card` command is transactionally persisted with schema, actor, session, causal id, and app payload before projection; an app-owned service worker caches only the generated shell/resources, and reload restores the projection after the fixture server is stopped and proven unreachable. Unknown schemas stop with an explicit diagnostic. Export/delete/reset, quota/corruption recovery, bounded replay, and performance proof remain.
|
||||||
- **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal.
|
- **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal.
|
||||||
- **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core.
|
- **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core.
|
||||||
- **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `performance/006`.
|
- **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `performance/006`.
|
||||||
- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, current-version replay after reload, stable identity metadata, and explicit unknown-schema refusal in real Firefox/WASM. The completed slice proof must additionally mutate and reload offline, export/delete/reset data, and fail recoverably under quota and corruption without claiming durability after persistence failure.
|
- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. The completed slice proof must additionally export/delete/reset data and fail recoverably under quota and corruption without claiming durability after persistence failure.
|
||||||
|
|
||||||
## Slice 4 — authoritative reconnect and convergence
|
## Slice 4 — authoritative reconnect and convergence
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ function clientReady(root) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareOfflineShell(root) {
|
||||||
|
if (!("serviceWorker" in navigator)) throw new Error("service workers are unavailable");
|
||||||
|
await navigator.serviceWorker.register("/offline.js", { scope: "/" });
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }));
|
||||||
|
}
|
||||||
|
root.setAttribute("data-kanban-offline-ready", "");
|
||||||
|
}
|
||||||
|
|
||||||
function stableSession() {
|
function stableSession() {
|
||||||
const key = "hemx-kanban-session-v1";
|
const key = "hemx-kanban-session-v1";
|
||||||
let session = sessionStorage.getItem(key);
|
let session = sessionStorage.getItem(key);
|
||||||
@@ -120,6 +130,7 @@ async function start() {
|
|||||||
const root = document.querySelector(ROOT);
|
const root = document.querySelector(ROOT);
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const databasePromise = openCommandLog();
|
const databasePromise = openCommandLog();
|
||||||
|
const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error));
|
||||||
await clientReady(root);
|
await clientReady(root);
|
||||||
let wasmHandler;
|
let wasmHandler;
|
||||||
const durableHandler = async (...wire) => {
|
const durableHandler = async (...wire) => {
|
||||||
@@ -157,6 +168,7 @@ async function start() {
|
|||||||
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||||
root.setAttribute("data-kanban-command-count", String(commands.length));
|
root.setAttribute("data-kanban-command-count", String(commands.length));
|
||||||
root.setAttribute("data-kanban-command-ready", "");
|
root.setAttribute("data-kanban-command-ready", "");
|
||||||
|
await offlineReady;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
report(root, "restore", error);
|
report(root, "restore", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const CACHE = "hemx-kanban-shell-v1";
|
||||||
|
const SHELL = [
|
||||||
|
"/",
|
||||||
|
"/hemx.js",
|
||||||
|
"/hemx.client.js",
|
||||||
|
"/kanban_client.js",
|
||||||
|
"/kanban_client_bg.wasm",
|
||||||
|
"/app.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((names) => Promise.all(names.filter((name) => name.startsWith("hemx-kanban-shell-") && name !== CACHE).map((name) => caches.delete(name))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin !== self.location.origin || !SHELL.includes(url.pathname)) return;
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request, { ignoreSearch: true }).then((cached) => cached || fetch(event.request)),
|
||||||
|
);
|
||||||
|
});
|
||||||
+68
-18
@@ -201,13 +201,13 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
|
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
|
||||||
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||||
let server = StaticServer::start(
|
let mut server = StaticServer::start(
|
||||||
package,
|
package,
|
||||||
runtime,
|
runtime,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
rendered,
|
rendered,
|
||||||
"kanban_client",
|
"kanban_client",
|
||||||
Some(workspace.join("examples/kanban/static/command-log.js")),
|
Some(kanban_app_assets(&workspace)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let webdriver_port = available_port();
|
let webdriver_port = available_port();
|
||||||
@@ -222,7 +222,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
|
|||||||
driver.goto(&server.url()).await?;
|
driver.goto(&server.url()).await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&driver,
|
||||||
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
driver
|
driver
|
||||||
@@ -268,11 +268,26 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
|
|||||||
assert!(!session.is_empty());
|
assert!(!session.is_empty());
|
||||||
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
|
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
|
||||||
assert_eq!(persisted["count"], "1");
|
assert_eq!(persisted["count"], "1");
|
||||||
|
let offline_shell = driver
|
||||||
|
.execute_async(
|
||||||
|
"const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(offline_shell["controlled"], true, "{offline_shell}");
|
||||||
|
assert_eq!(offline_shell["shell"], true, "{offline_shell}");
|
||||||
|
assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1");
|
||||||
|
|
||||||
driver.refresh().await?;
|
server.stop();
|
||||||
|
assert!(!server.is_reachable(), "fixture server must be unreachable");
|
||||||
|
driver
|
||||||
|
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||||
|
.await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&driver,
|
||||||
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let restored = driver
|
let restored = driver
|
||||||
@@ -307,10 +322,12 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
||||||
driver.refresh().await?;
|
driver
|
||||||
|
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||||
|
.await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&driver,
|
||||||
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
|
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let rejected = driver
|
let rejected = driver
|
||||||
@@ -350,7 +367,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
|
|||||||
bootstrap,
|
bootstrap,
|
||||||
rendered,
|
rendered,
|
||||||
"kanban_client",
|
"kanban_client",
|
||||||
Some(workspace.join("examples/kanban/static/command-log.js")),
|
Some(kanban_app_assets(&workspace)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let webdriver_port = available_port();
|
let webdriver_port = available_port();
|
||||||
@@ -592,9 +609,22 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
|
|||||||
.expect("generated hemx client bootstrap")
|
.expect("generated hemx client bootstrap")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppAssets {
|
||||||
|
module: PathBuf,
|
||||||
|
service_worker: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kanban_app_assets(workspace: &Path) -> AppAssets {
|
||||||
|
AppAssets {
|
||||||
|
module: workspace.join("examples/kanban/static/command-log.js"),
|
||||||
|
service_worker: workspace.join("examples/kanban/static/offline.js"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct StaticServer {
|
struct StaticServer {
|
||||||
address: String,
|
address: String,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
thread: Option<thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StaticServer {
|
impl StaticServer {
|
||||||
@@ -604,14 +634,14 @@ impl StaticServer {
|
|||||||
bootstrap: PathBuf,
|
bootstrap: PathBuf,
|
||||||
rendered: String,
|
rendered: String,
|
||||||
asset_stem: &'static str,
|
asset_stem: &'static str,
|
||||||
app_module: Option<PathBuf>,
|
app_assets: Option<AppAssets>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
|
||||||
listener.set_nonblocking(true).expect("nonblocking fixture");
|
listener.set_nonblocking(true).expect("nonblocking fixture");
|
||||||
let address = listener.local_addr().expect("fixture address").to_string();
|
let address = listener.local_addr().expect("fixture address").to_string();
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let thread_stop = Arc::clone(&stop);
|
let thread_stop = Arc::clone(&stop);
|
||||||
thread::spawn(move || {
|
let thread = thread::spawn(move || {
|
||||||
while !thread_stop.load(Ordering::Relaxed) {
|
while !thread_stop.load(Ordering::Relaxed) {
|
||||||
match listener.accept() {
|
match listener.accept() {
|
||||||
Ok((stream, _)) => serve(
|
Ok((stream, _)) => serve(
|
||||||
@@ -621,7 +651,7 @@ impl StaticServer {
|
|||||||
&bootstrap,
|
&bootstrap,
|
||||||
&rendered,
|
&rendered,
|
||||||
asset_stem,
|
asset_stem,
|
||||||
app_module.as_deref(),
|
app_assets.as_ref(),
|
||||||
),
|
),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
thread::sleep(Duration::from_millis(10))
|
thread::sleep(Duration::from_millis(10))
|
||||||
@@ -630,17 +660,32 @@ impl StaticServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Self { address, stop }
|
Self {
|
||||||
|
address,
|
||||||
|
stop,
|
||||||
|
thread: Some(thread),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url(&self) -> String {
|
fn url(&self) -> String {
|
||||||
format!("http://{}", self.address)
|
format!("http://{}", self.address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
thread.join().expect("stop browser fixture");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_reachable(&self) -> bool {
|
||||||
|
TcpStream::connect(&self.address).is_ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for StaticServer {
|
impl Drop for StaticServer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
self.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +696,7 @@ fn serve(
|
|||||||
bootstrap: &Path,
|
bootstrap: &Path,
|
||||||
rendered: &str,
|
rendered: &str,
|
||||||
asset_stem: &str,
|
asset_stem: &str,
|
||||||
app_module: Option<&Path>,
|
app_assets: Option<&AppAssets>,
|
||||||
) {
|
) {
|
||||||
let mut request = [0_u8; 2048];
|
let mut request = [0_u8; 2048];
|
||||||
let length = stream.read(&mut request).unwrap_or(0);
|
let length = stream.read(&mut request).unwrap_or(0);
|
||||||
@@ -660,7 +705,7 @@ fn serve(
|
|||||||
let (content_type, body) = match path {
|
let (content_type, body) = match path {
|
||||||
"/" => (
|
"/" => (
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
fixture_html(rendered, app_module.is_some()).into_bytes(),
|
fixture_html(rendered, app_assets.is_some()).into_bytes(),
|
||||||
),
|
),
|
||||||
"/hemx.js" => (
|
"/hemx.js" => (
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
@@ -678,16 +723,21 @@ fn serve(
|
|||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
fs::read(bootstrap).expect("read generated client bootstrap"),
|
fs::read(bootstrap).expect("read generated client bootstrap"),
|
||||||
),
|
),
|
||||||
"/app.js" if app_module.is_some() => (
|
"/app.js" if app_assets.is_some() => (
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
fs::read(app_module.expect("checked app module")).expect("read app module"),
|
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
|
||||||
|
),
|
||||||
|
"/offline.js" if app_assets.is_some() => (
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
fs::read(&app_assets.expect("checked app assets").service_worker)
|
||||||
|
.expect("read service worker"),
|
||||||
),
|
),
|
||||||
_ => ("text/plain", b"not found".to_vec()),
|
_ => ("text/plain", b"not found".to_vec()),
|
||||||
};
|
};
|
||||||
let status = if path == "/"
|
let status = if path == "/"
|
||||||
|| path == "/hemx.js"
|
|| path == "/hemx.js"
|
||||||
|| path == "/hemx.client.js"
|
|| path == "/hemx.client.js"
|
||||||
|| (path == "/app.js" && app_module.is_some())
|
|| ((path == "/app.js" || path == "/offline.js") && app_assets.is_some())
|
||||||
|| path.starts_with(&format!("/{asset_stem}"))
|
|| path.starts_with(&format!("/{asset_stem}"))
|
||||||
{
|
{
|
||||||
"200 OK"
|
"200 OK"
|
||||||
|
|||||||
Reference in New Issue
Block a user