Cada vegada que node --watch reinicia el servidor, els cafès tornen a ser dos i la comanda com_5001 ressuscita en pendent_pagament. Els arrays en memòria ens han servit per construir el contracte sense distraccions, però no són una base de dades: no sobreviuen a un reinici, no poden garantir que descomptar estoc de tres línies passi de manera atòmica i no escalen més enllà d'uns pocs milers de registres. Avui els substituïm per SQLite amb better-sqlite3 darrere del patró repositori, i ho farem complint la promesa que vam fer a 03-01: no es toca ni una línia dels controladors ni dels serveis. Pel camí veurem l'esquema SQL de la Botiga Aroma amb els diners en cèntims enters, migracions versionades, sentències preparades i per què eliminen la injecció SQL, transaccions per crear una comanda, control de concurrència optimista, el problema N+1 i la paginació per cursor implementada de debò.

Contingut

  1. Què canvia avui al projecte
  2. El patró repositori
  3. Quina base de dades i per què SQLite en aquest curs
  4. better-sqlite3: síncron i sense sorpreses
  5. L'esquema SQL de la Botiga Aroma
  6. snake_case a la base de dades i camelCase al JSON
  7. Migracions: què són i per què es versionen
  8. L'script de migració i les dades de sembra
  9. La connexió: src/config/base-dades.js
  10. src/repositoris/cafes-sqlite.js amb sentències preparades
  11. Per què les sentències preparades eviten la injecció SQL
  12. Consultes dinàmiques segures: filtres, ordre i paginació
  13. Transaccions: crear una comanda descomptant estoc
  14. Concurrència optimista i conflicte_versio
  15. El problema N+1 en carregar les línies
  16. Paginació per cursor de debò
  17. ORMs: quan compensen
  18. Connexions i tancament ordenat

  1. Què canvia avui al projecte

