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
|
## 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.
|
- [ ] **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.
|
- **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.
|
- **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`.
|
- **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
|
## 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.
|
- [ ] **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.
|
- **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.
|
- **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.
|
- **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),
|
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
|
||||||
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
||||||
};
|
};
|
||||||
(
|
let projection = hemx::IntoEffect::into_batch(
|
||||||
move_effect,
|
(
|
||||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
move_effect,
|
||||||
hemx_sync::SyncEffect::send_patch(patch),
|
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"))]
|
#[cfg(all(test, feature = "client"))]
|
||||||
@@ -115,7 +119,7 @@ mod client_tests {
|
|||||||
.into_batch(ui::BUILD_FINGERPRINT);
|
.into_batch(ui::BUILD_FINGERPRINT);
|
||||||
assert_eq!(batch.ops.len(), 3);
|
assert_eq!(batch.ops.len(), 3);
|
||||||
assert!(
|
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") {
|
} else if (op.kind === "emit") {
|
||||||
let payload = op.payload;
|
let payload = op.payload;
|
||||||
if (currentOperationId && op.name === "hemx:sync-patch") {
|
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") {
|
if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") {
|
||||||
throw new Error("hemx sync patch is missing its interaction identity");
|
throw new Error("hemx sync patch is missing its interaction identity");
|
||||||
}
|
}
|
||||||
patch.idempotencyKey = currentOperationId;
|
patch.idempotencyKey = currentOperationId;
|
||||||
patch.operationId = currentOperationId;
|
patch.operationId = currentOperationId;
|
||||||
payload = JSON.stringify(patch);
|
payload = JSON.stringify(event);
|
||||||
}
|
}
|
||||||
handleRuntimeEvent(scope, op.name, payload);
|
handleRuntimeEvent(scope, op.name, payload);
|
||||||
emit(scope, op.name, payload);
|
emit(scope, op.name, payload);
|
||||||
|
|||||||
@@ -62,6 +62,28 @@ function validatePatch(patch) {
|
|||||||
return 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() {
|
async function allPatches() {
|
||||||
const transaction = database.transaction(STORE, "readonly");
|
const transaction = database.transaction(STORE, "readonly");
|
||||||
const done = transactionDone(transaction);
|
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));
|
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 transaction = database.transaction(STORE, "readwrite");
|
||||||
const done = transactionDone(transaction);
|
const done = transactionDone(transaction);
|
||||||
transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() });
|
transaction.objectStore(STORE).add({ ...record, queuedAt: Date.now() });
|
||||||
await done;
|
await done;
|
||||||
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
||||||
}
|
}
|
||||||
@@ -90,7 +123,7 @@ async function pump() {
|
|||||||
pumping = true;
|
pumping = true;
|
||||||
try {
|
try {
|
||||||
for (const stored of await allPatches()) {
|
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 endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches";
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -122,18 +155,25 @@ async function pump() {
|
|||||||
async function start() {
|
async function start() {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
database = await openDatabase();
|
database = await openDatabase();
|
||||||
root.setAttribute("data-hemx-sync-ready", "");
|
const pending = await allPatches();
|
||||||
root.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
for (const stored of pending) {
|
||||||
|
const { projection } = normalizeStoredRecord(stored);
|
||||||
|
if (projection) {
|
||||||
|
window.hemx?.applyBatch(Uint8Array.from(projection).buffer, root);
|
||||||
|
}
|
||||||
|
}
|
||||||
document.addEventListener(EVENT, async (event) => {
|
document.addEventListener(EVENT, async (event) => {
|
||||||
try {
|
try {
|
||||||
const patch = validatePatch(JSON.parse(event.detail));
|
const record = normalizeEvent(JSON.parse(event.detail));
|
||||||
await persist(patch);
|
await persist(record);
|
||||||
await pump();
|
await pump();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
|
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener("online", () => pump());
|
window.addEventListener("online", () => pump());
|
||||||
|
root.setAttribute("data-hemx-sync-pending", String(pending.length));
|
||||||
|
root.setAttribute("data-hemx-sync-ready", "");
|
||||||
await pump();
|
await pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,29 @@ impl SyncEffect {
|
|||||||
payload: patch.payload(),
|
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 {
|
impl IntoEffect for SyncEffect {
|
||||||
@@ -429,6 +452,24 @@ impl IntoEffect for SyncEffect {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn acknowledgement_updates_atom_and_emits_queue_signal() {
|
fn acknowledgement_updates_atom_and_emits_queue_signal() {
|
||||||
let atom = Atom::<String>::new(17);
|
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]
|
#[tokio::test]
|
||||||
async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> {
|
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: accessibility/001 req: accessibility/002
|
||||||
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
|
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
|
||||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
@@ -399,7 +399,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
|||||||
bootstrap,
|
bootstrap,
|
||||||
rendered,
|
rendered,
|
||||||
"kanban_client",
|
"kanban_client",
|
||||||
Some(kanban_app_assets(&workspace)),
|
Some(framework_sync_assets(&workspace)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let webdriver_port = available_port();
|
let webdriver_port = available_port();
|
||||||
@@ -498,22 +498,30 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
|||||||
driver.goto(&server.url()).await?;
|
driver.goto(&server.url()).await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&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?;
|
.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
|
driver
|
||||||
.execute(
|
.execute(
|
||||||
r#"
|
"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",
|
||||||
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;
|
|
||||||
"#,
|
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -525,84 +533,50 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
|||||||
.await?;
|
.await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&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?;
|
.await?;
|
||||||
let persisted = driver
|
let command_id = driver
|
||||||
.execute(
|
.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(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.json()
|
.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()
|
.as_str()
|
||||||
.expect("persisted actor");
|
.expect("durable interaction identity")
|
||||||
let session = persisted["persisted"]["detail"]["session"]
|
.to_owned();
|
||||||
.as_str()
|
driver.refresh().await?;
|
||||||
.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?;
|
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&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?;
|
.await?;
|
||||||
let restored = driver
|
let restored = driver
|
||||||
.execute(
|
.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(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.json()
|
.json()
|
||||||
.clone();
|
.clone();
|
||||||
assert_eq!(restored["order"], "2|1");
|
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_eq!(restored["notice"], "Moved 1 with drop");
|
||||||
|
assert!(restored["error"].is_null());
|
||||||
|
|
||||||
let app_addr = server.address.to_string();
|
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);
|
let mut app_command = Command::new(&host_binary);
|
||||||
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
|
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
|
||||||
let _app = ProcessGuard::start(app_command, &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!(
|
let duplicate_script = format!(
|
||||||
r#"
|
r#"
|
||||||
const done = arguments[arguments.length - 1];
|
const done = arguments[arguments.length - 1];
|
||||||
const commandId = {command_id:?};
|
const commandId = {command_id:?};
|
||||||
(async () => {{
|
(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 duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
|
||||||
const duplicate = await duplicateResponse.json();
|
const duplicate = await duplicateResponse.json();
|
||||||
const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }});
|
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 peer = await peerResponse.json();
|
||||||
const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).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 history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
|
||||||
const open = indexedDB.open('hemx-kanban-v1');
|
done({{
|
||||||
open.onsuccess = () => {{
|
initialStatus: initialResponse.status,
|
||||||
const count = open.result.transaction('commands', 'readonly').objectStore('commands').count();
|
initial,
|
||||||
count.onsuccess = () => done({{
|
duplicateStatus: duplicateResponse.status,
|
||||||
duplicateStatus: duplicateResponse.status,
|
duplicate,
|
||||||
duplicate,
|
conflictStatus: conflictResponse.status,
|
||||||
conflictStatus: conflictResponse.status,
|
conflict,
|
||||||
conflict,
|
rejectionStatus: rejectionResponse.status,
|
||||||
rejectionStatus: rejectionResponse.status,
|
rejection,
|
||||||
rejection,
|
peerStatus: peerResponse.status,
|
||||||
peerStatus: peerResponse.status,
|
peer,
|
||||||
peer,
|
snapshot,
|
||||||
snapshot,
|
history,
|
||||||
history,
|
}});
|
||||||
queueCount: count.result,
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
}})().catch((error) => done({{ error: String(error), stack: error.stack }}));
|
}})().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(),
|
convergence.get("error").is_none(),
|
||||||
"sync convergence failed: {convergence}"
|
"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["duplicateStatus"], 200);
|
||||||
assert_eq!(convergence["duplicate"]["commandId"], command_id);
|
assert_eq!(convergence["duplicate"]["commandId"], command_id);
|
||||||
assert_eq!(convergence["duplicate"]["serverSequence"], 1);
|
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["peerStatus"], 200);
|
||||||
assert_eq!(convergence["peer"]["commandId"], "peer:1");
|
assert_eq!(convergence["peer"]["commandId"], "peer:1");
|
||||||
assert_eq!(convergence["peer"]["serverSequence"], 2);
|
assert_eq!(convergence["peer"]["serverSequence"], 2);
|
||||||
assert_eq!(convergence["queueCount"], 0);
|
|
||||||
assert_eq!(convergence["snapshot"]["serverSequence"], 2);
|
assert_eq!(convergence["snapshot"]["serverSequence"], 2);
|
||||||
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
|
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
|
||||||
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
|
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
|
||||||
@@ -667,70 +640,11 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
|||||||
convergence["history"]
|
convergence["history"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("acknowledgement history")
|
.expect("acknowledgement history")
|
||||||
.matches(command_id)
|
.matches(&command_id)
|
||||||
.count(),
|
.count(),
|
||||||
1,
|
1,
|
||||||
"duplicate replay emitted another acknowledgement: {convergence}"
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
@@ -1744,15 +1658,23 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppAssets {
|
struct AppAssets {
|
||||||
module: PathBuf,
|
module: Option<PathBuf>,
|
||||||
service_worker: PathBuf,
|
service_worker: Option<PathBuf>,
|
||||||
sync_runtime: PathBuf,
|
sync_runtime: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kanban_app_assets(workspace: &Path) -> AppAssets {
|
fn kanban_app_assets(workspace: &Path) -> AppAssets {
|
||||||
AppAssets {
|
AppAssets {
|
||||||
module: workspace.join("examples/kanban/static/command-log.js"),
|
module: Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||||
service_worker: workspace.join("examples/kanban/static/offline.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"),
|
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1841,7 +1763,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_assets.is_some()).into_bytes(),
|
fixture_html(rendered, app_assets).into_bytes(),
|
||||||
),
|
),
|
||||||
"/hemx.js" => (
|
"/hemx.js" => (
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
@@ -1859,15 +1781,36 @@ 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_assets.is_some() => (
|
"/app.js"
|
||||||
"text/javascript; charset=utf-8",
|
if app_assets
|
||||||
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
|
.and_then(|assets| assets.module.as_ref())
|
||||||
),
|
.is_some() =>
|
||||||
"/offline.js" if app_assets.is_some() => (
|
{
|
||||||
"text/javascript; charset=utf-8",
|
(
|
||||||
fs::read(&app_assets.expect("checked app assets").service_worker)
|
"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"),
|
.expect("read service worker"),
|
||||||
),
|
)
|
||||||
|
}
|
||||||
"/hemx-sync.js" if app_assets.is_some() => (
|
"/hemx-sync.js" if app_assets.is_some() => (
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
fs::read(&app_assets.expect("checked app assets").sync_runtime)
|
fs::read(&app_assets.expect("checked app assets").sync_runtime)
|
||||||
@@ -1903,8 +1846,14 @@ fn serve(
|
|||||||
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"
|
|| (((path == "/app.js"
|
||||||
|| path == "/offline.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 == "/hemx-sync.js"
|
||||||
|| path == "/sync/context"
|
|| path == "/sync/context"
|
||||||
|| path == "/sync/patches")
|
|| path == "/sync/patches")
|
||||||
@@ -1921,11 +1870,13 @@ fn serve(
|
|||||||
let _ = stream.write_all(&body);
|
let _ = stream.write_all(&body);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
fn fixture_html(rendered: &str, app_assets: Option<&AppAssets>) -> String {
|
||||||
let app_module = if has_app_module {
|
let app_module = match app_assets {
|
||||||
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
Some(assets) if assets.module.is_some() => {
|
||||||
} else {
|
"<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!(
|
format!(
|
||||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"
|
"<!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