feat(sync): replay durable projections from Rust

req: ms/001

req: ms/002

req: ms/003
This commit is contained in:
slhx agent
2026-07-14 00:17:20 +02:00
parent 2a74a28d3b
commit 3bad745da9
6 changed files with 217 additions and 178 deletions
+47 -7
View File
@@ -62,6 +62,28 @@ function validatePatch(patch) {
return patch;
}
function validateProjection(projection) {
if (!Array.isArray(projection)
|| projection.length > 1024 * 1024
|| projection.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
throw new Error("invalid durable projection");
}
return projection;
}
function normalizeEvent(payload) {
if (payload && Object.getPrototypeOf(payload) === Object.prototype && "patch" in payload) {
const keys = Object.keys(payload).sort();
if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "projection") {
throw new Error("durable patch fields do not match schema");
}
const patch = validatePatch(payload.patch);
return { idempotencyKey: patch.idempotencyKey, patch, projection: validateProjection(payload.projection) };
}
const patch = validatePatch(payload);
return { idempotencyKey: patch.idempotencyKey, patch, projection: null };
}
async function allPatches() {
const transaction = database.transaction(STORE, "readonly");
const done = transactionDone(transaction);
@@ -70,10 +92,21 @@ async function allPatches() {
return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey));
}
async function persist(patch) {
function normalizeStoredRecord(stored) {
if (stored.patch) {
return {
patch: validatePatch(stored.patch),
projection: stored.projection === null ? null : validateProjection(stored.projection),
};
}
const { queuedAt: _queuedAt, ...legacyPatch } = stored;
return { patch: validatePatch(legacyPatch), projection: null };
}
async function persist(record) {
const transaction = database.transaction(STORE, "readwrite");
const done = transactionDone(transaction);
transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() });
transaction.objectStore(STORE).add({ ...record, queuedAt: Date.now() });
await done;
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
}
@@ -90,7 +123,7 @@ async function pump() {
pumping = true;
try {
for (const stored of await allPatches()) {
const { queuedAt: _queuedAt, ...patch } = stored;
const { patch } = normalizeStoredRecord(stored);
const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches";
const response = await fetch(endpoint, {
method: "POST",
@@ -122,18 +155,25 @@ async function pump() {
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));
const pending = await allPatches();
for (const stored of pending) {
const { projection } = normalizeStoredRecord(stored);
if (projection) {
window.hemx?.applyBatch(Uint8Array.from(projection).buffer, root);
}
}
document.addEventListener(EVENT, async (event) => {
try {
const patch = validatePatch(JSON.parse(event.detail));
await persist(patch);
const record = normalizeEvent(JSON.parse(event.detail));
await persist(record);
await pump();
} catch (error) {
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
}
});
window.addEventListener("online", () => pump());
root.setAttribute("data-hemx-sync-pending", String(pending.length));
root.setAttribute("data-hemx-sync-ready", "");
await pump();
}