Fitxer Acció
migracions/001-inicial.sql Nou: esquema complet
migracions/aplicar.js Nou: aplica les migracions pendents
migracions/sembrar.js Nou: dades inicials del curs
src/config/base-dades.js Nou: obre i configura la connexió
src/repositoris/cafes-sqlite.js Nou: repositori de cafès sobre SQL
src/repositoris/comandes-sqlite.js Nou: repositori de comandes sobre SQL
src/repositoris/index.js Nou: tria quina implementació es fa servir
src/serveis/cafes.js Modificat: una línia d'import
src/serveis/comandes.js Modificat: una línia d'import
src/servidor.js Modificat: tanca la base de dades en apagar
package.json Modificat: scripts migrar i sembrar
src/repositoris/*-memoria.js Es conserven: els farem servir a les proves de 03-08

Que la llista de modificacions sigui tan curta és el resultat que buscàvem. Si els controladors haguessin parlat directament amb l'array, avui tocaríem vint fitxers.

  1. El patró repositori

Un repositori és un objecte que ofereix operacions sobre una col·lecció d'entitats i amaga completament on i com estan desades. La interfície que ja fem servir des de 03-02:

Mètode Què fa Retorna
buscar(criteris) Consulta amb filtres, ordre i paginació { elements, total }
buscarPerId(id) Un de concret L'entitat o undefined
crear(dades) Insereix L'entitat creada
actualitzar(id, canvis) Modifica L'entitat actualitzada o undefined
esborrar(id) Esborrat lògic true / false

Les tres propietats que fan que valgui la pena:

  • Substituïbilitat. cafes-memoria.js i cafes-sqlite.js compleixen la mateixa interfície. Canviar d'un a l'altre és canviar un import, i a 03-08 farem servir precisament el de memòria com a doble de prova.
  • Localització del SQL. Tot el SQL de cafès és en un fitxer. Quan una consulta va lenta, saps on mirar; quan canvia una columna, saps què revisar.
  • Vocabulari de domini. El servei demana buscarPerId('caf_001'), no executa un SELECT. La lògica de negoci es llegeix sense soroll tècnic.

I el límit del patró, dit amb honestedat: no fa que les bases de dades siguin intercanviables per art de màgia. Una consulta amb JSON_EXTRACT de SQLite no funciona igual a MongoDB. El que el repositori garanteix és que, si s'ha de reescriure, es reescriu en un sol lloc.

graph LR
  C[controladors] --> S[serveis]
  S --> I[repositoris/index.js]
  I --> M[cafes-memoria.js]
  I --> Q[cafes-sqlite.js]
  Q --> D[(SQLite)]

  1. Quina base de dades i per què SQLite en aquest curs

SQLite PostgreSQL MongoDB
Tipus Relacional, en fitxer Relacional, client-servidor Documental
Instal·lació Cap: és una llibreria Servidor a part Servidor a part
Concurrència d'escriptura Un escriptor alhora Molts, amb MVCC Molts
Transaccions Sí, ACID Sí, ACID i molt completes Sí, des de la 4.0
Esquema Rígid (amb CHECK) Rígid i ric en tipus Flexible
Tipus per als diners INTEGER (cèntims) NUMERIC(10,2) o BIGINT Decimal128 o enter
Quan fer-lo servir Desenvolupament, proves, aplicacions petites, encastades L'opció per defecte en producció Dades poc estructurades o molt variables

Per què SQLite al curs: perquè no hi ha res per instal·lar ni configurar, la base de dades és un fitxer que pots esborrar i regenerar en un segon, i el SQL que escriurem és SQL estàndard, així que el que aprenguis es transfereix. El que aprenguis de sentències preparades, transaccions, índexs i N+1 val exactament igual a PostgreSQL.

Què canviaria amb PostgreSQL. Poc en el disseny i bastant en la mecànica: es faria servir el paquet pg amb un pool de connexions, les crides serien asíncrones (await client.query(...)), els marcadors de posició serien $1, $2 en comptes de ?, i les transaccions s'escriurien amb BEGIN/COMMIT explícits sobre una connexió reservada del pool. Els tipus hi guanyarien precisió: TIMESTAMPTZ per a les dates, NUMERIC disponible per als diners, JSONB amb índexs per a les notes de tast. L'estructura del repositori, però, seria la mateixa; per això començar per SQLite no és una via morta.

Què canviaria amb MongoDB. Més: desapareixen les unions, una comanda desaria les seves línies incrustades dins del mateix document —cosa que, curiosament, elimina d'arrel el problema N+1 de la secció 15— i les transaccions multidocument existeixen però es fan servir molt menys. És una elecció raonable per a catàlegs amb atributs molt variables; per a comandes, amb la seva integritat referencial i els seus totals, el model relacional encaixa millor.

  1. better-sqlite3: síncron i sense sorpreses

La raresa de better-sqlite3 és que la seva API és síncrona: db.prepare(sql).get(id) retorna la fila directament, sense await. En un ecosistema on tot el que toca entrada/sortida és asíncron, això xoca. I és correcte:

  • SQLite no és un servidor: no hi ha xarxa, no hi ha latència. La consulta és una lectura de fitxer, sovint des de la memòria cau del sistema operatiu, que triga microsegons.
  • Embolcallar aquella operació en una promesa afegiria més sobrecàrrega que la consulta mateixa.
  • El codi resultant és més simple i les transaccions són trivials: sense await pel mig, no hi ha risc que una altra petició s'esmunyi enmig d'una transacció.

La contrapartida: si una consulta triga 200 ms, bloqueja el bucle d'esdeveniments i cap altra petició no avança durant aquest temps. La conseqüència pràctica és que cal mantenir les consultes ràpides —amb índexs, sense escanejos complets— i no fer informes pesats al mateix procés que serveix l'API. Amb PostgreSQL i pg, tot seria asíncron i aquest problema no existiria.

Els nostres serveis ja estan escrits de manera síncrona des de 03-03, així que la migració és directa. Si demà passéssim a PostgreSQL, caldria convertir en async els mètodes del repositori, del servei i del controlador —i allà l'embolcall asincron() que veurem a 03-07 esdevé imprescindible.

  1. L'esquema SQL de la Botiga Aroma

-- migracions/001-inicial.sql

PRAGMA foreign_keys = ON;

-- ---------------------------------------------------------------
-- CAFÈS
-- ---------------------------------------------------------------
CREATE TABLE cafes (
  id             TEXT    PRIMARY KEY,                    -- 'caf_001', clau de text
  nom            TEXT    NOT NULL,
  origen         TEXT    NOT NULL,
  torrefaccio    TEXT    NOT NULL CHECK (torrefaccio IN ('clar', 'mitja', 'fosc')),
  preu_centims   INTEGER NOT NULL CHECK (preu_centims > 0),
  estoc          INTEGER NOT NULL DEFAULT 0 CHECK (estoc >= 0),
  notes_tast     TEXT    NOT NULL DEFAULT '[]',          -- array JSON serialitzat
  descripcio     TEXT,                                   -- admet NULL
  data_creacio   TEXT    NOT NULL,                       -- ISO-8601 UTC amb Z
  actiu          INTEGER NOT NULL DEFAULT 1,             -- esborrat lògic: 1 / 0
  versio         INTEGER NOT NULL DEFAULT 1              -- concurrència optimista
);

-- Índexs per als filtres i l'ordenació decidits a 02-06
CREATE INDEX idx_cafes_origen       ON cafes (origen);
CREATE INDEX idx_cafes_torrefaccio  ON cafes (torrefaccio);
CREATE INDEX idx_cafes_preu         ON cafes (preu_centims);
CREATE INDEX idx_cafes_actiu        ON cafes (actiu);

-- ---------------------------------------------------------------
-- CLIENTS  (s'omple de debò a 03-06)
-- ---------------------------------------------------------------
CREATE TABLE clients (
  id                TEXT PRIMARY KEY,                     -- 'cli_842'
  nom               TEXT NOT NULL,
  email             TEXT NOT NULL UNIQUE,                 -- unicitat garantida per la BD
  hash_contrasenya  TEXT NOT NULL,                        -- MAI la contrasenya en clar
  rol               TEXT NOT NULL DEFAULT 'client'
                    CHECK (rol IN ('client', 'empleat', 'administrador', 'soci')),
  data_creacio      TEXT NOT NULL
);

-- ---------------------------------------------------------------
-- COMANDES
-- ---------------------------------------------------------------
CREATE TABLE comandes (
  id                  TEXT    PRIMARY KEY,                -- 'com_5001'
  client_id           TEXT    NOT NULL REFERENCES clients(id),
  estat               TEXT    NOT NULL DEFAULT 'pendent_pagament'
                      CHECK (estat IN ('pendent_pagament', 'pagat', 'enviat')),
  total_centims       INTEGER NOT NULL CHECK (total_centims >= 0),
  data_creacio        TEXT    NOT NULL,
  data_pagament       TEXT,
  data_enviament      TEXT,
  clau_idempotencia   TEXT    UNIQUE,                     -- Idempotency-Key de 02-03
  versio              INTEGER NOT NULL DEFAULT 1
);

-- Índex compost: serveix al filtre per client I a la paginació per
-- cursor de la secció 16, que ordena per (data_creacio, id).
CREATE INDEX idx_comandes_client_data ON comandes (client_id, data_creacio DESC, id DESC);
CREATE INDEX idx_comandes_estat_data  ON comandes (estat, data_creacio DESC, id DESC);

-- ---------------------------------------------------------------
-- LÍNIES DE COMANDA
-- ---------------------------------------------------------------
CREATE TABLE linies_comanda (
  comanda_id    TEXT    NOT NULL REFERENCES comandes(id) ON DELETE CASCADE,
  cafe_id       TEXT    NOT NULL REFERENCES cafes(id),
  nom_cafe      TEXT    NOT NULL,      -- còpia CONGELADA del nom
  quantitat     INTEGER NOT NULL CHECK (quantitat > 0),
  preu_centims  INTEGER NOT NULL,      -- preu CONGELAT del dia de la compra
  PRIMARY KEY (comanda_id, cafe_id)    -- un cafè no es pot repetir en una comanda
);

CREATE INDEX idx_linies_comanda ON linies_comanda (comanda_id);

-- ---------------------------------------------------------------
-- RESSENYES
-- ---------------------------------------------------------------
CREATE TABLE ressenyes (
  id            TEXT    PRIMARY KEY,                     -- 'res_101'
  cafe_id       TEXT    NOT NULL REFERENCES cafes(id) ON DELETE CASCADE,
  client_id     TEXT    NOT NULL REFERENCES clients(id),
  puntuacio     INTEGER NOT NULL CHECK (puntuacio BETWEEN 1 AND 5),
  comentari     TEXT,
  estat         TEXT    NOT NULL DEFAULT 'pendent_moderacio'
                CHECK (estat IN ('pendent_moderacio', 'publicada', 'rebutjada')),
  data_creacio  TEXT    NOT NULL
);

CREATE INDEX idx_ressenyes_cafe ON ressenyes (cafe_id, estat);

Decisions de l'esquema que convé justificar una per una:

preu_centims INTEGER. És la decisió de 02-05 portada a la base de dades. Un REAL (coma flotant) seria un error de disseny: SELECT SUM(preu) FROM ... acumularia errors d'arrodoniment i el total de la comanda podria diferir en cèntims del que se li va mostrar al client. Amb enters, la suma és exacta per definició. A PostgreSQL es podria fer servir NUMERIC(10,2), que també és exacte; l'enter de cèntims funciona en qualsevol motor.

Claus primàries de text (caf_001). Trenca el costum de l'INTEGER AUTOINCREMENT, i a canvi dona el que vam decidir a 02-02: identificadors opacs que diuen de quin tipus són i que no filtren quants registres hi ha. El cost és un índex una mica més gran i comparacions de cadena en lloc d'enters, irrellevant a aquesta escala.

CHECK als enumerats. La validació de Zod (03-04) ja rebutja una torrefacció invàlida, però el CHECK protegeix davant de qualsevol via d'entrada: un script d'importació, una correcció manual amb sqlite3, un bug futur. És defensa en profunditat, i no substitueix la validació a la vora, la complementa: el CHECK produeix un error de base de dades, no un 400 amable.

notes_tast com a text JSON. SQLite no té tipus array. Desem '["cítric","floral"]' i l'analitzem en llegir. És acceptable perquè mai no filtrem per una nota concreta amb SQL; el ?q= cerca dins del text. Si demà calgués filtrar per nota, el correcte seria una taula cafes_notes amb una fila per nota. A PostgreSQL faríem servir JSONB amb un índex GIN i no hi hauria discussió.

actiu INTEGER. SQLite no té boolean: es fan servir 1 i 0. I hi ha un detall pràctic important: better-sqlite3 rebutja els booleans de JavaScript com a paràmetres; cal passar 1 o 0 explícitament. És un error que sorprèn la primera vegada.

Dates com a TEXT en ISO-8601 UTC. SQLite tampoc no té tipus data. El format ISO-8601 té una propietat valuosíssima: l'ordre alfabètic coincideix amb l'ordre cronològic, així que ORDER BY data_creacio DESC funciona correctament sobre text. Això és exactament el que fa possible la paginació per cursor de la secció 16.

versio INTEGER. És el comptador per al control de concurrència optimista de la secció 14.

  1. snake_case a la base de dades i camelCase al JSON

Tenim tres vocabularis i cal saber on es tradueix cadascun:

Capa Convenció Exemple
Base de dades snake_case, unitats internes preu_centims
Model intern (del repositori cap amunt) camelCase, unitats internes preuCentims
Representació pública (JSON) camelCase, unitats públiques preuEuros

I les dues fronteres:

  • BD → model intern: al repositori, en una funció aModel(fila).
  • Model intern → JSON: al mapejador de 03-03 (cafeARepresentacio).

Ens hauríem pogut estalviar la primera traducció fent servir camelCase també a les columnes. No ho fem perquè snake_case és la convenció universal a SQL —les eines, els bolcats i els DBA s'ho esperen— i perquè a PostgreSQL els identificadors en majúscules obliguen a posar cometes a cada consulta. Que la traducció visqui en una sola funció del repositori fa que el cost sigui negligible.

  1. Migracions: què són i per què es versionen

Una migració és un canvi de l'esquema escrit com un fitxer SQL, numerat, immutable i versionat a Git.

El problema que resol és aquest: tu crees la taula cafes al teu portàtil executant SQL a mà. El teu company no la té. El servidor de proves té una versió antiga sense la columna versio. Producció en té una altra de diferent. Ningú no sap quin esquema hi ha a cada lloc, i aplicar un canvi es converteix en un ritual manual amb actes.

Les regles de les migracions, que no admeten excepcions:

  1. Numerades i ordenades: 001-inicial.sql, 002-afegir-descomptes.sql.
  2. Immutables: una migració aplicada mai no s'edita. Si estava malament, es corregeix amb una de nova.
  3. Versionades a Git, al costat del codi que les necessita.
  4. Registrades: la mateixa base de dades desa quines s'han aplicat.

Aquesta quarta regla exigeix una taula més, que afegim al principi del fitxer de migracions:

CREATE TABLE IF NOT EXISTS migracions (
  nom          TEXT PRIMARY KEY,
  aplicada_en  TEXT NOT NULL
);

  1. L'script de migració i les dades de sembra

// migracions/aplicar.js
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { baseDades } from '../src/config/base-dades.js';

const carpeta = dirname(fileURLToPath(import.meta.url));

// Taula de control: quines migracions s'han aplicat ja.
baseDades.exec(`
  CREATE TABLE IF NOT EXISTS migracions (
    nom         TEXT PRIMARY KEY,
    aplicada_en TEXT NOT NULL
  );
`);

const jaAplicades = new Set(
  baseDades.prepare('SELECT nom FROM migracions').all().map((fila) => fila.nom)
);

// S'ordenen per nom: per això el prefix numèric amb zeros a l'esquerra.
const fitxers = readdirSync(carpeta)
  .filter((f) => f.endsWith('.sql'))
  .sort();

let aplicades = 0;

for (const fitxer of fitxers) {
  if (jaAplicades.has(fitxer)) {
    console.log(`- ${fitxer}: ja aplicada`);
    continue;
  }

  const sql = readFileSync(join(carpeta, fitxer), 'utf8');

  // Cada migració s'aplica DINS D'UNA TRANSACCIÓ: si el fitxer
  // té deu sentències i falla la setena, no queda un esquema a mitges.
  const executar = baseDades.transaction(() => {
    baseDades.exec(sql);
    baseDades
      .prepare('INSERT INTO migracions (nom, aplicada_en) VALUES (?, ?)')
      .run(fitxer, new Date().toISOString());
  });

  executar();
  console.log(`+ ${fitxer}: aplicada`);
  aplicades++;
}

console.log(`\n${aplicades} migració/ons aplicada/es. Esquema al dia.`);

I la sembra, que carrega les dades amb què treballem tot el curs:

// migracions/sembrar.js
import { baseDades } from '../src/config/base-dades.js';

const inserirCafe = baseDades.prepare(`
  INSERT INTO cafes (id, nom, origen, torrefaccio, preu_centims, estoc,
                     notes_tast, descripcio, data_creacio, actiu, versio)
  VALUES (@id, @nom, @origen, @torrefaccio, @preuCentims, @estoc,
          @notesTast, @descripcio, @dataCreacio, 1, 1)
`);

const cafes = [
  {
    id: 'caf_001',
    nom: 'Etiòpia Yirgacheffe',
    origen: 'Etiòpia',
    torrefaccio: 'clar',
    preuCentims: 1450,
    estoc: 120,
    notesTast: JSON.stringify(['cítric', 'floral', 'te negre']),
    descripcio: null,
    dataCreacio: '2026-01-15T09:00:00Z',
  },
  {
    id: 'caf_002',
    nom: 'Colòmbia Huila',
    origen: 'Colòmbia',
    torrefaccio: 'mitja',
    preuCentims: 1290,
    estoc: 80,
    notesTast: JSON.stringify(['xocolata', 'caramel', 'nou']),
    descripcio: null,
    dataCreacio: '2026-01-20T11:15:00Z',
  },
];

// Tota la sembra, en una sola transacció.
const sembrar = baseDades.transaction(() => {
  baseDades.prepare('DELETE FROM linies_comanda').run();
  baseDades.prepare('DELETE FROM comandes').run();
  baseDades.prepare('DELETE FROM cafes').run();
  baseDades.prepare('DELETE FROM clients').run();

  for (const cafe of cafes) inserirCafe.run(cafe);

  baseDades
    .prepare(
      `INSERT INTO clients (id, nom, email, hash_contrasenya, rol, data_creacio)
       VALUES ('cli_842', 'Marta Garcia', '[email protected]', 'pendent-de-03-06',
               'client', '2026-01-10T08:00:00Z')`
    )
    .run();

  baseDades
    .prepare(
      `INSERT INTO comandes (id, client_id, estat, total_centims, data_creacio, versio)
       VALUES ('com_5001', 'cli_842', 'pendent_pagament', 2900, '2026-03-14T10:30:00Z', 1)`
    )
    .run();

  baseDades
    .prepare(
      `INSERT INTO linies_comanda (comanda_id, cafe_id, nom_cafe, quantitat, preu_centims)
       VALUES ('com_5001', 'caf_001', 'Etiòpia Yirgacheffe', 2, 1450)`
    )
    .run();
});

sembrar();
console.log('Dades de sembra carregades: 2 cafès, 1 client, 1 comanda.');

Amb els scripts a package.json:

"scripts": {
  "migrar": "node migracions/aplicar.js",
  "sembrar": "node migracions/sembrar.js",
  "bd:reiniciar": "rm -f dades/aroma.db && npm run migrar && npm run sembrar"
}
mkdir -p dades
npm run migrar
npm run sembrar
+ 001-inicial.sql: aplicada

1 migració/ons aplicada/es. Esquema al dia.
Dades de sembra carregades: 2 cafès, 1 client, 1 comanda.

Sembra i migració són coses diferents. La migració canvia l'estructura i s'executa a tots els entorns, producció inclosa. La sembra hi fica dades i només té sentit en desenvolupament i en proves. Barrejar-les —un INSERT dins d'una migració— és un error clàssic que acaba ficant cafès de prova a producció.

  1. La connexió: src/config/base-dades.js

// src/config/base-dades.js
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { entorn } from './entorn.js';

// Ens assegurem que existeixi la carpeta del fitxer .db
if (entorn.rutaBaseDades !== ':memory:') {
  mkdirSync(dirname(entorn.rutaBaseDades), { recursive: true });
}

export const baseDades = new Database(entorn.rutaBaseDades);

// --- PRAGMAs: configuració del motor ---

// WAL (Write-Ahead Logging): permet que les lectures no bloquegin
// l'escriptura ni a l'inrevés. És la diferència entre una SQLite de joguina
// i una d'utilitzable per una API amb concurrència.
baseDades.pragma('journal_mode = WAL');

// Les claus foranes estan DESACTIVADES per defecte a SQLite, per
// compatibilitat històrica. Sense aquesta línia, REFERENCES no es comprova.
baseDades.pragma('foreign_keys = ON');

// Si una altra connexió té la base bloquejada, esperar fins a 5 s abans de
// fallar amb SQLITE_BUSY, en comptes de rendir-se a l'instant.
baseDades.pragma('busy_timeout = 5000');

/** Tanca la connexió. La crida l'apagada ordenada de servidor.js. */
export function tancarBaseDades() {
  baseDades.close();
}

