@runnerpro/backend 1.31.2 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,19 @@ Connection with the PostgresSQL database
16
16
  - <b>Param</b>: array of values
17
17
  - <b>Return</b>: promise of array of values (each item is a row of the table)
18
18
 
19
+ > ⚠️ **T-219 — un fallo de CONEXIÓN puede rechazar, y se enciende por servicio.** `query()` no
20
+ > lanzaba nunca: tanto «la BD no respondió» como «no hay filas» devolvían `[]`. Desde la **1.32.0**,
21
+ > con `RP_DB_RECHAZA_FALLO_CONEXION=1` en el entorno, un fallo de conexión —agotado el reintento (1,
22
+ > a los 500 ms)— **rechaza la promesa**. Un error de SQL (sintaxis, constraint, columna inexistente)
23
+ > se sigue logueando y devolviendo `[]` en los dos modos, igual que siempre.
24
+ >
25
+ > **Sin esa variable no cambia nada**, y por eso puede salir en una menor: a un `^1.x` le entra en
26
+ > cuanto alguien refresque su lock (`npm install`/`npm update`, sin tocar el rango) y ahí se comporta
27
+ > como la 1.31.x. Con `npm ci` el lock manda, así que hasta ese refresco no llega. **Antes de encenderla en un
28
+ > servicio, lee [docs/T-219-auditoria-consumidores.md](./docs/T-219-auditoria-consumidores.md)**: ahí
29
+ > está, repo por repo y con fichero y línea, dónde aterriza ese rechazo hoy y qué hay que cerrar
30
+ > antes. Encenderla sin cerrar esos puntos deja peticiones sin respuesta.
31
+
19
32
  ### batchQuery
20
33
 
21
34
  Connection with PostgreSQL database in batches. Use for faster query execution when we need to execute many queries.
@@ -27,6 +27,29 @@ for (const meters of Object.keys(catalog_1.PR_DISTANCE_BADGE)) {
27
27
  BADGE_PR_DISTANCE[catalog_1.PR_DISTANCE_BADGE[Number(meters)]] = Number(meters);
28
28
  }
29
29
  const typesList = (types) => types.map((t) => `'${t}'`).join(',');
