@prabhask5/stellar-engine 1.1.7 → 1.1.8

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.
Files changed (191) hide show
  1. package/README.md +4 -1
  2. package/dist/actions/remoteChange.d.ts +143 -18
  3. package/dist/actions/remoteChange.d.ts.map +1 -1
  4. package/dist/actions/remoteChange.js +182 -58
  5. package/dist/actions/remoteChange.js.map +1 -1
  6. package/dist/actions/truncateTooltip.d.ts +26 -12
  7. package/dist/actions/truncateTooltip.d.ts.map +1 -1
  8. package/dist/actions/truncateTooltip.js +89 -34
  9. package/dist/actions/truncateTooltip.js.map +1 -1
  10. package/dist/auth/admin.d.ts +40 -3
  11. package/dist/auth/admin.d.ts.map +1 -1
  12. package/dist/auth/admin.js +45 -5
  13. package/dist/auth/admin.js.map +1 -1
  14. package/dist/auth/crypto.d.ts +55 -5
  15. package/dist/auth/crypto.d.ts.map +1 -1
  16. package/dist/auth/crypto.js +58 -5
  17. package/dist/auth/crypto.js.map +1 -1
  18. package/dist/auth/deviceVerification.d.ts +236 -20
  19. package/dist/auth/deviceVerification.d.ts.map +1 -1
  20. package/dist/auth/deviceVerification.js +293 -40
  21. package/dist/auth/deviceVerification.js.map +1 -1
  22. package/dist/auth/displayUtils.d.ts +98 -0
  23. package/dist/auth/displayUtils.d.ts.map +1 -0
  24. package/dist/auth/displayUtils.js +133 -0
  25. package/dist/auth/displayUtils.js.map +1 -0
  26. package/dist/auth/loginGuard.d.ts +108 -14
  27. package/dist/auth/loginGuard.d.ts.map +1 -1
  28. package/dist/auth/loginGuard.js +153 -31
  29. package/dist/auth/loginGuard.js.map +1 -1
  30. package/dist/auth/offlineCredentials.d.ts +132 -15
  31. package/dist/auth/offlineCredentials.d.ts.map +1 -1
  32. package/dist/auth/offlineCredentials.js +167 -23
  33. package/dist/auth/offlineCredentials.js.map +1 -1
  34. package/dist/auth/offlineLogin.d.ts +96 -10
  35. package/dist/auth/offlineLogin.d.ts.map +1 -1
  36. package/dist/auth/offlineLogin.js +82 -15
  37. package/dist/auth/offlineLogin.js.map +1 -1
  38. package/dist/auth/offlineSession.d.ts +83 -9
  39. package/dist/auth/offlineSession.d.ts.map +1 -1
  40. package/dist/auth/offlineSession.js +104 -13
  41. package/dist/auth/offlineSession.js.map +1 -1
  42. package/dist/auth/resolveAuthState.d.ts +70 -8
  43. package/dist/auth/resolveAuthState.d.ts.map +1 -1
  44. package/dist/auth/resolveAuthState.js +142 -46
  45. package/dist/auth/resolveAuthState.js.map +1 -1
  46. package/dist/auth/singleUser.d.ts +390 -37
  47. package/dist/auth/singleUser.d.ts.map +1 -1
  48. package/dist/auth/singleUser.js +500 -99
  49. package/dist/auth/singleUser.js.map +1 -1
  50. package/dist/bin/install-pwa.d.ts +18 -2
  51. package/dist/bin/install-pwa.d.ts.map +1 -1
  52. package/dist/bin/install-pwa.js +801 -25
  53. package/dist/bin/install-pwa.js.map +1 -1
  54. package/dist/config.d.ts +132 -12
  55. package/dist/config.d.ts.map +1 -1
  56. package/dist/config.js +87 -9
  57. package/dist/config.js.map +1 -1
  58. package/dist/conflicts.d.ts +246 -23
  59. package/dist/conflicts.d.ts.map +1 -1
  60. package/dist/conflicts.js +495 -46
  61. package/dist/conflicts.js.map +1 -1
  62. package/dist/data.d.ts +338 -18
  63. package/dist/data.d.ts.map +1 -1
  64. package/dist/data.js +385 -34
  65. package/dist/data.js.map +1 -1
  66. package/dist/database.d.ts +72 -14
  67. package/dist/database.d.ts.map +1 -1
  68. package/dist/database.js +120 -29
  69. package/dist/database.js.map +1 -1
  70. package/dist/debug.d.ts +77 -1
  71. package/dist/debug.d.ts.map +1 -1
  72. package/dist/debug.js +88 -1
  73. package/dist/debug.js.map +1 -1
  74. package/dist/deviceId.d.ts +38 -7
  75. package/dist/deviceId.d.ts.map +1 -1
  76. package/dist/deviceId.js +68 -10
  77. package/dist/deviceId.js.map +1 -1
  78. package/dist/engine.d.ts +175 -3
  79. package/dist/engine.d.ts.map +1 -1
  80. package/dist/engine.js +756 -109
  81. package/dist/engine.js.map +1 -1
  82. package/dist/entries/actions.d.ts +13 -0
  83. package/dist/entries/actions.d.ts.map +1 -1
  84. package/dist/entries/actions.js +26 -1
  85. package/dist/entries/actions.js.map +1 -1
  86. package/dist/entries/auth.d.ts +16 -0
  87. package/dist/entries/auth.d.ts.map +1 -1
  88. package/dist/entries/auth.js +73 -1
  89. package/dist/entries/auth.js.map +1 -1
  90. package/dist/entries/config.d.ts +12 -0
  91. package/dist/entries/config.d.ts.map +1 -1
  92. package/dist/entries/config.js +18 -1
  93. package/dist/entries/config.js.map +1 -1
  94. package/dist/entries/kit.d.ts +11 -0
  95. package/dist/entries/kit.d.ts.map +1 -1
  96. package/dist/entries/kit.js +52 -2
  97. package/dist/entries/kit.js.map +1 -1
  98. package/dist/entries/stores.d.ts +11 -0
  99. package/dist/entries/stores.d.ts.map +1 -1
  100. package/dist/entries/stores.js +43 -2
  101. package/dist/entries/stores.js.map +1 -1
  102. package/dist/entries/types.d.ts +10 -0
  103. package/dist/entries/types.d.ts.map +1 -1
  104. package/dist/entries/types.js +10 -0
  105. package/dist/entries/types.js.map +1 -1
  106. package/dist/entries/utils.d.ts +6 -0
  107. package/dist/entries/utils.d.ts.map +1 -1
  108. package/dist/entries/utils.js +22 -1
  109. package/dist/entries/utils.js.map +1 -1
  110. package/dist/entries/vite.d.ts +17 -0
  111. package/dist/entries/vite.d.ts.map +1 -1
  112. package/dist/entries/vite.js +24 -1
  113. package/dist/entries/vite.js.map +1 -1
  114. package/dist/index.d.ts +31 -0
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +175 -20
  117. package/dist/index.js.map +1 -1
  118. package/dist/kit/auth.d.ts +60 -5
  119. package/dist/kit/auth.d.ts.map +1 -1
  120. package/dist/kit/auth.js +45 -4
  121. package/dist/kit/auth.js.map +1 -1
  122. package/dist/kit/confirm.d.ts +93 -12
  123. package/dist/kit/confirm.d.ts.map +1 -1
  124. package/dist/kit/confirm.js +103 -16
  125. package/dist/kit/confirm.js.map +1 -1
  126. package/dist/kit/loads.d.ts +150 -23
  127. package/dist/kit/loads.d.ts.map +1 -1
  128. package/dist/kit/loads.js +140 -24
  129. package/dist/kit/loads.js.map +1 -1
  130. package/dist/kit/server.d.ts +142 -10
  131. package/dist/kit/server.d.ts.map +1 -1
  132. package/dist/kit/server.js +158 -15
  133. package/dist/kit/server.js.map +1 -1
  134. package/dist/kit/sw.d.ts +152 -23
  135. package/dist/kit/sw.d.ts.map +1 -1
  136. package/dist/kit/sw.js +182 -26
  137. package/dist/kit/sw.js.map +1 -1
  138. package/dist/queue.d.ts +274 -0
  139. package/dist/queue.d.ts.map +1 -1
  140. package/dist/queue.js +556 -38
  141. package/dist/queue.js.map +1 -1
  142. package/dist/realtime.d.ts +241 -27
  143. package/dist/realtime.d.ts.map +1 -1
  144. package/dist/realtime.js +633 -109
  145. package/dist/realtime.js.map +1 -1
  146. package/dist/runtime/runtimeConfig.d.ts +91 -8
  147. package/dist/runtime/runtimeConfig.d.ts.map +1 -1
  148. package/dist/runtime/runtimeConfig.js +146 -19
  149. package/dist/runtime/runtimeConfig.js.map +1 -1
  150. package/dist/stores/authState.d.ts +150 -11
  151. package/dist/stores/authState.d.ts.map +1 -1
  152. package/dist/stores/authState.js +169 -17
  153. package/dist/stores/authState.js.map +1 -1
  154. package/dist/stores/network.d.ts +39 -0
  155. package/dist/stores/network.d.ts.map +1 -1
  156. package/dist/stores/network.js +169 -16
  157. package/dist/stores/network.js.map +1 -1
  158. package/dist/stores/remoteChanges.d.ts +327 -52
  159. package/dist/stores/remoteChanges.d.ts.map +1 -1
  160. package/dist/stores/remoteChanges.js +337 -75
  161. package/dist/stores/remoteChanges.js.map +1 -1
  162. package/dist/stores/sync.d.ts +130 -0
  163. package/dist/stores/sync.d.ts.map +1 -1
  164. package/dist/stores/sync.js +167 -7
  165. package/dist/stores/sync.js.map +1 -1
  166. package/dist/supabase/auth.d.ts +325 -18
  167. package/dist/supabase/auth.d.ts.map +1 -1
  168. package/dist/supabase/auth.js +374 -26
  169. package/dist/supabase/auth.js.map +1 -1
  170. package/dist/supabase/client.d.ts +79 -6
  171. package/dist/supabase/client.d.ts.map +1 -1
  172. package/dist/supabase/client.js +158 -15
  173. package/dist/supabase/client.js.map +1 -1
  174. package/dist/supabase/validate.d.ts +101 -7
  175. package/dist/supabase/validate.d.ts.map +1 -1
  176. package/dist/supabase/validate.js +117 -8
  177. package/dist/supabase/validate.js.map +1 -1
  178. package/dist/sw/build/vite-plugin.d.ts +55 -10
  179. package/dist/sw/build/vite-plugin.d.ts.map +1 -1
  180. package/dist/sw/build/vite-plugin.js +77 -18
  181. package/dist/sw/build/vite-plugin.js.map +1 -1
  182. package/dist/sw/sw.js +99 -44
  183. package/dist/types.d.ts +150 -26
  184. package/dist/types.d.ts.map +1 -1
  185. package/dist/types.js +12 -10
  186. package/dist/types.js.map +1 -1
  187. package/dist/utils.d.ts +55 -13
  188. package/dist/utils.d.ts.map +1 -1
  189. package/dist/utils.js +83 -22
  190. package/dist/utils.js.map +1 -1
  191. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -1,3 +1,60 @@
