El teu projecte funciona: el domini és en verd, el primer tall vertical es veu al navegador i les regles R1–R15 es compleixen. I tanmateix hi ha una mentida en marxa, perquè en recarregar la pàgina tot desapareix. El repositori en memòria va fer exactament el que havia de fer —no bloquejar-te mentre construïes l'important— i ara toca canviar-lo per alguna cosa real, sense tocar ni una línia de vista. Aquesta és la prova de foc del contracte que vas definir a la lliçó anterior. Però persistir no és només cridar localStorage.setItem: és decidir quin emmagatzematge encaixa amb el teu cas, versionar el format que deses per poder canviar-lo d'aquí a tres mesos sense perdre les dades de ningú, parlar amb una API de manera que les errades de xarxa siguin part del disseny i no una sorpresa, decidir qui guanya quan dues persones editen el mateix, permetre treballar sense connexió amb una cua de canvis que es reenvia sola, i fer que l'aplicació sembli instantània amb actualitzacions optimistes que es reverteixen si el servidor diu que no. I hi ha una part que no és tècnica i sí que és obligatòria: què no ha d'estar mai al navegador, i què implica legalment desar dades de persones. En acabar tindràs persistència amb migracions provades, una capa d'API amb els seus estats, i una cua offline funcionant.

Contingut

  1. Triar l'emmagatzematge segons el cas
  2. Quan localStorage es queda curt
  3. IndexedDB a nivell pràctic
  4. Un embolcall mínim amb promeses
  5. Versionar el format desat
  6. Migracions numerades i les seves proves
  7. El repositori com a frontera: una interfície, tres implementacions
  8. Parlar amb l'API: fetch robust
  9. Els estats d'una operació asíncrona
  10. Reintents, cancel·lació i AbortController
  11. Sincronització: qui guanya quan dos editen alhora
  12. La cua de canvis pendents
  13. Idempotència i reenviament segur
  14. Actualització optimista amb reversió
  15. Temps real sense duplicar canvis propis
  16. Seguretat i privacitat del que es desa
  17. Errades de sincronització i el seu tractament
  18. Errors Habituals i Consells
  19. Exercicis
  20. Conclusió

  1. Triar l'emmagatzematge segons el cas

La lliçó 07-01 va presentar les opcions d'emmagatzematge del navegador. Aquí la taula torna amb la columna que llavors no importava i ara sí: quan triar-ne cadascuna per al teu projecte.

Opció Capacitat típica Síncron Estructurat Persisteix Quan la tries
Variables en memòria RAM Només la sessió Estat d'interfície que no ha de sobreviure a la recàrrega
sessionStorage ~5 MB No (només text) Fins a tancar la pestanya Dades d'un flux en curs: un formulari llarg a mitges
localStorage ~5–10 MB No (només text) Fins que s'esborri Preferències, i conjunts de dades petits i estables
IndexedDB Centenars de MB a GB No (promeses) Sí (objectes, índexs) Fins que s'esborri Volums grans, consultes per índex, dades binàries
Cache Storage Centenars de MB No Peticions i respostes Fins que s'esborri Recursos de l'aplicació: el service worker de 07-05
Cookies ~4 kB No Configurable Només identificació de sessió amb el servidor
API remota Il·limitada No Sempre Multidispositiu, multiusuari, dades que importen de debò

Dos advertiments que canvien decisions:

localStorage és síncron, i això significa que bloqueja el fil principal. Desar 2 MB de JSON pot costar desenes de mil·lisegons durant els quals la interfície no respon. Amb el pressupost d'INP ≤ 200 ms de 11-01, això importa. IndexedDB és asíncrona i no bloqueja.

Res del que és al navegador no és privat. Qualsevol persona amb accés al dispositiu, i qualsevol script que s'executi a la teva pàgina, ho pot llegir tot. Hi tornarem a l'apartat 16, però tingues-ho present ja en decidir què deses.

L'arbre de decisió per al teu projecte:

flowchart TD
    A["Les dades s'han de veure<br/>en un altre dispositiu?"] -->|Si| B["API remota<br/>+ memoria cau local"]
    A -->|No| C["Quant ocupen<br/>en el pitjor cas?"]
    C -->|"< 1 MB i estable"| D["localStorage<br/>amb migracions"]
    C -->|"> 1 MB o creix sense limit"| E["IndexedDB"]
    C -->|"No ho se"| F["Mesura-ho amb dades<br/>realistes ABANS de decidir"]
    F --> C
    E --> G["Necessites consultar<br/>per alguna cosa que no sigui l'id?"]
    G -->|Si| H["IndexedDB amb indexs"]
    G -->|No| I["IndexedDB com a<br/>magatzem clau-valor"]

    style D fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb
    style F fill:#fef3c7,stroke:#d97706

El node taronja és l'important. «No ho sé» és la resposta honesta al principi, i la sortida no és endevinar: és mesurar amb dades realistes. Genera 500 tasques, 3.000 entrades d'historial i 10 usuaris ficticis, serialitza-ho i mira quant ocupa:

// Un calcul de trenta segons que evita una decisio equivocada
const dades = generarDadesRealistes({ tasques: 500, historial: 3000, usuaris: 10 });
const text = JSON.stringify(dades);
console.log('Mida:', (new Blob([text]).size / 1024).toFixed(1), 'kB');
console.time('serialitzar'); JSON.stringify(dades); console.timeEnd('serialitzar');

Per a Òrbita, amb aquest volum, el resultat ronda els 900 kB i la serialització uns 12 ms. Conclusió: localStorage serveix per al MVP, amb dues condicions — que l'historial es podi (apartat 2) i que l'escriptura no passi a cada pulsació de tecla.

  1. Quan localStorage es queda curt

Té cinc límits, i convé reconèixer-los abans de topar-hi:

Límit Símptoma Moment en què apareix
Quota (~5–10 MB) QuotaExceededError en desar Quan l'historial (R14) porta uns mesos creixent
Síncron La interfície es congela en desar Amb més d'~1 MB, o en desar molt sovint
Només text JSON.parse a cada lectura Cost de CPU proporcional al total, encara que només vulguis una tasca
Tot o res Cal llegir i escriure el document sencer Canviar una tasca reescriu les 500
Sense consultes Filtrar exigeix carregar-ho tot en memòria Sempre, encara que amb volums petits no es noti

El més perillós és el primer, perquè falla en producció i al dispositiu d'una altra persona, no al teu. I la gestió correcta no és un try/catch buit:

// src/dades/repositori-local.js
async desarTot(doc) {
  const text = JSON.stringify(doc);
  try {
    localStorage.setItem(this.clau, text);
  } catch (error) {
    if (esErrorDeQuota(error)) {
      // 1 · Podar el que es pot podar: l'historial antic
      const podat = podarHistorial(doc, { conservar: 200 });
      try {
        localStorage.setItem(this.clau, JSON.stringify(podat));
        this.avisos.emetre('historial-podat', { eliminades: quantes });
        return;
      } catch { /* continua sense cabre-hi */ }
    }
    // 2 · Si no hi cap ni podat, ES UN ERROR DE L USUARI I CAL DIR-LI
    throw new ErrorDeDades(
      'No hi ha espai per desar. Exporta les teves dades i allibera espai.',
      { causa: error, recuperable: false }
    );
  }
}

function esErrorDeQuota(error) {
  return error instanceof DOMException &&
    (error.name === 'QuotaExceededError' ||
     error.name === 'NS_ERROR_DOM_QUOTA_REACHED');  // Firefox antic
}

Tres decisions d'aquest fragment:

  • S'intenta podar abans de rendir-se. L'historial és l'única cosa prescindible; les tasques no ho són mai.
  • S'avisa de la poda. Esborrar dades de l'usuari en silenci és inacceptable, encara que siguin dades secundàries.
  • Si no hi cap, es llança amb un missatge accionable. «Error en desar» no ajuda; «exporta les teves dades i allibera espai» sí. I recuperable: false diu a la interfície que no ofereixi un botó de reintentar que tornaria a fallar.

La comprovació que gairebé ningú no fa: en mode privat d'alguns navegadors, i amb certes configuracions de bloqueig, localStorage existeix però llança en escriure. Comprova-ho en arrencar i degrada amb elegància:

export function hiHaEmmagatzematge() {
  try {
    const prova = '__orbita_prova__';
    localStorage.setItem(prova, '1');
    localStorage.removeItem(prova);
    return true;
  } catch { return false; }
}

Si retorna false, l'aplicació ha de continuar funcionant en memòria i avisar clarament que els canvis no es desaran. És la diferència entre una aplicació trencada i una aplicació honesta.

  1. IndexedDB a nivell pràctic

IndexedDB és la base de dades del navegador. Té fama d'incòmoda i la té merescuda: la seva API nativa és del 2010, basada en esdeveniments i verbosa. Però la part que necessites és petita.

El model mental, en quatre conceptes:

