├── .gitignore ├── vitest.config.ts ├── vite.config.ts ├── package.json ├── src ├── utils │ └── indexedDB.ts └── vue-offline-sync.ts ├── dist ├── vue-offline-sync.umd 2.js ├── vue-offline-sync.umd.js ├── vue-offline-sync.es 2.js └── vue-offline-sync.es.js ├── README.md └── tests └── vue-offline-sync.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | globals: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | build: { 7 | lib: { 8 | entry: 'src/vue-offline-sync.ts', 9 | name: 'VueOfflineSync', 10 | fileName: (format) => `vue-offline-sync.${format}.js`, 11 | }, 12 | rollupOptions: { 13 | external: ['vue'], 14 | output: { 15 | globals: { 16 | vue: 'Vue', 17 | }, 18 | }, 19 | }, 20 | }, 21 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-offline-sync", 3 | "version": "1.3.3", 4 | "description": "A Vue 3 component for offline-first syncing. Save data while offline and sync it automatically when back online. Uses IndexedDB for storage.", 5 | "main": "dist/vue-offline-sync.umd.js", 6 | "module": "dist/vue-offline-sync.es.js", 7 | "exports": { 8 | "import": "./dist/vue-offline-sync.es.js", 9 | "require": "./dist/vue-offline-sync.umd.js" 10 | }, 11 | "files": [ 12 | "dist/", 13 | "README.md" 14 | ], 15 | "scripts": { 16 | "build": "vite build", 17 | "test": "vitest", 18 | "prepublishOnly": "npm run build" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/jrran90/vue-offline-sync" 23 | }, 24 | "keywords": [ 25 | "vue", 26 | "vue3", 27 | "offline", 28 | "sync", 29 | "indexedDB", 30 | "bulk" 31 | ], 32 | "author": "jrran90", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/jrran90/vue-offline-sync/issues" 36 | }, 37 | "homepage": "https://github.com/jrran90/vue-offline-sync#readme", 38 | "dependencies": { 39 | "vue": "^3.5.13" 40 | }, 41 | "devDependencies": { 42 | "@vitejs/plugin-vue": "^5.2.1", 43 | "@vue/test-utils": "^2.4.6", 44 | "typescript": "^5.7.3", 45 | "vitest": "^3.0.4", 46 | "jsdom": "^26.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/indexedDB.ts: -------------------------------------------------------------------------------- 1 | interface SyncData { 2 | [key: string]: any; 3 | } 4 | 5 | const DB_NAME: string = 'vueOfflineSync'; 6 | const STORE_NAME: string = 'syncData'; 7 | const DB_VERSION: number = 1; 8 | const keyPath: string = 'syncId'; 9 | 10 | export function openDB(): Promise { 11 | return new Promise((resolve, reject) => { 12 | const request = indexedDB.open(DB_NAME, DB_VERSION); 13 | 14 | request.onerror = () => reject(new Error('Failed to open IndexedDB.')); 15 | request.onsuccess = () => resolve(request.result); 16 | 17 | request.onupgradeneeded = (event) => { 18 | const db = (event.target as IDBRequest).result; 19 | 20 | // Re-create 21 | if (db.objectStoreNames.contains(STORE_NAME)) { 22 | db.deleteObjectStore(STORE_NAME); 23 | } 24 | db.createObjectStore(STORE_NAME, {keyPath, autoIncrement: true}); 25 | }; 26 | }); 27 | } 28 | 29 | export async function saveData(data: SyncData): Promise { 30 | const db = await openDB(); 31 | return new Promise((resolve, reject) => { 32 | const transaction = db.transaction(STORE_NAME, 'readwrite'); 33 | const store = transaction.objectStore(STORE_NAME); 34 | 35 | if (!(keyPath in data)) { 36 | data[keyPath] = Date.now(); 37 | } 38 | 39 | const request = store.put(data); 40 | request.onsuccess = () => resolve(data); 41 | request.onerror = () => reject(new Error('Failed to save data.')); 42 | }); 43 | } 44 | 45 | export async function getData(): Promise { 46 | const db = await openDB(); 47 | return new Promise((resolve, reject) => { 48 | const transaction = db.transaction(STORE_NAME, 'readonly'); 49 | const store = transaction.objectStore(STORE_NAME); 50 | const request = store.getAll(); 51 | 52 | request.onsuccess = () => resolve(request.result); 53 | request.onerror = () => reject(new Error('Failed to retrieve data.')); 54 | }); 55 | } 56 | 57 | export async function clearData(): Promise { 58 | const db = await openDB(); 59 | return new Promise((resolve, reject) => { 60 | const transaction = db.transaction(STORE_NAME, 'readwrite'); 61 | const store = transaction.objectStore(STORE_NAME); 62 | const request = store.clear(); 63 | 64 | request.onsuccess = () => resolve(); 65 | request.onerror = () => reject(new Error('Failed to clear data.')); 66 | }); 67 | } 68 | 69 | export async function removeData(id: number | string): Promise { 70 | const db = await openDB(); 71 | return new Promise((resolve, reject) => { 72 | const transaction = db.transaction(STORE_NAME, 'readwrite'); 73 | const store = transaction.objectStore(STORE_NAME); 74 | const request = store.delete(id); 75 | 76 | request.onsuccess = () => resolve(); 77 | request.onerror = () => reject(new Error(`Failed to remove entry with id: ${id}`)); 78 | }); 79 | } -------------------------------------------------------------------------------- /dist/vue-offline-sync.umd 2.js: -------------------------------------------------------------------------------- 1 | (function(y,w){typeof exports=="object"&&typeof module<"u"?w(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],w):(y=typeof globalThis<"u"?globalThis:y||self,w(y.VueOfflineSync={},y.Vue))})(this,function(y,w){"use strict";const p="vueOfflineSync",i="syncData",S="syncId";function h(){return new Promise((e,s)=>{const o=indexedDB.open(p,1);o.onerror=()=>s(new Error("Failed to open IndexedDB.")),o.onsuccess=()=>e(o.result),o.onupgradeneeded=t=>{const r=t.target.result;r.objectStoreNames.contains(i)&&r.deleteObjectStore(i),r.createObjectStore(i,{keyPath:S,autoIncrement:!0})}})}async function D(e){const s=await h();return new Promise((o,t)=>{const a=s.transaction(i,"readwrite").objectStore(i);S in e||(e[S]=Date.now());const f=a.put(e);f.onsuccess=()=>o(e),f.onerror=()=>t(new Error("Failed to save data."))})}async function v(){const e=await h();return new Promise((s,o)=>{const a=e.transaction(i,"readonly").objectStore(i).getAll();a.onsuccess=()=>s(a.result),a.onerror=()=>o(new Error("Failed to retrieve data."))})}async function O(){const e=await h();return new Promise((s,o)=>{const a=e.transaction(i,"readwrite").objectStore(i).clear();a.onsuccess=()=>s(),a.onerror=()=>o(new Error("Failed to clear data."))})}async function b(e){const s=await h();return new Promise((o,t)=>{const f=s.transaction(i,"readwrite").objectStore(i).delete(e);f.onsuccess=()=>o(),f.onerror=()=>t(new Error(`Failed to remove entry with id: ${e}`))})}function P(e){const s="syncId",o=new BroadcastChannel("vue-offline-sync"),t=w.reactive({isOnline:navigator.onLine,offlineData:[],isSyncInProgress:!1}),r=async()=>{t.offlineData=await v()},a=async(n,c=1)=>{var l;try{return await n()}catch{return c>=(((l=e.retryPolicy)==null?void 0:l.maxAttempts)||1)?(console.error("[vue-offline-sync] Max retry attempts reached."),null):(console.warn(`[vue-offline-sync] Retrying... (${c})`),await new Promise(d=>{var m;return setTimeout(d,((m=e.retryPolicy)==null?void 0:m.delayMs)||1e3)}),a(n,c+1))}},f=async()=>{if(!(!t.isOnline||t.offlineData.length===0)){try{e.bulkSync?await j():await T()}catch(n){console.error("[vue-offline-sync] Network error during sync:",n)}o.postMessage({type:"synced"}),await r()}},E=async n=>{if(t.isOnline){t.isSyncInProgress=!0;try{const{[s]:c,...l}=n,d=await a(async()=>await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(l),headers:{"Content-Type":"application/json",...e.headers}}));(!d||!d.ok)&&(console.error("[vue-offline-sync] Request failed. Storing offline data.",n),await g(n))}catch(c){console.error("[vue-offline-sync] Network error while syncing:",c),await g(n)}finally{t.isSyncInProgress=!1}}else await g(n)},I=async n=>{var l;return(l=e.uniqueKeys)!=null&&l.length?(await v()).some(u=>e.uniqueKeys.some(d=>u[d]===n[d])):!1},g=async n=>{if(await I(n)){console.warn("[vue-offline-sync] Duplicate entry detected. Skipping insert: ",n);return}await D(n),await r(),o.postMessage({type:"new-data"})},j=async()=>{if(t.offlineData.length===0)return;const n=t.offlineData.map(({[s]:l,...u})=>u),c=await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(n),headers:{"Content-Type":"application/json",...e.headers}});if(!c.ok){console.error(`[vue-offline-sync] Bulk sync failed with status: ${c.status}`);return}await O()},T=async()=>{for(const n of t.offlineData){const{[s]:c,...l}=n,u=await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(l),headers:{"Content-Type":"application/json",...e.headers}});if(!u.ok){console.error(`[vue-offline-sync] Sync failed with status: ${u.status}`);continue}await b(n[s])}};return w.onMounted(async()=>{console.log("[vue-offline-sync] Component mounted. Fetching offline data..."),await r(),window.addEventListener("online",async()=>{console.log("[vue-offline-sync] Device is back online. Starting sync..."),t.isOnline=!0,t.isSyncInProgress=!0,await f(),t.isSyncInProgress=!1}),window.addEventListener("offline",async()=>{console.log("[vue-offline-sync] Device is offline."),t.isOnline=!1}),o.addEventListener("message",async n=>{(n.data.type==="synced"||n.data.type==="new-data")&&(console.log("[vue-offline-sync] Sync event received from another tab, reloading offline data..."),await r())}),console.log("[vue-offline-sync] Initialization complete.")}),{state:t,saveOfflineData:E,syncOfflineData:f}}y.useOfflineSync=P,Object.defineProperty(y,Symbol.toStringTag,{value:"Module"})}); 2 | -------------------------------------------------------------------------------- /dist/vue-offline-sync.umd.js: -------------------------------------------------------------------------------- 1 | (function(y,w){typeof exports=="object"&&typeof module<"u"?w(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],w):(y=typeof globalThis<"u"?globalThis:y||self,w(y.VueOfflineSync={},y.Vue))})(this,function(y,w){"use strict";const p="vueOfflineSync",i="syncData",S="syncId";function h(){return new Promise((e,s)=>{const o=indexedDB.open(p,1);o.onerror=()=>s(new Error("Failed to open IndexedDB.")),o.onsuccess=()=>e(o.result),o.onupgradeneeded=t=>{const r=t.target.result;r.objectStoreNames.contains(i)&&r.deleteObjectStore(i),r.createObjectStore(i,{keyPath:S,autoIncrement:!0})}})}async function D(e){const s=await h();return new Promise((o,t)=>{const a=s.transaction(i,"readwrite").objectStore(i);S in e||(e[S]=Date.now());const f=a.put(e);f.onsuccess=()=>o(e),f.onerror=()=>t(new Error("Failed to save data."))})}async function v(){const e=await h();return new Promise((s,o)=>{const a=e.transaction(i,"readonly").objectStore(i).getAll();a.onsuccess=()=>s(a.result),a.onerror=()=>o(new Error("Failed to retrieve data."))})}async function O(){const e=await h();return new Promise((s,o)=>{const a=e.transaction(i,"readwrite").objectStore(i).clear();a.onsuccess=()=>s(),a.onerror=()=>o(new Error("Failed to clear data."))})}async function b(e){const s=await h();return new Promise((o,t)=>{const f=s.transaction(i,"readwrite").objectStore(i).delete(e);f.onsuccess=()=>o(),f.onerror=()=>t(new Error(`Failed to remove entry with id: ${e}`))})}function P(e){const s="syncId",o=new BroadcastChannel("vue-offline-sync"),t=w.reactive({isOnline:navigator.onLine,offlineData:[],isSyncInProgress:!1}),r=async()=>{t.offlineData=await v()},a=async(n,c=1)=>{var l;try{return await n()}catch{return c>=(((l=e.retryPolicy)==null?void 0:l.maxAttempts)||1)?(console.error("[vue-offline-sync] Max retry attempts reached."),null):(console.warn(`[vue-offline-sync] Retrying... (${c})`),await new Promise(d=>{var m;return setTimeout(d,((m=e.retryPolicy)==null?void 0:m.delayMs)||1e3)}),a(n,c+1))}},f=async()=>{if(!(!t.isOnline||t.offlineData.length===0)){try{e.bulkSync?await j():await T()}catch(n){console.error("[vue-offline-sync] Network error during sync:",n)}o.postMessage({type:"synced"}),await r()}},E=async n=>{if(t.isOnline){t.isSyncInProgress=!0;try{const{[s]:c,...l}=n,d=await a(async()=>await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(l),headers:{"Content-Type":"application/json",...e.headers}}));(!d||!d.ok)&&(console.error("[vue-offline-sync] Request failed. Storing offline data.",n),await g(n))}catch(c){console.error("[vue-offline-sync] Network error while syncing:",c),await g(n)}finally{t.isSyncInProgress=!1}}else await g(n)},I=async n=>{var l;return(l=e.uniqueKeys)!=null&&l.length?(await v()).some(u=>e.uniqueKeys.some(d=>u[d]===n[d])):!1},g=async n=>{if(await I(n)){console.warn("[vue-offline-sync] Duplicate entry detected. Skipping insert: ",n);return}await D(n),await r(),o.postMessage({type:"new-data"})},j=async()=>{if(t.offlineData.length===0)return;const n=t.offlineData.map(({[s]:l,...u})=>u),c=await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(n),headers:{"Content-Type":"application/json",...e.headers}});if(!c.ok){console.error(`[vue-offline-sync] Bulk sync failed with status: ${c.status}`);return}await O()},T=async()=>{for(const n of t.offlineData){const{[s]:c,...l}=n,u=await fetch(e.url,{method:e.method||"POST",body:JSON.stringify(l),headers:{"Content-Type":"application/json",...e.headers}});if(!u.ok){console.error(`[vue-offline-sync] Sync failed with status: ${u.status}`);continue}await b(n[s])}};return w.onMounted(async()=>{console.log("[vue-offline-sync] Component mounted. Fetching offline data..."),await r(),window.addEventListener("online",async()=>{console.log("[vue-offline-sync] Device is back online. Starting sync..."),t.isOnline=!0,t.isSyncInProgress=!0,await f(),t.isSyncInProgress=!1}),window.addEventListener("offline",async()=>{console.log("[vue-offline-sync] Device is offline."),t.isOnline=!1}),o.addEventListener("message",async n=>{(n.data.type==="synced"||n.data.type==="new-data")&&(console.log("[vue-offline-sync] Sync event received from another tab, reloading offline data..."),await r())}),console.log("[vue-offline-sync] Initialization complete.")}),{state:t,saveOfflineData:E,syncOfflineData:f}}y.useOfflineSync=P,Object.defineProperty(y,Symbol.toStringTag,{value:"Module"})}); 2 | -------------------------------------------------------------------------------- /dist/vue-offline-sync.es 2.js: -------------------------------------------------------------------------------- 1 | import { reactive as O, onMounted as b } from "vue"; 2 | const P = "vueOfflineSync", i = "syncData", E = 1, g = "syncId"; 3 | function d() { 4 | return new Promise((e, r) => { 5 | const o = indexedDB.open(P, E); 6 | o.onerror = () => r(new Error("Failed to open IndexedDB.")), o.onsuccess = () => e(o.result), o.onupgradeneeded = (t) => { 7 | const s = t.target.result; 8 | s.objectStoreNames.contains(i) && s.deleteObjectStore(i), s.createObjectStore(i, { keyPath: g, autoIncrement: !0 }); 9 | }; 10 | }); 11 | } 12 | async function I(e) { 13 | const r = await d(); 14 | return new Promise((o, t) => { 15 | const a = r.transaction(i, "readwrite").objectStore(i); 16 | g in e || (e[g] = Date.now()); 17 | const f = a.put(e); 18 | f.onsuccess = () => o(e), f.onerror = () => t(new Error("Failed to save data.")); 19 | }); 20 | } 21 | async function S() { 22 | const e = await d(); 23 | return new Promise((r, o) => { 24 | const a = e.transaction(i, "readonly").objectStore(i).getAll(); 25 | a.onsuccess = () => r(a.result), a.onerror = () => o(new Error("Failed to retrieve data.")); 26 | }); 27 | } 28 | async function k() { 29 | const e = await d(); 30 | return new Promise((r, o) => { 31 | const a = e.transaction(i, "readwrite").objectStore(i).clear(); 32 | a.onsuccess = () => r(), a.onerror = () => o(new Error("Failed to clear data.")); 33 | }); 34 | } 35 | async function j(e) { 36 | const r = await d(); 37 | return new Promise((o, t) => { 38 | const f = r.transaction(i, "readwrite").objectStore(i).delete(e); 39 | f.onsuccess = () => o(), f.onerror = () => t(new Error(`Failed to remove entry with id: ${e}`)); 40 | }); 41 | } 42 | function N(e) { 43 | const r = "syncId", o = new BroadcastChannel("vue-offline-sync"), t = O({ 44 | isOnline: navigator.onLine, 45 | offlineData: [], 46 | isSyncInProgress: !1 47 | }), s = async () => { 48 | t.offlineData = await S(); 49 | }, a = async (n, c = 1) => { 50 | var l; 51 | try { 52 | return await n(); 53 | } catch { 54 | return c >= (((l = e.retryPolicy) == null ? void 0 : l.maxAttempts) || 1) ? (console.error("[vue-offline-sync] Max retry attempts reached."), null) : (console.warn(`[vue-offline-sync] Retrying... (${c})`), await new Promise((u) => { 55 | var h; 56 | return setTimeout(u, ((h = e.retryPolicy) == null ? void 0 : h.delayMs) || 1e3); 57 | }), a(n, c + 1)); 58 | } 59 | }, f = async () => { 60 | if (!(!t.isOnline || t.offlineData.length === 0)) { 61 | try { 62 | e.bulkSync ? await D() : await p(); 63 | } catch (n) { 64 | console.error("[vue-offline-sync] Network error during sync:", n); 65 | } 66 | o.postMessage({ type: "synced" }), await s(); 67 | } 68 | }, v = async (n) => { 69 | if (t.isOnline) { 70 | t.isSyncInProgress = !0; 71 | try { 72 | const { [r]: c, ...l } = n, u = await a(async () => await fetch(e.url, { 73 | method: e.method || "POST", 74 | body: JSON.stringify(l), 75 | headers: { 76 | "Content-Type": "application/json", 77 | ...e.headers 78 | } 79 | })); 80 | (!u || !u.ok) && (console.error("[vue-offline-sync] Request failed. Storing offline data.", n), await w(n)); 81 | } catch (c) { 82 | console.error("[vue-offline-sync] Network error while syncing:", c), await w(n); 83 | } finally { 84 | t.isSyncInProgress = !1; 85 | } 86 | } else 87 | await w(n); 88 | }, m = async (n) => { 89 | var l; 90 | return (l = e.uniqueKeys) != null && l.length ? (await S()).some( 91 | (y) => e.uniqueKeys.some((u) => y[u] === n[u]) 92 | ) : !1; 93 | }, w = async (n) => { 94 | if (await m(n)) { 95 | console.warn("[vue-offline-sync] Duplicate entry detected. Skipping insert: ", n); 96 | return; 97 | } 98 | await I(n), await s(), o.postMessage({ type: "new-data" }); 99 | }, D = async () => { 100 | if (t.offlineData.length === 0) return; 101 | const n = t.offlineData.map(({ [r]: l, ...y }) => y), c = await fetch(e.url, { 102 | method: e.method || "POST", 103 | body: JSON.stringify(n), 104 | headers: { 105 | "Content-Type": "application/json", 106 | ...e.headers 107 | } 108 | }); 109 | if (!c.ok) { 110 | console.error(`[vue-offline-sync] Bulk sync failed with status: ${c.status}`); 111 | return; 112 | } 113 | await k(); 114 | }, p = async () => { 115 | for (const n of t.offlineData) { 116 | const { [r]: c, ...l } = n, y = await fetch(e.url, { 117 | method: e.method || "POST", 118 | body: JSON.stringify(l), 119 | headers: { 120 | "Content-Type": "application/json", 121 | ...e.headers 122 | } 123 | }); 124 | if (!y.ok) { 125 | console.error(`[vue-offline-sync] Sync failed with status: ${y.status}`); 126 | continue; 127 | } 128 | await j(n[r]); 129 | } 130 | }; 131 | return b(async () => { 132 | console.log("[vue-offline-sync] Component mounted. Fetching offline data..."), await s(), window.addEventListener("online", async () => { 133 | console.log("[vue-offline-sync] Device is back online. Starting sync..."), t.isOnline = !0, t.isSyncInProgress = !0, await f(), t.isSyncInProgress = !1; 134 | }), window.addEventListener("offline", async () => { 135 | console.log("[vue-offline-sync] Device is offline."), t.isOnline = !1; 136 | }), o.addEventListener("message", async (n) => { 137 | (n.data.type === "synced" || n.data.type === "new-data") && (console.log("[vue-offline-sync] Sync event received from another tab, reloading offline data..."), await s()); 138 | }), console.log("[vue-offline-sync] Initialization complete."); 139 | }), { 140 | state: t, 141 | saveOfflineData: v, 142 | syncOfflineData: f 143 | }; 144 | } 145 | export { 146 | N as useOfflineSync 147 | }; 148 | -------------------------------------------------------------------------------- /dist/vue-offline-sync.es.js: -------------------------------------------------------------------------------- 1 | import { reactive as O, onMounted as b } from "vue"; 2 | const P = "vueOfflineSync", i = "syncData", E = 1, g = "syncId"; 3 | function d() { 4 | return new Promise((e, r) => { 5 | const o = indexedDB.open(P, E); 6 | o.onerror = () => r(new Error("Failed to open IndexedDB.")), o.onsuccess = () => e(o.result), o.onupgradeneeded = (t) => { 7 | const s = t.target.result; 8 | s.objectStoreNames.contains(i) && s.deleteObjectStore(i), s.createObjectStore(i, { keyPath: g, autoIncrement: !0 }); 9 | }; 10 | }); 11 | } 12 | async function I(e) { 13 | const r = await d(); 14 | return new Promise((o, t) => { 15 | const a = r.transaction(i, "readwrite").objectStore(i); 16 | g in e || (e[g] = Date.now()); 17 | const f = a.put(e); 18 | f.onsuccess = () => o(e), f.onerror = () => t(new Error("Failed to save data.")); 19 | }); 20 | } 21 | async function S() { 22 | const e = await d(); 23 | return new Promise((r, o) => { 24 | const a = e.transaction(i, "readonly").objectStore(i).getAll(); 25 | a.onsuccess = () => r(a.result), a.onerror = () => o(new Error("Failed to retrieve data.")); 26 | }); 27 | } 28 | async function k() { 29 | const e = await d(); 30 | return new Promise((r, o) => { 31 | const a = e.transaction(i, "readwrite").objectStore(i).clear(); 32 | a.onsuccess = () => r(), a.onerror = () => o(new Error("Failed to clear data.")); 33 | }); 34 | } 35 | async function j(e) { 36 | const r = await d(); 37 | return new Promise((o, t) => { 38 | const f = r.transaction(i, "readwrite").objectStore(i).delete(e); 39 | f.onsuccess = () => o(), f.onerror = () => t(new Error(`Failed to remove entry with id: ${e}`)); 40 | }); 41 | } 42 | function N(e) { 43 | const r = "syncId", o = new BroadcastChannel("vue-offline-sync"), t = O({ 44 | isOnline: navigator.onLine, 45 | offlineData: [], 46 | isSyncInProgress: !1 47 | }), s = async () => { 48 | t.offlineData = await S(); 49 | }, a = async (n, c = 1) => { 50 | var l; 51 | try { 52 | return await n(); 53 | } catch { 54 | return c >= (((l = e.retryPolicy) == null ? void 0 : l.maxAttempts) || 1) ? (console.error("[vue-offline-sync] Max retry attempts reached."), null) : (console.warn(`[vue-offline-sync] Retrying... (${c})`), await new Promise((u) => { 55 | var h; 56 | return setTimeout(u, ((h = e.retryPolicy) == null ? void 0 : h.delayMs) || 1e3); 57 | }), a(n, c + 1)); 58 | } 59 | }, f = async () => { 60 | if (!(!t.isOnline || t.offlineData.length === 0)) { 61 | try { 62 | e.bulkSync ? await D() : await p(); 63 | } catch (n) { 64 | console.error("[vue-offline-sync] Network error during sync:", n); 65 | } 66 | o.postMessage({ type: "synced" }), await s(); 67 | } 68 | }, v = async (n) => { 69 | if (t.isOnline) { 70 | t.isSyncInProgress = !0; 71 | try { 72 | const { [r]: c, ...l } = n, u = await a(async () => await fetch(e.url, { 73 | method: e.method || "POST", 74 | body: JSON.stringify(l), 75 | headers: { 76 | "Content-Type": "application/json", 77 | ...e.headers 78 | } 79 | })); 80 | (!u || !u.ok) && (console.error("[vue-offline-sync] Request failed. Storing offline data.", n), await w(n)); 81 | } catch (c) { 82 | console.error("[vue-offline-sync] Network error while syncing:", c), await w(n); 83 | } finally { 84 | t.isSyncInProgress = !1; 85 | } 86 | } else 87 | await w(n); 88 | }, m = async (n) => { 89 | var l; 90 | return (l = e.uniqueKeys) != null && l.length ? (await S()).some( 91 | (y) => e.uniqueKeys.some((u) => y[u] === n[u]) 92 | ) : !1; 93 | }, w = async (n) => { 94 | if (await m(n)) { 95 | console.warn("[vue-offline-sync] Duplicate entry detected. Skipping insert: ", n); 96 | return; 97 | } 98 | await I(n), await s(), o.postMessage({ type: "new-data" }); 99 | }, D = async () => { 100 | if (t.offlineData.length === 0) return; 101 | const n = t.offlineData.map(({ [r]: l, ...y }) => y), c = await fetch(e.url, { 102 | method: e.method || "POST", 103 | body: JSON.stringify(n), 104 | headers: { 105 | "Content-Type": "application/json", 106 | ...e.headers 107 | } 108 | }); 109 | if (!c.ok) { 110 | console.error(`[vue-offline-sync] Bulk sync failed with status: ${c.status}`); 111 | return; 112 | } 113 | await k(); 114 | }, p = async () => { 115 | for (const n of t.offlineData) { 116 | const { [r]: c, ...l } = n, y = await fetch(e.url, { 117 | method: e.method || "POST", 118 | body: JSON.stringify(l), 119 | headers: { 120 | "Content-Type": "application/json", 121 | ...e.headers 122 | } 123 | }); 124 | if (!y.ok) { 125 | console.error(`[vue-offline-sync] Sync failed with status: ${y.status}`); 126 | continue; 127 | } 128 | await j(n[r]); 129 | } 130 | }; 131 | return b(async () => { 132 | console.log("[vue-offline-sync] Component mounted. Fetching offline data..."), await s(), window.addEventListener("online", async () => { 133 | console.log("[vue-offline-sync] Device is back online. Starting sync..."), t.isOnline = !0, t.isSyncInProgress = !0, await f(), t.isSyncInProgress = !1; 134 | }), window.addEventListener("offline", async () => { 135 | console.log("[vue-offline-sync] Device is offline."), t.isOnline = !1; 136 | }), o.addEventListener("message", async (n) => { 137 | (n.data.type === "synced" || n.data.type === "new-data") && (console.log("[vue-offline-sync] Sync event received from another tab, reloading offline data..."), await s()); 138 | }), console.log("[vue-offline-sync] Initialization complete."); 139 | }), { 140 | state: t, 141 | saveOfflineData: v, 142 | syncOfflineData: f 143 | }; 144 | } 145 | export { 146 | N as useOfflineSync 147 | }; 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Offline Sync 🔄 2 | 3 | A **Vue 3 composable** for **offline-first syncing**. Save data while offline and automatically sync it when back 4 | online. 5 | Uses **IndexedDB** for offline storage. 6 | 7 | ## 🚀 Features 8 | 9 | - Auto-sync when online 10 | - Custom API endpoint 11 | - Uses IndexedDB for offline storage 12 | - Bulk or individual syncing 13 | - Configurable retry policy for failed requests 14 | 15 | --- 16 | 17 | ## 📦 Installation 18 | 19 | ```sh 20 | npm install vue-offline-sync 21 | ``` 22 | 23 | ## ⚡ Quick Start 24 | 25 | ### 1️⃣ Basic Usage 26 | 27 | Here’s a basic implementation example 28 | 29 | ```vue 30 | 31 | 57 | 58 | 70 | ``` 71 | 72 | ### ⚙️ Options 73 | 74 | | Option | Type | Required | Default | Description | 75 | |:--------------|:---------|:---------|:--------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------| 76 | | `url` | String | ✅ Yes | `undefined` | API endpoint to sync data | 77 | | `method` | String | ❌ No | "POST" | HTTP method (e.g., "POST", "PUT", etc.) | 78 | | `headers` | Object | ❌ No | {} | Additional headers (e.g., authentication token) | 79 | | `bulkSync` | Boolean | ❌ No | false | Set to true if your API accepts batch sync requests | 80 | | `uniqueKeys` | String[] | ❌ No | `undefined` | Specifies the columns that must have unique values across all entries. If any of the defined columns contain duplicate values, the entry will be rejected. | 81 | | `retryPolicy` | Object | ❌ No | ```{maxAttempts: 1, delayMs: 1000}``` | Configures automatic retries for failed requests. See **[Retry Policy](#-retry-policy)** below. | 82 | 83 | ### 📡 States 84 | 85 | | State | Type | Description | 86 | |:-------------------------|---------------|:--------------------------------------------------------------------------------------------------| 87 | | `state.isOnline` | Boolean | `true` when online, `false` when offline | 88 | | `state.offlineData` | Array | Data stored in IndexedDB during offline mode | 89 | | `state.isSyncInProgress` | Boolean | Can be used to indicate a loading state in the UI, informing the user that syncing is in progress | 90 | 91 | ### 🔄 Methods 92 | 93 | | Method | Description | 94 | |:------------------------|:--------------------------------------------------------------------| 95 | | saveOfflineData(object) | Saves data to IndexedDB when offline, or syncs directly when online | 96 | | syncOfflineData() | Manually triggers syncing of offline data | 97 | 98 | ### 🔄 Retry Policy 99 | 100 | The `retryPolicy` option allows configuring **automatic retries** for failed API requests. 101 | 102 | | Property | Type | Default | Description | 103 | |---------------|--------|---------|------------------------------------------------| 104 | | `maxAttempts` | Number | `1` | Maximum number of retries before failing | 105 | | `delayMs` | Number | `1000` | Delay (in milliseconds) between retry attempts | 106 | 107 | **Example Usage:** 108 | 109 | ```js 110 | const {state, saveOfflineData} = useOfflineSync({ 111 | url: 'https://myapi.com/sync', 112 | retryPolicy: { 113 | maxAttempts: 5, 114 | delayMs: 2000, 115 | } 116 | }); 117 | ``` 118 | 119 | > 💡 If a request fails, it will retry up to 5 times with a **2-second delay** between each attempt. 120 | 121 |
122 | 123 | ### 📌 Bulk vs Individual Syncing 124 | 125 | > **Note:** The individual syncing is being used by default. 126 | 127 | #### 📥 Bulk Sync (bulkSync: true) 128 | 129 | ✔ Sends all offline data as one request
130 | ✔ Recommended for APIs that support bulk inserts 131 | 132 | **Example Requests** 133 | 134 | ```json 135 | [ 136 | { 137 | "name": "Name A", 138 | "message": "Hello!" 139 | }, 140 | { 141 | "name": "Name B", 142 | "message": "Hey there!" 143 | } 144 | ] 145 | ``` 146 | 147 | #### 📤 Individual Sync (bulkSync: false) 148 | 149 | ✔ Sends each offline entry separately
150 | ✔ Recommended for APIs that only accept single requests 151 | 152 | **Example Requests** 153 | 154 | ```json 155 | { 156 | "name": "Name A", 157 | "message": "Hello!" 158 | } 159 | ``` 160 | -------------------------------------------------------------------------------- /src/vue-offline-sync.ts: -------------------------------------------------------------------------------- 1 | import {onMounted, reactive} from 'vue'; 2 | import {clearData, getData, removeData, saveData} from './utils/indexedDB'; 3 | 4 | interface SyncData { 5 | [key: string]: any; 6 | } 7 | 8 | interface RetryPolicy { 9 | maxAttempts: number; 10 | delayMs: number; 11 | } 12 | 13 | interface UseOfflineSyncOptions { 14 | url: string; 15 | method?: string; 16 | headers?: Record; 17 | bulkSync?: boolean; 18 | uniqueKeys?: string[]; 19 | retryPolicy?: RetryPolicy; 20 | } 21 | 22 | interface SyncState { 23 | isOnline: boolean; 24 | offlineData: SyncData[]; 25 | isSyncInProgress: boolean; 26 | } 27 | 28 | export function useOfflineSync(options: UseOfflineSyncOptions): { 29 | state: SyncState, 30 | saveOfflineData: (data: SyncData) => Promise, 31 | syncOfflineData: () => Promise 32 | } { 33 | const keyPath: string = 'syncId'; 34 | const channel: BroadcastChannel = new BroadcastChannel('vue-offline-sync'); 35 | 36 | const state: SyncState = reactive({ 37 | isOnline: navigator.onLine, 38 | offlineData: [], 39 | isSyncInProgress: false, 40 | }); 41 | 42 | const fetchOfflineData = async () => { 43 | state.offlineData = await getData(); 44 | }; 45 | 46 | const retry = async (fn: () => Promise, attempt = 1): Promise => { 47 | try { 48 | return await fn(); 49 | } catch (error) { 50 | if (attempt >= (options.retryPolicy?.maxAttempts || 1)) { 51 | console.error('[vue-offline-sync] Max retry attempts reached.') 52 | return null; 53 | } 54 | 55 | console.warn(`[vue-offline-sync] Retrying... (${attempt})`); 56 | await new Promise(res => setTimeout(res, options.retryPolicy?.delayMs || 1000)); 57 | return retry(fn, attempt + 1); 58 | } 59 | } 60 | 61 | const syncOfflineData = async (): Promise => { 62 | if (!state.isOnline || state.offlineData.length === 0) return; 63 | 64 | try { 65 | if (options.bulkSync) { 66 | await applyBulkSync(); 67 | } else { 68 | await applyIndividualSync(); 69 | } 70 | } catch (error) { 71 | console.error('[vue-offline-sync] Network error during sync:', error); 72 | } 73 | 74 | // Notify other tabs that a sync occurred. 75 | channel.postMessage({type: 'synced'}); 76 | await fetchOfflineData(); 77 | }; 78 | 79 | const saveOfflineData = async (data: SyncData) => { 80 | if (state.isOnline) { 81 | state.isSyncInProgress = true; 82 | try { 83 | const {[keyPath]: _, ...rest} = data; 84 | const request = async () => await fetch(options.url, { 85 | method: options.method || 'POST', 86 | body: JSON.stringify(rest), 87 | headers: { 88 | 'Content-Type': 'application/json', 89 | ...options.headers, 90 | }, 91 | }); 92 | 93 | const response = await retry(request); 94 | if (!response || !response.ok) { 95 | console.error('[vue-offline-sync] Request failed. Storing offline data.', data); 96 | await storeOfflineData(data); 97 | } 98 | } catch (error) { 99 | console.error('[vue-offline-sync] Network error while syncing:', error); 100 | await storeOfflineData(data); 101 | } finally { 102 | state.isSyncInProgress = false; 103 | } 104 | } else { 105 | await storeOfflineData(data); 106 | } 107 | }; 108 | 109 | /** 110 | * Check if a data entry already exists based on uniqueKeys 111 | */ 112 | const hasDuplicateEntry = async (data: SyncData) => { 113 | if (!options.uniqueKeys?.length) return false; 114 | 115 | const existingData: SyncData[] = await getData() 116 | return existingData.some((entry: SyncData) => 117 | options.uniqueKeys.some(key => entry[key] === data[key]) 118 | ); 119 | } 120 | 121 | /** 122 | * Process and store data into IndexedDB 123 | */ 124 | const storeOfflineData = async (data: SyncData) => { 125 | if (await hasDuplicateEntry(data)) { 126 | console.warn('[vue-offline-sync] Duplicate entry detected. Skipping insert: ', data); 127 | return; 128 | } 129 | 130 | await saveData(data); 131 | await fetchOfflineData(); 132 | // Notify other tabs that new data was saved. 133 | channel.postMessage({type: 'new-data'}); 134 | } 135 | 136 | const applyBulkSync = async (): Promise => { 137 | if (state.offlineData.length === 0) return; 138 | 139 | const dataToSync = state.offlineData.map(({[keyPath]: _, ...rest}) => rest); 140 | const response = await fetch(options.url, { 141 | method: options.method || 'POST', 142 | body: JSON.stringify(dataToSync), 143 | headers: { 144 | 'Content-Type': 'application/json', 145 | ...options.headers, 146 | }, 147 | }); 148 | 149 | if (!response.ok) { 150 | console.error(`[vue-offline-sync] Bulk sync failed with status: ${response.status}`); 151 | return; 152 | } 153 | 154 | await clearData(); 155 | }; 156 | 157 | const applyIndividualSync = async (): Promise => { 158 | for (const data of state.offlineData) { 159 | const {[keyPath]: _, ...payload} = data; 160 | 161 | const response = await fetch(options.url, { 162 | method: options.method || 'POST', 163 | body: JSON.stringify(payload), 164 | headers: { 165 | 'Content-Type': 'application/json', 166 | ...options.headers, 167 | }, 168 | }); 169 | 170 | if (!response.ok) { 171 | console.error(`[vue-offline-sync] Sync failed with status: ${response.status}`); 172 | continue; 173 | } 174 | 175 | await removeData(data[keyPath]); 176 | } 177 | }; 178 | 179 | onMounted(async (): Promise => { 180 | console.log('[vue-offline-sync] Component mounted. Fetching offline data...'); 181 | await fetchOfflineData(); 182 | 183 | window.addEventListener('online', async (): Promise => { 184 | console.log('[vue-offline-sync] Device is back online. Starting sync...'); 185 | state.isOnline = true; 186 | state.isSyncInProgress = true; 187 | await syncOfflineData(); 188 | state.isSyncInProgress = false; 189 | }); 190 | 191 | window.addEventListener('offline', async (): Promise => { 192 | console.log('[vue-offline-sync] Device is offline.'); 193 | state.isOnline = false; 194 | }); 195 | 196 | // Listen for updates from other tabs 197 | channel.addEventListener('message', async (event): Promise => { 198 | if (event.data.type === 'synced' || event.data.type === 'new-data') { 199 | console.log('[vue-offline-sync] Sync event received from another tab, reloading offline data...'); 200 | await fetchOfflineData(); 201 | } 202 | }); 203 | 204 | console.log('[vue-offline-sync] Initialization complete.'); 205 | }); 206 | 207 | return { 208 | state, 209 | saveOfflineData, 210 | syncOfflineData, 211 | }; 212 | } 213 | -------------------------------------------------------------------------------- /tests/vue-offline-sync.test.ts: -------------------------------------------------------------------------------- 1 | import {vi, describe, it, expect, beforeEach, afterEach} from 'vitest'; 2 | import {saveData, getData, clearData, removeData} from '../src/utils/indexedDB'; 3 | import {mount} from "@vue/test-utils"; 4 | import {useOfflineSync} from '../src/vue-offline-sync'; 5 | 6 | // Mock BroadcastChannel 7 | const postMessageMock = vi.fn(); 8 | const addEventListenerMock = vi.fn(); 9 | 10 | class MockBroadcastChannel { 11 | postMessage = postMessageMock; 12 | addEventListener = addEventListenerMock; 13 | close = vi.fn(); 14 | } 15 | 16 | vi.stubGlobal('BroadcastChannel', MockBroadcastChannel); 17 | 18 | // Mock IndexedDB Functions 19 | vi.mock('../src/utils/indexedDB', () => ({ 20 | saveData: vi.fn(), 21 | getData: vi.fn(() => Promise.resolve([{id: 1, name: 'Test Data'}])), 22 | clearData: vi.fn(), 23 | removeData: vi.fn(), 24 | })); 25 | 26 | // Properly typed fetch mock 27 | vi.stubGlobal('fetch', vi.fn(async () => 28 | new Response(JSON.stringify({success: true}), {status: 200, headers: {'Content-Type': 'application/json'}}) 29 | )); 30 | 31 | beforeEach(() => { 32 | vi.restoreAllMocks(); 33 | vi.resetAllMocks(); 34 | 35 | // Mock navigator.online 36 | Object.defineProperty(globalThis.navigator, 'onLine', { 37 | value: true, 38 | writable: true, 39 | }); 40 | 41 | // Mock event listeners 42 | vi.spyOn(globalThis, 'addEventListener'); 43 | }); 44 | 45 | afterEach(() => { 46 | vi.restoreAllMocks(); 47 | vi.resetAllMocks(); 48 | }); 49 | 50 | describe('useOfflineSync Composable', () => { 51 | it('should initialize correctly', async () => { 52 | const wrapper = mount({ 53 | setup() { 54 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 55 | }, 56 | template: '
' 57 | }); 58 | 59 | const {state} = wrapper.vm; 60 | 61 | expect(state.isOnline).toBe(true); 62 | expect(getData).toHaveBeenCalled(); 63 | }); 64 | 65 | it('should save offline data when offline', async () => { 66 | const wrapper = mount({ 67 | setup() { 68 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 69 | }, 70 | template: '
' 71 | }); 72 | 73 | const {saveOfflineData, state} = wrapper.vm; 74 | 75 | state.isOnline = false; 76 | const testData = {id: 1, name: 'Test Data'}; 77 | 78 | await saveOfflineData(testData); 79 | 80 | expect(saveData).toHaveBeenCalledWith(testData); 81 | expect(getData).toHaveBeenCalled(); 82 | }); 83 | 84 | it('should sync offline data when online', async () => { 85 | const wrapper = mount({ 86 | setup() { 87 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 88 | }, 89 | template: '
' 90 | }); 91 | 92 | const {saveOfflineData, syncOfflineData, state} = wrapper.vm; 93 | 94 | state.isOnline = false; 95 | await saveOfflineData({id: 1, name: 'Test'}); 96 | 97 | state.isOnline = true; 98 | await syncOfflineData(); 99 | 100 | expect(fetch).toHaveBeenCalled(); 101 | expect(getData).toHaveBeenCalled(); 102 | }); 103 | 104 | it('should not sync if offline or no data', async () => { 105 | const wrapper = mount({ 106 | setup() { 107 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 108 | }, 109 | template: '
' 110 | }); 111 | 112 | const {syncOfflineData, state} = wrapper.vm; 113 | 114 | state.isOnline = false; 115 | await syncOfflineData(); 116 | expect(fetch).not.toHaveBeenCalled(); 117 | 118 | state.isOnline = true; 119 | state.offlineData = []; 120 | await syncOfflineData(); 121 | expect(fetch).not.toHaveBeenCalled(); 122 | }); 123 | 124 | it('should handle sync errors gracefully', async () => { 125 | const wrapper = mount({ 126 | setup() { 127 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 128 | }, 129 | template: '
' 130 | }); 131 | 132 | const {syncOfflineData, state} = wrapper.vm; 133 | 134 | state.isOnline = true; 135 | state.offlineData = [{id: 1, name: 'Test'}]; 136 | 137 | vi.stubGlobal('fetch', vi.fn(async () => 138 | new Response(JSON.stringify({success: false}), {status: 500, headers: {'Content-Type': 'application/json'}}) 139 | )); 140 | 141 | await syncOfflineData(); 142 | 143 | expect(fetch).toHaveBeenCalled(); 144 | expect(removeData).not.toHaveBeenCalled(); 145 | }); 146 | }); 147 | 148 | describe('useOfflineSync Multi-Tab Support', () => { 149 | it('should notify other tabs when data is saved', async () => { 150 | const wrapper = mount({ 151 | setup() { 152 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 153 | }, 154 | template: '
' 155 | }); 156 | 157 | const {state, saveOfflineData} = wrapper.vm; 158 | 159 | state.isOnline = false; 160 | await saveOfflineData({name: 'Test data'}); 161 | 162 | expect(postMessageMock).toHaveBeenCalledWith({type: 'new-data'}); 163 | }); 164 | 165 | it('should notify other tabs when data is synced', async () => { 166 | const wrapper = mount({ 167 | setup() { 168 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 169 | }, 170 | template: '
' 171 | }); 172 | 173 | const {state, syncOfflineData, saveOfflineData} = wrapper.vm; 174 | 175 | state.isOnline = false; 176 | await saveOfflineData({name: 'Test data new'}); 177 | 178 | state.isOnline = true; 179 | await syncOfflineData(); 180 | 181 | expect(postMessageMock).toHaveBeenCalledWith({type: 'synced'}); 182 | }); 183 | 184 | it('should listen for updates from other tabs', async () => { 185 | mount({ 186 | setup() { 187 | return useOfflineSync({url: 'https://mock-api.com/sync'}); 188 | }, 189 | template: '
' 190 | }); 191 | 192 | await new Promise(resolve => setTimeout(resolve, 10)); 193 | 194 | expect(addEventListenerMock).toHaveBeenCalledWith('message', expect.any(Function)); 195 | 196 | // Extract the listener function that was registered 197 | const eventListener = addEventListenerMock.mock.calls[0][1]; 198 | 199 | // Simulate a broadcast event being received 200 | eventListener({data: {type: 'synced'}}); 201 | 202 | expect(getData).toHaveBeenCalled(); 203 | }); 204 | }); 205 | 206 | describe('useOfflineSync - Unique Constraint (uniqueKeys)', () => { 207 | it('should insert data if unique constraint is not violated', async () => { 208 | const wrapper = mount({ 209 | setup() { 210 | return useOfflineSync({url: 'https://mock-api.com/sync', uniqueKeys: ['email']}); 211 | }, 212 | template: '
' 213 | }); 214 | 215 | const {state, saveOfflineData} = wrapper.vm; 216 | 217 | state.isOnline = false; 218 | await saveOfflineData({email: "johndoe@gmail.com", name: "John"}); 219 | 220 | expect(saveData).toHaveBeenCalledWith({email: "johndoe@gmail.com", name: "John"}); 221 | }); 222 | 223 | it('should not insert duplicate data based on uniqueKeys constraint', async () => { 224 | const wrapper = mount({ 225 | setup() { 226 | return useOfflineSync({url: 'https://mock-api.com/sync', uniqueKeys: ['email']}); 227 | }, 228 | template: '
' 229 | }); 230 | 231 | const {saveOfflineData} = wrapper.vm; 232 | 233 | // Simulate existing data in IndexedDB 234 | getData.mockResolvedValue([{email: "johndoe@gmail.com", name: "John"}]); 235 | 236 | await saveOfflineData({email: "johndoe@gmail.com", name: "Another John"}); 237 | 238 | expect(saveData).not.toHaveBeenCalled(); 239 | }); 240 | 241 | it('should allow insertion when a different unique field value is provided', async () => { 242 | const wrapper = mount({ 243 | setup() { 244 | return useOfflineSync({url: 'https://mock-api.com/sync', uniqueKeys: ['email']}); 245 | }, 246 | template: '
' 247 | }); 248 | 249 | const {state, saveOfflineData} = wrapper.vm; 250 | 251 | state.isOnline = false; 252 | 253 | // Simulate existing data 254 | getData.mockResolvedValue([{email: "johndoe@gmail.com", name: "John"}]); 255 | 256 | await saveOfflineData({email: "janedoe@gmail.com", name: "Jane"}); 257 | 258 | expect(saveData).toHaveBeenCalledWith({email: "janedoe@gmail.com", name: "Jane"}); 259 | }); 260 | 261 | it('should enforce uniqueness based on multiple fields', async () => { 262 | const wrapper = mount({ 263 | setup() { 264 | return useOfflineSync({url: 'https://mock-api.com/sync', uniqueKeys: ['email', 'name']}); 265 | }, 266 | template: '
' 267 | }); 268 | 269 | const {state, saveOfflineData} = wrapper.vm; 270 | 271 | state.isOnline = false; 272 | 273 | // Simulate existing data 274 | getData.mockResolvedValue([ 275 | {email: "johndoe@gmail.com", name: "John"} 276 | ]); 277 | 278 | await saveOfflineData({email: "johndoe@gmail.com", name: "John"}); // Duplicate on both fields 279 | await saveOfflineData({email: "johndoe@gmail.com", name: "Another John"}); // Unique "name", should be inserted 280 | await saveOfflineData({email: "anotheremail@gmail.com", name: "John"}); // Duplicate "name" → Should be rejected 281 | await saveOfflineData({email: "unique@gmail.com", name: "Unique Name"}); // Fully unique → Should be inserted 282 | 283 | expect(saveData).toHaveBeenCalledTimes(1); 284 | expect(saveData).toHaveBeenCalledWith({email: "unique@gmail.com", name: "Unique Name"}); 285 | }); 286 | }); 287 | 288 | describe('Retry Policy', () => { 289 | it('should retry failed requests based on retryPolicy', async () => { 290 | const fetchMock = vi.fn() 291 | .mockRejectedValueOnce(new Error('Network Error')) 292 | .mockRejectedValueOnce(new Error('Network Error')) 293 | // .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })) 294 | .mockResolvedValue({ok: true}); 295 | 296 | vi.stubGlobal('fetch', fetchMock); 297 | 298 | const wrapper = mount({ 299 | setup() { 300 | return useOfflineSync({ 301 | url: 'https://mock-api.com/sync', 302 | retryPolicy: {maxAttempts: 3, delayMs: 100}, 303 | }); 304 | }, 305 | template: '
' 306 | }); 307 | 308 | await wrapper.vm.saveOfflineData({id: 1, name: 'Test'}); 309 | 310 | console.log('Fetch mock calls:', fetchMock.mock.calls.length); 311 | expect(fetchMock).toHaveBeenCalledTimes(3); 312 | }) 313 | 314 | it('should store data offline when maxAttempts is reached', async () => { 315 | const fetchMock = vi.fn().mockRejectedValue(new Error('Network Error')); 316 | vi.stubGlobal('fetch', fetchMock); 317 | 318 | const wrapper = mount({ 319 | setup() { 320 | return useOfflineSync({ 321 | url: 'https://mock-api.com/sync', 322 | retryPolicy: { maxAttempts: 2, delayMs: 50 }, 323 | }); 324 | }, 325 | template: '
' 326 | }); 327 | 328 | await wrapper.vm.saveOfflineData({ id: 2, name: 'Failed Data' }); 329 | 330 | console.log('Fetch mock calls:', fetchMock.mock.calls.length); 331 | expect(fetchMock).toHaveBeenCalledTimes(2); 332 | expect(saveData).toHaveBeenCalledWith({ id: 2, name: 'Failed Data' }); 333 | }) 334 | }) --------------------------------------------------------------------------------