feat(kanban): deterministically rebase snapshot

req: sync/011
This commit is contained in:
slhx agent
2026-07-13 16:58:51 +02:00
parent db57b0c474
commit d6aaca8127
3 changed files with 67 additions and 13 deletions
+32 -3
View File
@@ -54,6 +54,24 @@ async function removeAcknowledged(database, commandId) {
await done;
}
function decideRebase(snapshot, command) {
const canonical = snapshot.cards.find((card) => String(card.id) === command.cardId);
if (!canonical) return { kind: "conflicted", reason: "card-missing", canonicalColumn: "missing" };
if (command.kind === "reorder_card" && canonical.column === "done") {
return { kind: "converged", reason: "intent-already-canonical", canonicalColumn: canonical.column };
}
return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column };
}
async function commitConvergedRebase(database, snapshot, command) {
const transaction = database.transaction([COMMANDS, "meta"], "readwrite");
const done = transactionDone(transaction);
transaction.objectStore("meta").put(snapshot, "canonicalSnapshot");
transaction.objectStore("meta").put(snapshot.serverSequence, "acknowledgementCursor");
transaction.objectStore(COMMANDS).delete(command.id);
await done;
}
function setPhase(phase, message) {
root.setAttribute("data-sync-phase", phase);
root.querySelector('[role="status"]').textContent = message;
@@ -146,13 +164,24 @@ async function synchronize(command) {
if (!response.ok) throw new Error(`snapshot failed with ${response.status}`);
const snapshot = await response.json();
const queued = await pendingCommands(database);
const decision = decideRebase(snapshot, command);
root.setAttribute("data-sync-snapshot-sequence", String(snapshot.serverSequence));
root.setAttribute("data-sync-snapshot-schema", String(snapshot.schemaVersion));
root.setAttribute("data-sync-snapshot-card-count", String(snapshot.cards.length));
root.setAttribute("data-sync-rebase-pending-count", String(queued.length));
root.setAttribute("data-sync-canonical-column", snapshot.cards.find((card) => String(card.id) === command.cardId)?.column || "missing");
setPhase("rebase-required", `History is unavailable; loaded snapshot ${snapshot.serverSequence} and preserved ${queued.length} pending command for rebase.`);
root.dispatchEvent(new CustomEvent("kanban:sync-rebase-required", { detail: { snapshot, pending: queued } }));
root.setAttribute("data-sync-rebase-decision", decision.kind);
root.setAttribute("data-sync-rebase-reason", decision.reason);
root.setAttribute("data-sync-canonical-column", decision.canonicalColumn);
if (decision.kind === "converged") {
await commitConvergedRebase(database, snapshot, command);
root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length));
root.setAttribute("data-sync-ack-sequence", String(snapshot.serverSequence));
setPhase("rebased", `Canonical snapshot ${snapshot.serverSequence} already satisfies ${command.id}; committed and removed the pending command.`);
root.dispatchEvent(new CustomEvent("kanban:sync-rebased", { detail: { snapshot, command, decision } }));
} else {
setPhase("conflicted", `Canonical snapshot ${snapshot.serverSequence} conflicts with ${command.id} (${decision.reason}); the pending command remains queued.`);
root.dispatchEvent(new CustomEvent("kanban:sync-conflicted", { detail: { snapshot, command, decision } }));
}
source.close();
});
} catch (error) {
+33 -8
View File
@@ -495,8 +495,8 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr
}
#[tokio::test]
async fn missing_history_loads_snapshot_and_preserves_pending_intent() -> WebDriverResult<()> {
// test req: sync/007 req: sync/020
async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDriverResult<()> {
// test req: sync/007 req: sync/011 req: sync/020
let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}");
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
@@ -564,31 +564,56 @@ async fn missing_history_loads_snapshot_and_preserves_pending_intent() -> WebDri
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'rebase-required'",
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'rebased'",
)
.await?;
let fallback = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), snapshotSchema: root.getAttribute('data-sync-snapshot-schema'), snapshotCards: root.getAttribute('data-sync-snapshot-card-count'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }",
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), ackSequence: root.getAttribute('data-sync-ack-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), snapshotSchema: root.getAttribute('data-sync-snapshot-schema'), snapshotCards: root.getAttribute('data-sync-snapshot-card-count'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), decision: root.getAttribute('data-sync-rebase-decision'), reason: root.getAttribute('data-sync-rebase-reason'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(fallback["phase"], "rebase-required");
assert_eq!(fallback["phase"], "rebased");
assert_eq!(fallback["uploadSequence"], "2");
assert_eq!(fallback["ackSequence"], "2");
assert_eq!(fallback["snapshotSequence"], "2");
assert_eq!(fallback["snapshotSchema"], "1");
assert_eq!(fallback["snapshotCards"], "3");
assert_eq!(fallback["pending"], "1");
assert_eq!(fallback["pending"], "0");
assert_eq!(fallback["rebasePending"], "1");
assert_eq!(fallback["decision"], "converged");
assert_eq!(fallback["reason"], "intent-already-canonical");
assert_eq!(fallback["canonicalColumn"], "done");
assert_eq!(
fallback["status"],
"History is unavailable; loaded snapshot 2 and preserved 1 pending command for rebase."
"Canonical snapshot 2 already satisfies history:2; committed and removed the pending command."
);
assert!(fallback["error"].is_null());
assert_eq!(command_count(&driver).await?, 1);
assert_eq!(command_count(&driver).await?, 0);
let committed = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1);
open.onsuccess = () => {
const tx = open.result.transaction('meta', 'readonly');
const meta = tx.objectStore('meta');
const cursor = meta.get('acknowledgementCursor');
const snapshot = meta.get('canonicalSnapshot');
tx.oncomplete = () => done({ cursor: cursor.result, snapshot: snapshot.result });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(committed["cursor"], 2);
assert_eq!(committed["snapshot"]["schemaVersion"], 1);
assert_eq!(committed["snapshot"]["serverSequence"], 2);
driver.goto(&format!("http://{app_addr}/")).await?;
let canonical = driver