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