@neetru/sdk 3.2.1 → 3.2.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to `@neetru/sdk` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.2.3] - 2026-08-07 — **fix(db-sql): re-lease REATIVO — query que bate em cert expirado se auto-cura (P1.5)**
9
+
10
+ Release **patch** (fecha o gap reativo que sobrou do 3.2.2 — mesma classe de bug, no caminho da query em vez do timer).
11
+
12
+ ### Contexto
13
+
14
+ O 3.2.2 curou o death-spiral no caminho **proativo** (o timer de renovação, ao falhar o renew, cai pra emitir um lease NOVO). Mas restava um gap: se o timer de renovação NÃO disparar (event-loop travado, processo suspenso pelo Cloud Run entre requests) ou o cert mTLS efêmero (~15min) expirar NO MEIO de uma request, não havia recuperação **reativa** — a query que batia em `certificate expired` (TLS alert 45) ou erro de conexão simplesmente morria, e o processo ficava preso até um restart. Este é o P1.5 da auditoria de estabilização (`docs/_review/DIAGNOSTICO_ESTABILIZACAO_SAAS_2026-08-05.md`), listado como follow-up no próprio 3.2.2.
15
+
16
+ ### Corrigido
17
+
18
+ - **Wrapper resiliente do pool** (`src/db/sql/resilient-pool.ts`): o `pg.Pool` que o Drizzle usa passa a ser embrulhado num Proxy. O handle Drizzle é construído UMA vez sobre o wrapper (referência estável). Ao pegar um erro de ESTABELECIMENTO de conexão numa query plana (`sql.orm.select()` etc.), o wrapper dispara um re-lease (lease NOVO, sem `leaseId` → pool interno novo com cert fresco → swap) e faz **RETRY 1x** da mesma query no pool novo. O `SqlLeaseManager` troca só o pool REAL interno do wrapper (`_swapPool` via `__neetruSwapInner`) — não reconstrói o Drizzle, então referências cacheadas pelo consumer continuam válidas.
19
+ - **Só retry em erro de ESTABELECIMENTO** (a query nunca foi enviada — sem risco de double-write): handshake TLS / cert expirado (`certificate expired`, `CERT_HAS_EXPIRED`, alerta TLS 45, `ERR_SSL_*`) e fase de conexão de socket/DNS (`ECONNREFUSED`/`ETIMEDOUT`/`ENOTFOUND`/`EHOSTUNREACH`/`EAI_AGAIN`). Erro de Postgres já conectado (constraint, statement timeout, deadlock, permissão) passa DIRETO sem retry. `Connection terminated`/`connection closed` fica de fora DE PROPÓSITO (ambíguo — pode ser meio-de-query; conservador vence).
20
+ - **Transações intactas (pass-through).** O Drizzle abre um client dedicado via `pool.connect()` num `transaction()` (detecta via `client instanceof Pool`); o target do Proxy herda `Pool.prototype`, então `instanceof` continua `true` e `connect` é encaminhado ao pool real sem interceptação. As queries da transação rodam no client dedicado, NUNCA no wrapper — o retry reativo jamais toca uma transação.
21
+ - Re-leases reativos concorrentes são coalescidos (N queries falhando ao mesmo tempo = 1 re-lease). Re-lease que falha propaga o erro ORIGINAL da query.
22
+ - Testes novos cobrindo: cert expirado → re-lease → retry no 2º pool → sucesso; erro não-conexão passa direto sem retry; transação continua funcionando; re-lease falho propaga o erro original; classificador de erro de estabelecimento.
23
+
24
+ ## [3.2.2] - 2026-08-05 — **fix(db-sql): renovação de lease cai pra emissão nova quando renew falha (death spiral do cert mTLS)**
25
+
26
+ Release **patch** (bug crítico de conexão em produção — mesma classe do 3.2.1, um nível mais fundo).
27
+
28
+ ### Contexto
29
+
30
+ Continuação direta do bug do 3.2.1. Confirmado em campo (pdv-agiliza, 2026-08-05, SSH na `snee-002-multi`): mesmo com o hot-swap do 3.2.1 correto, o `SqlLeaseManager._doRenew()` só chamava **renew** (com `leaseId`). O `renew` do Core (`renewLeaseCore`) só funciona com um lease AINDA vivo; se o lease expirou/foi revogado/foi GC'd do Firestore, o renew falha pra sempre — e o `_doRenew` antigo só re-tentava renew a cada 30s, sem NUNCA emitir um lease NOVO. Resultado (death spiral): o cert mTLS de cliente (efêmero, ~15min, mintado por lease em `issuer-core.ts`) expira, TODA leitura do banco do produto morre com `certificate expired` (TLS alert 45, `ERR_SSL_SSL/TLS_ALERT_CERTIFICATE_EXPIRED`), e o processo NUNCA se recupera até restart manual. Observado determinístico por 3h+ (`/api/db/doc` e tenant-resolve do pdv, 500 em toda leitura; schema/relógio/PgBouncer/Postgres todos confirmados sãos — era só o cert).
31
+
32
+ ### Corrigido
33
+
34
+ - `_doRenew()`: quando o `renew` (com `leaseId`) falha, **cai pra emissão de um lease NOVO** (`fetchLease()` sem `leaseId`) e faz o swap do pool com o certificado fresco. Quebra o death spiral sem depender de restart do processo. Só desiste (reagenda em 30s) se AMBOS falharem (Core inalcançável).
35
+ - Falha de renovação deixou de ser silenciosa em produção: `console.warn` sempre (não só em dev) — renovação que falha é degradação real de disponibilidade e precisa aparecer no log do produto (`app.log`).
36
+ - 3 testes novos: fallback pra lease novo no renew-fail + swap do pool; reagenda sem lançar quando ambos falham; renovação seguinte usa o novo `leaseId`.
37
+ - **Fix de drift:** `VERSION` em `src/index.ts` estava travado em `3.2.0` (o release 3.2.1 bumpou só o `package.json`) — o guard `SDK-QW-5` estava vermelho desde 2026-07-27 (CI off, ninguém pegou). Agora sincronizado em `3.2.2`.
38
+
39
+ ### Follow-up (não nesta release)
40
+
41
+ - **Re-lease REATIVO** (P1.5 da auditoria de estabilização 2026-08-05, `docs/_review/DIAGNOSTICO_ESTABILIZACAO_SAAS_2026-08-05.md`): ao pegar `certificate expired`/erro de conexão numa query, invalidar o pool + emitir lease novo + retry 1x, pra um processo em execução se auto-curar mesmo se o timer de renovação não tiver disparado. Exige wrapper resiliente sobre o `pg.Pool` (cuidado com transações Drizzle) — trabalho separado, testado à parte.
42
+
8
43
  ## [3.2.1] - 2026-07-27 — **fix(db-sql): hot-swap do pool quando host/port/user/senha mudam (bug_pdv_agiliza)**
