feat(kanban): persist local reorder commands
req: local/001\nreq: local/002\nreq: local/003\nreq: local/004\nreq: sync/009
This commit is contained in:
+75
-10
@@ -1,25 +1,90 @@
|
||||
#[hemx::surface]
|
||||
pub mod ui {}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CardId(String);
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct ReorderCommand {
|
||||
card: CardId,
|
||||
input_kind: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct CardReordered {
|
||||
card: CardId,
|
||||
input_kind: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct BoardProjection {
|
||||
first: CardId,
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct ProjectedReorder {
|
||||
card: CardId,
|
||||
before: Option<CardId>,
|
||||
input_kind: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl ReorderCommand {
|
||||
fn from_client(event: hemx::wasm::ClientEvent) -> Self {
|
||||
Self {
|
||||
card: CardId(
|
||||
event
|
||||
.value
|
||||
.filter(|card| !card.is_empty())
|
||||
.unwrap_or_else(|| "1".into()),
|
||||
),
|
||||
input_kind: event.kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn decide(self) -> CardReordered {
|
||||
CardReordered {
|
||||
card: self.card,
|
||||
input_kind: self.input_kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl BoardProjection {
|
||||
fn restore(state: hemx::wasm::ClientState) -> Self {
|
||||
Self {
|
||||
first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(self, event: CardReordered) -> ProjectedReorder {
|
||||
let before = (event.card != self.first).then_some(self.first);
|
||||
ProjectedReorder {
|
||||
card: event.card,
|
||||
before,
|
||||
input_kind: event.input_kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[hemx::handler(client)]
|
||||
pub fn reorder_card(
|
||||
event: hemx::wasm::ClientEvent,
|
||||
state: hemx::wasm::ClientState,
|
||||
) -> impl hemx::IntoEffect {
|
||||
let card = event.value.unwrap_or_else(|| "1".to_owned());
|
||||
let order = state.encoded.split('|').collect::<Vec<_>>();
|
||||
let move_effect = if card == order.first().copied().unwrap_or("1") {
|
||||
ui::client_board::client_cards.move_to_end(card.clone())
|
||||
} else {
|
||||
ui::client_board::client_cards.move_before(
|
||||
card.clone(),
|
||||
order.first().copied().unwrap_or("1").to_owned(),
|
||||
)
|
||||
let projected =
|
||||
BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide());
|
||||
let card = projected.card.0;
|
||||
let move_effect = match projected.before {
|
||||
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
|
||||
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
||||
};
|
||||
vec![
|
||||
move_effect,
|
||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", event.kind)),
|
||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
const DATABASE = "hemx-kanban-v1";
|
||||
const COMMANDS = "commands";
|
||||
const META = "meta";
|
||||
const COMMAND_SCHEMA = 1;
|
||||
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
|
||||
|
||||
function result(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.addEventListener("success", () => resolve(request.result), { once: true });
|
||||
request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function completed(transaction) {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.addEventListener("complete", resolve, { once: true });
|
||||
transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true });
|
||||
transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function openCommandLog() {
|
||||
const request = indexedDB.open(DATABASE, 1);
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" });
|
||||
if (!database.objectStoreNames.contains(META)) database.createObjectStore(META);
|
||||
});
|
||||
return result(request);
|
||||
}
|
||||
|
||||
function clientReady(root) {
|
||||
if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!root.hasAttribute("data-hemx-client-ready")) return;
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
});
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["data-hemx-client-ready"] });
|
||||
});
|
||||
}
|
||||
|
||||
function stableSession() {
|
||||
const key = "hemx-kanban-session-v1";
|
||||
let session = sessionStorage.getItem(key);
|
||||
if (!session) {
|
||||
session = crypto.randomUUID();
|
||||
sessionStorage.setItem(key, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function appendReorder(database, wire) {
|
||||
const transaction = database.transaction([COMMANDS, META], "readwrite");
|
||||
const done = completed(transaction);
|
||||
const meta = transaction.objectStore(META);
|
||||
const commands = transaction.objectStore(COMMANDS);
|
||||
const actorRequest = result(meta.get("actor"));
|
||||
const causalRequest = result(meta.get("causal"));
|
||||
const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]);
|
||||
const actor = storedActor || crypto.randomUUID();
|
||||
const causal = (storedCausal || 0) + 1;
|
||||
const command = {
|
||||
id: `${actor}:${causal}`,
|
||||
schemaVersion: COMMAND_SCHEMA,
|
||||
actor,
|
||||
session: stableSession(),
|
||||
causal,
|
||||
kind: "reorder_card",
|
||||
cardId: String(wire[2] || "1"),
|
||||
eventKind: String(wire[1] || "click"),
|
||||
key: wire[4] ? String(wire[4]) : null,
|
||||
};
|
||||
meta.put(actor, "actor");
|
||||
meta.put(causal, "causal");
|
||||
commands.add(command);
|
||||
const count = await result(commands.count());
|
||||
await done;
|
||||
return { command, count };
|
||||
}
|
||||
|
||||
async function storedCommands(database) {
|
||||
const transaction = database.transaction(COMMANDS, "readonly");
|
||||
const done = completed(transaction);
|
||||
const commands = await result(transaction.objectStore(COMMANDS).getAll());
|
||||
await done;
|
||||
return commands.sort((left, right) => left.causal - right.causal);
|
||||
}
|
||||
|
||||
function validate(command) {
|
||||
if (command.schemaVersion !== COMMAND_SCHEMA || command.kind !== "reorder_card" || !command.id || !command.actor || !command.session || !command.cardId || !Number.isSafeInteger(command.causal)) {
|
||||
throw new Error(`unsupported durable command ${command && command.id ? command.id : "record"}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
async function project(root, wasmHandler, command) {
|
||||
const checked = validate(command);
|
||||
const batch = await wasmHandler(
|
||||
1,
|
||||
checked.eventKind,
|
||||
checked.cardId,
|
||||
undefined,
|
||||
checked.key || undefined,
|
||||
1,
|
||||
root.getAttribute("data-hemx-st") || "",
|
||||
);
|
||||
if (!(batch instanceof Uint8Array)) throw new Error("reorder_card returned an invalid effect batch");
|
||||
return batch;
|
||||
}
|
||||
|
||||
function report(root, stage, error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
root.setAttribute("data-kanban-command-error", `${stage}: ${message}`);
|
||||
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } }));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const root = document.querySelector(ROOT);
|
||||
if (!root) return;
|
||||
const databasePromise = openCommandLog();
|
||||
await clientReady(root);
|
||||
let wasmHandler;
|
||||
const durableHandler = async (...wire) => {
|
||||
let command;
|
||||
let count;
|
||||
try {
|
||||
({ command, count } = await appendReorder(await databasePromise, wire));
|
||||
} catch (error) {
|
||||
report(root, "persist", error);
|
||||
throw error;
|
||||
}
|
||||
root.setAttribute("data-kanban-command-count", String(count));
|
||||
root.dispatchEvent(new CustomEvent("kanban:command-persisted", {
|
||||
detail: {
|
||||
id: command.id,
|
||||
schemaVersion: command.schemaVersion,
|
||||
actor: command.actor,
|
||||
session: command.session,
|
||||
causal: command.causal,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
return await project(root, wasmHandler, command);
|
||||
} catch (error) {
|
||||
report(root, "project", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
||||
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
||||
const database = await databasePromise;
|
||||
try {
|
||||
const commands = await storedCommands(database);
|
||||
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||
root.setAttribute("data-kanban-command-count", String(commands.length));
|
||||
root.setAttribute("data-kanban-command-ready", "");
|
||||
} catch (error) {
|
||||
report(root, "restore", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
const root = document.querySelector(ROOT);
|
||||
if (root && !root.hasAttribute("data-kanban-command-error")) report(root, "open", error);
|
||||
console.error("kanban durable command log failed", error);
|
||||
});
|
||||
Reference in New Issue
Block a user