Files
hemx/hemx-sync/runtime/hemx-sync.js
T
slhx agent 34728b9a35 feat(sync): add typed flat patch boundary
req: sync/002

req: sync/003
2026-07-13 21:02:43 +02:00

141 lines
5.4 KiB
JavaScript

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)));