Concepte Equivalent A Òrbita
Base de dades Una base de dades orbita
Magatzem d'objectes (object store) Una taula tasques, usuaris, historial, cua
Clau Clau primària id de la tasca
Índex Índex de columna perResponsable, perDataLimit

I quatre regles de funcionament que cal entendre abans de fer-la servir:

  1. Tot passa dins d'una transacció, que pot ser readonly o readwrite.
  2. Les transaccions es tanquen soles així que el bucle d'esdeveniments es queda sense feina pendent per a elles. Si fas un await d'una cosa aliena enmig, la transacció mor. És la font número u d'errors desconcertants.
  3. L'esquema només es canvia a onupgradeneeded, que es dispara en obrir amb un número de versió superior. És l'equivalent a la migració de l'apartat 6, però per a l'estructura.
  4. Desa objectes, no text. Fa servir l'algorisme de clonació estructurada, així que admet Date, Map, Set, ArrayBuffer… però no funcions ni instàncies de classe amb els seus mètodes. Desa objectes plans i reconstrueix les entitats en llegir, exactament com amb desDeJSON.

Quan migrar de localStorage a IndexedDB, amb criteris objectius:

Senyal Llindar
El document serialitzat supera ~2 MB
El temps de desat supera ~16 ms (un fotograma)
Necessites llegir una part sense carregar-ho tot Sempre
Deses dades binàries (imatges, adjunts) Sempre
Necessites consultar per alguna cosa que no sigui la clau Sempre

Per al MVP d'Òrbita, amb 900 kB estimats, no cal. I això també és una decisió defensable que mereix el seu ADR: «es tria localStorage perquè el volum previst és un ordre de magnitud per sota del límit, i la frontera del repositori permet canviar a IndexedDB sense tocar la resta».

  1. Un embolcall mínim amb promeses

Si el teu projecte sí que necessita IndexedDB, no facis servir l'API nativa directament al teu repositori: embolcalla-la una vegada, en un fitxer, i oblida-te'n.

// src/dades/idb.js — embolcall minim amb promeses
export function obrir(nom, versio, enActualitzar) {
  return new Promise((resoldre, rebutjar) => {
    const peticio = indexedDB.open(nom, versio);
    peticio.onupgradeneeded = (e) => enActualitzar(e.target.result, e.oldVersion, e.newVersion);
    peticio.onsuccess = () => resoldre(peticio.result);
    peticio.onerror = () => rebutjar(new ErrorDeDades('No s ha pogut obrir la base', { causa: peticio.error }));
    peticio.onblocked = () => rebutjar(new ErrorDeDades('Hi ha una altra pestanya amb una versio antiga oberta'));
  });
}

function promesaDe(peticio) {
  return new Promise((resoldre, rebutjar) => {
    peticio.onsuccess = () => resoldre(peticio.result);
    peticio.onerror = () => rebutjar(peticio.error);
  });
}

export async function llegirTot(db, magatzem) {
  const tx = db.transaction(magatzem, 'readonly');
  return promesaDe(tx.objectStore(magatzem).getAll());
}

export async function escriureLot(db, magatzem, objectes) {
  const tx = db.transaction(magatzem, 'readwrite');
  const store = tx.objectStore(magatzem);
  // COMPTE: res d await alie aqui dins, o la transaccio es tanca
  for (const objecte of objectes) store.put(objecte);
  return new Promise((resoldre, rebutjar) => {
    tx.oncomplete = () => resoldre();
    tx.onerror = () => rebutjar(tx.error);
    tx.onabort = () => rebutjar(tx.error ?? new Error('Transaccio avortada'));
  });
}

Quatre punts que expliquen per què aquest embolcall és així:

1 · promesaDe converteix el patró d'esdeveniments en una promesa. És exactament la tècnica de «promisificació» de 05-06: una funció que embolcalla una API de callbacks en un new Promise. Escrita una vegada, serveix per a totes les operacions.

2 · onblocked està contemplat. Passa quan l'usuari té dues pestanyes obertes i una intenta actualitzar l'esquema mentre l'altra fa servir la versió antiga. Ignorar-ho produeix una penjada silenciosa que és dificilíssima de diagnosticar.

3 · El comentari de l'await no és decoratiu. Aquest és el parany clàssic:

// ❌ La transaccio mor a mitges
const tx = db.transaction('tasques', 'readwrite');
for (const tasca of tasques) {
  const validada = await validarAlServidor(tasca);    // ← await alie: tx es tanca
  tx.objectStore('tasques').put(validada);            // ← TransactionInactiveError
}

// ✅ Preparar-ho tot abans, escriure despres
const validades = await Promise.all(tasques.map(validarAlServidor));
const tx = db.transaction('tasques', 'readwrite');
for (const t of validades) tx.objectStore('tasques').put(t);

4 · S'espera oncomplete, no l'última petició. Que l'última escriptura tingui èxit no significa que la transacció s'hagi confirmat. Només oncomplete garanteix que les dades són al disc.

  1. Versionar el format desat

Aquí hi ha l'apartat que separa un projecte de joguina d'un de seriós, i val la pena dir-ho sense embuts:

El dia que canviïs el model de dades, els usuaris ja tindran dades desades amb el format antic. Si no ho has previst, les perds.

A Nómada Tasques, la clau era 'nomada:tauler:v1'. Aquell v1 era la llavor d'aquesta idea. Ara es converteix en un mecanisme complet.

El document desat mai no és la llista de tasques i prou. És un sobre amb metadades:

{
  "versio": 3,
  "desatEl": "2026-09-20T18:42:11.320Z",
  "aplicacio": "orbita",
  "dades": {
    "tasques": [],
    "usuaris": [],
    "historial": []
  }
}
Camp Per a què
versio L'únic imprescindible. Diu quines migracions cal aplicar
desatEl Depuració i resolució de conflictes per marca de temps
aplicacio Detectar que la clau l'ha escrit una altra cosa; evita corrompre dades alienes
dades El contingut real, sempre imbricat, mai a l'arrel

Aquesta imbricació importa: si les dades van a l'arrel al costat de versio, afegir una metadada nova pot xocar amb una entitat. Amb dades a part, el sobre i el contingut evolucionen per separat.

Regla de la versió: el número només puja, d'un en un, i cada pujada té la seva migració. Mai no es reutilitza un número, ni tan sols durant el desenvolupament — perquè el teu propi navegador de desenvolupament ja té dades de la versió anterior, i és allà on trobaràs les errades de migració abans que ningú.

  1. Migracions numerades i les seves proves

Una migració és una funció pura que transforma el document de la versió N a la N+1.

// src/dades/migracions.js
export const MIGRACIONS = [
  {
    a: 1,
    descripcio: 'Format inicial',
    migrar: (doc) => doc
  },
  {
    a: 2,
    descripcio: 'responsable (text) passa a responsableId (referencia)',
    migrar: (doc) => {
      const perNom = new Map(doc.dades.usuaris.map((u) => [u.nom, u.id]));
      return {
        ...doc,
        dades: {
          ...doc.dades,
          tasques: doc.dades.tasques.map(({ responsable, revisor, ...resta }) => ({
            ...resta,
            responsableId: responsable ? (perNom.get(responsable) ?? null) : null,
            revisorId: revisor ? (perNom.get(revisor) ?? null) : null
          }))
        }
      };
    }
  },
  {
    a: 3,
    descripcio: 'Afegir tascaMareId i creadaEl a les tasques existents',
    migrar: (doc) => ({
      ...doc,
      dades: {
        ...doc.dades,
        tasques: doc.dades.tasques.map((t) => ({
          ...t,
          tascaMareId: t.tascaMareId ?? null,
          creadaEl: t.creadaEl ?? doc.desatEl ?? '2026-01-01T00:00:00.000Z'
        }))
      }
    })
  }
];

export const VERSIO_ACTUAL = MIGRACIONS.at(-1).a;

export function migrar(docOriginal) {
  let doc = docOriginal;
  const desDe = doc.versio ?? 0;

  if (desDe > VERSIO_ACTUAL) {
    throw new ErrorDeDades(
      `Les dades son d una versio mes recent (${desDe}) que aquesta aplicacio (${VERSIO_ACTUAL}). ` +
      'Actualitza l aplicacio per poder obrir-les.'
    );
  }

  for (const pas of MIGRACIONS) {
    if (pas.a <= desDe) continue;
    doc = { ...pas.migrar(doc), versio: pas.a };
  }
  return doc;
}

Sis propietats d'aquest disseny, i per què cadascuna importa:

1 · Les migracions són funcions pures. Reben un document i en retornen un altre. No llegeixen ni escriuen localStorage. Per això es poden provar amb un objecte literal, sense muntar res.

2 · S'apliquen en cadena. Un usuari que va abandonar l'aplicació a la versió 1 i torna avui passa per 1→2 i 2→3 automàticament. No cal una migració «d'1 a 3».

3 · Cada migració té la seva descripció. És documentació que viu al costat del codi i apareix al registre quan s'aplica.