30
+ /**
31
+ * Traza el fallo de un grupo de logros sin tumbar el resto.
32
+ *
33
+ * Los grupos van aislados a propósito (un fallo no debe impedir evaluar los demás), pero hasta
34
+ * T-219 lo hacían con un `catch (_) {}` mudo: cuando el pool no conectaba, los logros de ese
35
+ * cliente simplemente no se evaluaban y no quedaba ni rastro. Ahora sigue sin tumbar el resto,
36
+ * pero se sabe a quién le pasó y en qué grupo.
37
+ */
38
+ const avisoLogros = (etiqueta, userId, error) => {
39
+ // eslint-disable-next-line no-console
40
+ console.error(`achievements: fallo evaluando ${etiqueta} de idCliente=${userId} (${(error === null || error === void 0 ? void 0 : error.message) || error}). Ese grupo se queda sin evaluar.`);
41
+ };
42
+ /** Igual que `avisoLogros` pero para lo que NO va aislado: deja la traza y vuelve a lanzar. */
43
+ const conTraza = (etiqueta, userId, fn) => __awaiter(void 0, void 0, void 0, function* () {
44
+ try {
45
+ return yield fn();
46
+ }
47
+ catch (error) {
48
+ // eslint-disable-next-line no-console
49
+ console.error(`achievements: fallo leyendo/escribiendo ${etiqueta} de idCliente=${userId} (${(error === null || error === void 0 ? void 0 : error.message) || error}).`);
50
+ throw error;
51
+ }
52
+ });
30
53
  const countScalar = (sql, params) => __awaiter(void 0, void 0, void 0, function* () {
31
54
  const [row] = yield (0, db_1.query)(sql, params);
32
55
  return Number(row === null || row === void 0 ? void 0 : row.c) || 0;
@@ -49,7 +72,9 @@ const getAllAggregates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0
49
72
  params.push(sinceDate);
50
73
  }
51
74
  const runList = typesList(catalog_1.RUNNING_TYPES);
52
- const [row] = yield (0, db_1.query)(`SELECT
75
+ // ⚠️ T-219: 9 de los 176 client_login_timeout de la semana medida caen aquí. No va aislado a
76
+ // propósito: sin agregados no hay nada que evaluar, así que se traza y se propaga.
77
+ const [row] = yield conTraza('los agregados de workouts', userId, () => (0, db_1.query)(`SELECT
53
78
  COALESCE(MAX("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN MAX",
54
79
  COALESCE(SUM("DISTANCE") FILTER (WHERE "TYPE" IN (${runList})), 0) AS "RUN TOTAL",
55
80
  COUNT(*) FILTER (WHERE "TYPE" = 'FUERZA') AS "STR COUNT",
@@ -62,7 +87,7 @@ const getAllAggregates = (userId, sinceDate) => __awaiter(void 0, void 0, void 0
62
87
  COUNT(*) FILTER (WHERE "TYPE" = 'COMPETICION') AS "RACE COUNT",
63
88
  COUNT(*) FILTER (WHERE "FC MEDIA" IS NOT NULL) AS "HR COUNT"
64
89
  FROM "WORKOUT"
65
- WHERE "ID CLIENTE" = ? AND "DONE" = true${sinceFilter}`, params);
90
+ WHERE "ID CLIENTE" = ? AND "DONE" = true${sinceFilter}`, params));
66
91
  return {
67
92
  runMax: Number(row === null || row === void 0 ? void 0 : row.runMax) || 0,
68
93
  runTotal: Number(row === null || row === void 0 ? void 0 : row.runTotal) || 0,
@@ -311,10 +336,12 @@ const persistLogros = (userId, ids, markSeen = false) => __awaiter(void 0, void
311
336
  const params = [];
312
337
  for (const id of uniq)
313
338
  params.push(userId, id);
314
- const rows = yield (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "FECHA VISTO")
339
+ // ⚠️ T-219: es el alta de los logros. Si esto se pierde en silencio, el cliente no desbloquea
340
+ // nada y no queda rastro; se traza con el idCliente y se propaga.
341
+ const rows = yield conTraza(`el alta de ${uniq.length} logro(s)`, userId, () => (0, db_1.query)(`INSERT INTO "CLIENTE LOGRO" ("ID CLIENTE", "LOGRO ID", "FECHA VISTO")
315
342
  VALUES ${valuesSql}
316
343
  ON CONFLICT ("ID CLIENTE", "LOGRO ID") DO NOTHING
317
- RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO"`, params);
344
+ RETURNING "LOGRO ID" AS "ID", "FECHA DESBLOQUEO"`, params));
318
345
  return rows.map((r) => ({ id: r.id, fechaDesbloqueo: r.fechaDesbloqueo }));
319
346
  });
320
347
  // ── Reglas por categoría ────────────────────────────────────────────────────
@@ -366,7 +393,9 @@ const evalCount = (rules, count) => rules.filter((r) => count >= r.count).map((r
366
393
  * los 4 bloques (base/desarrollo/pico/taper). Devuelve null si no hay plan.
367
394
  */
368
395
  const getPlanContext = (userId) => __awaiter(void 0, void 0, void 0, function* () {
369
- const [row] = yield (0, db_1.query)(`SELECT
396
+ // ⚠️ T-219: va dentro de un grupo aislado (`evaluatePlan`), así que el error lo recoge
397
+ // `avisoLogros` arriba; aquí se deja la traza con el idCliente para saber a quién le pasó.
398
+ const [row] = yield conTraza('el contexto del plan', userId, () => (0, db_1.query)(`SELECT
370
399
  "ID PLAN CARRERA" AS "PLAN ID",
371
400
  "START DATE PLAN"::date AS "PLAN START",
372
401
  "GOAL DATE"::date AS "PLAN GOAL",
@@ -380,7 +409,7 @@ const getPlanContext = (userId) => __awaiter(void 0, void 0, void 0, function* (
380
409
  "ALGORITMO BLOQUE 3 FIN"::date AS "PICO FIN",
381
410
  "ALGORITMO BLOQUE 4 INICIO"::date AS "TAPER INI",
382
411
  "ALGORITMO BLOQUE 4 FIN"::date AS "TAPER FIN"
383
- FROM "CLIENTE" WHERE "ID" = ?`, [userId]);
412
+ FROM "CLIENTE" WHERE "ID" = ?`, [userId]));
384
413
  return row || null;
385
414
  });
386
415
  /**
@@ -682,8 +711,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
682
711
  else
683
712
  nuevos.push(...(yield recomputeAllPRs(userId, anchor, markSeen)));
684
713
  }
685
- catch (_) {
686
- // ignore
714
+ catch (error) {
715
+ avisoLogros('los récords personales (PR)', userId, error);
687
716
  }
688
717
  const [streak, agg] = yield Promise.all([
689
718
  (0, streak_1.computeStreakStats)(userId, anchor),
@@ -697,8 +726,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
697
726
  try {
698
727
  yield (0, db_1.query)('UPDATE "CLIENTE" SET "RACHA MAXIMA" = GREATEST(COALESCE("RACHA MAXIMA", 0), ?) WHERE "ID" = ?', [streak.longest, userId]);
699
728
  }
700
- catch (_) {
701
- // ignore
729
+ catch (error) {
730
+ avisoLogros('la racha máxima', userId, error);
702
731
  }
703
732
  eligible.push(...evaluateConsistency(streak.longest), ...evaluateDistance(agg.runMax), ...evaluateVolume(agg.runTotal), ...evaluateStrength(agg.strCount), ...evaluateCycling(agg), ...evaluateSwim(agg), ...evaluateTriSwim(agg), ...evaluateTrail(agg));
704
733
  const raceDates = agg.raceCount >= 3 ? yield getRaceDates(userId, anchor) : [];
@@ -711,31 +740,41 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
711
740
  try {
712
741
  eligible.push(...(yield evaluatePlan(userId, anchor)));
713
742
  }
714
- catch (_) { /* ignore */ }
743
+ catch (error) {
744
+ avisoLogros('el plan y sus hitos', userId, error);
745
+ }
715
746
  try {
716
747
  const strengthDates = yield getStrengthDates(userId, anchor);
717
748
  eligible.push(...evaluateStrengthFrequency(strengthDates));
718
749
  }
719
- catch (_) { /* ignore */ }
750
+ catch (error) {
751
+ avisoLogros('la frecuencia de fuerza', userId, error);
752
+ }
720
753
  try {
721
754
  eligible.push(...(yield evaluateBrick(userId, anchor)));
722
755
  }
723
- catch (_) { /* ignore */ }
756
+ catch (error) {
757
+ avisoLogros('los brick', userId, error);
758
+ }
724
759
  try {
725
760
  eligible.push(...(yield evaluateRecovery(userId, anchor)));
726
761
  }
727
- catch (_) { /* ignore */ }
762
+ catch (error) {
763
+ avisoLogros('la recuperación', userId, error);
764
+ }
728
765
  try {
729
766
  eligible.push(...(yield evaluateCoachCall(userId)));
730
767
  }
731
- catch (_) { /* ignore */ }
768
+ catch (error) {
769
+ avisoLogros('la llamada con el entrenador', userId, error);
770
+ }
732
771
  }
733
772
  if (want('chat') || want('survey') || want('availability') || want('nutrition') || want('food_photo') || want('wearable')) {
734
773
  try {
735
774
  eligible.push(...(yield evaluateNonWorkout(userId, event)));
736
775
  }
737
- catch (_) {
738
- // ignore
776
+ catch (error) {
777
+ avisoLogros(`los logros de '${event}'`, userId, error);
739
778
  }
740
779
  }
741
780
  // 'goal' (añadir objetivo) re-evalúa los logros de Plan (incl. pc_nuevo_capitulo).
@@ -744,7 +783,9 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
744
783
  try {
745
784
  eligible.push(...(yield evaluatePlan(userId, anchor)));
746
785
  }
747
- catch (_) { /* ignore */ }
786
+ catch (error) {
787
+ avisoLogros('el plan y sus hitos', userId, error);
788
+ }
748
789
  }
749
790
  nuevos.push(...(yield persistLogros(userId, eligible, markSeen)));
750
791
  // pace_*/pr_* se derivan del mapa de PRs ya persistido en los PARAMS de dist_*.
@@ -753,8 +794,8 @@ const evaluateAchievements = (userId, event = 'all', opts = {}) => __awaiter(voi
753
794
  const prMap = yield getPrMap(userId);
754
795
  nuevos.push(...(yield persistLogros(userId, [...evaluatePace(prMap), ...evaluateTimePR(prMap)])));
755
796
  }
756
- catch (_) {
757
- // ignore
797
+ catch (error) {
798
+ avisoLogros('los logros de ritmo y marca', userId, error);
758
799
  }
759
800
  }
760
801
  // Dedup por id, preservando el PRIMERO de cada id. applyPRs se acumula antes que
@@ -34,31 +34,167 @@ pool.on('error', (err) => {
34
34
  console.error('PG pool error', err);
35
35
  // El pool se encarga automáticamente del manejo
36
36
  });
37
- // FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
37
+ // Reintento de conexión: mismos números que `queryWithClient` (1 reintento, 500 ms).
38
+ const REINTENTOS_CONEXION = 1;
39
+ const ESPERA_REINTENTO_MS = 500;
40
+ // Códigos y mensajes que NO son un error de SQL sino del pool o del socket. La clase 08 de
41
+ // PostgreSQL es "Connection Exception"; 57P01 es "admin_shutdown"; el resto son errno de Node.
42
+ const CODIGOS_CONEXION = [
43
+ 'EPIPE',
44
+ 'ECONNRESET',
45
+ 'ECONNREFUSED',
46
+ 'ETIMEDOUT',
47
+ 'ENOTFOUND',
48
+ 'EHOSTUNREACH',
49
+ '57P01',
50
+ '08000',
51
+ '08001',
52
+ '08003',
53
+ '08004',
54
+ '08006',
55
+ '08P01',
56
+ ];
57
+ // pg y pg-pool no ponen `code` en sus propios timeouts, así que hay que mirar el mensaje.
58
+ // Los cuatro primeros son, literalmente, los que salen en prod-third-party-backend (T-219).
59
+ const MENSAJES_CONEXION = [
60
+ 'client_login_timeout',
61
+ 'timeout exceeded when trying to connect',
62
+ 'connection terminated due to connection timeout',
63
+ 'query read timeout',
64
+ 'connection terminated unexpectedly',
65
+ 'connection ended unexpectedly',
66
+ 'client has encountered a connection error',
67
+ ];
68
+ /**
69
+ * ¿Este error es "no he podido hablar con la BD" y no "tu SQL está mal"?
70
+ *
71
+ * Sirve para decidir dos cosas: si merece la pena reintentar y si hay que devolver la conexión
72
+ * al pool como rota. Un error de sintaxis o una constraint violada NO entran aquí: reintentarlos
73
+ * es tirar el tiempo y el resultado sería el mismo.
74
+ */
75
+ const esErrorDeConexion = (error) => {
76
+ if (!error)
77
+ return false;
78
+ if (error.code && CODIGOS_CONEXION.includes(String(error.code)))
79
+ return true;
80
+ const mensaje = String(error.message || '').toLowerCase();
81
+ return MENSAJES_CONEXION.some((m) => mensaje.includes(m));
82
+ };
83
+ /**
84
+ * Interruptor del contrato de T-219, APAGADO por defecto y a propósito.
85
+ *
86
+ * El rechazo salió primero como 2.0.0 justamente para que `^1.x` no se lo llevara solo. Sale ahora
87
+ * en una menor —que cualquier `^1.x` se lleva en cuanto refresque su lock, y esos repos suben
88
+ * dependencias a menudo— así que la barrera tiene que seguir en pie de otra forma: apagado, la
89
+ * librería se comporta exactamente como la 1.31.x.
90
+ *
91
+ * Cada servicio lo enciende con `RP_DB_RECHAZA_FALLO_CONEXION=1` CUANDO haya cerrado sus puntos
92
+ * de `docs/T-219-auditoria-consumidores.md` (hoy: Cliente-backend 10, Dashboard-backend 6,
93
+ * Cron-backend 3, IA-backend 0, back-office sin auditar).
94
+ *
95
+ * Se lee en cada llamada, no al cargar el módulo: así se puede encender por entorno sin depender
96
+ * del orden de los imports, y las pruebas pueden ir y volver.
97
+ */
98
+ const rechazaFalloDeConexion = () => process.env.RP_DB_RECHAZA_FALLO_CONEXION === '1';
99
+ const mapearFilas = (result) => ((result === null || result === void 0 ? void 0 : result.rows) || []).map((row) => {
100
+ const json = Object.keys(row);
101
+ const aux = {};
102
+ json.forEach((key) => {
103
+ aux[camelize(key)] = row[key];
104
+ });
105
+ return aux;
106
+ });
107
+ /**
108
+ * Coge una conexión del pool reintentando si el fallo es de conexión/login.
109
+ *
110
+ * Se separa del `client.query` a propósito (T-219): aquí todavía no se ha mandado nada a
111
+ * PostgreSQL, así que reintentar es seguro incluso con un INSERT o un UPDATE. Reintentar la
112
+ * query ya enviada no lo sería —podría duplicar una escritura—, por eso `query()` no lo hace.
113
+ */
114
+ const conectarConReintento = () => __awaiter(void 0, void 0, void 0, function* () {
115
+ for (let intento = 0;; intento++) {
116
+ try {
117
+ return yield pool.connect();
118
+ }
119
+ catch (error) {
120
+ if (intento >= REINTENTOS_CONEXION || !esErrorDeConexion(error))
121
+ throw error;
122
+ // eslint-disable-next-line no-console
123
+ console.error(`PG connect error (intento ${intento + 1}/${REINTENTOS_CONEXION + 1}): ${error === null || error === void 0 ? void 0 : error.message}. Reintentando en ${ESPERA_REINTENTO_MS} ms.`);
124
+ yield sleep(ESPERA_REINTENTO_MS);
125
+ }
126
+ }
127
+ });
128
+ /**
129
+ * ✅ FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
130
+ *
131
+ * ⚠️ CONTRATO (T-219): con `RP_DB_RECHAZA_FALLO_CONEXION=1`, un fallo de CONEXIÓN, agotados los
132
+ * reintentos, **rechaza**. Sin esa variable —el caso por defecto— devuelve `[]` como toda la 1.x.
133
+ * Un error de SQL (sintaxis, constraint, columna que no existe) se sigue logueando y
134
+ * devolviendo `[]` en los dos modos, como siempre.
135
+ *
136
+ * Antes todo devolvía `[]`, así que "la BD no ha conectado" y "no hay filas" eran la misma
137
+ * cosa para quien llamaba. En 7 días eso escondió 176 `client_login_timeout` en
138
+ * prod-third-party-backend: unos pocos reventaban con un TypeError y la mayoría se saltaban
139
+ * trabajo en silencio (entrenos que se quedaron sin estructura, notificaciones que no
140
+ * salieron). Ver docs de T-219.
141
+ *
142
+ * Para el que llama: si la fila puede no existir de verdad, sigue comprobándolo. Lo que ya no
143
+ * hace falta es adivinar si el `[]` era un fallo de BD.
144
+ *
145
+ * El cambio sale publicado en **1.32.0** con el interruptor apagado: la versión puede entrar en
146
+ * cualquier servicio sin avisar y aun así no cambia nada hasta que ese servicio lo enciende. Antes de
147
+ * encenderlo, `docs/T-219-auditoria-consumidores.md` dice, con fichero y línea, dónde cae ese
148
+ * rechazo en cada repo y qué queda por cerrar.
149
+ */
38
150
  const query = (queryText, values = []) => __awaiter(void 0, void 0, void 0, function* () {
151
+ let client;
152
+ try {
153
+ client = yield conectarConReintento();
154
+ }
155
+ catch (error) {
156
+ // Agotados los reintentos: se propaga para que el llamante decida, en vez de fingir "0 filas".
157
+ const enhancedError = enhancePostgreSQLError(error, queryText, values);
158
+ // eslint-disable-next-line no-console
159
+ console.error('PG query error', enhancedError);
160
+ if (rechazaFalloDeConexion())
161
+ throw error;
162
+ // Interruptor apagado = contrato de la 1.x: se devuelve []. La traza de arriba es lo único
163
+ // que queda del fallo, y es justo lo que T-219 vino a arreglar: enciéndelo cuando puedas.
164
+ return [];
165
+ }
166
+ let clienteRoto = false;
39
167
  try {
40
168
  const text = getParseQuery(queryText, values);
41
- const result = (yield pool.query({
169
+ const result = (yield client.query({
42
170
  text,
43
- // @ts-ignore
44
171
  values: values.flat(),
45
172
  }));
46
- return (result.rows || []).map((row) => {
47
- const json = Object.keys(row);
48
- const aux = {};
49
- json.forEach((key) => {
50
- aux[camelize(key)] = row[key];
51
- });
52
- return aux;
53
- });
173
+ return mapearFilas(result);
54
174
  }
55
175
  catch (error) {
56
176
  // Logging mejorado con información detallada del error
57
177
  const enhancedError = enhancePostgreSQLError(error, queryText, values);
58
178
  // eslint-disable-next-line no-console
59
179
  console.error('PG query error', enhancedError);
180
+ // Se cayó el socket a mitad de la query: la conexión ya no vale y el llamante tiene que
181
+ // enterarse. Un error de SQL, en cambio, mantiene el contrato de siempre y devuelve [].
182
+ // Marcar el cliente como roto NO depende del interruptor: devolver al pool una conexión
183
+ // muerta es un fallo por sí solo, y arreglarlo no cambia lo que ve quien llama.
184
+ clienteRoto = esErrorDeConexion(error);
185
+ if (clienteRoto && rechazaFalloDeConexion())
186
+ throw error;
60
187
  return [];
61
188
  }
189
+ finally {
190
+ try {
191
+ client.release(clienteRoto);
192
+ }
193
+ catch (releaseError) {
194
+ // eslint-disable-next-line no-console
195
+ console.error('Error releasing client:', releaseError);
196
+ }
197
+ }
62
198
  });
63
199
  exports.query = query;
64
200
  // ✅ Para casos especiales que requieren control manual de conexiones
@@ -72,14 +208,7 @@ const queryWithClient = (queryText, values = [], _retry = false) => __awaiter(vo
72
208
  text,
73
209
  values: values.flat(),
74
210
  }));
75
- return (result.rows || []).map((row) => {
76
- const json = Object.keys(row);
77
- const aux = {};
78
- json.forEach((key) => {
79
- aux[camelize(key)] = row[key];
80
- });
81
- return aux;
82
- });
211
+ return mapearFilas(result);
83
212
  }
84
213
  catch (error) {
85
214
  // Logging mejorado con información detallada del error
@@ -93,7 +222,7 @@ const queryWithClient = (queryText, values = [], _retry = false) => __awaiter(vo
93
222
  released = true;
94
223
  }
95
224
  if (!_retry) {
96
- yield sleep(500);
225
+ yield sleep(ESPERA_REINTENTO_MS);
97
226
  return queryWithClient(queryText, values, true);
98
227
  }
99
228
  }
@@ -144,14 +273,7 @@ const longRunningQuery = (queryText, values = [], timeoutMs = 300000 // 5 minuto
144
273
  // @ts-ignore
145
274
  values: values.flat(),
146
275
  }));
147
- return (result.rows || []).map((row) => {
148
- const json = Object.keys(row);
149
- const aux = {};
150
- json.forEach((key) => {
151
- aux[camelize(key)] = row[key];
152
- });
153
- return aux;
154
- });
276
+ return mapearFilas(result);
155
277
  }
156
278
  finally {
157
279
  client.release();
@@ -41,20 +41,36 @@ canvas_1.GlobalFonts.registerFromPath(path_1.default.join(__dirname, '../../..',
41
41
  */
42
42
  const generateShareMap = (image, idWorkout, options = {}) => __awaiter(void 0, void 0, void 0, function* () {
43
43
  const useDefaultPhoto = (options === null || options === void 0 ? void 0 : options.useDefaultPhoto) || false;
44
- const [workout] = yield (0, index_1.query)('SELECT [ID], [ID CLIENTE], [TYPE], [DISTANCE], [DURATION], [DESNIVEL] FROM [WORKOUT] WHERE [ID] = ?', [idWorkout]);
45
- // ⚠️ El guard va ANTES de usar `workout`: `query` devuelve [] tanto si el entreno
46
- // no existe como si la consulta falló (se traga el error y loguea 'PG query error',
47
- // p.ej. client_login_timeout del pool). Leer `workout.idCliente` sin comprobarlo
48
- // rompía el webhook con TypeError.
44
+ // ⚠️ El guard va ANTES de usar `workout`: `query` devuelve [] cuando el entreno no existe.
45
+ // Leer `workout.idCliente` sin comprobarlo rompía el webhook con TypeError (T-066).
46
+ // Desde T-219 un fallo de CONEXIÓN ya no llega como [] sino como excepción, así que se
47
+ // captura aquí para mantener el contrato de la función (devolver `null`, no lanzar) y se
48
+ // distingue en el log de "el entreno no existe".
49
+ let workout, cliente;
50
+ try {
51
+ [workout] = yield (0, index_1.query)('SELECT [ID], [ID CLIENTE], [TYPE], [DISTANCE], [DURATION], [DESNIVEL] FROM [WORKOUT] WHERE [ID] = ?', [idWorkout]);
52
+ }
53
+ catch (error) {
54
+ // eslint-disable-next-line no-console
55
+ console.error(`generateShareMap: la BD no respondió al leer el idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). No se genera la imagen de compartir.`);
56
+ return null;
57
+ }
49
58
  if (!workout) {
50
59
  // eslint-disable-next-line no-console
51
- console.error(`generateShareMap: sin fila de WORKOUT para idWorkout=${idWorkout}; el entreno no existe o la consulta falló (ver 'PG query error' justo antes). No se genera la imagen de compartir.`);
60
+ console.error(`generateShareMap: sin fila de WORKOUT para idWorkout=${idWorkout}; el entreno no existe. No se genera la imagen de compartir.`);
61
+ return null;
62
+ }
63
+ try {
64
+ [cliente] = yield (0, index_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [workout.idCliente]);
65
+ }
66
+ catch (error) {
67
+ // eslint-disable-next-line no-console
68
+ console.error(`generateShareMap: la BD no respondió al leer el idCliente=${workout.idCliente} del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). No se genera la imagen de compartir.`);
52
69
  return null;
53
70
  }
54
- const [cliente] = yield (0, index_1.query)('SELECT [PREFERRED LANGUAGE] FROM [CLIENTE] WHERE [ID] = ?', [workout.idCliente]);
55
71
  if (!cliente) {
56
72
  // eslint-disable-next-line no-console
57
- console.error(`generateShareMap: sin fila de CLIENTE idCliente=${workout.idCliente} del idWorkout=${idWorkout}; el cliente no existe o la consulta falló. No se genera la imagen de compartir.`);
73
+ console.error(`generateShareMap: sin fila de CLIENTE idCliente=${workout.idCliente} del idWorkout=${idWorkout}; el cliente no existe. No se genera la imagen de compartir.`);
58
74
  return null;
59
75
  }
60
76
  const width = 1080;
@@ -13,16 +13,30 @@ exports.sendNotification = void 0;
13
13
  const index_1 = require("../db/index");
14
14
  const common_1 = require("@runnerpro/common");
15
15
  const sendNotification = ({ firebaseMessaging, idCliente, title, body, screen = common_1.NOTIFICATION_SCREEN_TYPES.HOME, screenParams = {}, }) => __awaiter(void 0, void 0, void 0, function* () {
16
- const devices = yield (0, index_1.query)('SELECT [SUBSCRIPTION], [TYPE] FROM [PUSH MANAGER] WHERE [ID CLIENTE] = ?', [idCliente]);
17
- const [{ id: idNotification }] = yield (0, index_1.query)('INSERT INTO "CLIENTE NOTIFICACION" ("ID CLIENTE", "TIMESTAMP", "TITLE", "BODY", "PARAMS") VALUES (?, NOW(), ?, ?, ?) RETURNING "ID"', [
18
- idCliente,
19
- title !== null && title !== void 0 ? title : '',
20
- body,
21
- JSON.stringify({
22
- screen,
23
- screenParams,
24
- }),
25
- ]);
16
+ // ⚠️ T-219: el INSERT lleva RETURNING y se destructura directo, así que un [] revienta con
17
+ // "TypeError: Cannot read properties of undefined (reading 'id')" —2 veces en la semana
18
+ // medida—. Se traza con el idCliente y se propaga: sin fila no hay notificación que mandar.
19
+ let devices, idNotification;
20
+ try {
21
+ devices = yield (0, index_1.query)('SELECT [SUBSCRIPTION], [TYPE] FROM [PUSH MANAGER] WHERE [ID CLIENTE] = ?', [idCliente]);
22
+ const [fila] = yield (0, index_1.query)('INSERT INTO "CLIENTE NOTIFICACION" ("ID CLIENTE", "TIMESTAMP", "TITLE", "BODY", "PARAMS") VALUES (?, NOW(), ?, ?, ?) RETURNING "ID"', [
23
+ idCliente,
24
+ title !== null && title !== void 0 ? title : '',
25
+ body,
26
+ JSON.stringify({
27
+ screen,
28
+ screenParams,
29
+ }),
30
+ ]);
31
+ if (!fila)
32
+ throw new Error('el INSERT de CLIENTE NOTIFICACION no ha devuelto fila');
33
+ idNotification = fila.id;
34
+ }
35
+ catch (error) {
36
+ // eslint-disable-next-line no-console
37
+ console.error(`sendNotification: no se ha podido registrar la notificación de idCliente=${idCliente} (${error === null || error === void 0 ? void 0 : error.message}). No se envía.`);
38
+ throw error;
39
+ }
26
40
  const screenParamsString = JSON.stringify(screenParams);
27
41
  for (const device of devices) {
28
42
  yield notificationWEB(firebaseMessaging, {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/achievements/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAiK9C,qGAAqG;AAErG,eAAO,MAAM,qBAAqB,YAAa,GAAG,UAAU,GAAG,KAAG,GAuBjE,CAAC;AAkjBF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,WACvB,GAAG,UACJ,MAAM,SACP,GAAG,KACR,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAyGnE,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,WAC9B,GAAG,UACJ,MAAM,SACP,GAAG,yBAER,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAWnE,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,WAAkB,GAAG,KAAG,QAAQ,MAAM,EAAE,CAO3E,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/achievements/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AA6L9C,qGAAqG;AAErG,eAAO,MAAM,qBAAqB,YAAa,GAAG,UAAU,GAAG,KAAG,GAuBjE,CAAC;AA0jBF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,WACvB,GAAG,UACJ,MAAM,SACP,GAAG,KACR,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAyGnE,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,WAC9B,GAAG,UACJ,MAAM,SACP,GAAG,yBAER,QAAQ,MAAM;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,GAAG,CAAC;IAAC,MAAM,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAWnE,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,WAAkB,GAAG,KAAG,QAAQ,MAAM,EAAE,CAO3E,CAAC"}
@@ -1,5 +1,27 @@
1
1
  import postgresql from 'pg';
2
2
  declare const pool: postgresql.Pool;
3
+ /**
4
+ * ✅ FUNCIÓN PRINCIPAL - queryDirect (recomendada para 90% de casos)
5
+ *
6
+ * ⚠️ CONTRATO (T-219): con `RP_DB_RECHAZA_FALLO_CONEXION=1`, un fallo de CONEXIÓN, agotados los
7
+ * reintentos, **rechaza**. Sin esa variable —el caso por defecto— devuelve `[]` como toda la 1.x.
8
+ * Un error de SQL (sintaxis, constraint, columna que no existe) se sigue logueando y
9
+ * devolviendo `[]` en los dos modos, como siempre.
10
+ *
11
+ * Antes todo devolvía `[]`, así que "la BD no ha conectado" y "no hay filas" eran la misma
12
+ * cosa para quien llamaba. En 7 días eso escondió 176 `client_login_timeout` en
13
+ * prod-third-party-backend: unos pocos reventaban con un TypeError y la mayoría se saltaban
14
+ * trabajo en silencio (entrenos que se quedaron sin estructura, notificaciones que no
15
+ * salieron). Ver docs de T-219.
16
+ *
17
+ * Para el que llama: si la fila puede no existir de verdad, sigue comprobándolo. Lo que ya no
18
+ * hace falta es adivinar si el `[]` era un fallo de BD.
19
+ *
20
+ * El cambio sale publicado en **1.32.0** con el interruptor apagado: la versión puede entrar en
21
+ * cualquier servicio sin avisar y aun así no cambia nada hasta que ese servicio lo enciende. Antes de
22
+ * encenderlo, `docs/T-219-auditoria-consumidores.md` dice, con fichero y línea, dónde cae ese
23
+ * rechazo en cada repo y qué queda por cerrar.
24
+ */
3
25
  declare const query: (queryText: string, values?: (string | number | boolean)[]) => Promise<any>;
4
26
  declare const queryWithClient: (queryText: string, values?: (string | number | boolean)[], _retry?: boolean) => any;
5
27
  declare const batchQuery: (queries: any[], batchSize?: number) => Promise<any[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/db/index.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,IAAI,CAAC;AAI5B,QAAA,MAAM,IAAI,iBAYR,CAAC;AASH,QAAA,MAAM,KAAK,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,iBAyB5E,CAAC;AAGF,QAAA,MAAM,eAAe,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,0BAmDtF,CAAC;AAGF,QAAA,MAAM,UAAU,YAAmB,GAAG,EAAE,uCAmBvC,CAAC;AAGF,QAAA,MAAM,gBAAgB,cACT,MAAM,WACT,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,qCAyBtC,CAAC;AAGF,QAAA,MAAM,SAAS,QAAS,MAAM,EAAE,WAM/B,CAAC;AAoJF,OAAO,EACL,KAAK,EAAE,oCAAoC;AAC3C,eAAe,EAAE,wBAAwB;AACzC,gBAAgB,EAAE,uBAAuB;AACzC,UAAU,EAAE,aAAa;AACzB,SAAS,EAAE,WAAW;AACtB,IAAI,GACL,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/db/index.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,IAAI,CAAC;AAI5B,QAAA,MAAM,IAAI,iBAYR,CAAC;AAyGH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,QAAA,MAAM,KAAK,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,iBA+C5E,CAAC;AAGF,QAAA,MAAM,eAAe,cAAqB,MAAM,WAAU,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,0BA4CtF,CAAC;AAGF,QAAA,MAAM,UAAU,YAAmB,GAAG,EAAE,uCAmBvC,CAAC;AAGF,QAAA,MAAM,gBAAgB,cACT,MAAM,WACT,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,EAAE,qCAkBtC,CAAC;AAGF,QAAA,MAAM,SAAS,QAAS,MAAM,EAAE,WAM/B,CAAC;AAoJF,OAAO,EACL,KAAK,EAAE,oCAAoC;AAC3C,eAAe,EAAE,wBAAwB;AACzC,gBAAgB,EAAE,uBAAuB;AACzC,UAAU,EAAE,aAAa;AACzB,SAAS,EAAE,WAAW;AACtB,IAAI,GACL,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"generateShareMap.d.ts","sourceRoot":"","sources":["../../../../src/image/generateShareMap.ts"],"names":[],"mappings":";AAYA;;;;;;;;;;;;;;;GAeG;AACH,QAAA,MAAM,gBAAgB,+DA+BrB,CAAC;AA0HF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"generateShareMap.d.ts","sourceRoot":"","sources":["../../../../src/image/generateShareMap.ts"],"names":[],"mappings":";AAYA;;;;;;;;;;;;;;;GAeG;AACH,QAAA,MAAM,gBAAgB,+DA6CrB,CAAC;AA0HF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendNotification/index.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,iBAAiB,EAAE,GAAG,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,GAAG,CAAC;CACpB;AAED,QAAA,MAAM,gBAAgB,yEAOnB,YAAY,kBA8Bd,CAAC;AAuBF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/sendNotification/index.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,iBAAiB,EAAE,GAAG,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,GAAG,CAAC;CACpB;AAED,QAAA,MAAM,gBAAgB,yEAOnB,YAAY,kBA0Cd,CAAC;AAuBF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"estructuraWorkout.d.ts","sourceRoot":"","sources":["../../../../src/workout/estructuraWorkout.ts"],"names":[],"mappings":"AAsCA,QAAA,MAAM,mBAAmB,YAAa,GAAG,cAAc,GAAG,QAiCzD,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,QAAA,MAAM,wBAAwB,cAAqB,GAAG,kBAkBrD,CAAC;AAkPF,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,CAAC"}
1
+ {"version":3,"file":"estructuraWorkout.d.ts","sourceRoot":"","sources":["../../../../src/workout/estructuraWorkout.ts"],"names":[],"mappings":"AAsCA,QAAA,MAAM,mBAAmB,YAAa,GAAG,cAAc,GAAG,QAiCzD,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,QAAA,MAAM,wBAAwB,cAAqB,GAAG,kBAgCrD,CAAC;AAkPF,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAuTA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CA6EvB,CAAC;AA+EF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
1
+ {"version":3,"file":"saveWorkoutAplication.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutAplication.ts"],"names":[],"mappings":"AAuTA;;;;;;;;;;GAUG;AACH,QAAA,MAAM,qBAAqB,SACnB,GAAG,UAED,GAAG,KACV,QAAQ,MAAM,GAAG,IAAI,CA6EvB,CAAC;AA0GF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"saveWorkoutLaps.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutLaps.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,QAAA,MAAM,eAAe,cAAqB,GAAG,QAAQ,GAAG,EAAE,mBAAmB,GAAG,KAAG,QAAQ,IAAI,CAuB9F,CAAC;AAEF,OAAO,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"saveWorkoutLaps.d.ts","sourceRoot":"","sources":["../../../../src/workout/saveWorkoutLaps.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,QAAA,MAAM,eAAe,cAAqB,GAAG,QAAQ,GAAG,EAAE,mBAAmB,GAAG,KAAG,QAAQ,IAAI,CAyC9F,CAAC;AAEF,OAAO,EAAE,eAAe,EAAE,CAAC"}
@@ -85,12 +85,25 @@ exports.getStructuraWorkout = getStructuraWorkout;
85
85
  * @param {number|string} idWorkout
86
86
  */
87
87
  const saveDoneStructuraWorkout = (idWorkout) => __awaiter(void 0, void 0, void 0, function* () {
88
- const [laps, streamData, [workout], estructura] = yield Promise.all([
89
- (0, db_1.query)('SELECT * FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
90
- (0, db_1.query)('SELECT * FROM "WORKOUT STREAM" WHERE "ID WORKOUT" = ?', [idWorkout]),
91
- (0, db_1.query)('SELECT "DURATION", "ID CLIENTE", "TYPE" FROM "WORKOUT" WHERE "ID" = ?', [idWorkout]),
92
- (0, db_1.query)('SELECT * FROM "WORKOUT STRUCTURE" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
93
- ]);
88
+ // ⚠️ T-219: estas 4 lecturas son el punto donde más veces se cae el pool (135 de los 176
89
+ // client_login_timeout de una semana en prod-third-party-backend). Antes el fallo llegaba
90
+ // como `[]` y el `return` de abajo lo confundía con "este entreno no es por series": el
91
+ // entreno se quedaba sin estructura real y nadie se enteraba. Ahora se traza con el
92
+ // idWorkout y se propaga, para que el webhook que lo llamó lo registre.
93
+ let laps, streamData, workout, estructura;
94
+ try {
95
+ [laps, streamData, [workout], estructura] = yield Promise.all([
96
+ (0, db_1.query)('SELECT * FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
97
+ (0, db_1.query)('SELECT * FROM "WORKOUT STREAM" WHERE "ID WORKOUT" = ?', [idWorkout]),
98
+ (0, db_1.query)('SELECT "DURATION", "ID CLIENTE", "TYPE" FROM "WORKOUT" WHERE "ID" = ?', [idWorkout]),
99
+ (0, db_1.query)('SELECT * FROM "WORKOUT STRUCTURE" WHERE "ID WORKOUT" = ? ORDER BY "INDEX"', [idWorkout]),
100
+ ]);
101
+ }
102
+ catch (error) {
103
+ // eslint-disable-next-line no-console
104
+ console.error(`saveDoneStructuraWorkout: no se han podido leer los datos del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). El entreno se queda SIN estructura real.`);
105
+ throw error;
106
+ }
94
107
  // Workout sin estructura (no es entrenamiento por series) → nada que hacer.
95
108
  if (!estructura || estructura.length === 0 || !workout)
96
109
  return;
@@ -391,7 +391,21 @@ const postHooks = (idWorkout, opts) => __awaiter(void 0, void 0, void 0, functio
391
391
  // La imagen de compartir es accesoria: si falla (Storage, canvas, BD) no debe
392
392
  // romper la ingesta ni escapar como unhandled rejection (así llegaba a Sentry).
393
393
  }
394
- yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
394
+ // ⚠️ T-219: aislado como el resto de hooks, y por un motivo concreto. El WORKOUT ya está
395
+ // commiteado y la estructura real es un DERIVADO idempotente que se vuelve a calcular solo
396
+ // (saveWorkoutLaps la re-dispara al llegar los laps, y cualquier merge posterior también).
397
+ // Si el rechazo escapara de aquí subiría por saveWorkoutAplication hasta el llamante y
398
+ // abortaría lo que este hace DESPUÉS —los laps de Strava y la notificación de sentimientos—,
399
+ // que sí son irrecuperables: esos webhooks ya han respondido 200 y nadie los reintenta.
400
+ // Como aquí es donde más se cae el pool (135 de los 176 client_login_timeout), dejarlo
401
+ // propagar convertía el fallo más frecuente en la pérdida más cara. saveDoneStructuraWorkout
402
+ // ya traza el idWorkout antes de lanzar, así que el fallo no se pierde de vista.
403
+ try {
404
+ yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
405
+ }
406
+ catch (_) {
407
+ // ignore — trazado dentro; la estructura se recalcula en cuanto lleguen laps o un merge.
408
+ }
395
409
  // Logros: evaluar al completar el entreno (este es el embudo de los entrenos
396
410
  // sincronizados). Se pasa el idWorkout para calcular el PR solo de ESTE entreno
397
411
  // (incremental). Aislado: un fallo de logros NUNCA debe romper la ingesta.
@@ -429,7 +443,15 @@ const saveMap = (idWorkout, polyline) => __awaiter(void 0, void 0, void 0, funct
429
443
  // @ts-ignore
430
444
  yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${idWorkout}`).save(imageBuffer);
431
445
  const urlMap = `https://storage.googleapis.com/${process.env.CLOUD_STORAGE_BUCKET_PUBLIC}/Workout/${idWorkout}`;
432
- yield (0, db_1.query)('UPDATE "WORKOUT" SET "HAVE MAP IMAGE" = TRUE, "PHOTO URL" = ? WHERE "ID" = ?', [urlMap, idWorkout]);
446
+ try {
447
+ yield (0, db_1.query)('UPDATE "WORKOUT" SET "HAVE MAP IMAGE" = TRUE, "PHOTO URL" = ? WHERE "ID" = ?', [urlMap, idWorkout]);
448
+ }
449
+ catch (error) {
450
+ // T-219: la imagen ya está en Storage; si esto falla, el entreno se queda sin su mapa en BD.
451
+ // eslint-disable-next-line no-console
452
+ console.error(`saveMap: mapa subido pero NO guardado en BD para idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}).`);
453
+ throw error;
454
+ }
433
455
  });
434
456
  const saveShareWorkoutImage = (id, type) => __awaiter(void 0, void 0, void 0, function* () {
435
457
  // @ts-ignore
@@ -451,5 +473,13 @@ const saveShareWorkoutImage = (id, type) => __awaiter(void 0, void 0, void 0, fu
451
473
  // @ts-ignore
452
474
  yield storage.bucket(process.env.CLOUD_STORAGE_BUCKET_PUBLIC).file(`Workout/${id}-share`).save(shareMap);
453
475
  const urlShare = `https://storage.googleapis.com/${process.env.CLOUD_STORAGE_BUCKET_PUBLIC}/Workout/${id}-share`;
454
- yield (0, db_1.query)('UPDATE "WORKOUT" SET "PHOTO URL SHARE" = ? WHERE "ID" = ?', [urlShare, id]);
476
+ try {
477
+ yield (0, db_1.query)('UPDATE "WORKOUT" SET "PHOTO URL SHARE" = ? WHERE "ID" = ?', [urlShare, id]);
478
+ }
479
+ catch (error) {
480
+ // T-219: la imagen de compartir ya está subida; sin esta URL el usuario no la ve en la app.
481
+ // eslint-disable-next-line no-console
482
+ console.error(`saveShareWorkoutImage: imagen subida pero NO guardada en BD para idWorkout=${id} (${error === null || error === void 0 ? void 0 : error.message}).`);
483
+ throw error;
484
+ }
455
485
  });
@@ -44,16 +44,34 @@ const saveWorkoutLaps = (idWorkout, laps, aplicationType) => __awaiter(void 0, v
44
44
  const nuevos = Array.isArray(laps)
45
45
  ? laps.filter((l) => l && l.index !== null && l.index !== undefined && l.durationStartLap !== null && l.durationStartLap !== undefined)
46
46
  : [];
47
- const [countRow] = yield (0, db_1.query)('SELECT COUNT(*)::int AS "N" FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
48
- const existentes = Number((countRow === null || countRow === void 0 ? void 0 : countRow.n) || 0);
49
- if (nuevos.length > existentes) {
50
- yield (0, db_1.query)('DELETE FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
51
- for (const lap of nuevos) {
52
- yield (0, db_1.query)('INSERT INTO "WORKOUT LAP" ("ID WORKOUT", "INDEX", "DURATION START LAP") VALUES (?, ?, ?)', [idWorkout, lap.index, lap.durationStartLap]);
47
+ // ⚠️ T-219: si el pool se cae en mitad del DELETE + INSERT, los laps quedan a medias. Se traza
48
+ // con el idWorkout y cuántos laps había que escribir, y se propaga para que quede registrado.
49
+ try {
50
+ const [countRow] = yield (0, db_1.query)('SELECT COUNT(*)::int AS "N" FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
51
+ const existentes = Number((countRow === null || countRow === void 0 ? void 0 : countRow.n) || 0);
52
+ if (nuevos.length > existentes) {
53
+ yield (0, db_1.query)('DELETE FROM "WORKOUT LAP" WHERE "ID WORKOUT" = ?', [idWorkout]);
54
+ for (const lap of nuevos) {
55
+ yield (0, db_1.query)('INSERT INTO "WORKOUT LAP" ("ID WORKOUT", "INDEX", "DURATION START LAP") VALUES (?, ?, ?)', [idWorkout, lap.index, lap.durationStartLap]);
56
+ }
53
57
  }
54
58
  }
59
+ catch (error) {
60
+ // eslint-disable-next-line no-console
61
+ console.error(`saveWorkoutLaps: fallo guardando los ${nuevos.length} laps del idWorkout=${idWorkout} (${error === null || error === void 0 ? void 0 : error.message}). Pueden haber quedado a medias.`);
62
+ throw error;
63
+ }
55
64
  // si nuevos.length <= existentes: se conserva el conjunto existente (más detalle gana)
56
65
  // Re-dispara el recálculo de la estructura real ahora que hay laps en BD.
57
- yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
66
+ // ⚠️ T-219: misma política que en los postHooks de saveWorkoutAplication — la ESCRITURA de
67
+ // laps de arriba propaga (es dato que se pierde), pero este recálculo es un derivado de lo
68
+ // que ya está en BD: si se deja propagar tumba lo que el llamante haga después (en Strava,
69
+ // la notificación de sentimientos) por un fallo que se puede rehacer. Se traza dentro.
70
+ try {
71
+ yield (0, estructuraWorkout_1.saveDoneStructuraWorkout)(idWorkout);
72
+ }
73
+ catch (_) {
74
+ // ignore — trazado dentro; los laps ya están persistidos y el recálculo es rehacible.
75
+ }
58
76
  });
59
77
  exports.saveWorkoutLaps = saveWorkoutLaps;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runnerpro/backend",
3
- "version": "1.31.2",
3
+ "version": "1.32.0",
4
4
  "description": "A collection of common backend functions",
5
5
  "exports": {
6
6
  ".": "./lib/cjs/index.js"
@@ -18,6 +18,7 @@
18
18
  "lint": "eslint --ext .ts --ignore-path .gitignore .",
19
19
  "test:translate-workout-garmin": "ts-node --transpile-only scripts/translateWorkoutGarmin.ts",
20
20
  "test:generate-share-map-guard": "ts-node --transpile-only -P configs/tsconfig.cjs.json scripts/testGenerateShareMapGuard.ts",
21
+ "test:pg-query-conexion": "ts-node --transpile-only -P configs/tsconfig.cjs.json scripts/testPgQueryConexion.ts",
21
22
  "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
22
23
  "prepare": "husky"
23
24
  },
@@ -31,7 +32,7 @@
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
34
- "url": "https://gitlab.com/runner-pro/runnerpro-backend.git"
35
+ "url": "https://github.com/david-jimenez-lamas/runnerpro-backend.git"
35
36
  },
36
37
  "author": "Runner Pro",
37
38
  "license": "MIT",