1
+ /**
2
+ * @fileoverview Local-First Sync Engine - Core orchestrator for offline-first data synchronization.
3
+ *
4
+ * This is the heart of stellar-engine: a bidirectional sync engine that keeps local
5
+ * IndexedDB (via Dexie) in sync with a remote Supabase database. It implements the
6
+ * "local-first" pattern where all reads/writes happen against the local DB for instant
7
+ * responsiveness, and a background sync loop reconciles with the server.
8
+ *
9
+ * ## Architecture Overview
10
+ *
11
+ * ```
12
+ * ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
13
+ * │ UI Layer │────▶│ Local DB │────▶│ Sync Engine │────▶ Supabase
14
+ * │ (instant) │◀────│ (IndexedDB) │◀────│ (background)│◀──── (remote)
15
+ * └─────────────┘ └──────────────┘ └──────────────┘
16
+ * ```
17
+ *
18
+ * ## Core Rules
19
+ *
20
+ * 1. **All reads come from local DB** (IndexedDB via Dexie)
21
+ * 2. **All writes go to local DB first**, immediately (no waiting for network)
22
+ * 3. **Every write creates a pending operation** in the sync queue (outbox pattern)
23
+ * 4. **Sync loop ships outbox to server** in the background (push phase)
24
+ * 5. **On refresh, load local state instantly**, then run background sync (pull phase)
25
+ *
26
+ * ## Sync Cycle Flow
27
+ *
28
+ * 1. **Push**: Coalesce pending ops → send to Supabase → remove from queue
29
+ * 2. **Pull**: Fetch changes since last cursor → apply with conflict resolution → update cursor
30
+ * 3. **Notify**: Tell registered stores to refresh from local DB
31
+ *
32
+ * ## Key Subsystems
33
+ *
34
+ * - **Egress monitoring**: Tracks bytes/records transferred for debugging bandwidth usage
35
+ * - **Sync lock (mutex)**: Prevents concurrent sync cycles from corrupting state
36
+ * - **Watchdog**: Detects stuck syncs and auto-releases locks after timeout
37
+ * - **Tombstone cleanup**: Garbage-collects soft-deleted records after configured TTL
38
+ * - **Auth validation**: Ensures valid session before syncing (prevents silent RLS failures)
39
+ * - **Visibility sync**: Smart re-sync when user returns to tab after extended absence
40
+ * - **Realtime integration**: Skips polling pull when WebSocket subscription is healthy
41
+ *
42
+ * ## Egress Optimization Strategy
43
+ *
44
+ * The engine aggressively minimizes Supabase egress (bandwidth) through:
45
+ * - Operation coalescing (50 rapid updates → 1 request)
46
+ * - Push-only mode when realtime is healthy (skip pull after local writes)
47
+ * - Cached user validation (1 getUser() call per hour instead of per sync)
48
+ * - Visibility-aware sync (skip sync if tab was hidden briefly)
49
+ * - Reconnect cooldown (skip sync if we just synced before going offline)
50
+ * - Selective column fetching (only request configured columns, not `*`)
51
+ *
52
+ * @module engine
53
+ * @see {@link ./queue.ts} - Sync queue (outbox) management
54
+ * @see {@link ./conflicts.ts} - Field-level conflict resolution
55
+ * @see {@link ./realtime.ts} - Supabase Realtime WebSocket subscriptions
56
+ * @see {@link ./config.ts} - Engine configuration and table definitions
57
+ */
1
58
  import { getEngineConfig, getDexieTableFor, waitForDb } from './config';
2
59
  import { debugLog, debugWarn, debugError, isDebugMode } from './debug';
3
60
  import { getPendingSync, removeSyncItem, incrementRetry, getPendingEntityIds, cleanupFailedItems, coalescePendingOps, queueSyncOperation } from './queue';
@@ -11,69 +68,139 @@ import { supabase as supabaseProxy } from './supabase/client';
11
68
  import { getOfflineCredentials } from './auth/offlineCredentials';
12
69
  import { getValidOfflineSession, createOfflineSession } from './auth/offlineSession';
13
70
  import { validateSchema } from './supabase/validate';
14
- // ============================================================
15
- // LOCAL-FIRST SYNC ENGINE
71
+ // =============================================================================
72
+ // CONFIG ACCESSORS
73
+ // =============================================================================
16
74
  //
17
- // Rules:
18
- // 1. All reads come from local DB (IndexedDB)
19
- // 2. All writes go to local DB first, immediately
20
- // 3. Every write creates a pending operation in the outbox
21
- // 4. Sync loop ships outbox to server in background
22
- // 5. On refresh, load local state instantly, then run background sync
23
- // ============================================================
24
- // Helper functions for config-driven access
75
+ // These helper functions provide lazy access to engine configuration values.
76
+ // They exist because the engine config is set at runtime via `initEngine()`,
77
+ // so we can't read config values at module load time (they'd be undefined).
78
+ // Each function reads from the live config on every call to support hot-reloading.
79
+ // =============================================================================
80
+ /**
81
+ * Get the Dexie database instance from the engine config.
82
+ *
83
+ * @returns The initialized Dexie database
84
+ * @throws {Error} If the database hasn't been initialized via `initEngine()`
85
+ */
25
86
  function getDb() {
26
87
  const db = getEngineConfig().db;
27
88
  if (!db)
28
89
  throw new Error('Database not initialized. Provide db or database config to initEngine().');
29
90
  return db;
30
91
  }
92
+ /**
93
+ * Get the Supabase client instance.
94
+ *
95
+ * Prefers the explicitly-provided client from config, falling back to the
96
+ * proxy-based client (which defers initialization until first use).
97
+ *
98
+ * @returns The Supabase client for server communication
99
+ */
31
100
  function getSupabase() {
32
101
  const config = getEngineConfig();
33
102
  if (config.supabase)
34
103
  return config.supabase;
35
- // Fall back to the proxy-based supabase client
36
104
  return supabaseProxy;
37
105
  }
106
+ /**
107
+ * Map a Supabase table name to its corresponding Dexie (local) table name.
108
+ *
109
+ * Table name mapping allows the local DB schema to differ from the remote schema.
110
+ * Falls back to using the Supabase name directly if no mapping is configured.
111
+ *
112
+ * @param supabaseName - The remote Supabase table name
113
+ * @returns The local Dexie table name (may include a prefix)
114
+ */
38
115
  function getDexieTableName(supabaseName) {
39
116
  const table = getEngineConfig().tables.find((t) => t.supabaseName === supabaseName);
40
117
  return table ? getDexieTableFor(table) : supabaseName;
41
118
  }
119
+ /**
120
+ * Get the column selection string for a Supabase table.
121
+ *
122
+ * Used in SELECT queries to limit which columns are fetched from the server.
123
+ * This is an **egress optimization** — fetching only needed columns reduces
124
+ * bandwidth usage, especially for tables with large text/JSON columns.
125
+ *
126
+ * @param supabaseName - The remote Supabase table name
127
+ * @returns PostgREST column selector (e.g., `"id,name,updated_at"` or `"*"`)
128
+ */
42
129
  function getColumns(supabaseName) {
43
130
  const table = getEngineConfig().tables.find((t) => t.supabaseName === supabaseName);
44
131
  return table?.columns || '*';
45
132
  }
133
+ /**
134
+ * Check if a Supabase table is configured as a singleton (one row per user).
135
+ *
136
+ * Singleton tables have special handling during sync: when a duplicate key
137
+ * error occurs on create, the engine reconciles the local ID with the server's
138
+ * existing row instead of treating it as an error.
139
+ *
140
+ * @param supabaseName - The remote Supabase table name
141
+ * @returns `true` if the table is a singleton
142
+ */
46
143
  function isSingletonTable(supabaseName) {
47
144
  const table = getEngineConfig().tables.find((t) => t.supabaseName === supabaseName);
48
145
  return table?.isSingleton || false;
49
146
  }