4 · La versió futura es rebutja amb un missatge clar. Passa de debò: l'usuari té dos dispositius i un es va actualitzar abans. Intentar llegir un format futur i «apedaçar-ho» corromp les dades; negar-s'hi i explicar-ho, no.

5 · Els valors per defecte són conservadors. creadaEl fa servir desatEl si existeix, i només si no hi ha res recorre a una data fixa. Inventar new Date() posaria totes les tasques antigues com a creades avui, trencant R4 i l'ordre de l'historial.

6 · La migració 2 fa servir l'índex perNom. I quan un nom no existeix entre els usuaris, posa null en lloc de fallar. És la decisió correcta: perdre una assignació és dolent, però no poder obrir l'aplicació és pitjor.

6.1 Com es proven les migracions

Aquesta és la part que gairebé ningú no fa i la que evita el desastre. La tècnica: desa documents reals de cada versió antiga com a fitxers de prova.

test/dades/fixtures/
  document-v1.json       ← copiat literalment d un localStorage real de la v1
  document-v2.json
  document-v1-buit.json
  document-v1-corrupte.json
// test/dades/migracions.test.js
import { migrar, VERSIO_ACTUAL } from '../../src/dades/migracions.js';
import v1 from './fixtures/document-v1.json';
import v2 from './fixtures/document-v2.json';

describe('Migracions', () => {
  test.each([
    ['v1', v1],
    ['v2', v2]
  ])('%s migra a la versio actual sense perdre tasques', (nom, original) => {
    const resultat = migrar(structuredClone(original));

    expect(resultat.versio).toBe(VERSIO_ACTUAL);
    expect(resultat.dades.tasques).toHaveLength(original.dades.tasques.length);
  });

  test('v1: cada responsable amb nom conegut conserva la seva assignacio', () => {
    const resultat = migrar(structuredClone(v1));
    const original = v1.dades.tasques.find((t) => t.responsable === 'Marta');
    const migrada = resultat.dades.tasques.find((t) => t.id === original.id);
    expect(migrada.responsableId).toBe('u-marta');
    expect(migrada).not.toHaveProperty('responsable');   // el camp vell se n va
  });

  test('un responsable desconegut passa a null, no trenca la migracio', () => {
    const ambFantasma = structuredClone(v1);
    ambFantasma.dades.tasques[0].responsable = 'Persona Que No Existeix';
    expect(() => migrar(ambFantasma)).not.toThrow();
    expect(migrar(ambFantasma).dades.tasques[0].responsableId).toBeNull();
  });

  test('el resultat de migrar es valid per al domini', () => {
    const resultat = migrar(structuredClone(v1));
    for (const pla of resultat.dades.tasques) {
      expect(() => Tasca.desDeJSON(pla)).not.toThrow();
    }
  });

  test('migrar es idempotent: aplicar-la dues vegades no canvia res', () => {
    const una = migrar(structuredClone(v1));
    const dues = migrar(structuredClone(una));
    expect(dues).toEqual(una);
  });

  test('una versio futura es rebutja amb missatge explicatiu', () => {
    expect(() => migrar({ versio: 99, dades: {} })).toThrow(/mes recent/);
  });
});

Les sis proves cobreixen les sis errades possibles, i la quarta i la cinquena són les que més valor tenen:

  • «El resultat és vàlid per al domini» és la que de debò tanca el cercle. Una migració pot produir un document sintàcticament correcte i semànticament invàlid —una tasca sense títol, unes hores a 0— que rebentarà en construir l'entitat. Validar cada objecte migrat contra el domini ho detecta allà.
  • La idempotència protegeix contra l'errada més comuna de les migracions: aplicar-les dues vegades per un error de flux. Si migrar(migrar(x)) === migrar(x), aquest error és inofensiu.

I la regla d'or operativa: abans d'aplicar migracions sobre dades reals, fes una còpia de seguretat.

async function carregarAmbMigracio() {
  const cru = localStorage.getItem(CLAU);
  if (!cru) return docBuit();

  const doc = JSON.parse(cru);
  if (doc.versio === VERSIO_ACTUAL) return doc;

  // Copia de seguretat ABANS de tocar res
  localStorage.setItem(`${CLAU}:copia:v${doc.versio}`, cru);
  try {
    const migrat = migrar(doc);
    localStorage.setItem(CLAU, JSON.stringify(migrat));
    return migrat;
  } catch (error) {
    registrar(error, { cas: 'migracio', desDe: doc.versio });
    throw new ErrorDeDades(
      'No s han pogut actualitzar les teves dades. Se n conserva una copia de seguretat.',
      { causa: error, recuperable: false }
    );
  }
}

La còpia de seguretat costa una línia i converteix un desastre irreversible en un incident recuperable. A la lliçó 11-04 depuraràs precisament una migració que corromp dades, i aquesta còpia serà el que et permetrà investigar.

  1. El repositori com a frontera: una interfície, tres implementacions

Ara es cobra la inversió de la lliçó anterior. El contracte de src/dades/repositori.js no canvia; apareixen dues implementacions més:

flowchart LR
    A["aplicacio/<br/>casos d'us"] --> C{{"Contracte Repositori<br/>llistarTasques, desarTasca,<br/>esborrarTasca, afegirCanvi…"}}
    C --> M["RepositoriMemoria<br/><i>proves, arrencada</i>"]
    C --> L["RepositoriLocal<br/><i>localStorage + migracions</i>"]
    C --> P["RepositoriApi<br/><i>fetch + reintents</i>"]
    C --> S["RepositoriSincronitzat<br/><i>local + api + cua</i>"]

    style C fill:#f3e8ff,stroke:#9333ea
    style S fill:#dbeafe,stroke:#2563eb

I la mateixa bateria de proves de contracte s'executa contra les quatre:

// test/dades/repositoris.test.js
import { provesDeContracte } from './contracte-repositori.js';

provesDeContracte('memoria', async () => new RepositoriMemoria());

provesDeContracte('local', async () => {
  localStorage.clear();
  return new RepositoriLocal('orbita:proves');
});

provesDeContracte('api', async () => {
  servidorSimulat.reiniciar();
  return new RepositoriApi('http://localhost:3001');
});

Si les tres passen les mateixes proves, són substituïbles, i canviar d'emmagatzematge és canviar una línia a main.js:

// src/main.js
const repo = import.meta.env.VITE_ORIGEN === 'api'
  ? new RepositoriApi(import.meta.env.VITE_API_URL)
  : new RepositoriLocal('orbita:tauler');

Això és el que la lliçó 08-04 anomenava injecció de dependències, i aquí se'n veu el valor complet: la mateixa decisió de disseny que va fer possible provar amb dobles fa possible canviar de tecnologia d'emmagatzematge. No són dos beneficis: és el mateix, mirat des de dos llocs.

RepositoriSincronitzat és el que construiràs als apartats 12 a 14: combina local (ràpid, sempre disponible) amb API (compartit, autoritatiu) i una cua per al que no s'ha pogut enviar. I compleix el mateix contracte, així que l'aplicació no s'assabenta de la diferència.

  1. Parlar amb l'API: fetch robust

Si el teu projecte parlarà amb un servidor, la capa de xarxa es construeix una vegada i bé. És el que feia js/dades/http.js a Nómada Tasques amb demanarJson, ErrorDeApi i ambReintents, i val la pena reconstruir-ho entenent cada decisió.

// src/dades/http.js
export class ErrorDeApi extends Error {
  constructor(missatge, { status, codi, cos } = {}) {
    super(missatge);
    this.name = 'ErrorDeApi';
    this.status = status ?? 0;
    this.codi = codi ?? null;
    this.cos = cos ?? null;
  }
  get reintentable() {
    // 0 = fallada de xarxa. 408 temps esgotat. 429 massa peticions. 5xx servidor.
    return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500;
  }
  get esDeClient() { return this.status >= 400 && this.status < 500; }
}

export async function demanarJson(url, opcions = {}) {
  const { tempsMaxim = 8000, senyal, ...resta } = opcions;
  const abortador = new AbortController();
  const temporitzador = setTimeout(() => abortador.abort('temps esgotat'), tempsMaxim);
  senyal?.addEventListener('abort', () => abortador.abort(senyal.reason), { once: true });

  try {
    const resposta = await fetch(url, {
      ...resta,
      signal: abortador.signal,
      headers: { 'Content-Type': 'application/json', ...resta.headers }
    });

    if (!resposta.ok) {
      const cos = await llegirCosSegur(resposta);
      throw new ErrorDeApi(cos?.missatge ?? `Error ${resposta.status}`, {
        status: resposta.status,
        codi: cos?.codi,
        cos
      });
    }
    return resposta.status === 204 ? null : resposta.json();
  } catch (error) {
    if (error instanceof ErrorDeApi) throw error;
    if (error.name === 'AbortError') {
      throw new ErrorDeApi('La peticio ha trigat massa', { status: 408 });
    }
    throw new ErrorDeApi('No hi ha connexio amb el servidor', { status: 0 });
  } finally {
    clearTimeout(temporitzador);
  }
}

