Skip to content

Commit d98c9ca

Browse files
committed
use event driven architecture
1 parent 3d77fd8 commit d98c9ca

19 files changed

Lines changed: 2451 additions & 267 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@hyperbook/markdown": patch
3+
"hyperbook-studio": patch
4+
"@hyperbook/cloud": patch
5+
"hyperbook": patch
6+
---
7+
8+
Move to an event driven architecture for hyperbook cloud

packages/markdown/assets/cloud.js

Lines changed: 194 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ window.hyperbook.cloud = (function () {
22
// ===== Cloud Integration =====
33
const AUTH_TOKEN_KEY = "hyperbook_auth_token";
44
const AUTH_USER_KEY = "hyperbook_auth_user";
5+
const LAST_EVENT_ID_KEY = "hyperbook_last_event_id";
6+
const EVENT_BATCH_MAX_SIZE = 512 * 1024; // 512KB
57
let isLoadingFromCloud = false;
68
let syncManager = null;
79

@@ -40,7 +42,6 @@ window.hyperbook.cloud = (function () {
4042
this.maxWaitTime = options.maxWaitTime || 10000;
4143
this.minSaveInterval = options.minSaveInterval || 1000;
4244

43-
this.isDirty = false;
4445
this.lastSaveTime = 0;
4546
this.lastChangeTime = 0;
4647
this.debounceTimer = null;
@@ -49,20 +50,25 @@ window.hyperbook.cloud = (function () {
4950
this.saveMutex = new Mutex();
5051
this.retryCount = 0;
5152

52-
this.dirtyStores = new Set();
53+
this.pendingEvents = [];
54+
this.lastEventId = parseInt(
55+
localStorage.getItem(LAST_EVENT_ID_KEY) || "0",
56+
10,
57+
);
5358

5459
this.offlineQueue = [];
5560
this.isOnline = navigator.onLine;
5661

5762
this.setupEventListeners();
5863
}
5964

60-
markDirty(storeName = null) {
65+
get isDirty() {
66+
return this.pendingEvents.length > 0;
67+
}
68+
69+
addEvent(event) {
6170
if (isLoadingFromCloud || isReadOnlyMode()) return;
62-
if (storeName) {
63-
this.dirtyStores.add(storeName);
64-
}
65-
this.isDirty = true;
71+
this.pendingEvents.push(event);
6672
this.lastChangeTime = Date.now();
6773
this.updateUI("unsaved");
6874
this.scheduleSave();
@@ -105,24 +111,46 @@ window.hyperbook.cloud = (function () {
105111
this.updateUI("saving");
106112

107113
try {
108-
const dataToSave = await this.exportStores();
114+
// Take a snapshot of pending events
115+
const eventsToSend = this.pendingEvents.slice();
116+
const serialized = JSON.stringify(eventsToSend);
109117

110118
if (!this.isOnline) {
111119
this.offlineQueue.push({
112-
data: dataToSave,
120+
events: eventsToSend,
121+
afterEventId: this.lastEventId,
113122
timestamp: Date.now(),
114123
});
124+
this.pendingEvents = [];
115125
this.updateUI("offline-queued");
116126
return;
117127
}
118128

119-
await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
120-
method: "POST",
121-
body: JSON.stringify({ data: dataToSave }),
122-
});
129+
let result;
130+
131+
if (serialized.length > EVENT_BATCH_MAX_SIZE) {
132+
// Large batch — fall back to full snapshot
133+
result = await this.sendSnapshot();
134+
} else {
135+
// Normal path — send events
136+
result = await this.sendEvents(eventsToSend);
137+
}
123138

124-
this.isDirty = false;
125-
this.dirtyStores.clear();
139+
if (result.conflict) {
140+
// 409 — stale state, re-fetch
141+
console.log("⚠ Stale state detected, re-fetching from cloud...");
142+
await loadFromCloud();
143+
this.pendingEvents = [];
144+
window.location.reload();
145+
return;
146+
}
147+
148+
this.pendingEvents = [];
149+
this.lastEventId = result.lastEventId;
150+
localStorage.setItem(
151+
LAST_EVENT_ID_KEY,
152+
String(this.lastEventId),
153+
);
126154
this.lastSaveTime = Date.now();
127155
this.retryCount = 0;
128156
this.updateUI("saved");
@@ -137,15 +165,46 @@ window.hyperbook.cloud = (function () {
137165
});
138166
}
139167

140-
async exportStores() {
168+
async sendEvents(events, afterEventId) {
169+
var effectiveAfterId = afterEventId !== undefined ? afterEventId : this.lastEventId;
170+
try {
171+
const data = await apiRequest(
172+
`/api/store/${HYPERBOOK_CLOUD.id}/events`,
173+
{
174+
method: "POST",
175+
body: JSON.stringify({
176+
events: events,
177+
afterEventId: effectiveAfterId,
178+
}),
179+
},
180+
);
181+
return { lastEventId: data.lastEventId, conflict: false };
182+
} catch (error) {
183+
if (error.status === 409) {
184+
return { conflict: true };
185+
}
186+
throw error;
187+
}
188+
}
189+
190+
async sendSnapshot() {
141191
const hyperbookExport = await store.export({ prettyJson: false });
142-
return {
143-
version: 1,
144-
origin: window.location.origin,
145-
data: {
146-
hyperbook: JSON.parse(await hyperbookExport.text()),
192+
const exportData = JSON.parse(await hyperbookExport.text());
193+
194+
const data = await apiRequest(
195+
`/api/store/${HYPERBOOK_CLOUD.id}/snapshot`,
196+
{
197+
method: "POST",
198+
body: JSON.stringify({
199+
data: {
200+
version: 1,
201+
origin: window.location.origin,
202+
data: { hyperbook: exportData },
203+
},
204+
}),
147205
},
148-
};
206+
);
207+
return { lastEventId: data.lastEventId, conflict: false };
149208
}
150209

151210
scheduleRetry() {
@@ -177,7 +236,6 @@ window.hyperbook.cloud = (function () {
177236

178237
window.addEventListener("beforeunload", (e) => {
179238
if (this.isDirty) {
180-
this.performSave("unload");
181239
e.preventDefault();
182240
e.returnValue = "";
183241
}
@@ -194,21 +252,38 @@ window.hyperbook.cloud = (function () {
194252
if (this.offlineQueue.length === 0) return;
195253

196254
console.log(`Processing ${this.offlineQueue.length} queued saves...`);
197-
this.offlineQueue.sort((a, b) => a.timestamp - b.timestamp);
198255

199-
// Only send the latest queued save
200-
const latest = this.offlineQueue[this.offlineQueue.length - 1];
201-
try {
202-
await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
203-
method: "POST",
204-
body: JSON.stringify({ data: latest.data }),
205-
});
206-
this.offlineQueue = [];
207-
this.lastSaveTime = Date.now();
208-
console.log("✓ Offline queue processed");
209-
} catch (error) {
210-
console.error("Failed to process offline queue:", error);
256+
// Send queued events in order
257+
for (let i = 0; i < this.offlineQueue.length; i++) {
258+
const queued = this.offlineQueue[i];
259+
try {
260+
const result = await this.sendEvents(queued.events, queued.afterEventId);
261+
262+
if (result.conflict) {
263+
// Conflict — discard remaining queue, re-fetch
264+
console.log("⚠ Offline queue conflict, re-fetching...");
265+
this.offlineQueue = [];
266+
await loadFromCloud();
267+
window.location.reload();
268+
return;
269+
}
270+
271+
this.lastEventId = result.lastEventId;
272+
localStorage.setItem(
273+
LAST_EVENT_ID_KEY,
274+
String(this.lastEventId),
275+
);
276+
} catch (error) {
277+
console.error("Failed to process offline queue:", error);
278+
// Keep remaining items in queue
279+
this.offlineQueue = this.offlineQueue.slice(i);
280+
return;
281+
}
211282
}
283+
284+
this.offlineQueue = [];
285+
this.lastSaveTime = Date.now();
286+
console.log("✓ Offline queue processed");
212287
}
213288

214289
clearTimers() {
@@ -232,14 +307,30 @@ window.hyperbook.cloud = (function () {
232307
}
233308

234309
async manualSave() {
235-
this.isDirty = true;
310+
if (this.pendingEvents.length === 0) {
311+
// No pending events — send full snapshot
312+
this.clearTimers();
313+
try {
314+
this.updateUI("saving");
315+
const result = await this.sendSnapshot();
316+
this.lastEventId = result.lastEventId;
317+
localStorage.setItem(
318+
LAST_EVENT_ID_KEY,
319+
String(this.lastEventId),
320+
);
321+
this.updateUI("saved");
322+
} catch (error) {
323+
console.error("Manual save failed:", error);
324+
this.updateUI("error");
325+
}
326+
return;
327+
}
236328
this.clearTimers();
237329
await this.performSave("manual");
238330
}
239331

240332
reset() {
241-
this.isDirty = false;
242-
this.dirtyStores.clear();
333+
this.pendingEvents = [];
243334
this.clearTimers();
244335
this.offlineQueue = [];
245336
this.retryCount = 0;
@@ -304,6 +395,7 @@ window.hyperbook.cloud = (function () {
304395
function clearAuthToken() {
305396
localStorage.removeItem(AUTH_TOKEN_KEY);
306397
localStorage.removeItem(AUTH_USER_KEY);
398+
localStorage.removeItem(LAST_EVENT_ID_KEY);
307399
}
308400

309401
/**
@@ -371,12 +463,16 @@ window.hyperbook.cloud = (function () {
371463
const data = await response.json();
372464

373465
if (!response.ok) {
374-
throw new Error(data.error || "Request failed");
466+
const err = new Error(data.error || "Request failed");
467+
err.status = response.status;
468+
throw err;
375469
}
376470

377471
return data;
378472
} catch (error) {
379-
console.error("Cloud API error:", error);
473+
if (!error.status) {
474+
console.error("Cloud API error:", error);
475+
}
380476
throw error;
381477
}
382478
}
@@ -428,9 +524,8 @@ window.hyperbook.cloud = (function () {
428524
try {
429525
const data = await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`);
430526

431-
if (data && data.data) {
432-
// Import data into local stores
433-
const storeData = data.data.data || data.data;
527+
if (data && data.snapshot) {
528+
const storeData = data.snapshot.data || data.snapshot;
434529
const { hyperbook } = storeData;
435530

436531
if (hyperbook) {
@@ -440,6 +535,17 @@ window.hyperbook.cloud = (function () {
440535
await store.import(blob, { clearTablesBeforeImport: true });
441536
}
442537

538+
// Track the server's lastEventId
539+
if (data.lastEventId !== undefined) {
540+
localStorage.setItem(
541+
LAST_EVENT_ID_KEY,
542+
String(data.lastEventId),
543+
);
544+
if (syncManager) {
545+
syncManager.lastEventId = data.lastEventId;
546+
}
547+
}
548+
443549
console.log("✓ Store loaded from cloud");
444550
}
445551
} catch (error) {
@@ -482,12 +588,36 @@ window.hyperbook.cloud = (function () {
482588
minSaveInterval: 1000,
483589
});
484590

485-
// Hook Dexie tables to track changes (skip currentState — ephemeral UI data)
591+
// Hook Dexie tables to capture granular events (skip currentState — ephemeral UI data)
486592
store.tables.forEach((table) => {
487593
if (table.name === "currentState") return;
488-
table.hook("creating", () => syncManager.markDirty(table.name));
489-
table.hook("updating", () => syncManager.markDirty(table.name));
490-
table.hook("deleting", () => syncManager.markDirty(table.name));
594+
595+
table.hook("creating", function (primKey, obj) {
596+
syncManager.addEvent({
597+
table: table.name,
598+
op: "create",
599+
primKey: primKey,
600+
data: obj,
601+
});
602+
});
603+
604+
table.hook("updating", function (modifications, primKey) {
605+
syncManager.addEvent({
606+
table: table.name,
607+
op: "update",
608+
primKey: primKey,
609+
data: modifications,
610+
});
611+
});
612+
613+
table.hook("deleting", function (primKey) {
614+
syncManager.addEvent({
615+
table: table.name,
616+
op: "delete",
617+
primKey: primKey,
618+
data: null,
619+
});
620+
});
491621
});
492622
}
493623
}
@@ -618,6 +748,14 @@ window.hyperbook.cloud = (function () {
618748
updateUserUI(user);
619749
}
620750

751+
// Hide local export/import/reset when logged into cloud
752+
if (HYPERBOOK_CLOUD && getAuthToken()) {
753+
document.querySelectorAll(".export-icon, .import-icon, .reset-icon").forEach((el) => {
754+
const link = el.closest("a");
755+
if (link) link.style.display = "none";
756+
});
757+
}
758+
621759
// Show impersonation banner if in readonly mode
622760
if (isReadOnlyMode()) {
623761
const banner = document.createElement("div");
@@ -643,6 +781,14 @@ window.hyperbook.cloud = (function () {
643781

644782
return {
645783
save: () => syncManager?.manualSave(),
784+
sendSnapshot: async () => {
785+
if (!syncManager || !HYPERBOOK_CLOUD || !getAuthToken() || isReadOnlyMode()) return;
786+
syncManager.pendingEvents = [];
787+
syncManager.clearTimers();
788+
const result = await syncManager.sendSnapshot();
789+
syncManager.lastEventId = result.lastEventId;
790+
localStorage.setItem(LAST_EVENT_ID_KEY, String(result.lastEventId));
791+
},
646792
userToggle,
647793
login,
648794
logout,

0 commit comments

Comments
 (0)