feat(sync): replay durable projections from Rust
req: ms/001 req: ms/002 req: ms/003
This commit is contained in:
@@ -51,18 +51,18 @@ encryption, retention, backup, and deployment policy remain host concerns.
|
||||
## Slice 5 — local-first multiplayer Kanban milestone
|
||||
|
||||
- [ ] **User value:** the complete north-star app demonstrates SSR-first startup, direct manipulation, offline durability, optimistic projection, reconciliation, and live presence as one comprehensible workflow.
|
||||
- **State:** In progress — the single named milestone journey passes; closure still requires replacing the example-owned `offline.js`, `command-log.js`, and `sync.js` behavior because `v1_release/002` forbids app-authored JavaScript.
|
||||
- **State:** In progress — the named milestone now performs durable projection/replay/upload with generated Rust/WASM plus the framework sync runtime and loads no example-authored JavaScript; the app binary still exposes the legacy app-authored `/sync.js` demo route, so `v1_release/002` cannot close yet.
|
||||
- **Build:** connect the previous slices in the canonical Kanban example; keep native server-rendered fallback; add presence and server-canonical conflict presentation; exercise deploy fingerprint recovery and accessible online/offline/conflict state.
|
||||
- **Refusals:** no demo-only runtime, hidden app JS, proprietary service, or requirement to load collaboration code for server-first apps.
|
||||
- **Requirements:** `ms/001-003`, `v1_release/001-002`, `accessibility/001-007`, `operations/006`, `performance/006`.
|
||||
- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, durable command queue, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload durability, concurrent peer mutation and canonical convergence, presence projection, rejection and corrupt-queue recovery with deletion, mixed-version reload, and optional-asset isolation through the real public entry points. This does not yet close `v1_release/002`: the app still loads example-authored JavaScript for offline queue and sync orchestration.
|
||||
- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, framework-owned durable sync runtime, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload projection replay and upload, concurrent peer mutation and canonical convergence, presence projection, typed rejection recovery, mixed-version reload, and optional-asset isolation. The journey explicitly receives 404 for `/app.js` and `/offline.js` and asserts that its only scripts are generated/client runtime plus framework sync runtime. `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally preserves the legacy flat durable-record upgrade path. This proves the durable milestone path but does not yet close `v1_release/002` while the separate legacy `/sync.js` demo route remains in the app binary.
|
||||
|
||||
Execution cursor: move the app-owned durable command/event/projection orchestration behind the generated Rust/WASM boundary and rerun the named milestone journey with no example-authored JavaScript loaded.
|
||||
Execution cursor: delete or replace the legacy app-authored `/sync.js` demo route with the public framework sync mechanism, preserving its recovery/accessibility proof without bespoke example JavaScript.
|
||||
|
||||
## Slice 6 — production integration reference
|
||||
|
||||
- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
|
||||
- **State:** Blocked by the remaining Slice 5 app-authored JavaScript gap.
|
||||
- **State:** Blocked by the remaining Slice 5 legacy `/sync.js` route.
|
||||
- **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery.
|
||||
- **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework.
|
||||
- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts.
|
||||
|
||||
@@ -87,11 +87,15 @@ pub fn reorder_card(
|
||||
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
|
||||
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
||||
};
|
||||
(
|
||||
move_effect,
|
||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||
hemx_sync::SyncEffect::send_patch(patch),
|
||||
)
|
||||
let projection = hemx::IntoEffect::into_batch(
|
||||
(
|
||||
move_effect,
|
||||
ui::client_board::client_notice
|
||||
.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||
),
|
||||
ui::BUILD_FINGERPRINT,
|
||||
);
|
||||
hemx_sync::SyncEffect::durable(patch, projection)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "client"))]
|
||||
@@ -115,7 +119,7 @@ mod client_tests {
|
||||
.into_batch(ui::BUILD_FINGERPRINT);
|
||||
assert_eq!(batch.ops.len(), 3);
|
||||
assert!(
|
||||
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction"))
|
||||
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":["))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,13 +503,16 @@
|
||||
} else if (op.kind === "emit") {
|
||||
let payload = op.payload;
|
||||
if (currentOperationId && op.name === "hemx:sync-patch") {
|
||||
const patch = JSON.parse(payload);
|
||||
const event = JSON.parse(payload);
|
||||
const patch = event && Object.getPrototypeOf(event) === Object.prototype && "patch" in event
|
||||
? event.patch
|
||||
: event;
|
||||
if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") {
|
||||
throw new Error("hemx sync patch is missing its interaction identity");
|
||||
}
|
||||
patch.idempotencyKey = currentOperationId;
|
||||
patch.operationId = currentOperationId;
|
||||
payload = JSON.stringify(patch);
|
||||
payload = JSON.stringify(event);
|
||||
}
|
||||
handleRuntimeEvent(scope, op.name, payload);
|
||||
emit(scope, op.name, payload);
|
||||
|
||||
@@ -62,6 +62,28 @@ function validatePatch(patch) {
|
||||
return patch;
|
||||
}
|
||||
|
||||
function validateProjection(projection) {
|
||||
if (!Array.isArray(projection)
|
||||
|| projection.length > 1024 * 1024
|
||||
|| projection.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
||||
throw new Error("invalid durable projection");
|
||||
}
|
||||
return projection;
|
||||
}
|
||||
|
||||
function normalizeEvent(payload) {
|
||||
if (payload && Object.getPrototypeOf(payload) === Object.prototype && "patch" in payload) {
|
||||
const keys = Object.keys(payload).sort();
|
||||
if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "projection") {
|
||||
throw new Error("durable patch fields do not match schema");
|
||||
}
|
||||
const patch = validatePatch(payload.patch);
|
||||
return { idempotencyKey: patch.idempotencyKey, patch, projection: validateProjection(payload.projection) };
|
||||
}
|
||||
const patch = validatePatch(payload);
|
||||
return { idempotencyKey: patch.idempotencyKey, patch, projection: null };
|
||||
}
|
||||
|
||||
async function allPatches() {
|
||||
const transaction = database.transaction(STORE, "readonly");
|
||||
const done = transactionDone(transaction);
|
||||
@@ -70,10 +92,21 @@ async function allPatches() {
|
||||
return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey));
|
||||
}
|
||||
|
||||
async function persist(patch) {
|
||||
function normalizeStoredRecord(stored) {
|
||||
if (stored.patch) {
|
||||
return {
|
||||
patch: validatePatch(stored.patch),
|
||||
projection: stored.projection === null ? null : validateProjection(stored.projection),
|
||||
};
|
||||
}
|
||||
const { queuedAt: _queuedAt, ...legacyPatch } = stored;
|
||||
return { patch: validatePatch(legacyPatch), projection: null };
|
||||
}
|
||||
|
||||
async function persist(record) {
|
||||
const transaction = database.transaction(STORE, "readwrite");
|
||||
const done = transactionDone(transaction);
|
||||
transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() });
|
||||
transaction.objectStore(STORE).add({ ...record, queuedAt: Date.now() });
|
||||
await done;
|
||||
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
||||
}
|
||||
@@ -90,7 +123,7 @@ async function pump() {
|
||||
pumping = true;
|
||||
try {
|
||||
for (const stored of await allPatches()) {
|
||||
const { queuedAt: _queuedAt, ...patch } = stored;
|
||||
const { patch } = normalizeStoredRecord(stored);
|
||||
const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
@@ -122,18 +155,25 @@ async function pump() {
|
||||
async function start() {
|
||||
if (!root) return;
|
||||
database = await openDatabase();
|
||||
root.setAttribute("data-hemx-sync-ready", "");
|
||||
root.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
||||
const pending = await allPatches();
|
||||
for (const stored of pending) {
|
||||
const { projection } = normalizeStoredRecord(stored);
|
||||
if (projection) {
|
||||
window.hemx?.applyBatch(Uint8Array.from(projection).buffer, root);
|
||||
}
|
||||
}
|
||||
document.addEventListener(EVENT, async (event) => {
|
||||
try {
|
||||
const patch = validatePatch(JSON.parse(event.detail));
|
||||
await persist(patch);
|
||||
const record = normalizeEvent(JSON.parse(event.detail));
|
||||
await persist(record);
|
||||
await pump();
|
||||
} catch (error) {
|
||||
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
window.addEventListener("online", () => pump());
|
||||
root.setAttribute("data-hemx-sync-pending", String(pending.length));
|
||||
root.setAttribute("data-hemx-sync-ready", "");
|
||||
await pump();
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,29 @@ impl SyncEffect {
|
||||
payload: patch.payload(),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Apply an optimistic projection now and carry the same ordinary batch in
|
||||
/// the durable patch event so the framework sync runtime can replay it
|
||||
/// after reload before acknowledgement.
|
||||
pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self {
|
||||
patch.validate().expect("FlatPatch must remain valid");
|
||||
let projection_wire = projection
|
||||
.to_wire()
|
||||
.iter()
|
||||
.map(u8::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let payload = format!(
|
||||
r#"{{"patch":{},"projection":[{projection_wire}]}}"#,
|
||||
patch.payload()
|
||||
);
|
||||
let mut ops = projection.ops;
|
||||
ops.push(Effect::Emit {
|
||||
name: PATCH_EVENT.to_owned(),
|
||||
payload,
|
||||
});
|
||||
Self(ops)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoEffect for SyncEffect {
|
||||
@@ -429,6 +452,24 @@ impl IntoEffect for SyncEffect {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durable_patch_carries_and_applies_ordinary_projection_batch() {
|
||||
let patch =
|
||||
FlatPatch::for_interaction("cardColumn", PatchValue::String("done".into())).unwrap();
|
||||
let projection = Effect::Emit {
|
||||
name: "projected".into(),
|
||||
payload: "card:1".into(),
|
||||
}
|
||||
.into_batch(hemx_core::BuildFingerprint(7));
|
||||
let batch =
|
||||
SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7));
|
||||
assert_eq!(batch.ops.len(), 2);
|
||||
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
|
||||
assert!(
|
||||
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledgement_updates_atom_and_emits_queue_signal() {
|
||||
let atom = Atom::<String>::new(17);
|
||||
|
||||
+110
-159
@@ -361,7 +361,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> {
|
||||
// test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001
|
||||
// test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 req: v1_release/002
|
||||
// test req: accessibility/001 req: accessibility/002
|
||||
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -399,7 +399,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
bootstrap,
|
||||
rendered,
|
||||
"kanban_client",
|
||||
Some(kanban_app_assets(&workspace)),
|
||||
Some(framework_sync_assets(&workspace)),
|
||||
);
|
||||
|
||||
let webdriver_port = available_port();
|
||||
@@ -498,22 +498,30 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
driver.goto(&server.url()).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-hemx-sync-ready') === true",
|
||||
)
|
||||
.await?;
|
||||
let framework_only = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
Promise.all(['/app.js', '/offline.js'].map((path) => fetch(path).then((response) => response.status)))
|
||||
.then((statuses) => done({ statuses, scripts: [...document.scripts].map((script) => script.getAttribute('src')) }))
|
||||
.catch((error) => done({ error: String(error) }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(framework_only["statuses"], serde_json::json!([404, 404]));
|
||||
assert_eq!(
|
||||
framework_only["scripts"],
|
||||
serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"])
|
||||
);
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
const root = document.querySelector('[data-hemx-root]');
|
||||
window.__persistedCommand = null;
|
||||
root.addEventListener('kanban:command-persisted', (event) => {
|
||||
window.__persistedCommand = {
|
||||
detail: event.detail,
|
||||
order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'),
|
||||
};
|
||||
}, { once: true });
|
||||
return true;
|
||||
"#,
|
||||
"window.__durablePatch = null; document.addEventListener('hemx:sync-patch', (event) => { window.__durablePatch = event.detail; }, { once: true }); document.querySelector('[data-hemx-root]').setAttribute('data-sync-endpoint', '/unavailable'); return true",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -525,84 +533,50 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && window.__durablePatch !== null && root.getAttribute('data-hemx-sync-pending') === '1' && root.getAttribute('data-hemx-sync-error') === 'upload-404'",
|
||||
)
|
||||
.await?;
|
||||
let persisted = driver
|
||||
let command_id = driver
|
||||
.execute(
|
||||
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
|
||||
"return JSON.parse(window.__durablePatch).patch.idempotencyKey",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(persisted["persisted"]["order"], "1|2");
|
||||
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 2);
|
||||
assert_eq!(persisted["persisted"]["detail"]["targetColumn"], "done");
|
||||
assert_eq!(persisted["persisted"]["detail"]["causal"], 1);
|
||||
let actor = persisted["persisted"]["detail"]["actor"]
|
||||
.as_str()
|
||||
.expect("persisted actor");
|
||||
let session = persisted["persisted"]["detail"]["session"]
|
||||
.as_str()
|
||||
.expect("persisted session");
|
||||
assert!(!actor.is_empty());
|
||||
assert!(!session.is_empty());
|
||||
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}: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");
|
||||
|
||||
server.stop();
|
||||
assert!(!server.is_reachable(), "fixture server must be unreachable");
|
||||
driver
|
||||
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||
.await?;
|
||||
.expect("durable interaction identity")
|
||||
.to_owned();
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')",
|
||||
)
|
||||
.await?;
|
||||
let restored = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), notice: root.querySelector('[data-sid]').textContent, error: root.getAttribute('data-hemx-sync-error') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(restored["order"], "2|1");
|
||||
assert_eq!(restored["count"], "1");
|
||||
assert!(restored["error"].is_null());
|
||||
assert_eq!(restored["notice"], "Moved 1 with drop");
|
||||
assert!(restored["error"].is_null());
|
||||
|
||||
let app_addr = server.address.to_string();
|
||||
server.stop();
|
||||
assert!(!server.is_reachable(), "fixture server must be stopped before app restart");
|
||||
let mut app_command = Command::new(&host_binary);
|
||||
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
|
||||
let _app = ProcessGuard::start(app_command, &app_addr);
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
|
||||
)
|
||||
.await?;
|
||||
let command_id = persisted["persisted"]["detail"]["id"]
|
||||
.as_str()
|
||||
.expect("persisted command id");
|
||||
let duplicate_script = format!(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const commandId = {command_id:?};
|
||||
(async () => {{
|
||||
const initialResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
|
||||
const initial = await initialResponse.json();
|
||||
const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
|
||||
const duplicate = await duplicateResponse.json();
|
||||
const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }});
|
||||
@@ -613,23 +587,20 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
const peer = await peerResponse.json();
|
||||
const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json();
|
||||
const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {{
|
||||
const count = open.result.transaction('commands', 'readonly').objectStore('commands').count();
|
||||
count.onsuccess = () => done({{
|
||||
duplicateStatus: duplicateResponse.status,
|
||||
duplicate,
|
||||
conflictStatus: conflictResponse.status,
|
||||
conflict,
|
||||
rejectionStatus: rejectionResponse.status,
|
||||
rejection,
|
||||
peerStatus: peerResponse.status,
|
||||
peer,
|
||||
snapshot,
|
||||
history,
|
||||
queueCount: count.result,
|
||||
}});
|
||||
}};
|
||||
done({{
|
||||
initialStatus: initialResponse.status,
|
||||
initial,
|
||||
duplicateStatus: duplicateResponse.status,
|
||||
duplicate,
|
||||
conflictStatus: conflictResponse.status,
|
||||
conflict,
|
||||
rejectionStatus: rejectionResponse.status,
|
||||
rejection,
|
||||
peerStatus: peerResponse.status,
|
||||
peer,
|
||||
snapshot,
|
||||
history,
|
||||
}});
|
||||
}})().catch((error) => done({{ error: String(error), stack: error.stack }}));
|
||||
"#
|
||||
);
|
||||
@@ -642,6 +613,9 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
convergence.get("error").is_none(),
|
||||
"sync convergence failed: {convergence}"
|
||||
);
|
||||
assert_eq!(convergence["initialStatus"], 200);
|
||||
assert_eq!(convergence["initial"]["commandId"], command_id);
|
||||
assert_eq!(convergence["initial"]["serverSequence"], 1);
|
||||
assert_eq!(convergence["duplicateStatus"], 200);
|
||||
assert_eq!(convergence["duplicate"]["commandId"], command_id);
|
||||
assert_eq!(convergence["duplicate"]["serverSequence"], 1);
|
||||
@@ -657,7 +631,6 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
assert_eq!(convergence["peerStatus"], 200);
|
||||
assert_eq!(convergence["peer"]["commandId"], "peer:1");
|
||||
assert_eq!(convergence["peer"]["serverSequence"], 2);
|
||||
assert_eq!(convergence["queueCount"], 0);
|
||||
assert_eq!(convergence["snapshot"]["serverSequence"], 2);
|
||||
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
|
||||
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
|
||||
@@ -667,70 +640,11 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
convergence["history"]
|
||||
.as_str()
|
||||
.expect("acknowledgement history")
|
||||
.matches(command_id)
|
||||
.matches(&command_id)
|
||||
.count(),
|
||||
1,
|
||||
"duplicate replay emitted another acknowledgement: {convergence}"
|
||||
);
|
||||
|
||||
driver.goto(&format!("http://{app_addr}/")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
window.__futureCommandStored = false;
|
||||
const request = indexedDB.open('hemx-kanban-v1');
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'future:2', schemaVersion: 3, accountPartition: 'demo:demo', actor: 'future', session: 'future',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => { window.__futureCommandStored = true; };
|
||||
};
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
||||
driver
|
||||
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-error') === true",
|
||||
)
|
||||
.await?;
|
||||
let rejected = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
rejected["error"],
|
||||
"restore: unsupported durable command future:2"
|
||||
);
|
||||
assert_eq!(rejected["ready"], false);
|
||||
|
||||
let delete_commands = driver
|
||||
.find(By::Css("[data-kanban-command-action='delete']"))
|
||||
.await?;
|
||||
delete_commands.click().await?;
|
||||
assert_eq!(delete_commands.text().await?, "Confirm delete commands");
|
||||
delete_commands.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-kanban-command-ready') === true && root.getAttribute('data-kanban-command-count') === '0' && !root.hasAttribute('data-kanban-command-error')",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -1744,15 +1658,23 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
struct AppAssets {
|
||||
module: PathBuf,
|
||||
service_worker: PathBuf,
|
||||
module: Option<PathBuf>,
|
||||
service_worker: Option<PathBuf>,
|
||||
sync_runtime: 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"),
|
||||
module: Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||
service_worker: Some(workspace.join("examples/kanban/static/offline.js")),
|
||||
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||
}
|
||||
}
|
||||
|
||||
fn framework_sync_assets(workspace: &Path) -> AppAssets {
|
||||
AppAssets {
|
||||
module: None,
|
||||
service_worker: None,
|
||||
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||
}
|
||||
}
|
||||
@@ -1841,7 +1763,7 @@ fn serve(
|
||||
let (content_type, body) = match path {
|
||||
"/" => (
|
||||
"text/html; charset=utf-8",
|
||||
fixture_html(rendered, app_assets.is_some()).into_bytes(),
|
||||
fixture_html(rendered, app_assets).into_bytes(),
|
||||
),
|
||||
"/hemx.js" => (
|
||||
"text/javascript; charset=utf-8",
|
||||
@@ -1859,15 +1781,36 @@ fn serve(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(bootstrap).expect("read generated client bootstrap"),
|
||||
),
|
||||
"/app.js" if app_assets.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
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)
|
||||
"/app.js"
|
||||
if app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.is_some() =>
|
||||
{
|
||||
(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(
|
||||
app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.expect("checked app module"),
|
||||
)
|
||||
.expect("read app module"),
|
||||
)
|
||||
}
|
||||
"/offline.js"
|
||||
if app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.is_some() =>
|
||||
{
|
||||
(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(
|
||||
app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.expect("checked service worker"),
|
||||
)
|
||||
.expect("read service worker"),
|
||||
),
|
||||
)
|
||||
}
|
||||
"/hemx-sync.js" if app_assets.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(&app_assets.expect("checked app assets").sync_runtime)
|
||||
@@ -1903,8 +1846,14 @@ fn serve(
|
||||
let status = if path == "/"
|
||||
|| path == "/hemx.js"
|
||||
|| path == "/hemx.client.js"
|
||||
|| ((path == "/app.js"
|
||||
|| path == "/offline.js"
|
||||
|| (((path == "/app.js"
|
||||
&& app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.is_some())
|
||||
|| (path == "/offline.js"
|
||||
&& app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.is_some())
|
||||
|| path == "/hemx-sync.js"
|
||||
|| path == "/sync/context"
|
||||
|| path == "/sync/patches")
|
||||
@@ -1921,11 +1870,13 @@ fn serve(
|
||||
let _ = stream.write_all(&body);
|
||||
}
|
||||
|
||||
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||
let app_module = if has_app_module {
|
||||
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
||||
} else {
|
||||
""
|
||||
fn fixture_html(rendered: &str, app_assets: Option<&AppAssets>) -> String {
|
||||
let app_module = match app_assets {
|
||||
Some(assets) if assets.module.is_some() => {
|
||||
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
||||
}
|
||||
Some(_) => "<script type=\"module\" src=\"/hemx-sync.js\"></script>",
|
||||
None => "",
|
||||
};
|
||||
format!(
|
||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"
|
||||
|
||||
Reference in New Issue
Block a user