Els set punts que fan robusta aquesta funció, i que 07-03 va introduir:

  1. fetch no llança amb 404 ni 500. Només llança si la xarxa falla. Sense la comprovació de resposta.ok, un 500 es processaria com si fos un èxit amb cos estrany. És l'error número u amb fetch.
  2. Temps màxim explícit. fetch no té temps d'espera per defecte: una petició pot quedar-se penjada indefinidament i amb ella el teu indicador de càrrega.
  3. El senyal extern es propaga. Permet que qui crida pugui cancel·lar (apartat 10) a més del temporitzador intern.
  4. El cos de l'error es llegeix. Les API retornen informació útil al cos del 400: quin camp va fallar i per què. Descartar-lo obliga a mostrar «Error 400» a un usuari que no hi pot fer res.
  5. llegirCosSegur embolcalla el json() en try/catch, perquè un error de servidor pot retornar HTML en lloc de JSON, i llavors el json() llança i tapa l'error original.
  6. 204 retorna null. Sense contingut significa sense contingut; cridar json() sobre un cos buit llança.
  7. Tot error acaba sent ErrorDeApi. La capa superior gestiona un tipus, no cinc. Això simplifica moltíssim el catch dels casos d'ús.

I ambReintents, amb retrocés exponencial i variació aleatòria:

export async function ambReintents(fn, { intents = 3, base = 300, senyal } = {}) {
  for (let i = 0; i < intents; i++) {
    try {
      return await fn();
    } catch (error) {
      const ultim = i === intents - 1;
      if (ultim || !(error instanceof ErrorDeApi) || !error.reintentable) throw error;
      const espera = base * 2 ** i + Math.random() * 200;   // 300, 600, 1200 ms + soroll
      await dormir(espera, senyal);
    }
  }
}

Només es reintenta el reintentable. Reintentar un 400 («falta el títol») és inútil: el resultat serà idèntic les tres vegades, i hauràs multiplicat per tres l'espera de l'usuari abans de mostrar-li un error que ja es coneixia al primer intent.

La variació aleatòria (jitter) evita que, si el servidor cau i torna, tots els clients reintentin al mateix mil·lisegon i el tombin una altra vegada. Amb un sol usuari tant és; és una d'aquelles coses que costen una línia i s'agraeixen quan n'hi ha mil.

  1. Els estats d'una operació asíncrona

Una operació de xarxa no té dos desenllaços, en té quatre estats, i la interfície ha de poder pintar-los tots:

stateDiagram-v2
    [*] --> Inactiu
    Inactiu --> Carregant: es llanca la peticio
    Carregant --> Exit: 2xx
    Carregant --> Error: 4xx, 5xx o xarxa
    Carregant --> Cancellat: AbortController
    Error --> Carregant: reintentar
    Exit --> Carregant: recarregar
    Cancellat --> Inactiu
Estat Què es mostra Error típic si s'ignora
Inactiu Res, o l'estat buit
Carregant Esquelet o indicador + aria-busy="true" Doble enviament per impaciència
Èxit Les dades, anunciades si canvien
Error Missatge comprensible + acció («Reintentar») Pantalla en blanc sense explicació
Cancel·lat Es torna a l'estat anterior, sense error Missatge d'error per alguna cosa que l'usuari ha cancel·lat

Dos detalls d'implementació que marquen la diferència:

Deshabilita el disparador mentre carrega. Un botó «Desar» que continua sent clicable durant els dos segons de la petició produeix tres tasques idèntiques. És l'errada més freqüent de les aplicacions que parlen amb servidors.

Retarda l'indicador de càrrega uns 200 ms. Si la resposta arriba en 80 ms, un indicador que apareix i desapareix produeix un espurneig desagradable i contribueix al CLS. Mostrar-lo només si l'operació s'allarga és un detall petit amb efecte gran en la percepció de qualitat.

let temporitzadorCarrega = setTimeout(() => magatzem.actualitzar({ carregant: true }), 200);
try {
  const dades = await repo.llistarTasques();
  magatzem.actualitzar({ tasques: dades, carregant: false, error: null });
} finally {
  clearTimeout(temporitzadorCarrega);
}

Els esquelets davant dels indicadors giratoris. Un esquelet —blocs grisos amb la forma del contingut que arribarà— informa millor i, sobretot, reserva l'espai, evitant el salt de disseny que castiga el CLS del pressupost de 11-01. Un indicador giratori centrat no reserva res.

  1. Reintents, cancel·lació i AbortController

La cancel·lació és la part que més s'oblida, i produeix una errada molt concreta: la resposta obsoleta que trepitja la bona.

L'escenari, que passa sempre que hi ha un cercador:

t=0    ms  L usuari escriu "ser"    → peticio A
t=120  ms  L usuari escriu "seri"   → peticio B
t=400  ms  Arriba la resposta de B → es pinten els resultats de "seri"  ✅
t=650  ms  Arriba la resposta de A → es pinten els resultats de "ser"   ❌

L'usuari veu resultats d'una cerca que ja no està escrita. I no és una errada rara: és el que passa per defecte quan les peticions triguen diferent.

La solució amb AbortController:

// src/aplicacio/casos-us.js
let abortadorCerca = null;

export async function cercar(magatzem, repo, text) {
  abortadorCerca?.abort('cerca superada');    // cancella l anterior
  abortadorCerca = new AbortController();

  try {
    const resultats = await repo.cercarTasques(text, { senyal: abortadorCerca.signal });
    magatzem.actualitzar({ resultats, carregant: false });
  } catch (error) {
    if (error.name === 'AbortError' || error.causa?.name === 'AbortError') return;  // esperat
    magatzem.actualitzar({ error: aErrorInterficie(error), carregant: false });
  }
}

Tres regles de la cancel·lació:

  1. Una cancel·lació no és un error. S'ignora en silenci. Mostrar «Error: petició avortada» per alguna cosa que ha provocat el teu propi codi és desconcertant per a l'usuari.
  2. Cancel·la també en desmuntar. El destruir() d'una vista ha d'avortar les seves peticions en vol. Si no, la resposta arriba a una vista que ja no existeix, intenta tocar un DOM desconnectat i deixa viva tota la seva cadena de referències: és una de les fuites de memòria que buscaràs a 11-04.
  3. Cancel·lació i debounce es combinen. El debounce de 09-02 redueix el nombre de peticions; la cancel·lació assegura que, de les que sí que surten, només importa l'última. Necessites totes dues.

  1. Sincronització: qui guanya quan dos editen alhora

Així que hi ha més d'un dispositiu, apareix el problema central de la sincronització: dues persones editen la mateixa tasca i cal decidir què preval.

Les tres estratègies, amb les seves contrapartides reals:

Estratègia Com funciona Avantatges Inconvenients Quan triar-la
Última escriptura guanya El servidor accepta l'últim que arriba Trivial d'implementar; mai no bloqueja Perd canvis en silenci; depèn de rellotges Dades d'un sol amo; preferències
Versió / updatedAt El client envia la versió que va llegir; el servidor rebutja si ha canviat (409) No perd res sense avisar; detectable i explicable Requereix resoldre el conflicte a la interfície La recomanada per a Òrbita
Fusió per camps Es combinen canvis de camps diferents Molts conflictes desapareixen sols Complexa; pot produir estats incoherents Documents amb camps independents
CRDT / fusió automàtica Estructures que convergeixen sense conflicte Col·laboració real en temps real Molt complexa; format de dades condicionat Edició col·laborativa tipus document

Per al teu projecte, la segona. És la que ofereix la millor relació entre cost i garantia, i s'implementa així:

// El client envia la versio que tenia
await demanarJson(`${base}/tasques/${id}`, {
  method: 'PUT',
  headers: { 'If-Match': tasca.versio },      // o al cos, si l API ho prefereix
  body: JSON.stringify(tasca.toJSON())
});
// El servidor respon 409 Conflict si la seva versio es diferent

I què fer amb el 409, que és la part que decideix la qualitat de l'aplicació:

Opció Experiència Recomanació
Sobreescriure sense preguntar Es perd la feina d'una altra persona Mai
Descartar el meu sense preguntar Es perd la meva feina Mai
Recarregar i avisar «Aquesta tasca ha canviat; s'han recarregat les dades» Acceptable si el meu canvi era trivial
Mostrar totes dues versions i triar «Tu has posat 12 h; la Marta ha posat 8 h» amb dos botons La correcta

Implementar la quarta costa una pantalla petita i és exactament el tipus de detall que a 11-06 podràs explicar en una entrevista: demostra que has pensat en el cas incòmode.

Sobre els rellotges. «Última escriptura guanya» compara marques de temps, i els rellotges dels clients no són fiables: poden estar desajustats hores. Si fas servir marques de temps per decidir, fes servir sempre les del servidor, mai les del client. És un detall que produeix errades impossibles de reproduir.

  1. La cua de canvis pendents

