test(sync): prove deterministic reconciliation

req: sync/022
This commit is contained in:
slhx agent
2026-07-13 21:08:51 +02:00
parent 34728b9a35
commit 6d502d8ff5
4 changed files with 130 additions and 4 deletions
+43 -1
View File
@@ -114,6 +114,41 @@ function decideRebase(snapshot, command) {
return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column };
}
// The built-in policy is deliberately a named module export: applications that
// need custom merge or CRDT semantics must import and wire a different policy.
export function reconcileServerAuthoritative(snapshot, commandSequence, serverResults) {
if (!snapshot || !Array.isArray(snapshot.cards) || !Number.isSafeInteger(snapshot.serverSequence)) {
throw new TypeError("reconciliation snapshot is invalid");
}
if (!Array.isArray(commandSequence) || !Array.isArray(serverResults)) {
throw new TypeError("reconciliation commands and server results must be arrays");
}
const resultCursor = serverResults.reduce((cursor, result) => {
if (!result || !Number.isSafeInteger(result.serverSequence)) {
throw new TypeError("reconciliation server result is invalid");
}
return Math.max(cursor, result.serverSequence);
}, 0);
if (resultCursor > snapshot.serverSequence) {
throw new RangeError("reconciliation server result is newer than the canonical snapshot");
}
const command = commandSequence[0];
const decision = command
? decideRebase(snapshot, command)
: { kind: "idle", reason: "no-pending-command", canonicalColumn: "unchanged" };
return {
model: "server-authoritative-v1",
snapshotSequence: snapshot.serverSequence,
serverResultCursor: resultCursor,
serverResultCount: serverResults.length,
commandCount: commandSequence.length,
retainedCommandCount: decision.kind === "converged"
? Math.max(0, commandSequence.length - 1)
: commandSequence.length,
decision,
};
}
async function claimUploaderLease(database) {
const transaction = database.transaction("meta", "readwrite");
const done = transactionDone(transaction);
@@ -423,8 +458,15 @@ 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);
const reconciliation = reconcileServerAuthoritative(snapshot, queued, [{
status: "snapshot-required",
serverSequence: missing.latest,
}]);
const decision = reconciliation.decision;
const converged = decision.kind === "converged";
root.setAttribute("data-sync-reconciliation-model", reconciliation.model);
root.setAttribute("data-sync-reconciliation-result-cursor", String(reconciliation.serverResultCursor));
root.setAttribute("data-sync-reconciliation-retained-count", String(reconciliation.retainedCommandCount));
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));