feat(sync): add typed flat patch boundary
req: sync/002 req: sync/003
This commit is contained in:
Generated
+11
@@ -577,6 +577,7 @@ dependencies = [
|
|||||||
"hemx",
|
"hemx",
|
||||||
"hemx-axum",
|
"hemx-axum",
|
||||||
"hemx-build",
|
"hemx-build",
|
||||||
|
"hemx-sync",
|
||||||
"hemx-test",
|
"hemx-test",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -608,6 +609,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemx-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemx-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hemx-techdemo"
|
name = "hemx-techdemo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -653,6 +663,7 @@ name = "hemx-wasm"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hemx-core",
|
"hemx-core",
|
||||||
|
"serde_json",
|
||||||
"thirtyfour 0.36.1",
|
"thirtyfour 0.36.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"]
|
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ axum = { version = "0.8", optional = true }
|
|||||||
futures-util = { version = "0.3", optional = true }
|
futures-util = { version = "0.3", optional = true }
|
||||||
hemx = { path = "../../hemx" }
|
hemx = { path = "../../hemx" }
|
||||||
hemx-axum = { path = "../../hemx-axum", optional = true }
|
hemx-axum = { path = "../../hemx-axum", optional = true }
|
||||||
|
hemx-sync = { path = "../../hemx-sync" }
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"], optional = true }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"], optional = true }
|
||||||
|
|||||||
@@ -78,14 +78,46 @@ pub fn reorder_card(
|
|||||||
let projected =
|
let projected =
|
||||||
BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide());
|
BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide());
|
||||||
let card = projected.card.0;
|
let card = projected.card.0;
|
||||||
|
let patch = hemx_sync::FlatPatch::for_interaction(
|
||||||
|
"cardColumn",
|
||||||
|
hemx_sync::PatchValue::String("done".to_owned()),
|
||||||
|
)
|
||||||
|
.expect("generated Kanban patch is valid");
|
||||||
let move_effect = match projected.before {
|
let move_effect = match projected.before {
|
||||||
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()),
|
||||||
};
|
};
|
||||||
vec![
|
(
|
||||||
move_effect,
|
move_effect,
|
||||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||||
]
|
hemx_sync::SyncEffect::send_patch(patch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "client"))]
|
||||||
|
mod client_tests {
|
||||||
|
use super::*;
|
||||||
|
use hemx::IntoEffect;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_reorder_carries_flat_patch_in_ordinary_effect_batch() {
|
||||||
|
let batch = reorder_card(
|
||||||
|
hemx::wasm::ClientEvent {
|
||||||
|
kind: "drop".to_owned(),
|
||||||
|
value: None,
|
||||||
|
checked: None,
|
||||||
|
key: None,
|
||||||
|
},
|
||||||
|
hemx::wasm::ClientState {
|
||||||
|
encoded: "1|2".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))]
|
#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<section data-hemx-root="kanban_client" data-hemx-st="1|2" data-hemx-client-state-version="1" data-hemx-client-module="/kanban_client.js">
|
<section data-hemx-root="kanban_client" data-hemx-st="1|2" data-hemx-client-state-version="1" data-sync-endpoint="/sync/patches" data-hemx-client-module="/kanban_client.js">
|
||||||
<p id="kanban-status" data-hemx-slot="client_notice" role="status" aria-live="polite">Ready</p>
|
<p id="kanban-status" data-hemx-slot="client_notice" role="status" aria-live="polite">Ready</p>
|
||||||
<ul data-hemx-slot="client_cards">
|
<ul data-hemx-slot="client_cards">
|
||||||
<template h-for="card in &self.cards" h-key="card.id">
|
<template h-for="card in &self.cards" h-key="card.id">
|
||||||
|
|||||||
+19
-2
@@ -18,6 +18,7 @@
|
|||||||
const sseSources = new WeakMap();
|
const sseSources = new WeakMap();
|
||||||
const atomStores = new WeakMap();
|
const atomStores = new WeakMap();
|
||||||
const dragKeys = new WeakMap();
|
const dragKeys = new WeakMap();
|
||||||
|
let currentOperationId = null;
|
||||||
const clientHandlers = new Map();
|
const clientHandlers = new Map();
|
||||||
const clientRuns = new WeakMap();
|
const clientRuns = new WeakMap();
|
||||||
|
|
||||||
@@ -253,6 +254,7 @@
|
|||||||
try {
|
try {
|
||||||
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
|
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
|
||||||
const stateVersion = Number(root.getAttribute("data-hemx-client-state-version") || "1");
|
const stateVersion = Number(root.getAttribute("data-hemx-client-state-version") || "1");
|
||||||
|
const operationId = crypto.randomUUID();
|
||||||
const wire = await handler(
|
const wire = await handler(
|
||||||
1,
|
1,
|
||||||
event.type,
|
event.type,
|
||||||
@@ -264,7 +266,12 @@
|
|||||||
);
|
);
|
||||||
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
|
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
|
||||||
if (!active()) return;
|
if (!active()) return;
|
||||||
|
currentOperationId = operationId;
|
||||||
|
try {
|
||||||
applyBatch(wire, root);
|
applyBatch(wire, root);
|
||||||
|
} finally {
|
||||||
|
currentOperationId = null;
|
||||||
|
}
|
||||||
if (name === "reorder_card") {
|
if (name === "reorder_card") {
|
||||||
const key = dragKeys.get(root) || el.getAttribute("data-card-id");
|
const key = dragKeys.get(root) || el.getAttribute("data-card-id");
|
||||||
const moved = key ? firstElement(root, (node) => node.getAttribute("data-key") === key) : null;
|
const moved = key ? firstElement(root, (node) => node.getAttribute("data-key") === key) : null;
|
||||||
@@ -469,8 +476,18 @@
|
|||||||
if (op.title) document.title = op.title;
|
if (op.title) document.title = op.title;
|
||||||
}
|
}
|
||||||
} else if (op.kind === "emit") {
|
} else if (op.kind === "emit") {
|
||||||
handleRuntimeEvent(scope, op.name, op.payload);
|
let payload = op.payload;
|
||||||
emit(scope, op.name, op.payload);
|
if (currentOperationId && op.name === "hemx:sync-patch") {
|
||||||
|
const patch = JSON.parse(payload);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
handleRuntimeEvent(scope, op.name, payload);
|
||||||
|
emit(scope, op.name, payload);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "hemx-sync"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hemx-core = { path = "../hemx-core" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const DATABASE = "hemx-sync-v1";
|
||||||
|
const STORE = "patches";
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
const EVENT = "hemx:sync-patch";
|
||||||
|
const root = document.querySelector("[data-hemx-root]");
|
||||||
|
let database;
|
||||||
|
let pumping = false;
|
||||||
|
|
||||||
|
function requestResult(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.addEventListener("success", () => resolve(request.result), { once: true });
|
||||||
|
request.addEventListener("error", () => reject(request.error), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDone(transaction) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
transaction.addEventListener("complete", resolve, { once: true });
|
||||||
|
transaction.addEventListener("abort", () => reject(transaction.error), { once: true });
|
||||||
|
transaction.addEventListener("error", () => reject(transaction.error), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDatabase() {
|
||||||
|
const request = indexedDB.open(DATABASE, 1);
|
||||||
|
request.addEventListener("upgradeneeded", () => {
|
||||||
|
if (!request.result.objectStoreNames.contains(STORE)) {
|
||||||
|
request.result.createObjectStore(STORE, { keyPath: "idempotencyKey" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return requestResult(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIdentifier(value) {
|
||||||
|
return typeof value === "string" && value.length > 0 && value.length <= 128 && /^[A-Za-z0-9:_.-]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validKey(value) {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& value.length > 0
|
||||||
|
&& value.length <= 64
|
||||||
|
&& /^[A-Za-z][A-Za-z0-9_-]*$/.test(value)
|
||||||
|
&& !["schemaVersion", "idempotencyKey", "operationId", "key", "value"].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePatch(patch) {
|
||||||
|
if (!patch || Object.getPrototypeOf(patch) !== Object.prototype) throw new Error("patch must be an object");
|
||||||
|
const keys = Object.keys(patch).sort();
|
||||||
|
const expected = ["idempotencyKey", "key", "operationId", "schemaVersion", "value"];
|
||||||
|
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||||
|
throw new Error("patch fields do not match schema");
|
||||||
|
}
|
||||||
|
if (patch.schemaVersion !== SCHEMA_VERSION) throw new Error(`unsupported patch schema version ${patch.schemaVersion}`);
|
||||||
|
if (!validIdentifier(patch.idempotencyKey)) throw new Error("invalid idempotencyKey");
|
||||||
|
if (!validIdentifier(patch.operationId)) throw new Error("invalid operationId");
|
||||||
|
if (!validKey(patch.key)) throw new Error("invalid patch key");
|
||||||
|
if (!["string", "number", "boolean"].includes(typeof patch.value)
|
||||||
|
|| (typeof patch.value === "number" && !Number.isSafeInteger(patch.value))
|
||||||
|
|| (typeof patch.value === "string" && patch.value.length > 4096)) {
|
||||||
|
throw new Error("invalid patch value");
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allPatches() {
|
||||||
|
const transaction = database.transaction(STORE, "readonly");
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
const patches = await requestResult(transaction.objectStore(STORE).getAll());
|
||||||
|
await done;
|
||||||
|
return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(patch) {
|
||||||
|
const transaction = database.transaction(STORE, "readwrite");
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() });
|
||||||
|
await done;
|
||||||
|
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(idempotencyKey) {
|
||||||
|
const transaction = database.transaction(STORE, "readwrite");
|
||||||
|
const done = transactionDone(transaction);
|
||||||
|
transaction.objectStore(STORE).delete(idempotencyKey);
|
||||||
|
await done;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pump() {
|
||||||
|
if (pumping || !navigator.onLine) return;
|
||||||
|
pumping = true;
|
||||||
|
try {
|
||||||
|
for (const stored of await allPatches()) {
|
||||||
|
const { queuedAt: _queuedAt, ...patch } = stored;
|
||||||
|
const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches";
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
root?.setAttribute("data-hemx-sync-error", `upload-${response.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acknowledgement = await response.json();
|
||||||
|
if (acknowledgement.idempotencyKey !== patch.idempotencyKey
|
||||||
|
|| acknowledgement.operationId !== patch.operationId) {
|
||||||
|
root?.setAttribute("data-hemx-sync-error", "acknowledgement-mismatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await remove(patch.idempotencyKey);
|
||||||
|
root?.setAttribute("data-hemx-sync-ack", acknowledgement.idempotencyKey);
|
||||||
|
}
|
||||||
|
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
|
||||||
|
} catch {
|
||||||
|
root?.setAttribute("data-hemx-sync-error", "offline");
|
||||||
|
} finally {
|
||||||
|
pumping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
document.addEventListener(EVENT, async (event) => {
|
||||||
|
try {
|
||||||
|
const patch = validatePatch(JSON.parse(event.detail));
|
||||||
|
await persist(patch);
|
||||||
|
await pump();
|
||||||
|
} catch (error) {
|
||||||
|
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => pump());
|
||||||
|
await pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((error) => root?.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error)));
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
use hemx_core::{Effect, IntoEffect};
|
||||||
|
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
pub const PATCH_SCHEMA_VERSION: u16 = 1;
|
||||||
|
pub const PATCH_EVENT: &str = "hemx:sync-patch";
|
||||||
|
const INTERACTION_ID: &str = "$hemx-interaction";
|
||||||
|
pub const BROWSER_RUNTIME: &str = include_str!("../runtime/hemx-sync.js");
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum PatchValue {
|
||||||
|
Boolean(bool),
|
||||||
|
Integer(i64),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FlatPatch {
|
||||||
|
// req: sync/003
|
||||||
|
schema_version: u16,
|
||||||
|
idempotency_key: String,
|
||||||
|
operation_id: String,
|
||||||
|
key: String,
|
||||||
|
value: PatchValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct FlatPatchWire {
|
||||||
|
schema_version: u16,
|
||||||
|
idempotency_key: String,
|
||||||
|
operation_id: String,
|
||||||
|
key: String,
|
||||||
|
value: PatchValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for FlatPatch {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let wire = FlatPatchWire::deserialize(deserializer)?;
|
||||||
|
let patch = Self {
|
||||||
|
schema_version: wire.schema_version,
|
||||||
|
idempotency_key: wire.idempotency_key,
|
||||||
|
operation_id: wire.operation_id,
|
||||||
|
key: wire.key,
|
||||||
|
value: wire.value,
|
||||||
|
};
|
||||||
|
patch.validate().map_err(de::Error::custom)?;
|
||||||
|
Ok(patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlatPatch {
|
||||||
|
pub fn for_interaction(key: impl Into<String>, value: PatchValue) -> Result<Self, PatchError> {
|
||||||
|
let key = key.into();
|
||||||
|
validate_key(&key)?;
|
||||||
|
validate_value(&value)?;
|
||||||
|
Ok(Self {
|
||||||
|
schema_version: PATCH_SCHEMA_VERSION,
|
||||||
|
idempotency_key: INTERACTION_ID.to_owned(),
|
||||||
|
operation_id: INTERACTION_ID.to_owned(),
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
idempotency_key: impl Into<String>,
|
||||||
|
operation_id: impl Into<String>,
|
||||||
|
key: impl Into<String>,
|
||||||
|
value: PatchValue,
|
||||||
|
) -> Result<Self, PatchError> {
|
||||||
|
let patch = Self {
|
||||||
|
schema_version: PATCH_SCHEMA_VERSION,
|
||||||
|
idempotency_key: idempotency_key.into(),
|
||||||
|
operation_id: operation_id.into(),
|
||||||
|
key: key.into(),
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
patch.validate()?;
|
||||||
|
Ok(patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn payload(&self) -> String {
|
||||||
|
let value = match &self.value {
|
||||||
|
PatchValue::Boolean(value) => value.to_string(),
|
||||||
|
PatchValue::Integer(value) => value.to_string(),
|
||||||
|
PatchValue::String(value) => json_string(value),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"{{"schemaVersion":{},"idempotencyKey":{},"operationId":{},"key":{},"value":{value}}}"#,
|
||||||
|
self.schema_version,
|
||||||
|
json_string(&self.idempotency_key),
|
||||||
|
json_string(&self.operation_id),
|
||||||
|
json_string(&self.key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), PatchError> {
|
||||||
|
if self.schema_version != PATCH_SCHEMA_VERSION {
|
||||||
|
return Err(PatchError::SchemaVersion(self.schema_version));
|
||||||
|
}
|
||||||
|
if self.idempotency_key != INTERACTION_ID || self.operation_id != INTERACTION_ID {
|
||||||
|
validate_identifier("idempotency_key", &self.idempotency_key, 128)?;
|
||||||
|
validate_identifier("operation_id", &self.operation_id, 128)?;
|
||||||
|
}
|
||||||
|
validate_key(&self.key)?;
|
||||||
|
validate_value(&self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PatchError {
|
||||||
|
Empty(&'static str),
|
||||||
|
InvalidCharacter(&'static str),
|
||||||
|
TooLong(&'static str),
|
||||||
|
ReservedKey,
|
||||||
|
SchemaVersion(u16),
|
||||||
|
ValueTooLong,
|
||||||
|
IntegerOutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PatchError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Empty(field) => write!(formatter, "{field} must not be empty"),
|
||||||
|
Self::InvalidCharacter(field) => {
|
||||||
|
write!(formatter, "{field} contains an invalid character")
|
||||||
|
}
|
||||||
|
Self::TooLong(field) => write!(formatter, "{field} is too long"),
|
||||||
|
Self::ReservedKey => formatter.write_str("patch key is reserved"),
|
||||||
|
Self::SchemaVersion(version) => {
|
||||||
|
write!(formatter, "unsupported patch schema version {version}")
|
||||||
|
}
|
||||||
|
Self::ValueTooLong => formatter.write_str("patch string value is too long"),
|
||||||
|
Self::IntegerOutOfRange => {
|
||||||
|
formatter.write_str("patch integer value exceeds JavaScript's safe range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for PatchError {}
|
||||||
|
|
||||||
|
fn json_string(value: &str) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut encoded = String::with_capacity(value.len() + 2);
|
||||||
|
encoded.push('"');
|
||||||
|
for character in value.chars() {
|
||||||
|
match character {
|
||||||
|
'"' => encoded.push_str("\\\""),
|
||||||
|
'\\' => encoded.push_str("\\\\"),
|
||||||
|
'\n' => encoded.push_str("\\n"),
|
||||||
|
'\r' => encoded.push_str("\\r"),
|
||||||
|
'\t' => encoded.push_str("\\t"),
|
||||||
|
character if character <= '\u{1f}' => {
|
||||||
|
let byte = character as u8;
|
||||||
|
encoded.push_str("\\u00");
|
||||||
|
encoded.push(HEX[(byte >> 4) as usize] as char);
|
||||||
|
encoded.push(HEX[(byte & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
character => encoded.push(character),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded.push('"');
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_value(value: &PatchValue) -> Result<(), PatchError> {
|
||||||
|
match value {
|
||||||
|
PatchValue::String(value) if value.len() > 4096 => Err(PatchError::ValueTooLong),
|
||||||
|
PatchValue::Integer(value) if value.unsigned_abs() > 9_007_199_254_740_991 => {
|
||||||
|
Err(PatchError::IntegerOutOfRange)
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier(field: &'static str, value: &str, maximum: usize) -> Result<(), PatchError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(PatchError::Empty(field));
|
||||||
|
}
|
||||||
|
if value.len() > maximum {
|
||||||
|
return Err(PatchError::TooLong(field));
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.'))
|
||||||
|
{
|
||||||
|
return Err(PatchError::InvalidCharacter(field));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key(key: &str) -> Result<(), PatchError> {
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(PatchError::Empty("key"));
|
||||||
|
}
|
||||||
|
if key.len() > 64 {
|
||||||
|
return Err(PatchError::TooLong("key"));
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
key,
|
||||||
|
"schemaVersion" | "idempotencyKey" | "operationId" | "key" | "value"
|
||||||
|
) {
|
||||||
|
return Err(PatchError::ReservedKey);
|
||||||
|
}
|
||||||
|
let mut bytes = key.bytes();
|
||||||
|
if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic())
|
||||||
|
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(PatchError::InvalidCharacter("key"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SyncEffect(Effect); // req: sync/002
|
||||||
|
|
||||||
|
impl SyncEffect {
|
||||||
|
pub fn send_patch(patch: FlatPatch) -> Self {
|
||||||
|
patch.validate().expect("FlatPatch must remain valid");
|
||||||
|
Self(Effect::Emit {
|
||||||
|
name: PATCH_EVENT.to_owned(),
|
||||||
|
payload: patch.payload(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoEffect for SyncEffect {
|
||||||
|
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||||
|
self.0.append_to(ops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_is_flat_and_rejects_reserved_keys() {
|
||||||
|
let patch = FlatPatch::new(
|
||||||
|
"actor:1",
|
||||||
|
"move-card-to-done",
|
||||||
|
"cardColumn",
|
||||||
|
PatchValue::String("done".to_owned()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
patch.payload(),
|
||||||
|
r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move-card-to-done","key":"cardColumn","value":"done"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FlatPatch::new("actor:1", "move", "value", PatchValue::Integer(1)),
|
||||||
|
Err(PatchError::ReservedKey)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FlatPatch::new(
|
||||||
|
"actor:1",
|
||||||
|
"move",
|
||||||
|
"rank",
|
||||||
|
PatchValue::Integer(9_007_199_254_740_992),
|
||||||
|
),
|
||||||
|
Err(PatchError::IntegerOutOfRange)
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_str::<FlatPatch>(
|
||||||
|
r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move","key":"rank","value":9007199254740992}"#,
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("safe range"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,6 @@ hemx-core = { path = "../hemx-core" }
|
|||||||
wasm-bindgen = "=0.2.125"
|
wasm-bindgen = "=0.2.125"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
thirtyfour = "0.36"
|
thirtyfour = "0.36"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
|||||||
+126
-2
@@ -191,6 +191,98 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
|||||||
result.and(quit)
|
result.and(quit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
|
||||||
|
) -> WebDriverResult<()> {
|
||||||
|
// test req: sync/002 req: sync/003
|
||||||
|
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("workspace root")
|
||||||
|
.to_owned();
|
||||||
|
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
|
||||||
|
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||||
|
let server = StaticServer::start(
|
||||||
|
package,
|
||||||
|
runtime,
|
||||||
|
bootstrap,
|
||||||
|
rendered,
|
||||||
|
"kanban_client",
|
||||||
|
Some(kanban_app_assets(&workspace)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let webdriver_port = available_port();
|
||||||
|
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||||
|
let mut webdriver = Command::new("geckodriver");
|
||||||
|
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||||
|
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||||
|
let mut caps = DesiredCapabilities::firefox();
|
||||||
|
caps.set_headless()?;
|
||||||
|
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
driver.goto(&server.url()).await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-hemx-client-ready') && root.hasAttribute('data-hemx-sync-ready')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
driver
|
||||||
|
.execute(
|
||||||
|
"window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', event => window.__clientErrors.push(event.detail)); Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); document.querySelector('[data-hemx-client-event=drop]').dispatchEvent(new Event('drop', { bubbles: true })); return true",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return document.querySelector('[data-hemx-root]').getAttribute('data-hemx-sync-pending') === '1'",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
driver
|
||||||
|
.find(By::Css("#kanban-status"))
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?
|
||||||
|
.contains("Moved 1 with drop"),
|
||||||
|
"ordinary EffectBatch did not apply alongside the sync patch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
driver
|
||||||
|
.execute(
|
||||||
|
"return performance.getEntriesByType('resource').filter(entry => entry.name.endsWith('/sync/patches')).length",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.json(),
|
||||||
|
&serde_json::json!(0),
|
||||||
|
"offline patch attempted a network request"
|
||||||
|
);
|
||||||
|
|
||||||
|
driver
|
||||||
|
.execute(
|
||||||
|
"Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => true }); window.dispatchEvent(new Event('online')); return true",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"const root = document.querySelector('[data-hemx-root]'); return root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let identity = driver
|
||||||
|
.execute(
|
||||||
|
"const root = document.querySelector('[data-hemx-root]'); return { ack: root.getAttribute('data-hemx-sync-ack'), uuid: /^[0-9a-f-]{36}$/.test(root.getAttribute('data-hemx-sync-ack')) }",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(identity.json()["uuid"], true, "{identity:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = driver.quit().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay(
|
async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay(
|
||||||
) -> WebDriverResult<()> {
|
) -> WebDriverResult<()> {
|
||||||
@@ -1457,12 +1549,14 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
|
|||||||
struct AppAssets {
|
struct AppAssets {
|
||||||
module: PathBuf,
|
module: PathBuf,
|
||||||
service_worker: PathBuf,
|
service_worker: 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: workspace.join("examples/kanban/static/command-log.js"),
|
||||||
service_worker: workspace.join("examples/kanban/static/offline.js"),
|
service_worker: workspace.join("examples/kanban/static/offline.js"),
|
||||||
|
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,6 +1671,32 @@ fn serve(
|
|||||||
fs::read(&app_assets.expect("checked app assets").service_worker)
|
fs::read(&app_assets.expect("checked app assets").service_worker)
|
||||||
.expect("read 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)
|
||||||
|
.expect("read sync runtime"),
|
||||||
|
),
|
||||||
|
"/sync/patches" if app_assets.is_some() => {
|
||||||
|
let body = first.split("\r\n\r\n").nth(1).unwrap_or("");
|
||||||
|
let patch: serde_json::Value = serde_json::from_str(body).expect("valid flat patch");
|
||||||
|
let idempotency_key = patch["idempotencyKey"]
|
||||||
|
.as_str()
|
||||||
|
.expect("flat patch idempotency key");
|
||||||
|
let operation_id = patch["operationId"]
|
||||||
|
.as_str()
|
||||||
|
.expect("flat patch operation id");
|
||||||
|
assert_eq!(
|
||||||
|
operation_id, idempotency_key,
|
||||||
|
"interaction operation and idempotency identities diverged"
|
||||||
|
);
|
||||||
|
(
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
format!(
|
||||||
|
r#"{{"idempotencyKey":"{idempotency_key}","operationId":"{operation_id}"}}"#
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
"/sync/context" if app_assets.is_some() => (
|
"/sync/context" if app_assets.is_some() => (
|
||||||
"application/json; charset=utf-8",
|
"application/json; charset=utf-8",
|
||||||
br#"{"accountPartition":"demo:demo"}"#.to_vec(),
|
br#"{"accountPartition":"demo:demo"}"#.to_vec(),
|
||||||
@@ -1586,7 +1706,11 @@ 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 == "/offline.js" || path == "/sync/context")
|
|| ((path == "/app.js"
|
||||||
|
|| path == "/offline.js"
|
||||||
|
|| path == "/hemx-sync.js"
|
||||||
|
|| path == "/sync/context"
|
||||||
|
|| path == "/sync/patches")
|
||||||
&& app_assets.is_some())
|
&& app_assets.is_some())
|
||||||
|| path.starts_with(&format!("/{asset_stem}"))
|
|| path.starts_with(&format!("/{asset_stem}"))
|
||||||
{
|
{
|
||||||
@@ -1602,7 +1726,7 @@ fn serve(
|
|||||||
|
|
||||||
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||||
let app_module = if has_app_module {
|
let app_module = if has_app_module {
|
||||||
"<script type=\"module\" src=\"/app.js\"></script>"
|
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user