Treballar sense connexió —la promesa de la PWA de 07-05— exigeix que els canvis que no s'han pogut enviar no es perdin. L'estructura que ho resol és una cua persistent d'operacions.

// src/dades/cua.js
export class CuaDeCanvis {
  #clau;

  encuar(operacio) {
    const entrada = {
      id: crypto.randomUUID(),          // clau d idempotencia (apartat 13)
      tipus: operacio.tipus,            // 'crear' | 'actualitzar' | 'esborrar'
      recurs: operacio.recurs,          // 'tasca' | 'usuari'
      carregaUtil: operacio.carregaUtil,
      creadaEl: new Date().toISOString(),
      intents: 0,
      ultimError: null
    };
    this.#persistir([...this.llistar(), entrada]);
    return entrada.id;
  }

  llistar() { /* llegeix de localStorage o IndexedDB */ }
  marcarIntent(id, error) { /* intents++, ultimError */ }
  eliminar(id) { /* en confirmar-se */ }
  get pendents() { return this.llistar().length; }
}

El cicle de vida d'una operació en cua:

flowchart TD
    A["L usuari actua"] --> B["S aplica en local<br/><i>optimista</i>"]
    B --> C{"Hi ha connexio?"}
    C -->|Si| D["Enviar al servidor"]
    C -->|No| E["Encuar"]
    D -->|2xx| F["Confirmar:<br/>eliminar de la cua"]
    D -->|"4xx (no reintentable)"| G["Revertir + avisar<br/>+ treure de la cua"]
    D -->|"5xx / xarxa"| E
    E --> H["Esperar esdeveniment 'online'<br/>o reintent programat"]
    H --> I["Buidar la cua<br/>en ordre"]
    I --> D

    style E fill:#fef3c7,stroke:#d97706
    style G fill:#fee2e2,stroke:#b91c1c
    style F fill:#dcfce7,stroke:#16a34a

Les cinc regles d'una cua que funciona:

  1. Ordre estricte. Les operacions es reenvien en l'ordre en què es van encuar. Si «crear tasca 7» i «actualitzar tasca 7» s'envien a l'inrevés, la segona falla amb 404.
  2. Persistent, no en memòria. Si viu en una variable, es perd en tancar la pestanya — justament quan més falta fa.
  3. Cada entrada amb la seva clau d'idempotència. Apartat següent.
  4. Límit d'intents. Després de 5 fallades, l'entrada passa a «necessita atenció» i es mostra a l'usuari. Una cua que reintenta eternament una operació impossible consumeix bateria i no avisa mai.
  5. Visible. L'usuari ha de poder veure quants canvis estan pendents i per què. Un indicador discret: «3 canvis sense sincronitzar».

El dispar del buidatge, amb tres fonts:

window.addEventListener('online', () => sincronitzador.buidar());
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') sincronitzador.buidar();
});
setInterval(() => { if (navigator.onLine) sincronitzador.buidar(); }, 60_000);

Compte amb navigator.onLine: diu si hi ha interfície de xarxa, no si hi ha internet. Amb una wifi d'hotel sense autenticar, retorna true i les peticions fallen igual. Serveix com a pista per no intentar-ho quan clarament no hi ha xarxa, però la veritat la dona el fetch, no la propietat.

  1. Idempotència i reenviament segur

Una operació és idempotent si executar-la diverses vegades produeix el mateix resultat que executar-la una. És la propietat que fa que reenviar sigui segur, i sense ella la cua de l'apartat anterior és perillosa.

Operació Idempotent per naturalesa? Risc en reenviar
GET /tasques Cap
PUT /tasques/7 amb l'objecte complet Cap
DELETE /tasques/7 Sí (el segon dona 404, i tant és) Cap
POST /tasques No Tasques duplicades
PATCH /tasques/7 { hores: +2 } (increment) No Hores sumades dues vegades

L'escenari del duplicat és aquest, i passa més del que sembla:

1. El client envia POST /tasques
2. El servidor la crea correctament
3. La resposta es perd (xarxa caiguda justament llavors)
4. El client creu que ha fallat i reencua
5. En recuperar la xarxa, reenvia → SEGONA TASCA IDENTICA

La solució: clau d'idempotència. El client genera un identificador únic per operació —no per reintent— i l'envia a cada intent:

await demanarJson(`${base}/tasques`, {
  method: 'POST',
  headers: { 'Idempotency-Key': entrada.id },     // el mateix als 5 reintents
  body: JSON.stringify(entrada.carregaUtil)
});

El servidor desa les claus ja processades i, si en veu una de repetida, retorna el resultat original en lloc de crear-la un altre cop. És el mecanisme que fan servir les passarel·les de pagament, per raons evidents.

I si el servidor no admet claus d'idempotència? Amb json-server o una altra API que no controles, hi ha dues mitigacions parcials:

  1. Que el client generi l'id (un UUID) en lloc de deixar-ho al servidor. Llavors el POST és efectivament un PUT sobre un identificador conegut, i el segon enviament sobreescriu en comptes de duplicar. Trenca la R1 tal com estava escrita, així que és una decisió que cal anotar.
  2. Comprovar abans de reenviar: GET per un camp distintiu per veure si ja existeix. És més fràgil (hi ha una condició de cursa entre la comprovació i la creació) però millor que res.

I una regla de disseny d'API que convé conèixer: prefereix operacions absolutes a incrementals. PATCH { hores: 14 } és idempotent; PATCH { hores: '+2' } no ho és. La primera forma elimina un problema sencer en lloc de gestionar-lo.

  1. Actualització optimista amb reversió

Una actualització optimista aplica el canvi a la interfície abans que el servidor confirmi, suposant que funcionarà. Si falla, es reverteix.

Enfocament Percepció Risc
Pessimista: esperar la resposta 300–800 ms d'espera a cada acció Cap, però se sent lent
Optimista: aplicar ja, revertir si falla Instantani Cal gestionar bé la reversió

La diferència en sensació és enorme, i per això les aplicacions que se senten ràpides ho fan així. El patró:

export async function marcarFeta(magatzem, repo, id) {
  const abans = magatzem.obtenir().tasques;               // 1 · desar per revertir
  const optimista = abans.map((t) => t.id === id ? t.ambEstat('feta') : t);

  magatzem.actualitzar({ tasques: optimista });           // 2 · aplicar ja
  anunciar('Tasca marcada com a feta');

  try {
    const confirmada = await repo.actualitzarTasca(id, { estat: 'feta' });
    magatzem.actualitzar({                                 // 3 · substituir pel del servidor
      tasques: magatzem.obtenir().tasques.map((t) => t.id === id ? confirmada : t)
    });
  } catch (error) {
    magatzem.actualitzar({ tasques: abans, error: aErrorInterficie(error) });   // 4 · revertir
    anunciar('No s ha pogut desar el canvi. S ha desfet.');
    throw error;
  }
}

Les quatre regles de l'actualització optimista:

  1. Desa l'estat anterior complet abans de tocar res. És el que permet revertir amb exactitud.
  2. Substitueix pel que retorna el servidor, no deixis la teva versió optimista. El servidor pot haver afegit camps —un updatedAt, un id real— o haver normalitzat alguna cosa.
  3. La reversió ha de ser visible i anunciada. Un canvi que es desfà en silenci fa que l'usuari es cregui que va desar una cosa que no va desar. És pitjor que no ser optimista.
  4. No siguis optimista amb tot. La taula:
Operació Optimista? Per què
Canviar d'estat, marcar, reordenar Reversible, de baix risc, molt freqüent
Editar un camp de text Ídem
Crear una tasca Amb compte Sí, però amb id temporal marcat com a «pendent»
Esborrar No, millor confirmar Difícil de revertir de manera convincent; espanta si reapareix
Pagar, enviar, tancar definitivament Mai Les accions irreversibles es confirmen abans de mostrar-se fetes

L'id temporal en crear mereix explicació. Si crees la tasca optimista amb id: -1 i després el servidor retorna id: 42, tot el que es fes amb la tasca mentrestant apuntaria a un id inexistent. Dues solucions: generar l'UUID al client (apartat 13), o marcar visualment la tasca com a «desant» i impedir accions sobre ella fins que es confirmi. La primera és més neta.

  1. Temps real sense duplicar canvis propis

Si el teu projecte incorpora temps real —el CanalTauler de 07-04, que estenia EventTarget amb reconnexió, retrocés i batec—, hi ha un problema molt concret que apareix sempre:

El servidor et reenvia el teu propi canvi, i l'apliques dues vegades.

El símptoma típic: crees una tasca, apareix, i mig segon després apareix un altre cop. O el comptador d'hores es dobla. Tres maneres de resoldre-ho, de pitjor a millor:

Solució Com Valoració
Ignorar missatges durant N ms després d'actuar Finestra temporal Fràgil: depèn de la latència
Identificador de client Cada missatge porta origenId; s'ignora si és el propi Simple i fiable
Reconciliació per id i versió S'aplica sempre, però com a substitució idempotent La més robusta

La segona és suficient per a gairebé tot:

