hyperbook 0.84.3 → 0.84.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
- window.hyperbook = window.hyperbook || {};
2
1
  window.hyperbook.cloud = (function () {
3
2
  // ===== Cloud Integration =====
4
3
  const AUTH_TOKEN_KEY = "hyperbook_auth_token";
5
4
  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
6
7
  let isLoadingFromCloud = false;
7
8
  let syncManager = null;
8
9
 
@@ -41,7 +42,6 @@ window.hyperbook.cloud = (function () {
41
42
  this.maxWaitTime = options.maxWaitTime || 10000;
42
43
  this.minSaveInterval = options.minSaveInterval || 1000;
43
44
 
44
- this.isDirty = false;
45
45
  this.lastSaveTime = 0;
46
46
  this.lastChangeTime = 0;
47
47
  this.debounceTimer = null;
@@ -50,110 +50,25 @@ window.hyperbook.cloud = (function () {
50
50
  this.saveMutex = new Mutex();
51
51
  this.retryCount = 0;
52
52
 
53
- this.dirtyStores = new Set();
53
+ this.pendingEvents = [];
54
+ this.lastEventId = parseInt(
55
+ localStorage.getItem(LAST_EVENT_ID_KEY) || "0",
56
+ 10,
57
+ );
54
58
 
55
59
  this.offlineQueue = [];
56
60
  this.isOnline = navigator.onLine;
57
61
 
58
- // Snapshots for external DB polling
59
- this.externalDBs = options.externalDBs || [];
60
- this.externalSnapshots = {};
61
- this.pollInterval = options.pollInterval || 5000;
62
- this.pollTimer = null;
63
-
64
62
  this.setupEventListeners();
65
63
  }
66
64
 
67
- /**
68
- * Get a fingerprint of an external DB by hashing all row data.
69
- */
70
- _snapshotExternalDB(dbName) {
71
- return new Promise((resolve) => {
72
- const request = indexedDB.open(dbName);
73
- request.onerror = () => resolve(null);
74
- request.onsuccess = (event) => {
75
- const db = event.target.result;
76
- const storeNames = Array.from(db.objectStoreNames);
77
- if (storeNames.length === 0) {
78
- db.close();
79
- resolve(null);
80
- return;
81
- }
82
-
83
- const parts = [];
84
- const tx = db.transaction(storeNames, "readonly");
85
- let pending = storeNames.length;
86
- storeNames.forEach((name) => {
87
- const rows = [];
88
- const cursorReq = tx.objectStore(name).openCursor();
89
- cursorReq.onsuccess = (e) => {
90
- const cursor = e.target.result;
91
- if (cursor) {
92
- rows.push(JSON.stringify(cursor.value));
93
- cursor.continue();
94
- } else {
95
- parts.push(name + ":" + rows.join(","));
96
- pending--;
97
- if (pending === 0) {
98
- db.close();
99
- resolve(parts.join("|"));
100
- }
101
- }
102
- };
103
- cursorReq.onerror = () => {
104
- pending--;
105
- if (pending === 0) {
106
- db.close();
107
- resolve(parts.join("|"));
108
- }
109
- };
110
- });
111
- };
112
- });
113
- }
114
-
115
- /**
116
- * Take initial snapshots and start polling external DBs for changes.
117
- */
118
- async startPolling() {
119
- for (const dbName of this.externalDBs) {
120
- this.externalSnapshots[dbName] = await this._snapshotExternalDB(dbName);
121
- }
122
- this.pollTimer = setInterval(
123
- () => this._pollExternalDBs(),
124
- this.pollInterval,
125
- );
126
- }
127
-
128
- /**
129
- * Stop polling external DBs.
130
- */
131
- stopPolling() {
132
- if (this.pollTimer) {
133
- clearInterval(this.pollTimer);
134
- this.pollTimer = null;
135
- }
136
- }
137
-
138
- async _pollExternalDBs() {
139
- if (isLoadingFromCloud || this.saveInProgress) return;
140
- for (const dbName of this.externalDBs) {
141
- const current = await this._snapshotExternalDB(dbName);
142
- const prev = this.externalSnapshots[dbName];
143
-
144
- if (current !== prev) {
145
- this.externalSnapshots[dbName] = current;
146
- this.markDirty(dbName);
147
- }
148
- }
65
+ get isDirty() {
66
+ return this.pendingEvents.length > 0;
149
67
  }
150
68
 
151
- markDirty(storeName = null) {
69
+ addEvent(event) {
152
70
  if (isLoadingFromCloud || isReadOnlyMode()) return;
153
- if (storeName) {
154
- this.dirtyStores.add(storeName);
155
- }
156
- this.isDirty = true;
71
+ this.pendingEvents.push(event);
157
72
  this.lastChangeTime = Date.now();
158
73
  this.updateUI("unsaved");
159
74
  this.scheduleSave();
@@ -196,24 +111,46 @@ window.hyperbook.cloud = (function () {
196
111
  this.updateUI("saving");
197
112
 
198
113
  try {
199
- const dataToSave = await this.exportStores();
114
+ // Take a snapshot of pending events
115
+ const eventsToSend = this.pendingEvents.slice();
116
+ const serialized = JSON.stringify(eventsToSend);
200
117
 
201
118
  if (!this.isOnline) {
202
119
  this.offlineQueue.push({
203
- data: dataToSave,
120
+ events: eventsToSend,
121
+ afterEventId: this.lastEventId,
204
122
  timestamp: Date.now(),
205
123
  });
124
+ this.pendingEvents = [];
206
125
  this.updateUI("offline-queued");
207
126
  return;
208
127
  }
209
128
 
210
- await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
211
- method: "POST",
212
- body: JSON.stringify({ data: dataToSave }),
213
- });
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
+ }
138
+
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
+ }
214
147
 
215
- this.isDirty = false;
216
- this.dirtyStores.clear();
148
+ this.pendingEvents = [];
149
+ this.lastEventId = result.lastEventId;
150
+ localStorage.setItem(
151
+ LAST_EVENT_ID_KEY,
152
+ String(this.lastEventId),
153
+ );
217
154
  this.lastSaveTime = Date.now();
218
155
  this.retryCount = 0;
219
156
  this.updateUI("saved");
@@ -228,19 +165,46 @@ window.hyperbook.cloud = (function () {
228
165
  });
229
166
  }
230
167
 
231
- 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() {
232
191
  const hyperbookExport = await store.export({ prettyJson: false });
233
- const sqlIdeExport = await exportExternalDB("SQL-IDE");
234
- const learnJExport = await exportExternalDB("LearnJ");
235
- return {
236
- version: 1,
237
- origin: window.location.origin,
238
- data: {
239
- hyperbook: JSON.parse(await hyperbookExport.text()),
240
- sqlIde: sqlIdeExport || {},
241
- learnJ: learnJExport || {},
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
+ }),
242
205
  },
243
- };
206
+ );
207
+ return { lastEventId: data.lastEventId, conflict: false };
244
208
  }
245
209
 
246
210
  scheduleRetry() {
@@ -272,7 +236,6 @@ window.hyperbook.cloud = (function () {
272
236
 
273
237
  window.addEventListener("beforeunload", (e) => {
274
238
  if (this.isDirty) {
275
- this.performSave("unload");
276
239
  e.preventDefault();
277
240
  e.returnValue = "";
278
241
  }
@@ -289,21 +252,38 @@ window.hyperbook.cloud = (function () {
289
252
  if (this.offlineQueue.length === 0) return;
290
253
 
291
254
  console.log(`Processing ${this.offlineQueue.length} queued saves...`);
292
- this.offlineQueue.sort((a, b) => a.timestamp - b.timestamp);
293
255
 
294
- // Only send the latest queued save
295
- const latest = this.offlineQueue[this.offlineQueue.length - 1];
296
- try {
297
- await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`, {
298
- method: "POST",
299
- body: JSON.stringify({ data: latest.data }),
300
- });
301
- this.offlineQueue = [];
302
- this.lastSaveTime = Date.now();
303
- console.log("✓ Offline queue processed");
304
- } catch (error) {
305
- 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
+ }
306
282
  }
283
+
284
+ this.offlineQueue = [];
285
+ this.lastSaveTime = Date.now();
286
+ console.log("✓ Offline queue processed");
307
287
  }
308
288
 
309
289
  clearTimers() {
@@ -327,16 +307,31 @@ window.hyperbook.cloud = (function () {
327
307
  }
328
308
 
329
309
  async manualSave() {
330
- 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
+ }
331
328
  this.clearTimers();
332
329
  await this.performSave("manual");
333
330
  }
334
331
 
335
332
  reset() {
336
- this.isDirty = false;
337
- this.dirtyStores.clear();
333
+ this.pendingEvents = [];
338
334
  this.clearTimers();
339
- this.stopPolling();
340
335
  this.offlineQueue = [];
341
336
  this.retryCount = 0;
342
337
  }
@@ -400,6 +395,7 @@ window.hyperbook.cloud = (function () {
400
395
  function clearAuthToken() {
401
396
  localStorage.removeItem(AUTH_TOKEN_KEY);
402
397
  localStorage.removeItem(AUTH_USER_KEY);
398
+ localStorage.removeItem(LAST_EVENT_ID_KEY);
403
399
  }
404
400
 
405
401
  /**
@@ -467,12 +463,16 @@ window.hyperbook.cloud = (function () {
467
463
  const data = await response.json();
468
464
 
469
465
  if (!response.ok) {
470
- throw new Error(data.error || "Request failed");
466
+ const err = new Error(data.error || "Request failed");
467
+ err.status = response.status;
468
+ throw err;
471
469
  }
472
470
 
473
471
  return data;
474
472
  } catch (error) {
475
- console.error("Cloud API error:", error);
473
+ if (!error.status) {
474
+ console.error("Cloud API error:", error);
475
+ }
476
476
  throw error;
477
477
  }
478
478
  }
@@ -524,10 +524,9 @@ window.hyperbook.cloud = (function () {
524
524
  try {
525
525
  const data = await apiRequest(`/api/store/${HYPERBOOK_CLOUD.id}`);
526
526
 
527
- if (data && data.data) {
528
- // Import data into local stores
529
- const storeData = data.data.data || data.data;
530
- const { hyperbook, sqlIde, learnJ } = storeData;
527
+ if (data && data.snapshot) {
528
+ const storeData = data.snapshot.data || data.snapshot;
529
+ const { hyperbook } = storeData;
531
530
 
532
531
  if (hyperbook) {
533
532
  const blob = new Blob([JSON.stringify(hyperbook)], {
@@ -536,12 +535,15 @@ window.hyperbook.cloud = (function () {
536
535
  await store.import(blob, { clearTablesBeforeImport: true });
537
536
  }
538
537
 
539
- if (sqlIde) {
540
- await importExternalDB("SQL-IDE", sqlIde);
541
- }
542
-
543
- if (learnJ) {
544
- await importExternalDB("LearnJ", learnJ);
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
+ }
545
547
  }
546
548
 
547
549
  console.log("✓ Store loaded from cloud");
@@ -584,20 +586,39 @@ window.hyperbook.cloud = (function () {
584
586
  debounceDelay: 2000,
585
587
  maxWaitTime: 10000,
586
588
  minSaveInterval: 1000,
587
- externalDBs: ["SQL-IDE", "LearnJ"],
588
- pollInterval: 5000,
589
589
  });
590
590
 
591
- // Hook Dexie tables to track changes (skip currentState — ephemeral UI data)
591
+ // Hook Dexie tables to capture granular events (skip currentState — ephemeral UI data)
592
592
  store.tables.forEach((table) => {
593
593
  if (table.name === "currentState") return;
594
- table.hook("creating", () => syncManager.markDirty(table.name));
595
- table.hook("updating", () => syncManager.markDirty(table.name));
596
- table.hook("deleting", () => syncManager.markDirty(table.name));
597
- });
598
594
 
599
- // Start polling external DBs for changes
600
- syncManager.startPolling();
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
+ });
621
+ });
601
622
  }
602
623
  }
603
624
 
@@ -727,6 +748,14 @@ window.hyperbook.cloud = (function () {
727
748
  updateUserUI(user);
728
749
  }
729
750
 
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
+
730
759
  // Show impersonation banner if in readonly mode
731
760
  if (isReadOnlyMode()) {
732
761
  const banner = document.createElement("div");
@@ -752,6 +781,14 @@ window.hyperbook.cloud = (function () {
752
781
 
753
782
  return {
754
783
  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
+ },
755
792
  userToggle,
756
793
  login,
757
794
  logout,