La línia de foreign_keys = ON és la que més disgustos evita: sense ella, SQLite accepta alegrement una línia de comanda que apunta a un cafè inexistent i el problema es descobreix mesos després amb dades òrfenes.

  1. src/repositoris/cafes-sqlite.js amb sentències preparades

// src/repositoris/cafes-sqlite.js
import { baseDades } from '../config/base-dades.js';

/** Fila de la base de dades → model intern de l'aplicació. */
function aModel(fila) {
  if (!fila) return undefined;
  return {
    id: fila.id,
    nom: fila.nom,
    origen: fila.origen,
    torrefaccio: fila.torrefaccio,
    preuCentims: fila.preu_centims,
    estoc: fila.estoc,
    notesTast: JSON.parse(fila.notes_tast),
    descripcio: fila.descripcio,
    dataCreacio: fila.data_creacio,
    actiu: fila.actiu === 1,
    versio: fila.versio,
  };
}

// --- Sentències preparades ---
// Es preparen UNA VEGADA, en carregar el mòdul, i es reutilitzen a cada
// petició. SQLite analitza el SQL i calcula el pla d'execució només la
// primera vegada; després, executar és substituir paràmetres i córrer.
const sentencies = {
  perId: baseDades.prepare('SELECT * FROM cafes WHERE id = ? AND actiu = 1'),

  inserir: baseDades.prepare(`
    INSERT INTO cafes (id, nom, origen, torrefaccio, preu_centims, estoc,
                       notes_tast, descripcio, data_creacio, actiu, versio)
    VALUES (@id, @nom, @origen, @torrefaccio, @preuCentims, @estoc,
            @notesTast, @descripcio, @dataCreacio, 1, 1)
  `),

  actualitzar: baseDades.prepare(`
    UPDATE cafes
       SET nom = @nom, origen = @origen, torrefaccio = @torrefaccio,
           preu_centims = @preuCentims, estoc = @estoc,
           notes_tast = @notesTast, descripcio = @descripcio,
           versio = versio + 1
     WHERE id = @id AND actiu = 1
  `),

  esborratLogic: baseDades.prepare(
    'UPDATE cafes SET actiu = 0, versio = versio + 1 WHERE id = ? AND actiu = 1'
  ),

  seguentNumero: baseDades.prepare(
    "SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) + 1 AS seguent FROM cafes"
  ),
};