// src/dades/temps-real.js
const EL_MEU_ID = crypto.randomUUID();      // un per pestanya, en memoria

canal.addEventListener('tasca-actualitzada', (esdeveniment) => {
  const { tasca, origenId } = esdeveniment.detail;
  if (origenId === EL_MEU_ID) return;                 // es el meu propi eco
  magatzem.actualitzar({ tasques: fusionarPerId(magatzem.obtenir().tasques, tasca) });
  anunciar(`${tasca.titol} ha estat actualitzada per una altra persona`);
});

I fusionarPerId implementa la tercera de propina: substitueix per id si existeix, afegeix si no, i ignora si la versió que arriba és més antiga que la que tens. Amb aquesta condició, aplicar el mateix missatge dues vegades és inofensiu, i l'ordre d'arribada deixa d'importar.

Tres regles del temps real que eviten problemes:

  1. El canal es destrueix en sortir. El destruir() de 07-04 tanca el socket i treu les escoltes. Sense ell, navegar entre pantalles obre connexions que no es tanquen.
  2. No confiïs en l'ordre d'arribada. Els missatges poden arribar desordenats. Per això la fusió ha de ser idempotent i basada en versió, no en «aplicar el que arribi».
  3. Anuncia els canvis aliens, no els propis. Que la interfície canviï sola sense explicació és desconcertant, especialment per a qui fa servir lector de pantalla. aria-live="polite" amb «La Marta ha marcat Inventari de tintes com a feta».

  1. Seguretat i privacitat del que es desa

Aquest apartat no és opcional i convé llegir-lo sencer.

16.1 Què no ha d'estar mai al navegador

No desis Per què Què fer en el seu lloc
Claus d'API i secrets Tot el JavaScript del client és públic: es llegeix amb Veure Codi Font El servidor desa la clau i exposa un punt d'accés propi
Contrasenyes, encara que estiguin «codificades» Base64 no és xifratge; es descodifica en un segon Mai no surten del servidor; s'envien i s'obliden
Tokens de llarga durada a localStorage Qualsevol XSS els roba (apartat 16.2) Cookie HttpOnly + Secure + SameSite, o token curt en memòria
Dades personals sensibles Salut, ideologia, biometria… categoria especial del RGPD No les tractis; i si el producte les exigeix, amb assessorament legal
Dades de tercers sense base legal No són teves Dades fictícies en desenvolupament, sempre

El cas dels tokens mereix detall perquè és l'error més freqüent. Un token de sessió a localStorage és accessible des de qualsevol JavaScript de la pàgina. Si un atacant aconsegueix executar codi —una dependència compromesa, un XSS—, s'emporta la sessió completa. Una cookie HttpOnly no és accessible des de JavaScript, així que aquest vector desapareix. No és una diferència teòrica: és la diferència entre un XSS molest i un robatori de comptes.

16.2 XSS: per què importa més quan hi ha API

La lliçó 06-02 va establir la regla: textContent per a dades, innerHTML només per a literals teus. Amb dades que vénen d'una API, el risc es multiplica, perquè el contingut l'ha escrit una altra persona.

// ❌ Si el titol ve d una API i conte <img src=x onerror="...">, s executa
fila.innerHTML = `<h3>${tasca.titol}</h3>`;

// ✅ El text es text
fila.querySelector('h3').textContent = tasca.titol;

La regla completa:

Situació Ús correcte
Text de dades textContent
Atribut de dades setAttribute amb valor validat
URL de dades Validar l'esquema: només http: i https:
HTML enriquit d'usuari Sanejar amb una llibreria mantinguda, mai a mà
Estructura teva, fixa innerHTML amb literals, o <template>

La validació d'URL és la que s'oblida: javascript:alert(1) en un camp d'enllaç s'executa en prémer. Comprova sempre l'esquema abans d'assignar un href.

I a 11-05 hi afegiràs la segona capa: una Content-Security-Policy que impedeix executar scripts en línia encara que se'n coli un.

16.3 Dades personals i RGPD

Així que la teva aplicació desa noms, correus electrònics, fotos o qualsevol dada que identifiqui una persona —fins i tot indirectament—, estàs tractant dades personals, i a la Unió Europea això està regulat pel RGPD.

El que sí que et puc dir amb seguretat, perquè són principis del reglament:

Principi Què significa al teu projecte
Minimització Desa només el necessari. Necessites la data de naixement? Gairebé segur que no
Limitació de finalitat Les dades recollides per a una cosa no es fan servir per a una altra
Limitació del termini Defineix quant es conserven i esborra-les després
Integritat i confidencialitat Xifratge en trànsit (HTTPS, 11-05) i control d'accés
Transparència La persona ha de saber què deses, per què i durant quant de temps
Drets Accés, rectificació, supressió i portabilitat de les seves dades

I el que no et puc donar: assessorament legal.

Advertiment explícit. Un producte real que tracti dades de persones reals exigeix una revisió legal i de compliment normatiu: base legal del tractament, informació a la persona interessada, registre d'activitats, encarregats de tractament (qualsevol servei extern que facis servir), transferències internacionals, i en alguns casos avaluació d'impacte. Res d'això no es resol amb codi i res d'això no és matèria d'un curs de JavaScript. Mentre aprens, fes servir dades fictícies —com les de Taller Nómada— i no publiquis un producte amb dades reals sense haver-ho consultat amb qui correspongui.

El que sí que pots fer des d'ara, i és bona enginyeria a més de bona pràctica legal:

  • Documenta a docs/model-dades.md quins camps són personals i per a què.
  • Implementa l'exportació de totes les dades d'una persona (et serveix a més com a còpia de seguretat).
  • Implementa l'esborrat de debò, no un actiu: false disfressat. Compte: l'esborrat real xoca amb l'historial immutable de R14. La solució habitual és anonimitzar les entrades d'historial —substituir el nom per «Usuari eliminat»— conservant la integritat del registre. És una decisió que mereix el seu ADR.
  • No registris dades personals al sistema d'errors, com ja es va dir a 11-02.

  1. Errades de sincronització i el seu tractament

Aquesta taula és el resum operatiu de tota la lliçó. Tingues-la a mà mentre implementes: cada fila és una errada que passarà.

# Errada Símptoma Causa Tractament
1 Sense connexió en desar L'acció no arriba al servidor Xarxa caiguda Encuar + aplicar en local + indicador de pendents
2 Servidor caigut (5xx) Error després d'esperar Fallada temporal Reintentar amb retrocés; després de N, encuar
3 Petició penjada Indicador etern Sense temps màxim AbortController amb temps màxim (apartat 8)
4 Resposta obsoleta Dades d'una cerca anterior Cursa entre peticions Cancel·lar l'anterior abans de llançar
5 Conflicte d'edició (409) El canvi es rebutja Una altra persona va editar abans Mostrar totes dues versions i deixar triar
6 Duplicat en reenviar Dues tasques idèntiques POST no idempotent Clau d'idempotència o id generat al client
7 Eco del temps real El canvi propi apareix dues vegades El servidor reenvia a tothom origenId + fusió per id i versió
8 Quota plena QuotaExceededError Historial crescut Podar, avisar, i si no hi cap, missatge accionable
9 Dades de versió futura No es pot obrir Un altre dispositiu es va actualitzar abans Rebutjar amb missatge clar; no endevinar
10 Migració que corromp Dades estranyes després d'actualitzar Migració amb una errada Còpia de seguretat prèvia + validació contra el domini
11 JSON corrupte SyntaxError en arrencar Escriptura interrompuda try/catch en analitzar + arrencar des de la còpia
12 Rellotge del client desajustat Ordre de canvis equivocat Hora local errònia Fer servir sempre la marca de temps del servidor
13 Cua encallada Res no se sincronitza mai Una operació impossible bloqueja l'ordre Límit d'intents + entrada «necessita atenció»
14 Dues pestanyes del mateix usuari Es trepitgen les dades locals Totes dues escriuen a la mateixa clau storage event o BroadcastChannel per coordinar

El cas 14 és el que més sorprèn qui l'ensopega per primera vegada. Dues pestanyes obertes amb la mateixa aplicació escriuen al mateix localStorage sense saber-ho. L'esdeveniment storage avisa les altres pestanyes quan una escriu:

window.addEventListener('storage', (e) => {
  if (e.key !== CLAU) return;
  magatzem.actualitzar({ ...llegirDocument(), avisAltraPestanya: true });
});

Amb cinc línies, les dues pestanyes es mantenen coherents. Sense elles, l'última que desa trepitja la feina de l'altra.

Errors Habituals i Consells

No versionar el format desat. És l'error que més dades destrueix. Sense versio al document, el dia que afegeixis un camp obligatori tindràs dos formats indistingibles convivint, i cap manera neta de saber quin és quin. Posar versio: 1 des del primer dia costa una línia.

Migrar sense còpia de seguretat. Una migració amb una errada destrueix dades de manera irreversible. Desar l'original sota una altra clau abans de tocar-lo costa una línia i converteix el desastre en un incident.

