@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.
- package/README.md +4 -1
- package/dist/actions/remoteChange.d.ts +143 -18
- package/dist/actions/remoteChange.d.ts.map +1 -1
- package/dist/actions/remoteChange.js +182 -58
- package/dist/actions/remoteChange.js.map +1 -1
- package/dist/actions/truncateTooltip.d.ts +26 -12
- package/dist/actions/truncateTooltip.d.ts.map +1 -1
- package/dist/actions/truncateTooltip.js +89 -34
- package/dist/actions/truncateTooltip.js.map +1 -1
- package/dist/auth/admin.d.ts +40 -3
- package/dist/auth/admin.d.ts.map +1 -1
- package/dist/auth/admin.js +45 -5
- package/dist/auth/admin.js.map +1 -1
- package/dist/auth/crypto.d.ts +55 -5
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +58 -5
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/deviceVerification.d.ts +236 -20
- package/dist/auth/deviceVerification.d.ts.map +1 -1
- package/dist/auth/deviceVerification.js +293 -40
- package/dist/auth/deviceVerification.js.map +1 -1
- package/dist/auth/displayUtils.d.ts +98 -0
- package/dist/auth/displayUtils.d.ts.map +1 -0
- package/dist/auth/displayUtils.js +133 -0
- package/dist/auth/displayUtils.js.map +1 -0
- package/dist/auth/loginGuard.d.ts +108 -14
- package/dist/auth/loginGuard.d.ts.map +1 -1
- package/dist/auth/loginGuard.js +153 -31
- package/dist/auth/loginGuard.js.map +1 -1
- package/dist/auth/offlineCredentials.d.ts +132 -15
- package/dist/auth/offlineCredentials.d.ts.map +1 -1
- package/dist/auth/offlineCredentials.js +167 -23
- package/dist/auth/offlineCredentials.js.map +1 -1
- package/dist/auth/offlineLogin.d.ts +96 -10
- package/dist/auth/offlineLogin.d.ts.map +1 -1
- package/dist/auth/offlineLogin.js +82 -15
- package/dist/auth/offlineLogin.js.map +1 -1
- package/dist/auth/offlineSession.d.ts +83 -9
- package/dist/auth/offlineSession.d.ts.map +1 -1
- package/dist/auth/offlineSession.js +104 -13
- package/dist/auth/offlineSession.js.map +1 -1
- package/dist/auth/resolveAuthState.d.ts +70 -8
- package/dist/auth/resolveAuthState.d.ts.map +1 -1
- package/dist/auth/resolveAuthState.js +142 -46
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts +390 -37
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +500 -99
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/bin/install-pwa.d.ts +18 -2
- package/dist/bin/install-pwa.d.ts.map +1 -1
- package/dist/bin/install-pwa.js +801 -25
- package/dist/bin/install-pwa.js.map +1 -1
- package/dist/config.d.ts +132 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +87 -9
- package/dist/config.js.map +1 -1
- package/dist/conflicts.d.ts +246 -23
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +495 -46
- package/dist/conflicts.js.map +1 -1
- package/dist/data.d.ts +338 -18
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +385 -34
- package/dist/data.js.map +1 -1
- package/dist/database.d.ts +72 -14
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +120 -29
- package/dist/database.js.map +1 -1
- package/dist/debug.d.ts +77 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/debug.js +88 -1
- package/dist/debug.js.map +1 -1
- package/dist/deviceId.d.ts +38 -7
- package/dist/deviceId.d.ts.map +1 -1
- package/dist/deviceId.js +68 -10
- package/dist/deviceId.js.map +1 -1
- package/dist/engine.d.ts +175 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +756 -109
- package/dist/engine.js.map +1 -1
- package/dist/entries/actions.d.ts +13 -0
- package/dist/entries/actions.d.ts.map +1 -1
- package/dist/entries/actions.js +26 -1
- package/dist/entries/actions.js.map +1 -1
- package/dist/entries/auth.d.ts +16 -0
- package/dist/entries/auth.d.ts.map +1 -1
- package/dist/entries/auth.js +73 -1
- package/dist/entries/auth.js.map +1 -1
- package/dist/entries/config.d.ts +12 -0
- package/dist/entries/config.d.ts.map +1 -1
- package/dist/entries/config.js +18 -1
- package/dist/entries/config.js.map +1 -1
- package/dist/entries/kit.d.ts +11 -0
- package/dist/entries/kit.d.ts.map +1 -1
- package/dist/entries/kit.js +52 -2
- package/dist/entries/kit.js.map +1 -1
- package/dist/entries/stores.d.ts +11 -0
- package/dist/entries/stores.d.ts.map +1 -1
- package/dist/entries/stores.js +43 -2
- package/dist/entries/stores.js.map +1 -1
- package/dist/entries/types.d.ts +10 -0
- package/dist/entries/types.d.ts.map +1 -1
- package/dist/entries/types.js +10 -0
- package/dist/entries/types.js.map +1 -1
- package/dist/entries/utils.d.ts +6 -0
- package/dist/entries/utils.d.ts.map +1 -1
- package/dist/entries/utils.js +22 -1
- package/dist/entries/utils.js.map +1 -1
- package/dist/entries/vite.d.ts +17 -0
- package/dist/entries/vite.d.ts.map +1 -1
- package/dist/entries/vite.js +24 -1
- package/dist/entries/vite.js.map +1 -1
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +175 -20
- package/dist/index.js.map +1 -1
- package/dist/kit/auth.d.ts +60 -5
- package/dist/kit/auth.d.ts.map +1 -1
- package/dist/kit/auth.js +45 -4
- package/dist/kit/auth.js.map +1 -1
- package/dist/kit/confirm.d.ts +93 -12
- package/dist/kit/confirm.d.ts.map +1 -1
- package/dist/kit/confirm.js +103 -16
- package/dist/kit/confirm.js.map +1 -1
- package/dist/kit/loads.d.ts +150 -23
- package/dist/kit/loads.d.ts.map +1 -1
- package/dist/kit/loads.js +140 -24
- package/dist/kit/loads.js.map +1 -1
- package/dist/kit/server.d.ts +142 -10
- package/dist/kit/server.d.ts.map +1 -1
- package/dist/kit/server.js +158 -15
- package/dist/kit/server.js.map +1 -1
- package/dist/kit/sw.d.ts +152 -23
- package/dist/kit/sw.d.ts.map +1 -1
- package/dist/kit/sw.js +182 -26
- package/dist/kit/sw.js.map +1 -1
- package/dist/queue.d.ts +274 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +556 -38
- package/dist/queue.js.map +1 -1
- package/dist/realtime.d.ts +241 -27
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +633 -109
- package/dist/realtime.js.map +1 -1
- package/dist/runtime/runtimeConfig.d.ts +91 -8
- package/dist/runtime/runtimeConfig.d.ts.map +1 -1
- package/dist/runtime/runtimeConfig.js +146 -19
- package/dist/runtime/runtimeConfig.js.map +1 -1
- package/dist/stores/authState.d.ts +150 -11
- package/dist/stores/authState.d.ts.map +1 -1
- package/dist/stores/authState.js +169 -17
- package/dist/stores/authState.js.map +1 -1
- package/dist/stores/network.d.ts +39 -0
- package/dist/stores/network.d.ts.map +1 -1
- package/dist/stores/network.js +169 -16
- package/dist/stores/network.js.map +1 -1
- package/dist/stores/remoteChanges.d.ts +327 -52
- package/dist/stores/remoteChanges.d.ts.map +1 -1
- package/dist/stores/remoteChanges.js +337 -75
- package/dist/stores/remoteChanges.js.map +1 -1
- package/dist/stores/sync.d.ts +130 -0
- package/dist/stores/sync.d.ts.map +1 -1
- package/dist/stores/sync.js +167 -7
- package/dist/stores/sync.js.map +1 -1
- package/dist/supabase/auth.d.ts +325 -18
- package/dist/supabase/auth.d.ts.map +1 -1
- package/dist/supabase/auth.js +374 -26
- package/dist/supabase/auth.js.map +1 -1
- package/dist/supabase/client.d.ts +79 -6
- package/dist/supabase/client.d.ts.map +1 -1
- package/dist/supabase/client.js +158 -15
- package/dist/supabase/client.js.map +1 -1
- package/dist/supabase/validate.d.ts +101 -7
- package/dist/supabase/validate.d.ts.map +1 -1
- package/dist/supabase/validate.js +117 -8
- package/dist/supabase/validate.js.map +1 -1
- package/dist/sw/build/vite-plugin.d.ts +55 -10
- package/dist/sw/build/vite-plugin.d.ts.map +1 -1
- package/dist/sw/build/vite-plugin.js +77 -18
- package/dist/sw/build/vite-plugin.js.map +1 -1
- package/dist/sw/sw.js +99 -44
- package/dist/types.d.ts +150 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -10
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +55 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +83 -22
- package/dist/utils.js.map +1 -1
- 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
|
-
//
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// CONFIG ACCESSORS
|
|
73
|
+
// =============================================================================
|
|
16
74
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
72
|
-
let
|
|
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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
266
|
-
//
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
1758
|
-
//
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
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
|
|
1836
|
-
//
|
|
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
|
|
1839
|
-
//
|
|
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
|
-
|
|
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;
|