@@ -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