Migracions que no es proven amb dades reals. Provar la migració amb un objecte que has escrit a mà per a la prova demostra poc: les dades reals tenen camps inesperats, null on no els esperaves i estructures de versions intermèdies. Desa documents reals com a fitxers de prova.

Suposar que fetch llança amb un error HTTP. No ho fa. Sense if (!resposta.ok), un 500 es processa com a èxit i l'errada apareix tres capes més amunt amb un missatge incomprensible.

No posar temps màxim a les peticions. fetch espera indefinidament. Amb una xarxa dolenta, l'indicador de càrrega es queda girant per sempre i l'usuari no té sortida.

Reintentar el que no s'ha de reintentar. Un 400 donarà 400 les tres vegades. Reintentar-ho només triplica l'espera abans del mateix error. Només es reintenta el reintentable: xarxa, 408, 429 i 5xx.

No cancel·lar peticions obsoletes. Produeix l'errada més desconcertant de totes: resultats d'una cerca anterior que trepitgen els bons, de manera intermitent i depenent de la latència. Pràcticament impossible de reproduir a propòsit si no saps que existeix.

POST a la cua sense idempotència. Reenviar una creació després d'una fallada de xarxa duplica el registre. És l'errada que produeix «tinc la mateixa tasca tres vegades» i que l'usuari no sap explicar mai.

Revertir en silenci. Si una actualització optimista falla i desfàs sense avisar, l'usuari es creu que va desar una cosa que no es va desar. És pitjor que no haver estat optimista.

Desar tokens a localStorage. Qualsevol XSS s'emporta la sessió completa. Cookie HttpOnly o token curt en memòria.

Posar dades personals al registre d'errors. És un problema legal, no només d'estil, i s'agreuja així que aquest registre s'envia a un servei extern (11-05). Registra identificadors i noms de camp, mai valors.

Consell · Mesura la mida de les teves dades abans de triar emmagatzematge. Trenta segons de JSON.stringify amb dades realistes eviten tant la ingenuïtat de localStorage amb 20 MB com la sobreenginyeria d'IndexedDB amb 200 kB.

Consell · Prova amb la xarxa estrangulada i desconnectada. El panell Network de DevTools té mode Offline i perfils lents. La meitat de les errades d'aquesta lliçó només apareixen allà. Fes-ho part de la teva rutina de tancament d'increment.

Consell · Implementa exportar i importar aviat. Un botó que descarrega totes les dades en JSON et serveix de còpia de seguretat manual, d'eina de depuració, de manera de moure dades entre dispositius sense servidor, i de compliment del dret de portabilitat. Quatre beneficis per una tarda de feina.

Consell · Deixa un plafó de diagnòstic amagat. Una pantalla amb la versió del format, la mida ocupada, les entrades de la cua i els últims errors registrats. T'estalviarà hores quan alguna cosa falli en un dispositiu que no és el teu.

Exercicis

Aquests exercicis són la fita H4 del teu projecte: persistència amb migracions, la capa d'API amb els seus estats, i la cua offline.

Exercici 1 — Persistència amb migracions provades.

  1. Implementa RepositoriLocal complint el contracte complet, amb el document embolcallat (versio, desatEl, aplicacio, dades).
  2. Executa la bateria de proves de contracte contra ell i contra RepositoriMemoria; totes dues han de passar exactament les mateixes proves.
  3. Implementa migracions.js amb almenys tres migracions reals del teu projecte (no inventades: canvis que de debò hagis fet o faràs al model).
  4. Desa documents de prova reals de cada versió antiga a test/dades/fixtures/, incloent-hi un de buit i un amb una dada inesperada.
  5. Escriu les sis proves de migració de l'apartat 6.1: no perd dades, mapeja correctament, tolera dades desconegudes, produeix documents vàlids per al domini, és idempotent, i rebutja versions futures.
  6. Implementa la còpia de seguretat prèvia, la gestió de QuotaExceededError amb poda de l'historial i avís, i la detecció d'emmagatzematge no disponible amb degradació a memòria.
  7. Implementa exportar i importar totes les dades en JSON, amb validació en importar.

Exercici 2 — La capa d'API amb els seus estats.

Munta un servidor de proves local (json-server o equivalent) i:

  1. Implementa http.js amb ErrorDeApi (amb status, codi, reintentable), demanarJson amb temps màxim i ambReintents amb retrocés exponencial i variació aleatòria.
  2. Implementa RepositoriApi complint el contracte i passant la mateixa bateria de proves que els altres dos.
  3. Implementa els cinc estats a la interfície: inactiu, carregant (amb esquelet, aria-busy i retard de 200 ms), èxit, error (amb acció de reintent) i cancel·lat.
  4. Implementa la cancel·lació de la cerca amb AbortController, combinada amb debounce, i demostra amb una prova que una resposta obsoleta no trepitja la bona.
  5. Implementa la gestió del 409 amb la pantalla de resolució que mostra totes dues versions.
  6. Escriu proves amb fetch simulat (08-04) per a: 200, 400 amb cos útil, 500 amb reintent reeixit al segon intent, temps esgotat, i cancel·lació.

Exercici 3 — La cua offline i l'actualització optimista.

  1. Implementa CuaDeCanvis persistent, amb id d'idempotència, comptador d'intents, ordre estricte i límit de reintents.
  2. Implementa RepositoriSincronitzat que compleix el mateix contracte combinant local + API + cua.
  3. Implementa el buidatge disparat per online, visibilitychange i temporitzador, amb l'advertiment de navigator.onLine.
  4. Implementa l'actualització optimista amb reversió en almenys tres operacions, amb anunci en cas de reversió, i respecta la taula de què no ha de ser optimista.
  5. Implementa l'indicador de «N canvis sense sincronitzar» accessible i una vista de la cua amb les entrades que necessiten atenció.
  6. Implementa la coordinació entre pestanyes amb l'esdeveniment storage o BroadcastChannel.
  7. Escriu proves amb temporitzadors falsos per a: encuar sense xarxa, buidar en recuperar-la, ordre preservat, no duplicar en reenviar, i reversió en fallar.
  8. Demostra-ho a mà: amb DevTools en mode Offline, fes cinc canvis, torna a connectar i comprova que els cinc arriben en ordre i sense duplicats. Grava un GIF: et servirà per a la demo de 11-06.

Solucions

Criteris d'acceptació de l'exercici 1 — Persistència