export const repositoriCafes = {
  buscarPerId(id) {
    return aModel(sentencies.perId.get(id));
  },

  crear(dades) {
    const numero = sentencies.seguentNumero.get().seguent;
    const registre = {
      id: `caf_${String(numero).padStart(3, '0')}`,
      nom: dades.nom,
      origen: dades.origen,
      torrefaccio: dades.torrefaccio,
      preuCentims: dades.preuCentims,
      estoc: dades.estoc,
      notesTast: JSON.stringify(dades.notesTast ?? []),
      descripcio: dades.descripcio ?? null,
      dataCreacio: new Date().toISOString(),
    };
    sentencies.inserir.run(registre);
    return this.buscarPerId(registre.id);
  },

  actualitzar(id, canvis) {
    const actual = this.buscarPerId(id);
    if (!actual) return undefined;

    // Fusionem l'estat actual amb els canvis: així el mateix SQL serveix
    // per al PUT (arriben tots els camps) i per al PATCH (n'arriben alguns).
    const fusionat = { ...actual, ...canvis };

    sentencies.actualitzar.run({
      id,
      nom: fusionat.nom,
      origen: fusionat.origen,
      torrefaccio: fusionat.torrefaccio,
      preuCentims: fusionat.preuCentims,
      estoc: fusionat.estoc,
      notesTast: JSON.stringify(fusionat.notesTast),
      descripcio: fusionat.descripcio,
    });

    return this.buscarPerId(id);
  },

  esborrar(id) {
    // .run() retorna, entre altres coses, quantes files s'han modificat.
    return sentencies.esborratLogic.run(id).changes > 0;
  },

  // buscar() s'implementa a la secció 12: necessita SQL dinàmic.
};

Sobre la generació d'identificadors: seguentNumero calcula el màxim existent i hi suma un. Funciona perquè SQLite serialitza les escriptures, i l'embolcallarem en la mateixa transacció que la inserció. A PostgreSQL faríem servir una SEQUENCE, i en un sistema distribuït l'habitual seria un ULID o un UUID amb prefix (caf_01HQ...), que es genera sense consultar res i no revela el volum de negoci.

  1. Per què les sentències preparades eviten la injecció SQL

Compara aquestes dues maneres de cercar per origen:

// PERILLÓS: concatenació de text. NO ho facis mai.
const sql = `SELECT * FROM cafes WHERE origen = '${origen}'`;
baseDades.prepare(sql).all();

// SEGUR: marcador de posició.
baseDades.prepare('SELECT * FROM cafes WHERE origen = ?').all(origen);

Amb la primera, si el client envia ?origen=x' OR '1'='1, el SQL que s'executa és:

SELECT * FROM cafes WHERE origen = 'x' OR '1'='1'

Retorna el catàleg sencer. I amb una mica més d'imaginació —x'; DROP TABLE comandes; --— destrueix dades. Aquella tècnica és al primer lloc dels riscos de seguretat des de fa vint anys i continua funcionant en aplicacions reals.

Per què la versió amb ? és immune. No és que "escapi les cometes": és que el SQL i les dades viatgen per camins separats. La sentència s'analitza i es compila abans de conèixer els valors, i produeix un pla d'execució amb forats. Quan s'executa, cada valor es col·loca al seu forat com a dada, ja al motor, sense tornar a passar per l'analitzador. Per definició, un valor no es pot convertir en instrucció: x' OR '1'='1 es cerca literalment com a origen, no troba res, i retorna zero files. El motor no hi veu una cometa especial; hi veu una cadena de vint caràcters.

Tres conseqüències pràctiques:

  • Marcadors a tots els valors. Sempre. Encara que la dada "vingui de dins", encara que sigui un número, encara que n'estiguis segur.
  • Els marcadors només valen per a valors, no per a noms de taula, de columna ni per a ASC/DESC. Això es resol amb llistes blanques, com veurem ara mateix.
  • La validació de 03-04 no substitueix això. És defensa en profunditat: la validació filtra formes, les sentències preparades fan impossible l'atac.

  1. Consultes dinàmiques segures: filtres, ordre i paginació

buscar() ha de combinar filtres opcionals. La tècnica és construir la llista de condicions i la llista de paràmetres alhora, sense concatenar mai un valor:

// src/repositoris/cafes-sqlite.js  (continuació)

/**
 * Llista blanca d'ordenació: nom públic → columna real.
 * És OBLIGATÒRIA perquè ORDER BY no admet marcadors de posició i el seu
 * valor s'hauria de concatenar. Només es concatena el que surt d'aquí.
 */
const COLUMNES_ORDENABLES = {
  nom: 'nom',
  preuEuros: 'preu_centims',
  estoc: 'estoc',
  dataCreacio: 'data_creacio',
  id: 'id',
};

export function buscarCafes(criteris = {}) {
  const {
    origen, torrefaccio, preuMinCentims, preuMaxCentims,
    disponible, q, ordenar = [], limit = 20, desplacament = 0,
  } = criteris;

  const condicions = ['actiu = 1'];
  const parametres = [];

  if (origen !== undefined) {
    condicions.push('LOWER(origen) = LOWER(?)');
    parametres.push(origen);
  }
  if (torrefaccio !== undefined) {
    condicions.push('torrefaccio = ?');
    parametres.push(torrefaccio);
  }
  if (preuMinCentims !== undefined) {
    condicions.push('preu_centims >= ?');
    parametres.push(preuMinCentims);
  }
  if (preuMaxCentims !== undefined) {
    condicions.push('preu_centims <= ?');
    parametres.push(preuMaxCentims);
  }
  if (disponible !== undefined) {
    condicions.push(disponible ? 'estoc > 0' : 'estoc = 0');
  }
  if (q !== undefined) {
    // El comodí va al PARÀMETRE, no al SQL: continua sent una dada.
    condicions.push('(nom LIKE ? OR origen LIKE ? OR notes_tast LIKE ?)');
    const patro = `%${q}%`;
    parametres.push(patro, patro, patro);
  }

  const clausulaOn = `WHERE ${condicions.join(' AND ')}`;

  // --- Total: la MATEIXA clàusula WHERE, sense ORDER BY ni LIMIT ---
  const total = baseDades
    .prepare(`SELECT COUNT(*) AS n FROM cafes ${clausulaOn}`)
    .get(...parametres).n;

  // --- ORDER BY a partir de la llista blanca ---
  const trossos = ordenar
    .map(({ camp, descendent }) => {
      const columna = COLUMNES_ORDENABLES[camp];
      if (!columna) return null;              // camp no permès: s'ignora
      return `${columna} ${descendent ? 'DESC' : 'ASC'}`;
    })
    .filter(Boolean);

  trossos.push('id ASC');                      // desempat estable (03-03)
  const ordreSql = `ORDER BY ${trossos.join(', ')}`;

  const files = baseDades
    .prepare(`SELECT * FROM cafes ${clausulaOn} ${ordreSql} LIMIT ? OFFSET ?`)
    .all(...parametres, limit, desplacament);

  return { elements: files.map(aModel), total };
}