50
- // Getter functions for config values (can't read config at module level)
147
+ // --- Timing & Threshold Config Accessors ---
148
+ // Each has a sensible default if not configured by the consumer.
149
+ /** Delay before pushing local writes to server (debounces rapid edits). Default: 2000ms */
51
150
  function getSyncDebounceMs() {
52
151
  return getEngineConfig().syncDebounceMs ?? 2000;
53
152
  }
153
+ /** Interval for periodic background sync (polling fallback when realtime is down). Default: 15min */
54
154
  function getSyncIntervalMs() {
55
155
  return getEngineConfig().syncIntervalMs ?? 900000;
56
156
  }
157
+ /** How long to keep soft-deleted (tombstone) records before hard-deleting. Default: 7 days */
57
158
  function getTombstoneMaxAgeDays() {
58
159
  return getEngineConfig().tombstoneMaxAgeDays ?? 7;
59
160
  }
161
+ /** Minimum time tab must be hidden before triggering a sync on return. Default: 5min */
60
162
  function getVisibilitySyncMinAwayMs() {
61
163
  return getEngineConfig().visibilitySyncMinAwayMs ?? 300000;
62
164
  }
165
+ /** Cooldown after a successful sync before allowing reconnect-triggered sync. Default: 2min */
63
166
  function getOnlineReconnectCooldownMs() {
64
167
  return getEngineConfig().onlineReconnectCooldownMs ?? 120000;
65
168
  }
169
+ /** Engine prefix used for localStorage keys and debug window utilities. Default: "engine" */
66
170
  function getPrefix() {
67
171
  return getEngineConfig().prefix || 'engine';
68
172
  }
69
- // Track if we were recently offline (for auth validation on reconnect)
173
+ // =============================================================================
174
+ // AUTH STATE TRACKING
175
+ // =============================================================================
176
+ //
177
+ // When the device goes offline and comes back online, we must re-validate the
178
+ // user's session before allowing any sync operations. Without this, an expired
179
+ // or revoked session could cause Supabase RLS to silently block all writes
180
+ // (returning success but affecting 0 rows — the "ghost sync" bug).
181
+ // =============================================================================
182
+ /** Whether the device was recently offline (triggers auth validation on reconnect) */
70
183
  let wasOffline = false;
71
- let authValidatedAfterReconnect = true; // Start as true (no validation needed initially)
72
- let _schemaValidated = false; // One-time schema validation flag
184
+ /** Whether auth has been validated since the last offline→online transition. Starts `true` (no validation needed on fresh start) */
185
+ let authValidatedAfterReconnect = true;
186
+ /** One-time flag: has the Supabase schema been validated this session? */
187
+ let _schemaValidated = false;
73
188
  /**
74
- * Clear all pending sync operations (used when auth is invalid)
75
- * SECURITY: Called when offline credentials are found to be invalid
76
- * to prevent unauthorized data from being synced to the server
189
+ * Clear all pending sync operations from the outbox queue.
190
+ *
191
+ * **SECURITY**: Called when offline credentials are found to be invalid, to prevent
192
+ * unauthorized data from being synced to the server. Without this, a user who
193
+ * tampered with offline credentials could queue malicious writes that get pushed
194
+ * once the device reconnects.
195
+ *
196
+ * @returns The number of operations that were cleared
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * // Called during auth validation failure
201
+ * const cleared = await clearPendingSyncQueue();
202
+ * console.log(`Prevented ${cleared} unauthorized sync operations`);
203
+ * ```
77
204
  */