# Criteri Com es comprova
1 Les dades sobreviuen a la recàrrega Crear una tasca, F5, hi continua
2 El document porta versio Inspeccionar localStorage a DevTools
3 Un document v1 s'obre sense perdre res Enganxar un v1 real i comprovar el nombre de tasques
4 Es fa còpia de seguretat abans de migrar Existeix orbita:tauler:copia:v1 després de migrar
5 Migrar és idempotent migrar(migrar(x)) és igual a migrar(x)
6 El migrat és vàlid per al domini Tasca.desDeJSON no llança amb cap element
7 Una versió futura es rebutja amb missatge Posar versio: 99 i veure l'avís
8 Un JSON corrupte no impedeix arrencar Escriure {{{ a la clau; l'aplicació arrenca i avisa
9 Quota plena poda i avisa Omplir localStorage a propòsit i observar
10 Sense emmagatzematge, funciona en memòria i avisa Simular la fallada de setItem
11 Contracte: memòria i local passen el mateix La mateixa funció de proves, dues crides
12 Exportar produeix un JSON reimportable Exportar, esborrar-ho tot, importar, comparar

Rúbrica de l'exercici 1 (21 punts)

Dimensió 0 1 2 3
Versionat Sense versió Camp present Document embolcallat complet A més amb aplicacio i comprovació
Migracions Cap Una, sense provar ≥ 3 provades Amb dades reals i validació contra el domini
Robustesa Sense try/catch Captura genèrica Quota, corrupció i no disponible tractats A més amb degradació i missatges accionables
Còpia de seguretat No n'hi ha Manual Automàtica abans de migrar A més recuperable des de la interfície
Contracte Només una implementació Dues sense proves comunes Proves comunes Idèntiques i en verd per a les tres
Exportar/importar No Exporta Exporta i importa Amb validació i missatges d'error per fila
Proves < 5 5–9 ≥ 10 incloses les 6 de migració A més amb casos límit reals

Llindar: 15/21, amb obligatòriament 3 a «Migracions». És la part que destrueix dades si està malament.

Criteris d'acceptació de l'exercici 2 — API

# Criteri Com es comprova
1 Un 500 es tracta com a error Simular i comprovar que no es processa com a èxit
2 Un 400 mostra el missatge del servidor El cos de l'error arriba a la interfície
3 Una petició penjada es talla Retardar 30 s; als 8 s hi ha error de temps esgotat
4 Un 5xx es reintenta, un 400 no Comptar les crides al fetch simulat
5 El retrocés és exponencial amb soroll Comprovar els temps amb temporitzadors falsos
6 L'indicador triga 200 ms a aparèixer Resposta de 80 ms: no espurneja
7 El disparador es deshabilita en carregar Doble clic ràpid produeix una petició
8 La resposta obsoleta no trepitja Prova amb dues respostes desordenades
9 Cancel·lar no mostra error Cap alerta en avortar
10 El 409 ofereix triar versió Pantalla amb tots dos valors i dues accions
11 Contracte: l'API passa les mateixes proves Bateria comuna en verd
12 Cap secret al client Cercar claus a dist/ després de compilar: zero resultats

Criteris d'acceptació de l'exercici 3 — Cua i optimisme

# Criteri Com es comprova
1 Sense xarxa, l'acció s'aplica i s'encua Mode Offline + inspeccionar la cua
2 La cua sobreviu al tancament de la pestanya Tancar i reobrir; les entrades hi continuen
3 En recuperar la xarxa es buida sola Tornar a Online sense recarregar
4 L'ordre es preserva Crear i després actualitzar: arriben en aquest ordre
5 No hi ha duplicats Tallar la xarxa després d'enviar i abans de rebre; en reenviar, una sola tasca
6 Després de N intents es marca «necessita atenció» Forçar un 400 permanent
7 La reversió es veu i s'anuncia Forçar la fallada; el canvi es desfà amb avís
8 Esborrar no és optimista Es confirma abans
9 L'indicador de pendents és accessible Text, no només icona; anunciat en canviar
10 Dues pestanyes es mantenen coherents Canviar en una, veure l'efecte a l'altra
11 L'eco de temps real no duplica Si ho implementes: crear i observar una sola targeta
12 La demostració manual funciona El GIF de 5 canvis offline

Rúbrica global de la fita H4 (24 punts)

Dimensió Pes Què s'avalua
Persistència i migracions 6 Versionat, cadena de migracions, còpia de seguretat, proves amb dades reals
Robustesa de xarxa 5 Errors tipats, temps màxim, reintents selectius, cancel·lació
Estats d'interfície 4 Els cinc estats, sense espurneig, sense doble enviament, accessibles
Cua i sense connexió 5 Persistència, ordre, idempotència, límit d'intents, visibilitat
Conflictes 2 Detecció i resolució amb participació de l'usuari
Seguretat i privacitat 2 Sense secrets, sense innerHTML amb dades, registre sense dades personals

Llindar: 17/24. Un 0 a «Seguretat i privacitat» invalida la fita independentment de la resta: un producte que filtra una clau d'API o que executa l'HTML que li envien no està acabat, per bé que funcioni tota la resta.

Autoavaluació de la fita H4:

Pregunta Sí / No
Podria canviar de localStorage a IndexedDB tocant només un fitxer?
Sé què passa si un usuari obre dades d'una versió antiga? I d'una de futura?
He provat la meva aplicació amb la xarxa desconnectada?
Hi ha alguna clau, token o secret al meu codi de client?
Faig servir innerHTML amb alguna dada que no hagi escrit jo?
El meu registre d'errors conté alguna dada personal?
Puc exportar totes les meves dades i tornar-les a importar?

Les preguntes 4, 5 i 6 són les que cal respondre «no». Si alguna és «sí», arregla-la abans de passar a la lliçó següent: a 11-05 aquesta aplicació estarà publicada a internet.

Conclusió

Has convertit una aplicació que ho perdia tot en recarregar en un producte les dades del qual sobreviuen, es comparteixen i es recuperen.

Saps triar l'emmagatzematge amb un arbre de decisió que comença per una pregunta honesta —«quant ocupa en el pitjor cas?»— la resposta correcta de la qual al principi és «no ho sé» i la sortida de la qual no és endevinar sinó mesurar amb dades realistes: trenta segons de JSON.stringify que eviten tant la ingenuïtat com la sobreenginyeria. Coneixes els cinc límits de localStorage —quota, sincronia, només text, tot o res, sense consultes—, saps que el perillós és la quota perquè falla al dispositiu d'una altra persona, i saps tractar-lo podant el prescindible, avisant de la poda i donant un missatge accionable quan ja no hi cap. I coneixes IndexedDB a nivell pràctic: transaccions que es tanquen soles si hi poses un await aliè, esquema que només canvia a onupgradeneeded, onblocked per a les dues pestanyes, i un embolcall amb promeses que s'escriu una vegada.

Tens el que separa un projecte seriós d'un de joguina: el format desat versionat en un sobre amb versio, desatEl i aplicacio, i una cadena de migracions numerades que són funcions pures, s'apliquen en seqüència, rebutgen les versions futures amb un missatge clar, fan servir valors per defecte conservadors i prefereixen perdre una assignació abans que impedir obrir l'aplicació. Amb les sis proves que les avalen —no perd dades, mapeja bé, tolera el desconegut, produeix documents vàlids per al domini, és idempotent i rebutja el futur— i amb documents reals desats com a fitxers de prova, perquè les dades de debò tenen camps que tu no hauries escrit a mà. I amb la còpia de seguretat prèvia, que costa una línia i converteix un desastre irreversible en un incident investigable.

Has cobrat la inversió del contracte del repositori: la mateixa bateria de proves executada contra memòria, local i API demostra que són intercanviables, i canviar d'emmagatzematge és canviar una línia a main.js. És la injecció de dependències de 08-04 mirada des de l'altre costat: el que va fer possible provar amb dobles fa possible canviar de tecnologia.

Saps parlar amb una API amb els set punts de demanarJson: comprovar resposta.ok perquè fetch no llança amb un 500, posar temps màxim perquè no en té, propagar el senyal extern, llegir el cos de l'error perquè allà hi ha la informació útil, embolcallar el json() perquè un 500 pot retornar HTML, tractar el 204, i unificar-ho tot a ErrorDeApi. Amb reintents només del reintentable, retrocés exponencial i variació aleatòria. I saps que una operació asíncrona té cinc estats i no dos, que l'indicador s'ha de retardar 200 ms per no espurnejar, que el disparador es deshabilita mentre carrega, i que un esquelet reserva l'espai que un indicador giratori no reserva.

Saps cancel·lar, que és el que evita l'errada més desconcertant de totes: la resposta obsoleta que trepitja la bona. Amb les tres regles —una cancel·lació no és un error, es cancel·la també en desmuntar, i debounce i cancel·lació es combinen perquè resolen coses diferents.

Saps decidir qui guanya quan dues persones editen: la taula de quatre estratègies, la recomanació de versió amb 409, i sobretot què fer amb aquest 409 — ni sobreescriure ni descartar en silenci, sinó mostrar totes dues versions i deixar triar, que és el detall que demostra que has pensat en el cas incòmode. Amb l'advertiment sobre els rellotges del client, que no són fiables mai.

Tens la cua de canvis pendents amb les seves cinc regles —ordre estricte, persistent, amb clau d'idempotència, amb límit d'intents i visible—, els seus tres disparadors de buidatge, i l'advertiment sobre navigator.onLine, que diu si hi ha interfície de xarxa i no si hi ha internet. Saps què és la idempotència, per què POST és l'únic verb perillós, com la clau d'idempotència evita el duplicat que produeix «tinc la mateixa tasca tres vegades», i per què convé preferir operacions absolutes a incrementals.

Saps fer que l'aplicació sembli instantània amb actualitzacions optimistes: desar l'estat anterior, aplicar ja, substituir pel que retorna el servidor i revertir amb avís si falla — perquè una reversió silenciosa és pitjor que no haver estat optimista. I saps amb què no ser optimista: esborrar, pagar, i tot el que és irreversible. I saps integrar el temps real sense duplicar els teus propis canvis, amb origenId i una fusió per id i versió que fa inofensiu aplicar el mateix missatge dues vegades.

I saps el que no pot estar al navegador: claus, contrasenyes, tokens de llarga durada a localStorage que qualsevol XSS s'emporta, i dades personals sense base legal. Saps que textContent per a dades i innerHTML només per al que és teu importa molt més quan el contingut ve d'una API, que les URL cal validar-les per esquema, i que els principis del RGPD —minimització, finalitat, termini, transparència, drets— es tradueixen en decisions de modelatge concretes. Amb l'advertiment sense embuts: un producte real amb dades de persones reals exigeix revisió legal i de compliment, i això no es resol amb codi.

Tanques amb la taula de les catorze errades de sincronització que passaran, inclosa la de les dues pestanyes del mateix usuari que es trepitgen i que es resol amb cinc línies i l'esdeveniment storage.

La fita H4 està tancada: persistència amb migracions, capa d'API amb els seus estats i cua offline. El teu projecte ja fa tot el que promet. La pregunta que queda és la incòmoda: com saps que ho continua fent demà? Perquè ara hi ha migracions que poden corrompre dades, curses que només apareixen amb mala xarxa i vistes que poden retenir memòria en navegar — tres errades que no es veuen mirant la pantalla. Convertir la qualitat en alguna cosa automàtica i verificable, i depurar aquestes tres errades concretes amb mètode, és Proves i Depuració del Projecte.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats