Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 136 additions & 17 deletions packages/markdown/assets/cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ hyperbook.cloud = (function () {
const AUTH_TOKEN_KEY = "hyperbook_auth_token";
const AUTH_USER_KEY = "hyperbook_auth_user";
const LAST_EVENT_ID_KEY = "hyperbook_last_event_id";
const OFFLINE_QUEUE_KEY = "hyperbook_offline_queue";
const STATE_CHECKSUM_KEY = "hyperbook_state_checksum";
const EVENT_BATCH_MAX_SIZE = 512 * 1024; // 512KB
const OFFLINE_QUEUE_MAX_SIZE = 100; // FIX: cap offline queue to avoid unbounded memory growth
let isLoadingFromCloud = false;
Expand Down Expand Up @@ -67,17 +69,39 @@ hyperbook.cloud = (function () {
localStorage.getItem(LAST_EVENT_ID_KEY) || "0",
10,
);

this.offlineQueue = [];
this.lastKnownChecksum = localStorage.getItem(STATE_CHECKSUM_KEY);
this.offlineQueue = this.loadOfflineQueue();
this.isOnline = navigator.onLine;

this.setupEventListeners();
if (this.isOnline && this.offlineQueue.length > 0) {
this.processOfflineQueue();
}
}

get isDirty() {
return this.pendingEvents.length > 0;
}

loadOfflineQueue() {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn("Failed to parse offline queue from localStorage:", error);
return [];
}
}

persistOfflineQueue() {
localStorage.setItem(
OFFLINE_QUEUE_KEY,
JSON.stringify(this.offlineQueue),
);
}

addEvent(event) {
if (isLoadingFromCloud || isReadOnlyMode()) return;
this.pendingEvents.push(event);
Expand Down Expand Up @@ -142,6 +166,7 @@ hyperbook.cloud = (function () {
timestamp: Date.now(),
});
}
this.persistOfflineQueue();
// FIX: only clear the events we snapshotted
this.pendingEvents = this.pendingEvents.slice(eventsToSend.length);
this.updateUI("offline-queued");
Expand All @@ -159,24 +184,54 @@ hyperbook.cloud = (function () {
}

if (result.conflict) {
// 409 — stale state, re-fetch
console.log("⚠ Stale state detected, re-fetching from cloud...");
await loadFromCloud();
// FIX: only discard the events we tried to send, not any that
// arrived concurrently during the async round-trip
this.pendingEvents = this.pendingEvents.slice(eventsToSend.length);
window.location.reload();
// 409 — stale state, force overwrite with current local snapshot.
// This avoids repeated reload loops and makes sync deterministic.
console.log("⚠ Stale state detected, overwriting cloud state with local snapshot...");
try {
const snapshotResult = await this.sendSnapshot({ force: true });
const snapshotLastEventId =
snapshotResult && snapshotResult.lastEventId !== undefined
? snapshotResult.lastEventId
: 0;
// FIX: only discard the events we tried to send, not any that
// arrived concurrently during the async round-trip
this.pendingEvents = this.pendingEvents.slice(eventsToSend.length);
this.lastEventId = snapshotLastEventId;
this.lastKnownChecksum = snapshotResult?.stateChecksum || null;
localStorage.setItem(
LAST_EVENT_ID_KEY,
String(this.lastEventId),
);
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
this.lastSaveTime = Date.now();
this.retryCount = 0;
this.updateUI("saved");
console.log("✓ Conflict resolved by snapshot overwrite");
} catch (snapshotError) {
console.error(
"Conflict resolution via snapshot failed:",
snapshotError,
);
this.updateUI("error");
this.scheduleRetry();
}
return;
}

// FIX: only discard the events we snapshotted, preserving any
// events that were added while the network request was in flight
this.pendingEvents = this.pendingEvents.slice(eventsToSend.length);
this.lastEventId = result.lastEventId;
this.lastKnownChecksum = result.stateChecksum || this.lastKnownChecksum;
localStorage.setItem(
LAST_EVENT_ID_KEY,
String(this.lastEventId),
);
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
this.lastSaveTime = Date.now();
this.retryCount = 0;
this.updateUI("saved");
Expand Down Expand Up @@ -206,10 +261,15 @@ hyperbook.cloud = (function () {
body: JSON.stringify({
events: events,
afterEventId: afterEventId,
ifMatchChecksum: this.lastKnownChecksum || undefined,
}),
},
);
return { lastEventId: data.lastEventId, conflict: false };
return {
lastEventId: data.lastEventId,
stateChecksum: data.stateChecksum,
conflict: false,
};
} catch (error) {
if (error.status === 409) {
return { conflict: true };
Expand All @@ -218,7 +278,8 @@ hyperbook.cloud = (function () {
}
}

async sendSnapshot() {
async sendSnapshot(options = {}) {
const force = options.force === true;
const storeExport = await hyperbook.store.db.export({ prettyJson: false });
const exportData = JSON.parse(await storeExport.text());

Expand All @@ -232,10 +293,17 @@ hyperbook.cloud = (function () {
origin: window.location.origin,
data: { hyperbook: exportData },
},
ifMatchLastEventId: force ? undefined : this.lastEventId,
ifMatchChecksum: force ? undefined : this.lastKnownChecksum || undefined,
forceOverwrite: force,
}),
},
);
return { lastEventId: data.lastEventId, conflict: false };
return {
lastEventId: data.lastEventId,
stateChecksum: data.stateChecksum,
conflict: false,
};
}

scheduleRetry() {
Expand Down Expand Up @@ -288,10 +356,15 @@ hyperbook.cloud = (function () {
// snapshot instead of trying to replay individual events
if (this.offlineQueue.length === 1 && this.offlineQueue[0].snapshot) {
this.offlineQueue = [];
this.persistOfflineQueue();
try {
const result = await this.sendSnapshot();
this.lastEventId = result.lastEventId;
this.lastKnownChecksum = result.stateChecksum || this.lastKnownChecksum;
localStorage.setItem(LAST_EVENT_ID_KEY, String(this.lastEventId));
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
this.lastSaveTime = Date.now();
console.log("✓ Offline snapshot flushed");
} catch (error) {
Expand All @@ -308,28 +381,57 @@ hyperbook.cloud = (function () {
const result = await this.sendEvents(queued.events, { afterEventId: queued.afterEventId });

if (result.conflict) {
// Conflict — discard remaining queue, re-fetch
console.log("⚠ Offline queue conflict, re-fetching...");
this.offlineQueue = [];
await loadFromCloud();
window.location.reload();
// Conflict while replaying offline changes — resolve by replacing
// cloud state with the current local snapshot.
console.log("⚠ Offline queue conflict, overwriting cloud state...");
try {
const snapshotResult = await this.sendSnapshot({ force: true });
const snapshotLastEventId =
snapshotResult && snapshotResult.lastEventId !== undefined
? snapshotResult.lastEventId
: 0;
this.offlineQueue = [];
this.persistOfflineQueue();
this.lastEventId = snapshotLastEventId;
this.lastKnownChecksum = snapshotResult?.stateChecksum || null;
localStorage.setItem(
LAST_EVENT_ID_KEY,
String(this.lastEventId),
);
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
this.lastSaveTime = Date.now();
console.log("✓ Offline conflict resolved by snapshot overwrite");
} catch (snapshotError) {
console.error(
"Offline conflict resolution via snapshot failed:",
snapshotError,
);
}
return;
}

this.lastEventId = result.lastEventId;
this.lastKnownChecksum = result.stateChecksum || this.lastKnownChecksum;
localStorage.setItem(
LAST_EVENT_ID_KEY,
String(this.lastEventId),
);
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
} catch (error) {
console.error("Failed to process offline queue:", error);
// Keep remaining items in queue
this.offlineQueue = this.offlineQueue.slice(i);
this.persistOfflineQueue();
return;
}
}

this.offlineQueue = [];
this.persistOfflineQueue();
this.lastSaveTime = Date.now();
console.log("✓ Offline queue processed");
}
Expand Down Expand Up @@ -365,10 +467,14 @@ hyperbook.cloud = (function () {
this.updateUI("saving");
const result = await this.sendSnapshot();
this.lastEventId = result.lastEventId;
this.lastKnownChecksum = result.stateChecksum || this.lastKnownChecksum;
localStorage.setItem(
LAST_EVENT_ID_KEY,
String(this.lastEventId),
);
if (this.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, this.lastKnownChecksum);
}
this.updateUI("saved");
} catch (error) {
console.error("Manual save failed:", error);
Expand All @@ -384,6 +490,7 @@ hyperbook.cloud = (function () {
this.pendingEvents = [];
this.clearTimers();
this.offlineQueue = [];
this.persistOfflineQueue();
this.retryCount = 0;
}
}
Expand Down Expand Up @@ -447,6 +554,8 @@ hyperbook.cloud = (function () {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
localStorage.removeItem(LAST_EVENT_ID_KEY);
localStorage.removeItem(OFFLINE_QUEUE_KEY);
localStorage.removeItem(STATE_CHECKSUM_KEY);
}

/**
Expand Down Expand Up @@ -596,6 +705,12 @@ hyperbook.cloud = (function () {
syncManager.lastEventId = data.lastEventId;
}
}
if (data.stateChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, data.stateChecksum);
if (syncManager) {
syncManager.lastKnownChecksum = data.stateChecksum;
}
}

console.log("✓ Store loaded from cloud");
}
Expand Down Expand Up @@ -846,7 +961,11 @@ hyperbook.cloud = (function () {
syncManager.clearTimers();
const result = await syncManager.sendSnapshot();
syncManager.lastEventId = result.lastEventId;
syncManager.lastKnownChecksum = result.stateChecksum || syncManager.lastKnownChecksum;
localStorage.setItem(LAST_EVENT_ID_KEY, String(result.lastEventId));
if (syncManager.lastKnownChecksum) {
localStorage.setItem(STATE_CHECKSUM_KEY, syncManager.lastKnownChecksum);
}
},
userToggle,
login,
Expand Down
Loading