78
205
  export async function clearPendingSyncQueue() {
79
206
  try {
@@ -110,7 +237,9 @@ function markAuthValidated() {
110
237
  function needsAuthValidation() {
111
238
  return wasOffline && !authValidatedAfterReconnect;
112
239
  }
240
+ /** Rolling log of recent sync cycles (max 100 entries) */
113
241
  const syncStats = [];
242
+ /** Total number of sync cycles since page load */
114
243
  let totalSyncCycles = 0;
115
244
  const egressStats = {
116
245
  totalBytes: 0,
@@ -118,7 +247,15 @@ const egressStats = {
118
247
  byTable: {},
119
248
  sessionStart: new Date().toISOString()
120
249
  };
121
- // Helper to estimate JSON size in bytes
250
+ /**
251
+ * Estimate the byte size of a JSON-serializable value.
252
+ *
253
+ * Uses `Blob` for accurate UTF-8 byte counting when available,
254
+ * falling back to string length (which undercounts multi-byte chars).
255
+ *
256
+ * @param data - Any JSON-serializable value
257
+ * @returns Estimated size in bytes
258
+ */
122
259
  function estimateJsonSize(data) {
123
260
  try {
124
261
  return new Blob([JSON.stringify(data)]).size;
@@ -128,7 +265,16 @@ function estimateJsonSize(data) {
128
265
  return JSON.stringify(data).length;
129
266
  }
130
267
  }
131
- // Track egress for a table
268
+ /**
269
+ * Record egress (data downloaded) for a specific table.
270
+ *
271
+ * Updates both the per-table and global cumulative counters. Called after
272
+ * every Supabase SELECT query to build an accurate picture of bandwidth usage.
273
+ *
274
+ * @param tableName - The Supabase table name
275
+ * @param data - The rows returned from the query (null/empty = no egress)
276
+ * @returns The bytes and record count for this specific fetch
277
+ */
132
278
  function trackEgress(tableName, data) {
133
279
  if (!data || data.length === 0) {
134
280
  return { bytes: 0, records: 0 };
@@ -146,7 +292,12 @@ function trackEgress(tableName, data) {
146
292
  egressStats.byTable[tableName].records += records;
147
293
  return { bytes, records };
148
294
  }
149
- // Format bytes for display
295
+ /**
296
+ * Format a byte count into a human-readable string (B, KB, or MB).
297
+ *
298
+ * @param bytes - Raw byte count
299
+ * @returns Formatted string like `"1.23 KB"` or `"456 B"`
300
+ */
150
301
  function formatBytes(bytes) {
151
302
  if (bytes < 1024)
152
303
  return `${bytes} B`;
@@ -154,6 +305,14 @@ function formatBytes(bytes) {
154
305
  return `${(bytes / 1024).toFixed(2)} KB`;
155
306
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
156
307
  }
308
+ /**
309
+ * Record a completed sync cycle in the rolling stats log.
310
+ *
311
+ * Automatically timestamps the entry and trims the log to 100 entries.
312
+ * Also emits a debug log line summarizing the cycle for real-time monitoring.
313
+ *
314
+ * @param stats - Sync cycle metrics (trigger, items pushed/pulled, egress, duration)
315
+ */
157
316
  function logSyncCycle(stats) {
158
317
  const entry = {
159
318
  ...stats,
@@ -169,11 +328,24 @@ function logSyncCycle(stats) {
169
328
  `trigger=${stats.trigger}, pushed=${stats.pushedItems}, ` +
170
329
  `pulled=${stats.pulledRecords} records (${formatBytes(stats.egressBytes)}), ${stats.durationMs}ms`);
171
330
  }
172
- // Export for debugging in browser console
173
- // Uses configurable prefix: window.__<prefix>SyncStats?.()
174
- // Also: window.__<prefix>Tombstones?.() or window.__<prefix>Tombstones?.({ cleanup: true, force: true })
175
- // Also: window.__<prefix>Egress?.()
176
- // Also: window.__<prefix>Sync.forceFullSync(), .resetSyncCursor(), .sync(), .getStatus(), .checkConnection(), .realtimeStatus()
331
+ /**
332
+ * Expose debug utilities on the `window` object for browser console access.
333
+ *
334
+ * Only initializes when running in a browser AND debug mode is enabled.
335
+ * All utilities are namespaced under a configurable prefix to avoid collisions
336
+ * when multiple engines run on the same page.
337
+ *
338
+ * Available utilities (where `<prefix>` defaults to `"engine"`):
339
+ * - `window.__<prefix>SyncStats()` — View recent sync cycle statistics
340
+ * - `window.__<prefix>Egress()` — View cumulative bandwidth usage by table
341
+ * - `window.__<prefix>Tombstones()` — Inspect soft-deleted record counts
342
+ * - `window.__<prefix>Sync.forceFullSync()` — Reset cursor and re-download everything
343
+ * - `window.__<prefix>Sync.resetSyncCursor()` — Reset cursor (next sync pulls all)
344
+ * - `window.__<prefix>Sync.sync()` — Trigger an immediate full sync
345
+ * - `window.__<prefix>Sync.getStatus()` — Get current sync cursor and pending op count
346
+ * - `window.__<prefix>Sync.checkConnection()` — Test Supabase connectivity
347
+ * - `window.__<prefix>Sync.realtimeStatus()` — Check WebSocket connection health
348
+ */
177
349
  function initDebugWindowUtilities() {
178
350
  if (typeof window === 'undefined' || !isDebugMode())
179
351
  return;
@@ -218,29 +390,82 @@ function initDebugWindowUtilities() {
218
390
  // Tombstone debug - will be initialized after debugTombstones function is defined
219
391
  // See below where it's assigned after the function definition
220
392
  }
393
+ // =============================================================================
394
+ // MODULE-LEVEL STATE
395
+ // =============================================================================
396
+ //
397
+ // These variables track the engine's runtime state. They're module-scoped
398
+ // (not class properties) because the engine is a singleton — there's only ever
399
+ // one sync engine per page. All state is reset by `stopSyncEngine()`.
400
+ // =============================================================================
401
+ /** Timer handle for the debounced sync-after-write (cleared on each new write) */
221
402
  let syncTimeout = null;
403
+ /** Timer handle for the periodic background sync interval */
222
404
  let syncInterval = null;
223
- let _hasHydrated = false; // Track if initial hydration has been attempted
224
- // EGRESS OPTIMIZATION: Cache getUser() validation to avoid network call every sync cycle
405
+ /** Whether initial hydration (empty-DB pull) has been attempted this session */
406
+ let _hasHydrated = false;
407
+ // --- EGRESS OPTIMIZATION: Cached user validation ---
408
+ // `getUser()` makes a network round-trip to Supabase. Calling it every sync cycle
409
+ // wastes bandwidth. Instead, we cache the result and only re-validate once per hour.
410
+ // If the token is revoked server-side between validations, the push will fail with
411
+ // an RLS error — which is acceptable since it triggers a session refresh.
412
+ /** Timestamp of the last successful `getUser()` network call */
225
413
  let lastUserValidation = 0;
414
+ /** User ID returned by the last successful `getUser()` call */
226
415
  let lastValidatedUserId = null;
227
- const USER_VALIDATION_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
228
- // EGRESS OPTIMIZATION: Track last successful sync for online-reconnect cooldown
416
+ /** How often to re-validate the user with a network call (1 hour) */
417
+ const USER_VALIDATION_INTERVAL_MS = 60 * 60 * 1000;
418
+ // --- Sync timing & visibility tracking ---
419
+ /** Timestamp of the last successful sync completion (used for reconnect cooldown) */
229
420
  let lastSuccessfulSyncTimestamp = 0;
230
- let isTabVisible = true; // Track tab visibility
421
+ /** Whether the browser tab is currently visible (drives periodic sync decisions) */
422
+ let isTabVisible = true;
423
+ /** Timer handle for debouncing visibility-change-triggered syncs */
231
424
  let visibilityDebounceTimeout = null;
232
- let tabHiddenAt = null; // Track when tab became hidden for smart sync
233
- const VISIBILITY_SYNC_DEBOUNCE_MS = 1000; // Debounce for visibility change syncs
234
- const RECENTLY_MODIFIED_TTL_MS = 2000; // Protect recently modified entities for 2 seconds
235
- // Industry standard: 500ms-2000ms. 2s covers sync debounce (1s) + network latency with margin.
236
- // Track recently modified entity IDs to prevent pull from overwriting fresh local changes
237
- // This provides an additional layer of protection beyond the pending queue check
425
+ /** When the tab became hidden (null if currently visible) used to calculate away duration */
426
+ let tabHiddenAt = null;
427
+ /** Debounce delay for visibility-change syncs (prevents rapid tab-switching spam) */
428
+ const VISIBILITY_SYNC_DEBOUNCE_MS = 1000;
429
+ /**
430
+ * How long a locally-modified entity is "protected" from being overwritten by pull.
431
+ *
432
+ * When the user writes locally, the entity is marked as recently modified.
433
+ * During pull, if a remote version arrives within this TTL, it's skipped to
434
+ * prevent the pull from reverting the user's fresh local change before the
435
+ * push has a chance to send it to the server.
436
+ *
437
+ * Industry standard range: 500ms–2000ms. We use 2s to cover the sync debounce
438
+ * window (1s default) plus network latency with margin.
439
+ */
440
+ const RECENTLY_MODIFIED_TTL_MS = 2000;
441
+ /**
442
+ * Map of entity ID → timestamp for recently modified entities.
443
+ *
444
+ * This provides an additional layer of protection beyond the pending queue check.
445
+ * Even if the queue is coalesced or cleared, recently modified entities won't be
446
+ * overwritten by stale remote data during pull.
447
+ */
238
448
  const recentlyModifiedEntities = new Map();
239
- // Mark an entity as recently modified (called by repositories after local writes)
449
+ /**
450
+ * Mark an entity as recently modified to protect it from being overwritten by pull.
451
+ *
452
+ * Called by repository functions after every local write. The protection expires
453
+ * after `RECENTLY_MODIFIED_TTL_MS` (2 seconds).
454
+ *
455
+ * @param entityId - The UUID of the entity that was just modified locally
456
+ */
240
457
  export function markEntityModified(entityId) {
241
458
  recentlyModifiedEntities.set(entityId, Date.now());
242
459
  }
243
- // Check if entity was recently modified locally
460
+ /**
461
+ * Check if an entity was recently modified locally (within the TTL window).
462
+ *
463
+ * Used during pull to skip remote updates for entities the user just edited.
464
+ * Automatically cleans up expired entries on access.
465
+ *
466
+ * @param entityId - The UUID to check
467
+ * @returns `true` if the entity was modified within `RECENTLY_MODIFIED_TTL_MS`
468
+ */
244
469
  function isRecentlyModified(entityId) {
245
470
  const modifiedAt = recentlyModifiedEntities.get(entityId);
246
471
  if (!modifiedAt)
@@ -253,7 +478,12 @@ function isRecentlyModified(entityId) {
253
478
  }
254
479
  return true;
255
480
  }
256
- // Clean up expired entries (called periodically)
481
+ /**
482
+ * Garbage-collect expired entries from the recently-modified map.
483
+ *
484
+ * Called periodically by the sync interval timer to prevent the map
485
+ * from growing unbounded in long-running sessions.
486
+ */
257
487
  function cleanupRecentlyModified() {
258
488
  const now = Date.now();
259
489
  for (const [entityId, modifiedAt] of recentlyModifiedEntities) {
@@ -262,20 +492,49 @@ function cleanupRecentlyModified() {
262
492
  }
263
493
  }
264
494
  }
265
- // Proper async mutex to prevent concurrent syncs
266
- // Uses a queue-based approach where each caller waits for the previous one
495
+ // =============================================================================
496
+ // SYNC LOCK (MUTEX)
497
+ // =============================================================================
498
+ //
499
+ // Prevents concurrent sync cycles from running simultaneously. Without this,
500
+ // two overlapping syncs could both read the same pending ops and push duplicates,
501
+ // or interleave pull writes causing inconsistent local state.
502
+ //
503
+ // The lock uses a simple promise-based approach: acquiring the lock creates a
504
+ // promise that blocks subsequent acquirers. Releasing resolves the promise.
505
+ // A timeout ensures the lock is force-released if a sync hangs.
506
+ // =============================================================================
507
+ /** The pending lock promise (null = lock is free) */
267
508
  let lockPromise = null;
509
+ /** Resolver function for the current lock holder */
268
510
  let lockResolve = null;
511
+ /** Timestamp when the lock was acquired (for stale-lock detection) */
269
512
  let lockAcquiredAt = null;
270
- const SYNC_LOCK_TIMEOUT_MS = 60000; // Force-release lock after 60s
271
- // Store event listener references for cleanup
513
+ /** Maximum time a sync lock can be held before force-release (60 seconds) */
514
+ const SYNC_LOCK_TIMEOUT_MS = 60000;
515
+ // --- Event listener references (stored for cleanup in stopSyncEngine) ---
272
516
  let handleOnlineRef = null;
273
517
  let handleOfflineRef = null;
274
518
  let handleVisibilityChangeRef = null;
275
- // Watchdog: detect stuck syncs and auto-retry
519
+ // --- Watchdog timer ---
520
+ // Runs every 15s to detect stuck syncs. If the lock has been held longer than
521
+ // SYNC_LOCK_TIMEOUT_MS, the watchdog force-releases it and triggers a retry.
522
+ // This prevents permanent sync stalls from unhandled promise rejections.
523
+ /** Timer handle for the sync watchdog */
276
524
  let watchdogInterval = null;
277
- const WATCHDOG_INTERVAL_MS = 15000; // Check every 15s
278
- const SYNC_OPERATION_TIMEOUT_MS = 45000; // Abort sync operations after 45s
525
+ /** How often the watchdog checks for stuck locks */
526
+ const WATCHDOG_INTERVAL_MS = 15000;
527
+ /** Maximum time allowed for individual push/pull operations before abort */
528
+ const SYNC_OPERATION_TIMEOUT_MS = 45000;
529
+ /**
530
+ * Attempt to acquire the sync lock (non-blocking).
531
+ *
532
+ * If the lock is currently held, checks whether it's stale (held beyond timeout).
533
+ * Stale locks are force-released to recover from stuck syncs. If the lock is
534
+ * legitimately held by an active sync, returns `false` immediately (no waiting).
535
+ *
536
+ * @returns `true` if the lock was acquired, `false` if another sync is in progress
537
+ */
279
538
  async function acquireSyncLock() {
280
539
  // If lock is held, check if it's stale (held too long)
281
540
  if (lockPromise !== null) {
@@ -294,6 +553,12 @@ async function acquireSyncLock() {
294
553
  lockAcquiredAt = Date.now();
295
554
  return true;
296
555
  }
556
+ /**
557
+ * Release the sync lock, allowing the next sync cycle to proceed.
558
+ *
559
+ * Resolves the lock promise (unblocking any waiters), then clears all lock state.
560
+ * Safe to call even if the lock isn't held (no-op).
561
+ */
297
562
  function releaseSyncLock() {
298
563
  if (lockResolve) {
299
564
  lockResolve();
@@ -302,7 +567,19 @@ function releaseSyncLock() {
302
567
  lockResolve = null;
303
568
  lockAcquiredAt = null;
304
569
  }
305
- // Timeout wrapper: races a promise against a timeout
570
+ /**
571
+ * Race a promise against a timeout, rejecting if the timeout fires first.
572
+ *
573
+ * Used to prevent sync operations from hanging indefinitely when Supabase
574
+ * doesn't respond (e.g., during a service outage or DNS failure).
575
+ *
576
+ * @template T - The resolved type of the promise
577
+ * @param promise - The async operation to wrap
578
+ * @param ms - Maximum wait time in milliseconds
579
+ * @param label - Human-readable label for the timeout error message
580
+ * @returns The resolved value if the promise wins the race
581
+ * @throws {Error} `"<label> timed out after <N>s"` if the timeout fires first
582
+ */
306
583
  function withTimeout(promise, ms, label) {
307
584
  return new Promise((resolve, reject) => {
308
585
  const timer = setTimeout(() => {
@@ -317,8 +594,35 @@ function withTimeout(promise, ms, label) {
317
594
  });
318
595
  });
319
596
  }
320
- // Callbacks for when sync completes (stores can refresh from local)
597
+ // =============================================================================
598
+ // SYNC COMPLETION CALLBACKS
599
+ // =============================================================================
600
+ //
601
+ // Svelte stores register callbacks here to be notified when a sync cycle
602
+ // completes (either push or pull). This triggers stores to re-read from
603
+ // the local DB, ensuring the UI reflects the latest synced state.
604
+ // =============================================================================
605
+ /** Set of registered callbacks to invoke after every successful sync cycle */
321
606
  const syncCompleteCallbacks = new Set();
607
+ /**
608
+ * Register a callback to be invoked when a sync cycle completes.
609
+ *
610
+ * Used by Svelte stores to refresh their data from the local DB after new
611
+ * remote data has been pulled. Returns an unsubscribe function for cleanup.
612
+ *
613
+ * @param callback - Function to call after each sync completion
614
+ * @returns Unsubscribe function that removes the callback
615
+ *
616
+ * @example
617
+ * ```ts
618
+ * // In a Svelte store
619
+ * const unsubscribe = onSyncComplete(() => {
620
+ * refreshFromLocalDb();
621
+ * });
622
+ * // Later, during cleanup:
623
+ * unsubscribe();
624
+ * ```
625
+ */
322
626
  export function onSyncComplete(callback) {
323
627
  syncCompleteCallbacks.add(callback);
324
628
  debugLog(`[SYNC] Store registered for sync complete (total: ${syncCompleteCallbacks.size})`);
@@ -327,6 +631,12 @@ export function onSyncComplete(callback) {
327
631
  debugLog(`[SYNC] Store unregistered from sync complete (total: ${syncCompleteCallbacks.size})`);
328
632
  };
329
633
  }
634
+ /**
635
+ * Invoke all registered sync-complete callbacks.
636
+ *
637
+ * Each callback is wrapped in try/catch so a failing store refresh doesn't
638
+ * prevent other stores from updating.
639
+ */
330
640
  function notifySyncComplete() {
331
641
  debugLog(`[SYNC] Notifying ${syncCompleteCallbacks.size} store callbacks to refresh`);
332
642
  for (const callback of syncCompleteCallbacks) {
@@ -338,10 +648,40 @@ function notifySyncComplete() {
338
648
  }
339
649
  }
340
650
  }
341
- // ============================================================
342
- // SYNC OPERATIONS - Background sync to/from Supabase
343
- // ============================================================
344
- // Schedule a debounced sync after local writes
651
+ // =============================================================================
652
+ // SYNC OPERATIONS Push & Pull
653
+ // =============================================================================
654
+ //
655
+ // The core sync cycle has two phases:
656
+ //
657
+ // 1. **PUSH** (outbox → server): Read pending ops from the sync queue,
658
+ // coalesce redundant updates, then send each operation to Supabase.
659
+ // Operations are intent-based (create/set/increment/delete) not CRUD,
660
+ // which enables smarter coalescing and conflict resolution.
661
+ //
662
+ // 2. **PULL** (server → local): Fetch all rows modified since the last
663
+ // sync cursor, apply them to the local DB with field-level conflict
664
+ // resolution, and advance the cursor.
665
+ //
666
+ // Push always runs before pull so that local changes are persisted to the
667
+ // server before we fetch remote changes. This ordering ensures that the
668
+ // pull's conflict resolution has access to the server's view of our changes.
669
+ // =============================================================================
670
+ /**
671
+ * Schedule a debounced sync push after a local write.
672
+ *
673
+ * Called by repository functions after every write to the local DB. The debounce
674
+ * prevents hammering the server during rapid edits (e.g., typing in a text field).
675
+ * When realtime is healthy, runs in push-only mode (skips the pull phase) since
676
+ * remote changes arrive via WebSocket.
677
+ *
678
+ * @example
679
+ * ```ts
680
+ * // After a local write in a repository
681
+ * await db.table('todos').put(newTodo);
682
+ * scheduleSyncPush(); // Sync will fire after debounce delay
683
+ * ```
684
+ */
345
685
  export function scheduleSyncPush() {
346
686
  if (syncTimeout) {
347
687
  clearTimeout(syncTimeout);
@@ -356,8 +696,21 @@ export function scheduleSyncPush() {
356
696
  runFullSync(false, skipPull).catch((e) => debugError('[SYNC] Push-triggered sync failed:', e)); // Show syncing indicator for user-triggered writes
357
697
  }, getSyncDebounceMs());
358
698
  }
359
- // Get current user ID for sync cursor isolation
360
- // CRITICAL: This validates the session is actually valid, not just cached
699
+ /**
700
+ * Get the current authenticated user's ID, validating the session is actually valid.
701
+ *
702
+ * **CRITICAL**: This doesn't just read a cached token — it verifies the session
703
+ * is genuinely valid. This catches cases where:
704
+ * - The session token expired while the tab was in the background
705
+ * - The token was revoked server-side (e.g., password change, admin action)
706
+ * - The refresh token is invalid (e.g., user signed out on another device)
707
+ *
708
+ * **EGRESS OPTIMIZATION**: The expensive `getUser()` network call is cached for
709
+ * 1 hour. Between validations, we trust the local session token. If the token
710
+ * was revoked, the next push will fail with an RLS error, triggering a refresh.
711
+ *
712
+ * @returns The user's UUID, or `null` if not authenticated / session invalid
713
+ */
361
714
  async function getCurrentUserId() {
362
715
  try {
363
716
  const supabase = getSupabase();
@@ -430,14 +783,29 @@ async function getCurrentUserId() {
430
783
  return null;
431
784
  }
432
785
  }
433
- // Get last sync cursor from localStorage (per-user to prevent cross-user sync issues)
786
+ /**
787
+ * Read the last sync cursor from localStorage.
788
+ *
789
+ * The cursor is an ISO 8601 timestamp representing the `updated_at` of the most
790
+ * recent record we've seen. It's stored **per-user** to prevent cross-user sync
791
+ * issues when multiple accounts use the same browser (each user has their own
792
+ * cursor so switching accounts doesn't skip or re-pull data).
793
+ *
794
+ * @param userId - The user ID for cursor isolation (null = legacy global cursor)
795
+ * @returns ISO timestamp cursor, or epoch if no cursor is stored
796
+ */
434
797
  function getLastSyncCursor(userId) {
435
798
  if (typeof localStorage === 'undefined')
436
799
  return '1970-01-01T00:00:00.000Z';
437
800
  const key = userId ? `lastSyncCursor_${userId}` : 'lastSyncCursor';
438
801
  return localStorage.getItem(key) || '1970-01-01T00:00:00.000Z';
439
802
  }
440
- // Set last sync cursor (per-user)
803
+ /**
804
+ * Persist the sync cursor to localStorage (per-user).
805
+ *
806
+ * @param cursor - ISO 8601 timestamp of the newest `updated_at` seen
807
+ * @param userId - The user ID for cursor isolation
808
+ */
441
809
  function setLastSyncCursor(cursor, userId) {
442
810
  if (typeof localStorage !== 'undefined') {
443
811
  const key = userId ? `lastSyncCursor_${userId}` : 'lastSyncCursor';
@@ -498,9 +866,25 @@ async function forceFullSync() {
498
866
  releaseSyncLock();
499
867
  }
500
868
  }
501
- // PULL: Fetch changes from remote since last sync
502
- // Returns egress stats for this pull operation
503
- // minCursor: optional minimum cursor to use (e.g., timestamp after push completes)
869
+ /**
870
+ * **PULL PHASE**: Fetch all changes from Supabase since the last sync cursor.
871
+ *
872
+ * This is the "download" half of the sync cycle. It:
873
+ * 1. Queries all configured tables for rows with `updated_at > lastSyncCursor`
874
+ * 2. For each remote record, applies it to local DB with conflict resolution
875
+ * 3. Skips recently-modified entities (protected by the TTL guard)
876
+ * 4. Skips entities just processed by realtime (prevents double-processing)
877
+ * 5. Advances the sync cursor to the newest `updated_at` seen
878
+ *
879
+ * All table queries run in parallel for minimal wall-clock time. The entire
880
+ * local write is wrapped in a Dexie transaction for atomicity.
881
+ *
882
+ * @param minCursor - Optional minimum cursor override (e.g., post-push timestamp
883
+ * to avoid re-fetching records we just pushed). Uses the later of this and
884
+ * the stored cursor.
885
+ * @returns Egress stats (bytes and record count) for this pull
886
+ * @throws {Error} If no authenticated user is available
887
+ */
504
888
  async function pullRemoteChanges(minCursor) {
505
889
  const userId = await getCurrentUserId();
506
890
  // Abort if no authenticated user (avoids confusing RLS errors)
@@ -540,7 +924,20 @@ async function pullRemoteChanges(minCursor) {
540
924
  pullBytes += egress.bytes;
541
925
  pullRecords += egress.records;
542
926
  }
543
- // Helper function to apply remote changes with field-level conflict resolution
927
+ /**
928
+ * Apply remote records to local DB with field-level conflict resolution.
929
+ *
930
+ * For each remote record:
931
+ * - **No local copy**: Accept remote (simple insert)
932
+ * - **Local is newer**: Skip (no conflict possible)
933
+ * - **Remote is newer, no pending ops**: Accept remote (fast path)
934
+ * - **Remote is newer, has pending ops**: Run 3-tier conflict resolution
935
+ * (auto-merge non-conflicting fields, then pending-local-wins for conflicting fields)
936
+ *
937
+ * @param entityType - The Supabase table name (for conflict history logging)
938
+ * @param remoteRecords - Records fetched from the server
939
+ * @param table - Dexie table handle for local reads/writes
940
+ */
544
941
  async function applyRemoteWithConflictResolution(entityType, remoteRecords, table) {
545
942
  // Fetch pending entity IDs per-table to avoid stale data from earlier in the pull
546
943
  const pendingEntityIds = await getPendingEntityIds();
@@ -606,10 +1003,31 @@ async function pullRemoteChanges(minCursor) {
606
1003
  setLastSyncCursor(newestUpdate, userId);
607
1004
  return { bytes: pullBytes, records: pullRecords };
608
1005
  }
609
- // PUSH: Send pending operations to remote
610
- // Continues until queue is empty to catch items added during sync
611
- // Track push errors for this sync cycle
1006
+ /**
1007
+ * Errors collected during the current push phase.
1008
+ *
1009
+ * Reset at the start of each push cycle. Used by `runFullSync()` to determine
1010
+ * whether to show error status in the UI. Only "significant" errors (persistent
1011
+ * or final-retry transient) are added here.
1012
+ */
612
1013
  let pushErrors = [];
1014
+ /**
1015
+ * **PUSH PHASE**: Send all pending operations from the sync queue to Supabase.
1016
+ *
1017
+ * This is the "upload" half of the sync cycle. It:
1018
+ * 1. Pre-flight auth check (fail fast if session is expired)
1019
+ * 2. Coalesces redundant operations (e.g., 50 rapid edits → 1 update)
1020
+ * 3. Processes each queue item via `processSyncItem()`
1021
+ * 4. Removes successfully pushed items from the queue
1022
+ * 5. Increments retry count for failed items (exponential backoff)
1023
+ *
1024
+ * Loops until the queue is empty or `maxIterations` is reached. The loop
1025
+ * catches items that were added to the queue *during* the push (e.g., the
1026
+ * user made another edit while sync was running).
1027
+ *
1028
+ * @returns Push statistics (original count, coalesced count, actually pushed)
1029
+ * @throws {Error} If auth validation fails before push
1030
+ */
613
1031
  async function pushPendingOps() {
614
1032
  const maxIterations = 10; // Safety limit to prevent infinite loops
615
1033
  let iterations = 0;
@@ -707,7 +1125,17 @@ async function pushPendingOps() {
707
1125
  }
708
1126
  return { originalCount, coalescedCount, actualPushed };
709
1127
  }
710
- // Check if error is a duplicate key violation (item already exists)
1128
+ /**
1129
+ * Check if a Supabase/PostgreSQL error is a duplicate key violation.
1130
+ *
1131
+ * Handles multiple error formats: PostgreSQL error code `23505`,
1132
+ * PostgREST error code `PGRST409`, and fallback message matching.
1133
+ * Used during CREATE operations to gracefully handle race conditions
1134
+ * where the same entity was created on multiple devices simultaneously.
1135
+ *
1136
+ * @param error - The error object from Supabase
1137
+ * @returns `true` if this is a duplicate key / unique constraint violation
1138
+ */
711
1139
  function isDuplicateKeyError(error) {
712
1140
  // PostgreSQL error code for unique violation
713
1141
  if (error.code === '23505')
@@ -719,7 +1147,15 @@ function isDuplicateKeyError(error) {
719
1147
  const msg = (error.message || '').toLowerCase();
720
1148
  return msg.includes('duplicate') || msg.includes('unique') || msg.includes('already exists');
721
1149
  }
722
- // Check if error is a "not found" error (item doesn't exist)
1150
+ /**
1151
+ * Check if a Supabase/PostgreSQL error indicates the target row doesn't exist.
1152
+ *
1153
+ * Used during DELETE and UPDATE operations. If a row doesn't exist, the operation
1154
+ * is treated as a no-op (idempotent) rather than an error.
1155
+ *
1156
+ * @param error - The error object from Supabase
1157
+ * @returns `true` if this is a "not found" / "no rows" error
1158
+ */
723
1159
  function isNotFoundError(error) {
724
1160
  // PostgREST error code for no rows affected/found
725
1161
  if (error.code === 'PGRST116')
@@ -731,9 +1167,19 @@ function isNotFoundError(error) {
731
1167
  const msg = (error.message || '').toLowerCase();
732
1168
  return msg.includes('not found') || msg.includes('no rows');
733
1169
  }
734
- // Classify an error as transient (will likely succeed on retry) or persistent (won't improve)
735
- // Transient errors should not show UI errors until retries are exhausted
736
- // Persistent errors should show immediately since they require user action
1170
+ /**
1171
+ * Classify an error as transient (will likely succeed on retry) or persistent (won't improve).
1172
+ *
1173
+ * This classification drives the UI error strategy:
1174
+ * - **Transient errors** (network, timeout, rate limit, 5xx): Don't show error in UI
1175
+ * until retry attempts are exhausted. The user doesn't need to know about a brief
1176
+ * network hiccup that resolved on the next attempt.
1177
+ * - **Persistent errors** (auth, validation, RLS): Show error immediately since they
1178
+ * require user action (re-login, fix data, etc.) and won't resolve with retries.
1179
+ *
1180
+ * @param error - The error to classify
1181
+ * @returns `true` if the error is transient (likely to succeed on retry)
1182
+ */
737
1183
  function isTransientError(error) {
738
1184
  const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
739
1185
  const errObj = error;
@@ -769,9 +1215,24 @@ function isTransientError(error) {
769
1215
  // These require user action and won't fix themselves with retries
770
1216
  return false;
771
1217
  }
772
- // Process a single sync item (intent-based operation format)
773
- // CRITICAL: All operations use .select() to verify they succeeded
774
- // RLS can silently block operations - returning success but affecting 0 rows
1218
+ /**
1219
+ * Process a single sync queue item by sending it to Supabase.
1220
+ *
1221
+ * Handles four operation types: `create`, `set`, `increment`, and `delete`.
1222
+ * Each operation maps to a specific Supabase query pattern.
1223
+ *
1224
+ * **CRITICAL**: All operations use `.select()` to verify they actually affected a row.
1225
+ * Without this, Supabase's Row Level Security (RLS) can **silently block** operations —
1226
+ * returning a successful response with 0 rows affected. The `.select()` call lets us
1227
+ * detect this and throw an appropriate error instead of silently losing data.
1228
+ *
1229
+ * **Singleton table handling**: For tables marked as `isSingleton` (one row per user),
1230
+ * special reconciliation logic handles the case where the local UUID doesn't match
1231
+ * the server's UUID (e.g., created offline before the server row existed).
1232
+ *
1233
+ * @param item - The sync queue item to process
1234
+ * @throws {Error} If the operation fails or is blocked by RLS
1235
+ */
775
1236
  async function processSyncItem(item) {
776
1237
  const { table, entityId, operationType, field, value, timestamp } = item;
777
1238
  const deviceId = getDeviceId();
@@ -780,16 +1241,20 @@ async function processSyncItem(item) {
780
1241
  const dexieTable = getDexieTableName(table);
781
1242
  switch (operationType) {
782
1243
  case 'create': {
783
- // Create: insert the full payload with device_id
1244
+ // INSERT the full entity payload with the originating device_id.
1245
+ // Uses .select('id').maybeSingle() to verify the row was actually created
1246
+ // (RLS can silently block inserts, returning success with no data).
784
1247
  const payload = value;
785
1248
  const { data, error } = await supabase
786
1249
  .from(table)
787
1250
  .insert({ id: entityId, ...payload, device_id: deviceId })
788
1251
  .select('id')
789
1252
  .maybeSingle();
790
- // Ignore duplicate key errors (item already synced from another device)
1253
+ // Duplicate key = another device already created this entity.
1254
+ // For regular tables, this is a no-op (the entity exists, which is what we wanted).
1255
+ // For singleton tables, we need to reconcile: the local UUID was generated offline
1256
+ // and doesn't match the server's UUID, so we swap the local ID to match.
791
1257
  if (error && isDuplicateKeyError(error)) {
792
- // For singleton tables, reconcile local ID with server
793
1258
  if (isSingletonTable(table) && payload.user_id) {
794
1259
  const { data: existing } = await supabase
795
1260
  .from(table)
@@ -825,7 +1290,9 @@ async function processSyncItem(item) {
825
1290
  break;
826
1291
  }
827
1292
  case 'delete': {
828
- // Delete: soft delete with tombstone and device_id
1293
+ // SOFT DELETE: Set `deleted: true` rather than physically removing the row.
1294
+ // Other devices discover the deletion during their next pull and remove their local copy.
1295
+ // The tombstone is eventually hard-deleted by `cleanupServerTombstones()` after the TTL.
829
1296
  const { data, error } = await supabase
830
1297
  .from(table)
831
1298
  .update({ deleted: true, updated_at: timestamp, device_id: deviceId })
@@ -844,9 +1311,11 @@ async function processSyncItem(item) {
844
1311
  break;
845
1312
  }
846
1313
  case 'increment': {
847
- // Increment: we need to read current value, add delta, and update
848
- // This is done atomically by reading from local DB (which has the current state)
849
- // The value we push is already the final computed value from local
1314
+ // INCREMENT: Push the final computed value (not the delta) to the server.
1315
+ // The local DB already has the correct value after the increment was applied locally.
1316
+ // We read it from IndexedDB and send it as a SET to the server. This avoids the
1317
+ // need for a server-side atomic increment (which Supabase doesn't natively support
1318
+ // without an RPC function) and ensures eventual consistency.
850
1319
  if (!field) {
851
1320
  throw new Error('Increment operation requires a field');
852
1321
  }
@@ -883,7 +1352,9 @@ async function processSyncItem(item) {
883
1352
  break;
884
1353
  }
885
1354
  case 'set': {
886
- // Set: update the field(s) with the new value(s) and device_id
1355
+ // SET: Update one or more fields on the server. Supports both single-field
1356
+ // updates (field + value) and multi-field updates (value is a payload object).
1357
+ // Includes singleton table reconciliation for ID mismatches (same as 'create').
887
1358
  let updatePayload;
888
1359
  if (field) {
889
1360
  // Single field set
@@ -951,7 +1422,16 @@ async function processSyncItem(item) {
951
1422
  throw new Error(`Unknown operation type: ${operationType}`);
952
1423
  }
953
1424
  }
954
- // Extract raw error message from various error formats (Supabase, Error, etc.)
1425
+ /**
1426
+ * Extract a raw error message from various error object formats.
1427
+ *
1428
+ * Handles: standard `Error` objects, Supabase/PostgreSQL error objects
1429
+ * (with `message`, `details`, `hint`, `code` properties), wrapper objects
1430
+ * with `error` or `description` properties, and primitive values.
1431
+ *
1432
+ * @param error - Any error value (Error, Supabase error object, string, etc.)
1433
+ * @returns A human-readable error message string
1434
+ */
955
1435
  function extractErrorMessage(error) {
956
1436
  // Standard Error object
957
1437
  if (error instanceof Error) {
@@ -991,7 +1471,15 @@ function extractErrorMessage(error) {
991
1471
  // Primitive types
992
1472
  return String(error);
993
1473
  }
994
- // Parse error into user-friendly message
1474
+ /**
1475
+ * Convert a technical error into a user-friendly message for the UI.
1476
+ *
1477
+ * Maps common error patterns (network, auth, rate limiting, server errors)
1478
+ * to clear, actionable messages. Truncates unknown errors to 100 characters.
1479
+ *
1480
+ * @param error - The error to parse
1481
+ * @returns A user-friendly error message suitable for display in the UI
1482
+ */
995
1483
  function parseErrorMessage(error) {
996
1484
  if (error instanceof Error) {
997
1485
  const msg = error.message.toLowerCase();
@@ -1022,8 +1510,26 @@ function parseErrorMessage(error) {
1022
1510
  }
1023
1511
  return 'An unexpected error occurred';
1024
1512
  }
1025
- // Full sync: push first (so our changes are persisted), then pull
1026
- // quiet: if true, don't update UI status at all (for background periodic syncs)
1513
+ /**
1514
+ * Execute a full sync cycle: push local changes, then pull remote changes.
1515
+ *
1516
+ * This is the main entry point for sync. It orchestrates the complete cycle:
1517
+ * 1. **Pre-flight checks**: Online status, auth validation, session validity
1518
+ * 2. **Acquire lock**: Prevent concurrent syncs
1519
+ * 3. **Push phase**: Send pending local changes to Supabase
1520
+ * 4. **Pull phase**: Fetch remote changes since last cursor (with retry)
1521
+ * 5. **Post-sync**: Update UI status, notify stores, log egress stats
1522
+ *
1523
+ * The `quiet` flag controls whether the UI sync indicator is shown. Background
1524
+ * periodic syncs use `quiet=true` to avoid distracting the user. User-triggered
1525
+ * syncs (after local writes) use `quiet=false` to show progress.
1526
+ *
1527
+ * The `skipPull` flag enables push-only mode when realtime subscriptions are
1528
+ * healthy — since remote changes arrive via WebSocket, polling is redundant.
1529
+ *
1530
+ * @param quiet - If `true`, don't update the UI status indicator
1531
+ * @param skipPull - If `true`, skip the pull phase (push-only mode)
1532
+ */
1027
1533
  export async function runFullSync(quiet = false, skipPull = false) {
1028
1534
  if (typeof navigator === 'undefined' || !navigator.onLine) {
1029
1535
  if (!quiet) {
@@ -1309,7 +1815,28 @@ async function fullReconciliation() {
1309
1815
  }
1310
1816
  return totalRemoved;
1311
1817
  }
1312
- // Initial hydration: if local DB is empty, pull everything from remote
1818
+ /**
1819
+ * Initial hydration: populate an empty local DB from the remote server.
1820
+ *
1821
+ * This runs once on first load (or after a cache clear). If the local DB already
1822
+ * has data, it falls through to a normal sync cycle instead.
1823
+ *
1824
+ * **Flow for empty local DB**:
1825
+ * 1. Acquire sync lock
1826
+ * 2. Pull ALL non-deleted records from every configured table
1827
+ * 3. Store in local DB via bulk put (single transaction)
1828
+ * 4. Set sync cursor to the max `updated_at` seen (not "now") to avoid missing
1829
+ * changes that happened during the hydration query
1830
+ * 5. Notify stores to render the freshly loaded data
1831
+ *
1832
+ * **Flow for populated local DB**:
1833
+ * 1. Run full reconciliation (if cursor is stale past tombstone TTL)
1834
+ * 2. Reconcile orphaned local changes (items modified after cursor with empty queue)
1835
+ * 3. Run normal full sync
1836
+ *
1837
+ * @see {@link fullReconciliation} - Handles the "been offline too long" case
1838
+ * @see {@link reconcileLocalWithRemote} - Re-queues orphaned local changes
1839
+ */
1313
1840
  async function hydrateFromRemote() {
1314
1841
  if (typeof navigator === 'undefined' || !navigator.onLine)
1315
1842
  return;
@@ -1417,14 +1944,35 @@ async function hydrateFromRemote() {
1417
1944
  releaseSyncLock();
1418
1945
  }
1419
1946
  }
1420
- // ============================================================
1947
+ // =============================================================================
1421
1948
  // TOMBSTONE CLEANUP
1422
- // ============================================================
1423
- // Clean up old tombstones (deleted records) from local DB AND Supabase
1424
- // This prevents indefinite accumulation of soft-deleted records
1425
- const CLEANUP_INTERVAL_MS = 86400000; // 24 hours - only run server cleanup once per day
1949
+ // =============================================================================
1950
+ //
1951
+ // The engine uses "soft deletes" deleted records are marked `deleted: true`
1952
+ // rather than being physically removed. This allows other devices to discover
1953
+ // the deletion during their next sync (they see the tombstone and remove their
1954
+ // local copy).
1955
+ //
1956
+ // However, tombstones accumulate over time. After `tombstoneMaxAgeDays` (default 7),
1957
+ // tombstones are "hard deleted" (physically removed) from both local and server.
1958
+ //
1959
+ // **Important**: If a device has been offline longer than the tombstone TTL, it may
1960
+ // miss tombstone deletions. The `fullReconciliation()` function handles this by
1961
+ // comparing local IDs against server IDs and removing orphans.
1962
+ // =============================================================================
1963
+ /** Minimum interval between server-side tombstone cleanups (24 hours) */
1964
+ const CLEANUP_INTERVAL_MS = 86400000;
1965
+ /** Timestamp of the last server-side cleanup (prevents running more than once/day) */
1426
1966
  let lastServerCleanup = 0;
1427
- // Clean up old tombstones from LOCAL IndexedDB
1967
+ /**
1968
+ * Remove expired tombstones from the local IndexedDB.
1969
+ *
1970
+ * Scans all entity tables for records with `deleted === true` and
1971
+ * `updated_at` older than the tombstone cutoff date. Runs inside
1972
+ * a Dexie transaction for atomicity.
1973
+ *
1974
+ * @returns Number of tombstones removed
1975
+ */
1428
1976
  async function cleanupLocalTombstones() {
1429
1977
  const tombstoneMaxAgeDays = getTombstoneMaxAgeDays();
1430
1978
  const cutoffDate = new Date();
@@ -1456,7 +2004,16 @@ async function cleanupLocalTombstones() {
1456
2004
  }
1457
2005
  return totalDeleted;
1458
2006
  }
1459
- // Clean up old tombstones from SUPABASE (runs once per day max)
2007
+ /**
2008
+ * Remove expired tombstones from Supabase (server-side cleanup).
2009
+ *
2010
+ * Rate-limited to once per 24 hours to avoid unnecessary API calls.
2011
+ * Uses actual DELETE (not soft delete) to physically remove the rows.
2012
+ * Can be forced via the `force` parameter (used by debug utilities).
2013
+ *
2014
+ * @param force - If `true`, bypass the 24-hour rate limit
2015
+ * @returns Number of tombstones removed from the server
2016
+ */
1460
2017
  async function cleanupServerTombstones(force = false) {
1461
2018
  // Only run once per day to avoid unnecessary requests (unless forced)
1462
2019
  const now = Date.now();
@@ -1498,13 +2055,34 @@ async function cleanupServerTombstones(force = false) {
1498
2055
  }
1499
2056
  return totalDeleted;
1500
2057
  }
1501
- // Combined cleanup function
2058
+ /**
2059
+ * Run both local and server tombstone cleanup.
2060
+ *
2061
+ * @returns Object with counts of tombstones removed locally and from the server
2062
+ */
1502
2063
  async function cleanupOldTombstones() {
1503
2064
  const local = await cleanupLocalTombstones();
1504
2065
  const server = await cleanupServerTombstones();
1505
2066
  return { local, server };
1506
2067
  }
1507
- // Debug function to check tombstone status and manually trigger cleanup
2068
+ /**
2069
+ * Debug utility: inspect tombstone counts and optionally trigger cleanup.
2070
+ *
2071
+ * Available in browser console via `window.__<prefix>Tombstones()`.
2072
+ * Displays per-table tombstone counts, ages, and eligibility for cleanup.
2073
+ *
2074
+ * @param options - Control cleanup behavior
2075
+ * @param options.cleanup - If `true`, run tombstone cleanup after inspection
2076
+ * @param options.force - If `true`, bypass the 24-hour server cleanup rate limit
2077
+ *
2078
+ * @example
2079
+ * ```js
2080
+ * // In browser console:
2081
+ * __engineTombstones() // Inspect only
2082
+ * __engineTombstones({ cleanup: true }) // Inspect + cleanup
2083
+ * __engineTombstones({ cleanup: true, force: true }) // Force server cleanup too
2084
+ * ```
2085
+ */
1508
2086
  async function debugTombstones(options) {
1509
2087
  const tombstoneMaxAgeDays = getTombstoneMaxAgeDays();
1510
2088
  const cutoffDate = new Date();
@@ -1588,13 +2166,50 @@ async function debugTombstones(options) {
1588
2166
  }
1589
2167
  debugLog('========================');
1590
2168
  }
1591
- // ============================================================
1592
- // LIFECYCLE
1593
- // ============================================================
1594
- // Store cleanup functions for realtime subscriptions
2169
+ // =============================================================================
2170
+ // LIFECYCLE — Start / Stop
2171
+ // =============================================================================
2172
+ //
2173
+ // The sync engine has a clear lifecycle:
2174
+ //
2175
+ // 1. **startSyncEngine()**: Called once after `initEngine()` configures the engine.
2176
+ // Sets up all event listeners, timers, realtime subscriptions, and runs the
2177
+ // initial hydration/sync. Idempotent — safe to call multiple times (cleans up
2178
+ // existing listeners first).
2179
+ //
2180
+ // 2. **stopSyncEngine()**: Called during app teardown or before reconfiguring.
2181
+ // Removes all event listeners, clears timers, unsubscribes from realtime,
2182
+ // and releases the sync lock. After this, no sync activity occurs.
2183
+ //
2184
+ // 3. **clearLocalCache()**: Called during logout to wipe all local data.
2185
+ // Clears entity tables, sync queue, conflict history, and sync cursors.
2186
+ // =============================================================================
2187
+ /** Unsubscribe function for realtime data update events */
1595
2188
  let realtimeDataUnsubscribe = null;
2189
+ /** Unsubscribe function for realtime connection state change events */
1596
2190
  let realtimeConnectionUnsubscribe = null;
2191
+ /** Supabase auth state change subscription (has nested unsubscribe structure) */
1597
2192
  let authStateUnsubscribe = null;
2193
+ /**
2194
+ * Start the sync engine: initialize all listeners, timers, and subscriptions.
2195
+ *
2196
+ * This is the main "boot" function for the sync engine. It:
2197
+ * 1. Ensures the Dexie DB is open and upgraded
2198
+ * 2. Cleans up any existing listeners (idempotent restart support)
2199
+ * 3. Sets up debug window utilities
2200
+ * 4. Subscribes to Supabase auth state changes (handles sign-out/token-refresh)
2201
+ * 5. Registers online/offline handlers with auth validation
2202
+ * 6. Registers visibility change handler for smart tab-return syncing
2203
+ * 7. Starts realtime WebSocket subscriptions
2204
+ * 8. Starts periodic background sync interval
2205
+ * 9. Validates Supabase schema (one-time)
2206
+ * 10. Runs initial hydration (if local DB is empty) or full sync
2207
+ * 11. Runs initial cleanup (tombstones, conflicts, failed items)
2208
+ * 12. Starts the watchdog timer
2209
+ *
2210
+ * **Must be called after `initEngine()`** — requires configuration to be set.
2211
+ * Safe to call multiple times (previous listeners are cleaned up first).
2212
+ */
1598
2213
  export async function startSyncEngine() {
1599
2214
  if (typeof window === 'undefined')
1600
2215
  return;
@@ -1643,7 +2258,10 @@ export async function startSyncEngine() {
1643
2258
  initDebugWindowUtilities();
1644
2259
  // Initialize network status monitoring (idempotent)
1645
2260
  isOnline.init();
1646
- // Subscribe to auth state changes - critical for iOS PWA where sessions can expire
2261
+ // Subscribe to auth state changes.
2262
+ // CRITICAL for iOS PWA: Safari aggressively kills background tabs, which can expire
2263
+ // the Supabase session. When the user returns, TOKEN_REFRESHED fires and we need
2264
+ // to restart realtime + trigger a sync to catch up on missed changes.
1647
2265
  authStateUnsubscribe = supabase.auth.onAuthStateChange(async (event, session) => {
1648
2266
  debugLog(`[SYNC] Auth state change: ${event}`);
1649
2267
  if (event === 'SIGNED_OUT') {
@@ -1673,7 +2291,9 @@ export async function startSyncEngine() {
1673
2291
  config.onAuthStateChange(event, session);
1674
2292
  }
1675
2293
  });
1676
- // Register disconnect handler: create offline session from cached credentials
2294
+ // Register disconnect handler: proactively create an offline session from cached
2295
+ // credentials so the user can continue working without interruption. The offline
2296
+ // session is validated on reconnect before any sync operations are allowed.
1677
2297
  isOnline.onDisconnect(async () => {
1678
2298
  debugLog('[Engine] Gone offline - creating offline session if credentials cached');
1679
2299
  try {
@@ -1754,10 +2374,13 @@ export async function startSyncEngine() {
1754
2374
  if (!navigator.onLine) {
1755
2375
  markOffline();
1756
2376
  }
1757
- // Handle online event - restart realtime subscriptions
1758
- // NOTE: Sync is triggered by isOnline.onReconnect() which runs AFTER auth validation.
1759
- // This handler only restarts realtime calling runFullSync() here would race
1760
- // with the reconnect handler's auth check and could sync before validation completes.
2377
+ // Handle browser 'online' event restart realtime WebSocket subscriptions.
2378
+ //
2379
+ // IMPORTANT: We do NOT trigger a sync here. Sync is handled by the
2380
+ // `isOnline.onReconnect()` callback (registered above), which runs AFTER auth
2381
+ // has been validated. If we called `runFullSync()` here, it would race with
2382
+ // the reconnect handler's auth check and could attempt to sync with an expired
2383
+ // session, causing silent RLS failures.
1761
2384
  handleOnlineRef = async () => {
1762
2385
  const userId = await getCurrentUserId();
1763
2386
  if (userId) {
@@ -1832,11 +2455,15 @@ export async function startSyncEngine() {
1832
2455
  // Start realtime subscriptions
1833
2456
  startRealtimeSubscriptions(userId);
1834
2457
  }
1835
- // Start periodic sync (quiet mode - don't show indicator unless needed)
1836
- // Reduced frequency when realtime is healthy
2458
+ // Start the periodic background sync timer.
2459
+ // This is the polling fallback for when realtime subscriptions are down.
2460
+ // When realtime IS healthy, periodic sync is skipped entirely — realtime
2461
+ // delivers changes in near-real-time, making polling redundant.
2462
+ // Also runs housekeeping tasks (tombstone cleanup, conflict history, etc.)
1837
2463
  syncInterval = setInterval(async () => {
1838
- // Only run periodic sync if tab is visible and online
1839
- // Skip if realtime is healthy (reduces egress significantly)
2464
+ // Only poll if: tab is visible AND online AND realtime is NOT healthy.
2465
+ // This egress optimization is critical without it, every open tab polls
2466
+ // the entire database every 15 minutes regardless of realtime status.
1840
2467
  if (navigator.onLine && isTabVisible && !isRealtimeHealthy()) {
1841
2468
  runFullSync(true).catch((e) => debugError('[SYNC] Periodic sync failed:', e)); // Quiet background sync
1842
2469
  }
@@ -1942,6 +2569,17 @@ export async function startSyncEngine() {
1942
2569
  debugLog(`[SYNC] Debug utilities available at window.__${prefix}Sync`);
1943
2570
  }
1944
2571
  }
2572
+ /**
2573
+ * Stop the sync engine: tear down all listeners, timers, and subscriptions.
2574
+ *
2575
+ * After calling this, no sync activity will occur. All event listeners are
2576
+ * removed to prevent memory leaks. The sync lock is released in case a sync
2577
+ * was in progress. Hydration and schema validation flags are reset so the
2578
+ * engine can be cleanly restarted.
2579
+ *
2580
+ * Call this during app teardown, before reconfiguring the engine, or when
2581
+ * the user navigates away from pages that need sync.
2582
+ */
1945
2583
  export async function stopSyncEngine() {
1946
2584
  if (typeof window === 'undefined')
1947
2585
  return;
@@ -1994,7 +2632,16 @@ export async function stopSyncEngine() {
1994
2632
  _hasHydrated = false;
1995
2633
  _schemaValidated = false;
1996
2634
  }
1997
- // Clear local cache (for logout)
2635
+ /**
2636
+ * Clear all local data from IndexedDB (used during logout).
2637
+ *
2638
+ * Wipes all entity tables, the sync queue, and conflict history in a single
2639
+ * transaction. Also removes the user's sync cursor from localStorage and
2640
+ * resets the hydration flag so the next login triggers a fresh hydration.
2641
+ *
2642
+ * **IMPORTANT**: Call this BEFORE calling `stopSyncEngine()` to ensure the
2643
+ * database is still open when clearing tables.
2644
+ */
1998
2645
  export async function clearLocalCache() {
1999
2646
  const config = getEngineConfig();
2000
2647
  const db = config.db;