9
44
 
10
45
  Release **patch** (bug crítico de conexão em produção — bloqueava login/queries em qualquer produto que renovasse lease sem reiniciar o processo).
package/dist/auth.cjs CHANGED
@@ -277,7 +277,99 @@ var init_db_errors = __esm({
277
277
  }
278
278
  });
279
279
 
280
+ // src/db/sql/resilient-pool.ts
281
+ function isResilientPool(x) {
282
+ return typeof x === "object" && x !== null && x[RESILIENT_POOL_BRAND] === true;
283
+ }
284
+ function isConnectionEstablishmentError(err) {
285
+ const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
286
+ if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND" || code === "EHOSTUNREACH" || code === "EAI_AGAIN") {
287
+ return true;
288
+ }
289
+ if (typeof code === "string") {
290
+ const upper = code.toUpperCase();
291
+ if (upper === "CERT_HAS_EXPIRED" || upper.startsWith("ERR_SSL") || upper.startsWith("ERR_TLS") || upper.includes("CERTIFICATE_EXPIRED")) {
292
+ return true;
293
+ }
294
+ }
295
+ const rawMessage = err instanceof Error ? err.message : typeof err === "string" ? err : "";
296
+ const message = rawMessage.toLowerCase();
297
+ if (message.length === 0) return false;
298
+ if (message.includes("certificate expired") || message.includes("certificate has expired") || message.includes("cert_has_expired") || message.includes("alert certificate expired") || message.includes("err_ssl") || message.includes("sslv3 alert") || message.includes("tlsv1 alert")) {
299
+ return true;
300
+ }
301
+ if (message.includes("econnrefused") || message.includes("etimedout") || message.includes("enotfound") || message.includes("ehostunreach") || message.includes("getaddrinfo")) {
302
+ return true;
303
+ }
304
+ return false;
305
+ }
306
+ function createResilientPool(initialInner) {
307
+ const state = { current: initialInner };
308
+ let reLease = null;
309
+ const controls = {
310
+ __neetruSwapInner(newInner) {
311
+ const old = state.current;
312
+ state.current = newInner;
313
+ return old;
314
+ },
315
+ __neetruGetInner() {
316
+ return state.current;
317
+ },
318
+ __neetruSetReLease(fn) {
319
+ reLease = fn;
320
+ }
321
+ };
322
+ const invokeQuery = (pool, args) => pool.query(...args);
323
+ const wrappedQuery = (...args) => {
324
+ if (args.length > 0 && typeof args[args.length - 1] === "function") {
325
+ return invokeQuery(state.current, args);
326
+ }
327
+ return (async () => {
328
+ try {
329
+ return await invokeQuery(state.current, args);
330
+ } catch (err) {
331
+ const fn = reLease;
332
+ if (fn === null || !isConnectionEstablishmentError(err)) {
333
+ throw err;
334
+ }
335
+ try {
336
+ await fn();
337
+ } catch {
338
+ throw err;
339
+ }
340
+ return await invokeQuery(state.current, args);
341
+ }
342
+ })();
343
+ };
344
+ const target = Object.create(Object.getPrototypeOf(initialInner));
345
+ const proxy = new Proxy(target, {
346
+ get(_t, prop) {
347
+ if (prop === RESILIENT_POOL_BRAND) return true;
348
+ if (prop === "__neetruSwapInner") return controls.__neetruSwapInner;
349
+ if (prop === "__neetruGetInner") return controls.__neetruGetInner;
350
+ if (prop === "__neetruSetReLease") return controls.__neetruSetReLease;
351
+ if (prop === "query") return wrappedQuery;
352
+ if (prop === "constructor") {
353
+ return state.current.constructor;
354
+ }
355
+ const current = state.current;
356
+ const value = Reflect.get(current, prop, current);
357
+ return typeof value === "function" ? value.bind(current) : value;
358
+ }
359
+ });
360
+ return proxy;
361
+ }
362
+ var RESILIENT_POOL_BRAND;
363
+ var init_resilient_pool = __esm({
364
+ "src/db/sql/resilient-pool.ts"() {
365
+ RESILIENT_POOL_BRAND = /* @__PURE__ */ Symbol.for("neetru.sdk.resilientPool");
366
+ }
367
+ });
368
+
280
369
  // src/db/sql/lease.ts