Tres punts crítics:

El total surt d'una consulta a part amb el mateix WHERE. No es pot obtenir de la consulta paginada, perquè LIMIT retalla. I ha de portar exactament els mateixos filtres, o el client calcularà malament el nombre de pàgines. És un COUNT(*) extra per petició, i és el preu de la paginació per desplaçament.

ORDER BY es construeix per concatenació, però només amb valors de COLUMNES_ORDENABLES. El nom que envia el client es fa servir com a clau de cerca al mapa, mai com a text SQL. Si no és al mapa, no hi ha columna. És impossible injectar-hi res.

El comodí del LIKE va al paràmetre. '%' || ? || '%' també seria vàlid, però posar el patró complet al paràmetre és més clar. El que no es pot fer mai és LIKE '%${q}%'.

Una nota de rendiment: LIKE '%text%' amb comodí inicial no pot fer servir un índex i obliga a recórrer la taula sencera. Amb 200 cafès és irrellevant; amb 200.000 registres caldria un índex de text complet (FTS5 a SQLite, tsvector a PostgreSQL) o un motor de cerca dedicat. És una limitació coneguda i acceptada del ?q= que vam prometre a 02-06.

Finalment, el selector d'implementació:

// src/repositoris/index.js
export { repositoriCafes } from './cafes-sqlite.js';
export { repositoriComandes } from './comandes-sqlite.js';

I a src/serveis/cafes.js canvia una línia:

// Abans: import { repositoriCafes } from '../repositoris/cafes-memoria.js';
import { repositoriCafes } from '../repositoris/index.js';

Aquí hi ha la promesa complerta. Reinicia, prova curl -s http://localhost:3000/v1/cafes | jq .total i comprova que tot respon igual… i que ara sobreviu als reinicis.

  1. Transaccions: crear una comanda descomptant estoc

Crear una comanda no és una operació: en són diverses que han de passar totes o cap.

  1. Comprovar que cada cafè existeix i té estoc.
  2. Inserir la fila de la comanda.
  3. Inserir una línia per cafè.
  4. Descomptar l'estoc de cada cafè.

Sense transacció, una fallada al pas 4 —per exemple, perquè l'estoc ja no arriba— deixaria una comanda cobrable amb l'inventari sense tocar. Això és corrupció de dades.

Una transacció garanteix les quatre propietats ACID; la que ens importa aquí és l'atomicitat: o s'apliquen tots els canvis, o cap.

// src/repositoris/comandes-sqlite.js
import { baseDades } from '../config/base-dades.js';

const sentencies = {
  inserirComanda: baseDades.prepare(`
    INSERT INTO comandes (id, client_id, estat, total_centims,
                          data_creacio, clau_idempotencia, versio)
    VALUES (@id, @clientId, 'pendent_pagament', @totalCentims,
            @dataCreacio, @clauIdempotencia, 1)
  `),

  inserirLinia: baseDades.prepare(`
    INSERT INTO linies_comanda (comanda_id, cafe_id, nom_cafe, quantitat, preu_centims)
    VALUES (@comandaId, @cafeId, @nomCafe, @quantitat, @preuCentims)
  `),

  // El descompte d'estoc porta la seva condició de seguretat:
  // 'AND estoc >= ?' fa que la fila NO s'actualitzi si no n'hi ha prou.
  descomptarEstoc: baseDades.prepare(
    'UPDATE cafes SET estoc = estoc - ?, versio = versio + 1 WHERE id = ? AND estoc >= ?'
  ),

  cafePerComanda: baseDades.prepare(
    'SELECT id, nom, preu_centims, estoc FROM cafes WHERE id = ? AND actiu = 1'
  ),

  seguentNumero: baseDades.prepare(
    "SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 5000) + 1 AS seguent FROM comandes"
  ),
};

/**
 * Crea una comanda de manera ATÒMICA.
 * baseDades.transaction(fn) retorna una funció: en invocar-la, executa
 * BEGIN, corre el cos i fa COMMIT. Si el cos llança una excepció,
 * fa ROLLBACK automàtic i torna a llançar l'error.
 */
export const crearComandaAtomica = baseDades.transaction(
  ({ clientId, linies, clauIdempotencia = null }) => {
    const id = `com_${sentencies.seguentNumero.get().seguent}`;
    const dataCreacio = new Date().toISOString();
    let totalCentims = 0;

    const liniesResoltes = linies.map((linia) => {
      const cafe = sentencies.cafePerComanda.get(linia.cafeId);

      if (!cafe) {
        // Llançar aquí provoca ROLLBACK: res del que s'ha fet abans persisteix.
        throw Object.assign(new Error(`El cafè '${linia.cafeId}' no existeix`), {
          codiDomini: 'cafe_no_trobat',
        });
      }
      if (cafe.estoc < linia.quantitat) {
        throw Object.assign(
          new Error(`Només queden ${cafe.estoc} unitats de '${cafe.nom}'`),
          { codiDomini: 'estoc_insuficient' }
        );
      }

      totalCentims += cafe.preu_centims * linia.quantitat;

      return {
        comandaId: id,
        cafeId: cafe.id,
        nomCafe: cafe.nom,               // congelat
        quantitat: linia.quantitat,
        preuCentims: cafe.preu_centims,  // congelat
      };
    });

    sentencies.inserirComanda.run({
      id, clientId, totalCentims, dataCreacio, clauIdempotencia,
    });

    for (const linia of liniesResoltes) {
      sentencies.inserirLinia.run(linia);

      // Segona comprovació d'estoc, aquesta vegada al mateix UPDATE.
      const resultat = sentencies.descomptarEstoc.run(
        linia.quantitat, linia.cafeId, linia.quantitat
      );
      if (resultat.changes === 0) {
        throw Object.assign(
          new Error(`Sense estoc suficient de '${linia.cafeId}'`),
          { codiDomini: 'estoc_insuficient' }
        );
      }
    }

    return id;
  }
);

Què passa si falla a mitges. Suposa que la segona línia es queda sense estoc perquè un altre client s'ha avançat per 30 mil·lisegons. L'excepció puja, better-sqlite3 executa ROLLBACK i l'estat de la base de dades torna exactament a com estava: no hi ha comanda, no hi ha línies, i l'estoc del primer cafè no s'ha descomptat. El servei tradueix aquell error a 409 estoc_insuficient i el client pot reintentar-ho. Sense transacció tindríem una comanda incompleta, una línia òrfena i estoc descomptat per una comanda que no va existir mai.

Per què l'estoc es comprova dues vegades. La primera lectura (cafePerComanda) serveix per donar un missatge d'error útil amb el nom del cafè i les unitats que queden. La segona, la condició AND estoc >= ? dins de l'UPDATE, és la que de debò protegeix: és una comprovació i una escriptura en una sola sentència atòmica, així que no hi ha finestra entre llegir i escriure. Aquest patró —comprovar al WHERE de l'actualització i mirar changes— és la manera correcta d'evitar que dues peticions simultànies venguin la mateixa darrera bossa de cafè.

Un detall de better-sqlite3 que cal respectar: dins d'una transacció no hi pot haver await. Com que la seva API és síncrona, no ens cal; però si hi barregessis una crida asíncrona, la transacció es tancaria abans d'hora. Amb PostgreSQL, en canvi, tot el bloc seria async i caldria reservar una connexió del pool per a tota la transacció.

  1. Concurrència optimista i conflicte_versio

