@neetru/sdk 3.2.2 → 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 +16 -0
- package/dist/auth.cjs +154 -4
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.mjs +154 -4
- package/dist/auth.mjs.map +1 -1
- package/dist/db.cjs +154 -4
- package/dist/db.cjs.map +1 -1
- package/dist/db.mjs +154 -4
- package/dist/db.mjs.map +1 -1
- package/dist/index.cjs +155 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +155 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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
|
+
|
|
8
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)**
|
|
9
25
|
|
|
10
26
|
Release **patch** (bug crítico de conexão em produção — mesma classe do 3.2.1, um nível mais fundo).
|
package/dist/auth.cjs
CHANGED
|
@@ -277,6 +277,95 @@ 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
|
|
281
370
|
function hasConnectionChanged(oldLease, newLease) {
|
|
282
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;
|
|
@@ -306,6 +395,7 @@ var RENEWAL_THRESHOLD, MIN_RENEWAL_DELAY_MS, SqlLeaseManager;
|
|
|
306
395
|
var init_lease = __esm({
|
|
307
396
|
"src/db/sql/lease.ts"() {
|
|
308
397
|
init_db_errors();
|
|
398
|
+
init_resilient_pool();
|
|
309
399
|
RENEWAL_THRESHOLD = 0.8;
|
|
310
400
|
MIN_RENEWAL_DELAY_MS = 3e4;
|
|
311
401
|
SqlLeaseManager = class {
|
|
@@ -314,6 +404,12 @@ var init_lease = __esm({
|
|
|
314
404
|
_orm;
|
|
315
405
|
_renewalTimer = null;
|
|
316
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;
|
|
317
413
|
_deps;
|
|
318
414
|
constructor(lease, pool, orm, deps) {
|
|
319
415
|
this._lease = lease;
|
|
@@ -408,6 +504,49 @@ var init_lease = __esm({
|
|
|
408
504
|
}
|
|
409
505
|
this._scheduleRenewal();
|
|
410
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
|
+
}
|
|
411
550
|
/**
|
|
412
551
|
* Hot-swap do pool: cria pool novo, atualiza ponteiros, drena pool antigo.
|
|
413
552
|
*
|
|
@@ -419,6 +558,13 @@ var init_lease = __esm({
|
|
|
419
558
|
return;
|
|
420
559
|
}
|
|
421
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
|
+
}
|
|
422
568
|
const newOrm = this._deps.createDrizzle(newPool, this._deps.schema);
|
|
423
569
|
const oldPool = this._pool;
|
|
424
570
|
this._lease = newLease;
|
|
@@ -573,23 +719,26 @@ async function createSqlClientWithDeps(deps, options) {
|
|
|
573
719
|
const msg = err instanceof Error ? err.message : String(err);
|
|
574
720
|
throw new NeetruDbError("db_unavailable", `Falha ao obter lease inicial: ${msg}`);
|
|
575
721
|
}
|
|
576
|
-
let
|
|
722
|
+
let innerPool;
|
|
577
723
|
try {
|
|
578
|
-
|
|
724
|
+
innerPool = depsWithDb.createPool(lease);
|
|
579
725
|
} catch (err) {
|
|
580
726
|
const msg = err instanceof Error ? err.message : String(err);
|
|
581
727
|
throw new NeetruDbError("db_unavailable", `Falha ao abrir pool: ${msg}`);
|
|
582
728
|
}
|
|
729
|
+
const pool = createResilientPool(innerPool);
|
|
583
730
|
let orm;
|
|
584
731
|
try {
|
|
585
732
|
orm = depsWithDb.createDrizzle(pool, depsWithDb.schema);
|
|
586
733
|
} catch (err) {
|
|
587
|
-
await
|
|
734
|
+
await innerPool.end().catch(() => {
|
|
588
735
|
});
|
|
589
736
|
const msg = err instanceof Error ? err.message : String(err);
|
|
590
737
|
throw new NeetruDbError("db_unavailable", `Falha ao criar handle Drizzle: ${msg}`);
|
|
591
738
|
}
|
|
592
|
-
|
|
739
|
+
const manager = new SqlLeaseManager(lease, pool, orm, depsWithDb);
|
|
740
|
+
pool.__neetruSetReLease(() => manager._reactiveReLease());
|
|
741
|
+
return manager;
|
|
593
742
|
}
|
|
594
743
|
async function createSqlClientFromConfig(config, schema, options) {
|
|
595
744
|
const fetchLease = createHttpLeaseFetcher(config);
|
|
@@ -648,6 +797,7 @@ var init_sql_client = __esm({
|
|
|
648
797
|
"src/db/sql/sql-client.ts"() {
|
|
649
798
|
init_db_errors();
|
|
650
799
|
init_lease();
|
|
800
|
+
init_resilient_pool();
|
|
651
801
|
LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
|
|
652
802
|
LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
|
|
653
803
|
}
|