370
+ function hasConnectionChanged(oldLease, newLease) {
371
+ return oldLease.leaseId !== newLease.leaseId || oldLease.credentialVersion !== newLease.credentialVersion || oldLease.host !== newLease.host || oldLease.port !== newLease.port || oldLease.dbName !== newLease.dbName || oldLease.user !== newLease.user || oldLease.password !== newLease.password || oldLease.sslca !== newLease.sslca || oldLease.clientCert !== newLease.clientCert || oldLease.clientKey !== newLease.clientKey;
372
+ }
281
373
  function mapPoolError(err) {
282
374
  if (err instanceof NeetruDbError) return err;
283
375
  const message = err instanceof Error ? err.message : String(err);
@@ -303,6 +395,7 @@ var RENEWAL_THRESHOLD, MIN_RENEWAL_DELAY_MS, SqlLeaseManager;
303
395
  var init_lease = __esm({
304
396
  "src/db/sql/lease.ts"() {
305
397
  init_db_errors();
398
+ init_resilient_pool();
306
399
  RENEWAL_THRESHOLD = 0.8;
307
400
  MIN_RENEWAL_DELAY_MS = 3e4;
308
401
  SqlLeaseManager = class {
@@ -311,6 +404,12 @@ var init_lease = __esm({
311
404
  _orm;
312
405
  _renewalTimer = null;
313
406
  _closed = false;
407
+ /**
408
+ * Promessa de re-lease REATIVO em voo — coalesce chamadas concorrentes
409
+ * (N queries falhando ao mesmo tempo disparam UM único re-lease). Ver
410
+ * `_reactiveReLease`.
411
+ */
412
+ _reLeaseInFlight = null;
314
413
  _deps;
315
414
  constructor(lease, pool, orm, deps) {
316
415
  this._lease = lease;
@@ -382,37 +481,90 @@ var init_lease = __esm({
382
481
  async _doRenew() {
383
482
  if (this._closed) return;
384
483
  try {
385
- const renewOpts = {
386
- leaseId: this._lease.leaseId
484
+ const withDb = (o) => {
485
+ if (this._deps.database !== void 0) o.database = this._deps.database;
486
+ return o;
387
487
  };
388
- if (this._deps.database !== void 0) {
389
- renewOpts.database = this._deps.database;
488
+ let newLease;
489
+ try {
490
+ newLease = await this._deps.fetchLease(withDb({ leaseId: this._lease.leaseId }));
491
+ } catch (renewErr) {
492
+ newLease = await this._deps.fetchLease(withDb({}));
390
493
  }
391
- const newLease = await this._deps.fetchLease(renewOpts);
392
494
  await this._swapPool(newLease);
393
495
  } catch (err) {
394
496
  this._renewalTimer = setTimeout(() => {
395
497
  void this._doRenew();
396
498
  }, MIN_RENEWAL_DELAY_MS);
397
- if (process.env["NODE_ENV"] !== "production") {
398
- console.warn("[SqlLeaseManager] Lease renewal failed, will retry:", err);
399
- }
499
+ console.warn(
500
+ `[SqlLeaseManager] renova\xE7\xE3o E emiss\xE3o de lease novo falharam; re-tentando em ${MIN_RENEWAL_DELAY_MS}ms (cert mTLS expira ~15min ap\xF3s o \xFAltimo swap):`,
501
+ err instanceof Error ? err.message : err
502
+ );
400
503
  return;
401
504
  }
402
505
  this._scheduleRenewal();
403
506
  }
507
+ // ─── Re-lease REATIVO (P1.5) ───────────────────────────────────────────────
508
+ /**
509
+ * Recuperação REATIVA: chamado pelo wrapper resiliente (`resilient-pool.ts`)
510
+ * quando uma query plana falha num erro de ESTABELECIMENTO de conexão (cert
511
+ * mTLS expirado no meio de uma request, ou timer de renovação que não
512
+ * disparou). Emite um lease NOVO e faz o swap do pool interno do wrapper com
513
+ * o certificado fresco; o wrapper então re-tenta a query 1x no pool novo.
514
+ *
515
+ * Vai DIRETO ao lease novo (sem `leaseId`) — a conexão falhou porque o
516
+ * certificado do lease atual está ruim, então `renew` (que depende do lease
517
+ * antigo estar vivo) não é o caminho mais robusto. Emitir do zero é o que já
518
+ * cura o death-spiral no caminho proativo (ver `_doRenew`).
519
+ *
520
+ * Coalesce concorrência: N queries falhando ao mesmo tempo compartilham UM
521
+ * único re-lease (não N leases). Após o swap, cada query re-tenta no pool
522
+ * vigente.
523
+ *
524
+ * NÃO engole erro: se o re-lease falhar, propaga — o wrapper então relança o
525
+ * erro ORIGINAL da query pro consumer.
526
+ */
527
+ async _reactiveReLease() {
528
+ if (this._closed) {
529
+ throw new NeetruDbError(
530
+ "db_unavailable",
531
+ "SqlLeaseManager foi fechado \u2014 re-lease reativo abortado."
532
+ );
533
+ }
534
+ if (this._reLeaseInFlight !== null) return this._reLeaseInFlight;
535
+ const run = (async () => {
536
+ const withDb = (o) => {
537
+ if (this._deps.database !== void 0) o.database = this._deps.database;
538
+ return o;
539
+ };
540
+ const fresh = await this._deps.fetchLease(withDb({}));
541
+ await this._swapPool(fresh);
542
+ })();
543
+ this._reLeaseInFlight = run;
544
+ try {
545
+ await run;
546
+ } finally {
547
+ this._reLeaseInFlight = null;
548
+ }
549
+ }
404
550
  /**
405
551
  * Hot-swap do pool: cria pool novo, atualiza ponteiros, drena pool antigo.
406
552
  *
407
553
  * Exposto como método protegido para testes.
408
554
  */
409
555
  async _swapPool(newLease) {
410
- const credRotated = newLease.credentialVersion !== this._lease.credentialVersion;
411
- if (!credRotated && newLease.leaseId === this._lease.leaseId) {
556
+ if (!hasConnectionChanged(this._lease, newLease)) {
412
557
  this._lease = newLease;
413
558
  return;
414
559
  }
415
560
  const newPool = this._deps.createPool(newLease);
561
+ if (isResilientPool(this._pool)) {
562
+ const oldInner = this._pool.__neetruSwapInner(newPool);
563
+ this._lease = newLease;
564
+ void oldInner.end().catch(() => {
565
+ });
566
+ return;
567
+ }
416
568
  const newOrm = this._deps.createDrizzle(newPool, this._deps.schema);
417
569
  const oldPool = this._pool;
418
570
  this._lease = newLease;
@@ -567,23 +719,26 @@ async function createSqlClientWithDeps(deps, options) {
567
719
  const msg = err instanceof Error ? err.message : String(err);
568
720
  throw new NeetruDbError("db_unavailable", `Falha ao obter lease inicial: ${msg}`);
569
721
  }
570
- let pool;
722
+ let innerPool;
571
723
  try {
572
- pool = depsWithDb.createPool(lease);
724
+ innerPool = depsWithDb.createPool(lease);
573
725
  } catch (err) {
574
726
  const msg = err instanceof Error ? err.message : String(err);
575
727
  throw new NeetruDbError("db_unavailable", `Falha ao abrir pool: ${msg}`);
576
728
  }
729
+ const pool = createResilientPool(innerPool);
577
730
  let orm;
578
731
  try {
579
732
  orm = depsWithDb.createDrizzle(pool, depsWithDb.schema);
580
733
  } catch (err) {
581
- await pool.end().catch(() => {
734
+ await innerPool.end().catch(() => {
582
735
  });
583
736
  const msg = err instanceof Error ? err.message : String(err);
584
737
  throw new NeetruDbError("db_unavailable", `Falha ao criar handle Drizzle: ${msg}`);
585
738
  }
586
- return new SqlLeaseManager(lease, pool, orm, depsWithDb);
739
+ const manager = new SqlLeaseManager(lease, pool, orm, depsWithDb);
740
+ pool.__neetruSetReLease(() => manager._reactiveReLease());
741
+ return manager;
587
742
  }
588
743
  async function createSqlClientFromConfig(config, schema, options) {
589
744
  const fetchLease = createHttpLeaseFetcher(config);
@@ -642,6 +797,7 @@ var init_sql_client = __esm({
642
797
  "src/db/sql/sql-client.ts"() {
643
798
  init_db_errors();
644
799
  init_lease();
800
+ init_resilient_pool();
645
801
  LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
646
802
  LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
647
803
  }