Un altre problema de concurrència, diferent del de l'estoc: l'actualització perduda.

sequenceDiagram
  participant A as Empleat A
  participant API
  participant BD as Base de dades
  A->>API: GET /v1/cafes/caf_001 (preu 14.50, versio 3)
  Note over API: L'empleat B llegeix el mateix
  A->>API: PATCH preu 15.90
  API->>BD: UPDATE ... versio 3 → 4
  Note over API: B envia PATCH estoc 200 amb versio 3
  API->>BD: UPDATE ... WHERE versio = 3
  BD-->>API: 0 files modificades
  API-->>A: 409 conflicte_versio

Sense control, el segon PATCH esclafaria el preu nou amb el que B va llegir fa un minut, i ningú no se n'assabentaria. El bloqueig optimista ho detecta: cada actualització exigeix la versió que el client va llegir.

// src/repositoris/cafes-sqlite.js  (afegit)

const actualitzarAmbVersio = baseDades.prepare(`
  UPDATE cafes
     SET nom = @nom, origen = @origen, torrefaccio = @torrefaccio,
         preu_centims = @preuCentims, estoc = @estoc,
         notes_tast = @notesTast, descripcio = @descripcio,
         versio = versio + 1
   WHERE id = @id AND actiu = 1 AND versio = @versio
`);

/**
 * Actualitza només si la versió coincideix.
 * @returns el cafè actualitzat, o null si hi ha hagut conflicte de versió.
 */
export function actualitzarCafeAmbVersio(id, canvis, versioEsperada) {
  const actual = repositoriCafes.buscarPerId(id);
  if (!actual) return undefined;

  const fusionat = { ...actual, ...canvis };
  const resultat = actualitzarAmbVersio.run({
    id,
    versio: versioEsperada,
    nom: fusionat.nom,
    origen: fusionat.origen,
    torrefaccio: fusionat.torrefaccio,
    preuCentims: fusionat.preuCentims,
    estoc: fusionat.estoc,
    notesTast: JSON.stringify(fusionat.notesTast),
    descripcio: fusionat.descripcio,
  });

  // 0 files modificades amb el recurs existent = un altre l'ha canviat abans.
  return resultat.changes === 0 ? null : repositoriCafes.buscarPerId(id);
}

I el servei tradueix aquell null al 409 conflicte_versio del catàleg:

{
  "error": {
    "codi": "conflicte_versio",
    "missatge": "El cafè 'caf_001' ha estat modificat per una altra persona. Torna a carregar-lo i repeteix el canvi.",
    "detalls": []
  }
}

Dues precisions sobre el catàleg de 02-04. Allà conflicte_versio apareixia associat a 412 Precondition Failed, que és el codi correcte quan el client envia la condició a la capçalera If-Match amb un ETag —el mecanisme estàndard d'HTTP, que veurem amb la memòria cau condicional a 04-06—. Aquí la condició viatja com un camp versio al cos, sense capçalera condicional, i llavors el codi adequat és 409 Conflict: la petició era vàlida però xoca amb l'estat actual del recurs. Mateix problema, mateix codi de catàleg, dos codis HTTP segons com s'expressi la condició. I el matís sobre el nom "optimista": es diu així perquè no bloqueja res; assumeix que els conflictes són rars i es limita a detectar-los. El pessimista (SELECT ... FOR UPDATE) bloqueja la fila, i només compensa quan els conflictes són freqüents.

  1. El problema N+1 en carregar les línies

Així és com no es carreguen les comandes amb les seves línies:

// MALAMENT: 1 consulta per a les comandes + N consultes, una per comanda.
const comandes = baseDades.prepare('SELECT * FROM comandes LIMIT 20').all();
for (const comanda of comandes) {
  comanda.linies = baseDades
    .prepare('SELECT * FROM linies_comanda WHERE comanda_id = ?')
    .all(comanda.id);
}

Amb 20 comandes són 21 consultes. El codi sembla innocent perquè la consulta està amagada dins del bucle, i per això el problema N+1 és tan comú. A SQLite, amb la base en local, el cost és petit; amb PostgreSQL en una altra màquina i 2 ms de xarxa per consulta, aquells 21 viatges són 42 ms de latència pura per retornar una pàgina. I si la llista fos de 100 comandes, 200 ms.

La solució: una segona consulta per a totes les línies de cop.

// src/repositoris/comandes-sqlite.js  (continuació)

function carregarLiniesDe(comandes) {
  if (comandes.length === 0) return comandes;

  // Un marcador '?' per cada id: (?, ?, ?, ...). El SQL es genera, però
  // els VALORS continuen sent paràmetres: no hi ha concatenació de dades.
  const marcadors = comandes.map(() => '?').join(', ');
  const files = baseDades
    .prepare(
      `SELECT comanda_id, cafe_id, nom_cafe, quantitat, preu_centims
         FROM linies_comanda
        WHERE comanda_id IN (${marcadors})
        ORDER BY comanda_id, cafe_id`
    )
    .all(...comandes.map((c) => c.id));

  // Agrupem en memòria: cost O(n), sense consultes addicionals.
  const perComanda = new Map();
  for (const fila of files) {
    if (!perComanda.has(fila.comanda_id)) perComanda.set(fila.comanda_id, []);
    perComanda.get(fila.comanda_id).push({
      cafeId: fila.cafe_id,
      nom: fila.nom_cafe,
      quantitat: fila.quantitat,
      preuCentims: fila.preu_centims,
    });
  }

  return comandes.map((comanda) => ({ ...comanda, linies: perComanda.get(comanda.id) ?? [] }));
}

Dues consultes, sigui quin sigui el nombre de comandes. L'alternativa seria un únic JOIN, que també funciona però retorna la comanda repetida tantes vegades com línies tingui i obliga a deduplicar. Amb dues consultes el codi queda més clar i el rendiment és equivalent.

Un avís: IN (...) té un límit de paràmetres (999 per defecte a SQLite). Com que les nostres pàgines són de 100 com a màxim, no ens afecta; en un procés per lots caldria trossejar.

  1. Paginació per cursor de debò

A 02-06 vam decidir que /v1/comandes faria servir cursor, i ara es veu per què. Amb OFFSET, el motor ha de recórrer i descartar totes les files anteriors:

-- Per retornar 20 files, SQLite en llegeix i en descarta 100.000. Cada vegada.
SELECT * FROM comandes ORDER BY data_creacio DESC, id DESC LIMIT 20 OFFSET 100000;

És el deep paging: la pàgina 1 és instantània i la 5.000 triga segons. I a més és inestable: si entra una comanda nova mentre el client pagina, tot es desplaça una posició i en veu una de repetida.

El cursor resol les dues coses desant el darrer element vist en lloc d'un número de posició:

-- Sense OFFSET: l'índex salta directament al punt de tall.
SELECT * FROM comandes
 WHERE (data_creacio, id) < (?, ?)
 ORDER BY data_creacio DESC, id DESC
 LIMIT 20;

La comparació de tuples (a, b) < (x, y) és SQL estàndard i significa "a < x, o bé a = x i b < y". És exactament el desempat que necessitàvem, expressat en una condició.

// src/repositoris/comandes-sqlite.js  (continuació)

/** Codifica el cursor en base64url perquè sigui OPAC (02-06). */
function codificarCursor(comanda) {
  return Buffer.from(JSON.stringify({ d: comanda.data_creacio, i: comanda.id })).toString(
    'base64url'
  );
}

function descodificarCursor(cursor) {
  try {
    const { d, i } = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
    if (typeof d !== 'string' || typeof i !== 'string') return null;
    return { data: d, id: i };
  } catch {
    return null; // cursor manipulat o corrupte → 400 parametre_invalid
  }
}

export function buscarComandesPerCursor({ clientId, estat, cursor, limit = 20 }) {
  const condicions = [];
  const parametres = [];

  if (clientId !== undefined) {
    condicions.push('client_id = ?');
    parametres.push(clientId);
  }
  if (estat !== undefined) {
    condicions.push('estat = ?');
    parametres.push(estat);
  }

  if (cursor !== undefined) {
    const punt = descodificarCursor(cursor);
    if (!punt) return { cursorInvalid: true };
    condicions.push('(data_creacio, id) < (?, ?)');
    parametres.push(punt.data, punt.id);
  }

  const clausulaOn = condicions.length > 0 ? `WHERE ${condicions.join(' AND ')}` : '';

  // Demanem UNA fila de més: si ve, és que hi ha pàgina següent.
  const files = baseDades
    .prepare(
      `SELECT * FROM comandes ${clausulaOn}
        ORDER BY data_creacio DESC, id DESC
        LIMIT ?`
    )
    .all(...parametres, limit + 1);

  const hiHaMes = files.length > limit;
  const pagina = hiHaMes ? files.slice(0, limit) : files;

  return {
    elements: carregarLiniesDe(pagina).map(aModelComanda),
    cursorSeguent: hiHaMes ? codificarCursor(pagina[pagina.length - 1]) : null,
  };
}
curl -i -s "http://localhost:3000/v1/comandes?limit=20" | grep -i "^link"
Link: <http://localhost:3000/v1/comandes?limit=20&cursor=eyJkIjoiMjAyNi0wMy0xNFQxMDozMDowMFoiLCJpIjoiY29tXzUwMDEifQ>; rel="next"

Tres decisions a subratllar. El cursor és opac: base64 d'un JSON intern. No és xifrat —qualsevol el pot descodificar— però sí que és explícitament "no és cosa teva": el contracte de 02-06 prohibeix construir-lo a mà, i així en podem canviar el contingut sense trencar res a ningú. Demanar limit + 1 files és el truc estàndard per saber si n'hi ha més sense un COUNT extra. I la paginació per cursor no ofereix total ni salt a la darrera pàgina: és el preu que es paga, i per això el contracte va reservar el cursor per a /v1/comandes —on l'històric és enorme i només s'avança— i va deixar el desplaçament a /v1/cafes, on el catàleg és petit i saber el total importa.

L'índex idx_comandes_client_data (client_id, data_creacio DESC, id DESC) està construït justament per a aquesta consulta: el motor localitza el punt de tall amb una cerca a l'arbre i llegeix 21 files seguides. El cost no depèn de com de lluny estigui la pàgina.

  1. ORMs: quan compensen

Eina Enfocament Notes
Prisma Esquema propi + client generat Excel·lent experiència i tipus; genera les seves migracions
Drizzle SQL amb tipus, molt fi Proper al SQL; poc pes en temps d'execució
Sequelize ORM clàssic, actiu-registre Veterà; abstreu molt
TypeORM ORM amb decoradors Popular amb NestJS (05-03)
Knex Constructor de consultes, no ORM Compon SQL sense amagar-lo

A favor: menys codi repetitiu, migracions integrades, tipatge del resultat, portabilitat entre motors, i relacions carregades sense escriure el JOIN.

En contra: una abstracció més per aprendre i depurar; consultes generades que sorprenen; el problema N+1 ocult —un .linies que sembla un accés a propietat i en realitat dispara una consulta—; i dificultat per expressar SQL avançat.

El criteri: si la teva aplicació té moltes entitats amb relacions semblants i CRUD repetitiu, un ORM t'estalvia setmanes. Si en té poques i consultes exigents, el SQL directe darrere d'un repositori —el que hem fet— és més simple i més ràpid. I amb el patró repositori, la decisió és reversible: adoptar Prisma demà significaria reescriure els fitxers de src/repositoris/, i res més.

  1. Connexions i tancament ordenat

SQLite és un fitxer i no necessita pool: una única connexió per procés, oberta en arrencar. El mode WAL permet lectures concurrents amb l'escriptura, i busy_timeout gestiona els xocs d'escriptors.

Amb PostgreSQL sí que caldria un pool: obrir una connexió TCP costa desenes de mil·lisegons, així que se'n manté un conjunt reutilitzable (new Pool({ max: 10 })). Allà apareixen problemes propis —esgotament del pool per connexions que no es tornen, transaccions que s'han d'executar sobre una connexió reservada— que aquí no tenim.

El que sí que cal fer és tancar la base de dades en apagar. Actualitzem src/servidor.js:

// src/servidor.js  (modificat)
import { app } from './app.js';
import { entorn } from './config/entorn.js';
import { tancarBaseDades } from './config/base-dades.js';

const servidor = app.listen(entorn.port, () => {
  console.log(`API de la Botiga Aroma escoltant a ${entorn.baseUrl}/v1`);
});

function tancarOrdenadament(senyal) {
  console.log(`\nRebut el senyal ${senyal}. Tancant...`);
  servidor.close(() => {
    // L'ordre importa: primer deixem d'acceptar i acabem les
    // peticions en curs, i només llavors tanquem la base de dades.
    tancarBaseDades();
    console.log('Servidor i base de dades tancats.');
    process.exit(0);
  });
}

process.on('SIGINT', () => tancarOrdenadament('SIGINT'));
process.on('SIGTERM', () => tancarOrdenadament('SIGTERM'));

El close() de better-sqlite3 bolca el WAL al fitxer principal i allibera el bloqueig. Sense ell, una apagada brusca deixa fitxers -wal i -shm solts que SQLite sap recuperar, però no hi ha cap raó per dependre d'això.

Errors Comuns i Consells

1. Concatenar valors al SQL. Injecció garantida. Marcadors de posició sempre, sense excepcions ni "és que aquest valor ve de dins".

2. Oblidar PRAGMA foreign_keys = ON. SQLite les ignora per defecte i acabes amb línies que apunten a comandes inexistents.

3. Passar un boolean de JavaScript com a paràmetre. better-sqlite3 el rebutja. Converteix-lo a 1 / 0.

4. Desar diners en REAL. L'error més car de la llista. INTEGER de cèntims, o NUMERIC on existeixi.

5. Editar una migració ja aplicada. La teva base s'actualitza, la dels teus companys no, i el registre de migracions menteix. Corregeix sempre amb una migració nova.

6. Barrejar sembra i migració. Les dades de prova acaben a producció.

7. Consultar dins d'un bucle. És l'N+1. Si veus un prepare dins d'un for, atura't: gairebé sempre hi ha una versió amb IN (...).

8. Calcular el total sobre la consulta paginada. El COUNT va amb els mateixos filtres i sense LIMIT.

9. Preparar sentències dins del gestor. db.prepare() a cada petició malbarata l'anàlisi del SQL. Prepara en carregar el mòdul i reutilitza.

Consell: quan una consulta vagi lenta, demana el pla abans de tocar res: EXPLAIN QUERY PLAN SELECT .... Si apareix SCAN TABLE, falta un índex; si apareix SEARCH TABLE ... USING INDEX, va bé.

Exercicis

Exercici 1

Escriu la migració 002-afegir-descomptes.sql, que afegeix a cafes una columna descompte_percentatge (enter, entre 0 i 50, per defecte 0). Explica per què aquesta migració és un canvi retrocompatible per als consumidors de l'API (02-07) i què caldria fer a més perquè el camp aparegui al JSON.

Exercici 2

Implementa buscarRessenyesDeCafe(cafeId, { estat, limit, desplacament }) en un nou src/repositoris/ressenyes-sqlite.js. Ha de retornar { elements, total }, filtrar per estat si s'indica, ordenar per data_creacio DESC amb desempat per id, i no retornar ressenyes d'un cafè inexistent sense distingir-ho d'un cafè sense ressenyes. Indica quin índex de l'esquema la sosté.

Exercici 3

Dos empleats obren alhora la fitxa de caf_001 (versio: 7). El primer canvia el preu a 15,90 € i el segon, trenta segons després, canvia l'estoc a 200 fent servir la versió 7 que va llegir. Descriu què passa pas a pas amb actualitzarCafeAmbVersio, què respon l'API al segon, i compara-ho amb el que passaria sense control de versió. Després explica per què aquest mecanisme no serviria per al descompte d'estoc de la comanda i què es fa servir al seu lloc.

Solucions

Solució 1

-- migracions/002-afegir-descomptes.sql
ALTER TABLE cafes ADD COLUMN descompte_percentatge INTEGER NOT NULL DEFAULT 0;

-- SQLite no permet afegir un CHECK a una taula existent amb ALTER TABLE,
-- així que la restricció s'aplica a l'esquema Zod (03-04) i, si es
-- volgués a la base, caldria recrear la taula. A PostgreSQL seria:
--   ALTER TABLE cafes ADD CONSTRAINT chk_descompte
--     CHECK (descompte_percentatge BETWEEN 0 AND 50);

CREATE INDEX idx_cafes_descompte ON cafes (descompte_percentatge)
  WHERE descompte_percentatge > 0;   -- índex parcial: només els que tenen descompte

Per què és retrocompatible: la columna té DEFAULT 0 i NOT NULL, així que les files existents s'omplen soles i cap inserció anterior no deixa de funcionar. Des del punt de vista de l'API, afegir un camp a una representació és un canvi additiu (02-07): un consumidor que ignori descomptePercentatge continua funcionant igual, perquè un client JSON ben escrit ignora els camps que no coneix. No caldria una v2.

Què falta perquè aparegui al JSON: tres coses, i cap a la base de dades. Afegir descomptePercentatge: fila.descompte_percentatge a aModel() del repositori; afegir-lo a cafeARepresentacio() al mapejador; i afegir-lo a openapi.yaml amb la seva descripció. Que calguin exactament aquests tres llocs, i que siguin sempre els mateixos, és el senyal que l'arquitectura per capes està ben posada.

Solució 2

// src/repositoris/ressenyes-sqlite.js
import { baseDades } from '../config/base-dades.js';

function aModel(fila) {
  return {
    id: fila.id,
    cafeId: fila.cafe_id,
    clientId: fila.client_id,
    puntuacio: fila.puntuacio,
    comentari: fila.comentari,
    estat: fila.estat,
    dataCreacio: fila.data_creacio,
  };
}

const cafeExisteix = baseDades.prepare('SELECT 1 FROM cafes WHERE id = ? AND actiu = 1');

export function buscarRessenyesDeCafe(cafeId, { estat, limit = 20, desplacament = 0 } = {}) {
  // Distingir "cafè inexistent" de "cafè sense ressenyes" exigeix comprovar-ho:
  // el primer és 404 cafe_no_trobat; el segon, 200 amb dades buides.
  if (!cafeExisteix.get(cafeId)) return { cafeInexistent: true };

  const condicions = ['cafe_id = ?'];
  const parametres = [cafeId];

  if (estat !== undefined) {
    condicions.push('estat = ?');
    parametres.push(estat);
  }

  const clausulaOn = `WHERE ${condicions.join(' AND ')}`;

  const total = baseDades
    .prepare(`SELECT COUNT(*) AS n FROM ressenyes ${clausulaOn}`)
    .get(...parametres).n;

  const files = baseDades
    .prepare(
      `SELECT * FROM ressenyes ${clausulaOn}
        ORDER BY data_creacio DESC, id DESC
        LIMIT ? OFFSET ?`
    )
    .all(...parametres, limit, desplacament);

  return { elements: files.map(aModel), total };
}

Índex que la sosté: idx_ressenyes_cafe ON ressenyes (cafe_id, estat). Cobreix el filtre obligatori per cafe_id i, quan s'hi afegeix estat, també el segon. L'ordenació per data_creacio DESC no queda coberta, així que el motor ordena en memòria el subconjunt de ressenyes d'aquell cafè —acceptable, perquè en són poques—. Si un cafè arribés a tenir-ne milers, l'índex ideal seria (cafe_id, estat, data_creacio DESC, id DESC), que serviria filtre i ordre alhora. Comprova-ho amb EXPLAIN QUERY PLAN.

Solució 3

Pas a pas:

  1. Tots dos llegeixen caf_001 amb versio: 7.
  2. El primer envia el seu canvi amb versio: 7. L'UPDATE ... WHERE id = 'caf_001' AND versio = 7 troba la fila, aplica el preu i puja la versió a 8. changes === 1, i l'API respon 200 amb la representació actualitzada.
  3. El segon envia el seu canvi amb versio: 7. El WHERE ... AND versio = 7 no troba cap fila, perquè ara val 8. changes === 0 i el cafè sí que existeix → conflicte.
  4. L'API respon 409 conflicte_versio amb el missatge que li demana tornar a carregar i repetir.

Sense control de versió: el segon UPDATE hauria escrit la seva còpia completa del cafè, inclòs el preu_centims de 1450 que va llegir fa mig minut. El canvi de preu del primer desapareix sense deixar rastre: cap error, cap log, cap indici. És l'actualització perduda, i la seva gravetat rau en el fet que és silenciosa: el primer empleat veu el seu 200 OK, se'n va tranquil, i el preu antic reapareix.

Per què no serveix per a l'estoc de la comanda. Són dos problemes diferents. El bloqueig optimista exigeix que el client hagi llegit el recurs i en retorni la versió, i protegeix una substitució completa del recurs. El descompte d'estoc, en canvi, és una operació relativa —"resta 2 al que hi hagi"— que ningú no ha llegit prèviament: el comprador no envia la versió del cafè ni té per què conèixer-la. Si ho fes, dues compres simultànies de cafès diferents de la mateixa comanda donarien 409 constantment i la botiga seria inusable.

El que es fa servir al seu lloc és la condició dins del mateix UPDATE:

UPDATE cafes SET estoc = estoc - ? WHERE id = ? AND estoc >= ?

Aquí la lectura i l'escriptura passen a la mateixa sentència atòmica, així que no hi ha finestra entre comprovar i actualitzar. Si dues peticions intenten comprar la darrera bossa, una obté changes === 1 i l'altra changes === 0, i aquesta darrera rep 409 estoc_insuficient. La regla general: el bloqueig optimista per a actualitzacions absolutes; la condició al WHERE per a les relatives.

Conclusió

Les dades de la Botiga Aroma ja viuen en una base de dades de debò, i el canvi ha costat tres línies d'import fora de la carpeta repositoris/. Això és el que compra el patró repositori: la resta de l'aplicació no va saber mai on eren els cafès. L'esquema SQL tradueix les decisions de contracte a estructura —preu_centims enter perquè els diners no són mai coma flotant, claus primàries de text amb prefix, CHECK als enumerats com a defensa en profunditat, dates ISO-8601 l'ordre alfabètic de les quals és el cronològic, i índexs posats exactament on 02-06 va dir que hi hauria filtres i ordenacions—. Les migracions numerades i immutables converteixen "l'esquema" en una cosa que es desplega, es revisa en un pull request i es reprodueix a qualsevol màquina amb npm run migrar.

I has vist els quatre problemes que separen una capa de dades ingènua d'una de professional. Les sentències preparades, que no escapen cometes sinó que envien el SQL i les dades per camins diferents, amb la llista blanca per a l'única cosa que no admet marcadors, l'ORDER BY. Les transaccions, que fan que crear una comanda descomptant estoc de diverses línies sigui tot o res, amb la condició AND estoc >= ? a l'UPDATE com a protecció veritable davant de dos compradors simultanis. El control de concurrència optimista amb versio, que converteix una actualització perduda silenciosa en un 409 conflicte_versio visible. I l'N+1, juntament amb la paginació per cursor que fa que la pàgina cinc mil costi el mateix que la primera.

Queda una porta oberta de bat a bat: qualsevol pot crear, modificar i esborrar cafès, i GET /v1/comandes retorna les comandes de tots els clients a qui ho pregunti. A 03-06, Autenticació i autorització, la tanquem: distingirem autenticació d'autorització —el 401 del 403 que el contracte separa des de 02-04—, registrarem clients amb la contrasenya protegida amb bcrypt, emetrem i verificarem JWT signats amb el secret que ja viu a .env, escriurem el middleware que distingeix token absent de token caducat i retorna WWW-Authenticate, i aplicarem els rols client, empleat, administrador i soci amb una matriu de permisos per endpoint i comprovació de propietat a nivell de recurs.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats