@neetru/sdk 3.4.0 → 3.4.2
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 +31 -0
- package/dist/ai.d.cts +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/auth.cjs +230 -38
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +1 -1
- package/dist/auth.d.ts +1 -1
- package/dist/auth.mjs +230 -38
- package/dist/auth.mjs.map +1 -1
- package/dist/build-guard.cjs +41 -6
- package/dist/build-guard.cjs.map +1 -1
- package/dist/build-guard.d.cts +24 -1
- package/dist/build-guard.d.ts +24 -1
- package/dist/build-guard.mjs +40 -7
- package/dist/build-guard.mjs.map +1 -1
- package/dist/catalog.d.cts +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/checkout.d.cts +1 -1
- package/dist/checkout.d.ts +1 -1
- package/dist/db-react.d.cts +1 -1
- package/dist/db-react.d.ts +1 -1
- package/dist/db.cjs +230 -38
- package/dist/db.cjs.map +1 -1
- package/dist/db.d.cts +1 -1
- package/dist/db.d.ts +1 -1
- package/dist/db.mjs +230 -38
- package/dist/db.mjs.map +1 -1
- package/dist/entitlements.d.cts +1 -1
- package/dist/entitlements.d.ts +1 -1
- package/dist/firestore-compat.d.cts +1 -1
- package/dist/firestore-compat.d.ts +1 -1
- package/dist/index.cjs +231 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +231 -39
- package/dist/index.mjs.map +1 -1
- package/dist/mocks.d.cts +1 -1
- package/dist/mocks.d.ts +1 -1
- package/dist/notifications.d.cts +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/react.cjs +593 -31
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +97 -2
- package/dist/react.d.ts +97 -2
- package/dist/react.mjs +590 -31
- package/dist/react.mjs.map +1 -1
- package/dist/support.d.cts +1 -1
- package/dist/support.d.ts +1 -1
- package/dist/telemetry.d.cts +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/{types-DdJyqBy-.d.ts → types-BlOLDMR0.d.ts} +79 -6
- package/dist/{types-BoFSCHF_.d.cts → types-D-QdG502.d.cts} +79 -6
- package/dist/usage.d.cts +1 -1
- package/dist/usage.d.ts +1 -1
- package/dist/webhooks.d.cts +1 -1
- package/dist/webhooks.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.4.2] - 2026-09-06 — **barra de conta (app-switcher) + build-guard OIDC de PROD + pool SQL tunável**
|
|
9
|
+
|
|
10
|
+
Release **patch** (tudo aditivo/retrocompatível: nova superfície opcional, guard só-prod, tuning de pool). Nenhuma mudança de wire, nenhuma remoção de API.
|
|
11
|
+
|
|
12
|
+
### Adicionado
|
|
13
|
+
|
|
14
|
+
- **Barra de conta / app-switcher** (#17) — superfície `account-bar` que dá ao produto uma barra canônica de identidade + troca de app sem cada produto reinventar. Aditiva e opcional.
|
|
15
|
+
- **Build-guard de PROD** (#18) — `assertNeetruProdBuildEnv` **falha o build de produção** (`builtEnv === 'prod'`) quando `oidcClientId`/`productId` vêm em branco, transformando o incidente do gestovendas (env var OIDC vazia chegando em prod) em erro de build, não de runtime. Só-prod: dev/test/preview passam inalterados (sem falso-positivo).
|
|
16
|
+
|
|
17
|
+
### Alterado (performance)
|
|
18
|
+
|
|
19
|
+
- **Pool SQL** (#19) — `client.db.sql` (`pg.Pool`): `max` 3→10, `idleTimeout` 30s→60s, `keepAlive` ligado; tunável por `NEETRU_DB_POOL_MAX` / `NEETRU_DB_POOL_IDLE_MS` via `resolvePoolTuning()`. Endereça a lentidão de conexão DB↔app observada no PDV sob carga (o teto de 3 conexões serializava as queries).
|
|
20
|
+
|
|
21
|
+
## [3.4.1] - 2026-08-20 — **fix(db offline): resiliência da fila offline + honestidade do transporte Documentos**
|
|
22
|
+
|
|
23
|
+
Release **patch** (correção de resiliência + aditivos opcionais retrocompatíveis; nenhuma mudança de wire, nenhuma remoção de API). Camada offline `client.db` (Mundo Documentos).
|
|
24
|
+
|
|
25
|
+
### Corrigido
|
|
26
|
+
|
|
27
|
+
- **P1-C — fila offline sem dead-letter (mutação-veneno travava o FIFO de TODAS as coleções para sempre):** o `SyncEngine` só fazia `markRetry` em falha transiente, então uma mutação com 5xx/timeout persistente na menor `seq` era re-tentada a cada ciclo e bloqueava a fila inteira (head-of-line block). Agora, após esgotar o teto de tentativas (`MAX_MUTATION_ATTEMPTS = 8`), a mutação vira `failed` via `markFailed` (código antes morto) — sai do conjunto `queued`, então `drain()` para de devolvê-la e a fila **destrava** — e grava um `ConflictRecord` com razão nova `failed_max_attempts` (visível em `getConflicts()`). Adicionado **backoff exponencial keyed em `attempts`** (base 1s, teto 5min) na head do FIFO para não marretar o Core. Mutações atrás da head (nunca enviadas) voltam à fila **sem inflar `attempts`** (`requeueInflight`), evitando dead-letter por engano.
|
|
28
|
+
- **P1-B — teto de 200 docs do transporte REST Documentos era silencioso e podia causar perda de dado:** o full-resync baixa no máximo 200 docs/coleção (paginação por cursor é tarefa do Core, fora do escopo do SDK). Agora, quando o retorno bate no teto, a coleção é reportada em `FullResyncResult.truncatedCollections`; o `SyncEngine` (a) **suprime a detecção-de-deleção-por-ausência** para ela (docs além do teto não viram mais tombstone por engano) e (b) persiste o sinal → `DbListResult.truncated === true` (+ `stale: true`). JSDoc público de `list()`/`NeetruDbOptions.dbId` documenta o teto. **Não** pagina (isso exigiria mudança no Core) — apenas sinaliza + documenta honestamente.
|
|
29
|
+
|
|
30
|
+
### Adicionado (aditivo, opcional)
|
|
31
|
+
|
|
32
|
+
- **P2-C — diagnóstico de falha de sync (antes engolido com "log silencioso"):** `SyncState.lastError?: string | null` (novo campo opcional, visível via `client.db.syncState` / `onSyncStateChanged`) reflete a última falha de ciclo (exceção nas fases ou dead-letter) e é resetado a cada ciclo bem-sucedido. Novo hook `client.db.onError(cb)` / `SyncEngine.onError(cb)` + tipo `SyncErrorEvent` exportado.
|
|
33
|
+
- `DbListResult.truncated?: boolean` e `Mutation.lastAttemptAt?: number` (interno) — campos opcionais aditivos.
|
|
34
|
+
|
|
35
|
+
### Alterado
|
|
36
|
+
|
|
37
|
+
- **P2-A — aviso dev de `dbId` INVERTIDO:** antes avisava quando `dbId` estava AUSENTE; agora avisa quando `dbId` está **PRESENTE** — este é o caso perigoso (falsa sensação de isolamento server-side): dbIds distintos no mesmo tenant/produto **não** isolam os dados no servidor (co-mingling), pois as escritas Documentos sempre caem no datastore genérico escopado só por `x-neetru-tenant`. Continua dev-only, one-time, browser-only, sem efeito de wire.
|
|
38
|
+
|
|
8
39
|
## [3.4.0] - 2026-08-15 — **feat(support): superfície "Product Feedback" — markResolvedSeen/recordSatisfactionAttempt/submitSatisfaction/contestTicket**
|
|
9
40
|
|
|
10
41
|
Release **minor** (API pública aditiva no namespace `client.support`: 4 métodos novos + 9 campos novos em `SupportTicket` + 3 opcionais em `CreateTicketInput`).
|
package/dist/ai.d.cts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { A as AiChatChoice, a as AiChatMessage, b as AiChatOptions, c as AiChatResult, d as AiChatUsage, e as AiContentPart, f as AiImageUrlPart, g as AiNamespace, h as AiResponseFormat, i as AiStreamHandle, j as AiStreamMeta, k as AiTextPart,
|
|
1
|
+
export { A as AiChatChoice, a as AiChatMessage, b as AiChatOptions, c as AiChatResult, d as AiChatUsage, e as AiContentPart, f as AiImageUrlPart, g as AiNamespace, h as AiResponseFormat, i as AiStreamHandle, j as AiStreamMeta, k as AiTextPart, aJ as createAiNamespace } from './types-D-QdG502.cjs';
|
|
2
2
|
import 'drizzle-orm/node-postgres';
|
|
3
3
|
import './errors.cjs';
|
package/dist/ai.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { A as AiChatChoice, a as AiChatMessage, b as AiChatOptions, c as AiChatResult, d as AiChatUsage, e as AiContentPart, f as AiImageUrlPart, g as AiNamespace, h as AiResponseFormat, i as AiStreamHandle, j as AiStreamMeta, k as AiTextPart,
|
|
1
|
+
export { A as AiChatChoice, a as AiChatMessage, b as AiChatOptions, c as AiChatResult, d as AiChatUsage, e as AiContentPart, f as AiImageUrlPart, g as AiNamespace, h as AiResponseFormat, i as AiStreamHandle, j as AiStreamMeta, k as AiTextPart, aJ as createAiNamespace } from './types-BlOLDMR0.js';
|
|
2
2
|
import 'drizzle-orm/node-postgres';
|
|
3
3
|
import './errors.js';
|
package/dist/auth.cjs
CHANGED
|
@@ -684,13 +684,17 @@ var init_lease = __esm({
|
|
|
684
684
|
// src/db/sql/sql-client.ts
|
|
685
685
|
var sql_client_exports = {};
|
|
686
686
|
__export(sql_client_exports, {
|
|
687
|
+
DEFAULT_POOL_CONNECT_TIMEOUT_MS: () => DEFAULT_POOL_CONNECT_TIMEOUT_MS,
|
|
688
|
+
DEFAULT_POOL_IDLE_MS: () => DEFAULT_POOL_IDLE_MS,
|
|
689
|
+
DEFAULT_POOL_MAX: () => DEFAULT_POOL_MAX,
|
|
687
690
|
LEASE_ENDPOINT: () => LEASE_ENDPOINT,
|
|
688
691
|
LEASE_RENEW_ENDPOINT: () => LEASE_RENEW_ENDPOINT,
|
|
689
692
|
createDrizzleHandle: () => createDrizzleHandle,
|
|
690
693
|
createHttpLeaseFetcher: () => createHttpLeaseFetcher,
|
|
691
694
|
createPgPool: () => createPgPool,
|
|
692
695
|
createSqlClientFromConfig: () => createSqlClientFromConfig,
|
|
693
|
-
createSqlClientWithDeps: () => createSqlClientWithDeps
|
|
696
|
+
createSqlClientWithDeps: () => createSqlClientWithDeps,
|
|
697
|
+
resolvePoolTuning: () => resolvePoolTuning
|
|
694
698
|
});
|
|
695
699
|
function mapEnvToCore(sdkEnv) {
|
|
696
700
|
if (sdkEnv === "workspace") return "staging";
|
|
@@ -779,6 +783,30 @@ function parseLease(raw) {
|
|
|
779
783
|
expiresAt: req("expiresAt", "string")
|
|
780
784
|
};
|
|
781
785
|
}
|
|
786
|
+
function readEnvInt(name, def, min, max) {
|
|
787
|
+
const raw = typeof process !== "undefined" ? process.env?.[name] : void 0;
|
|
788
|
+
if (raw === void 0 || raw.trim() === "") return def;
|
|
789
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
790
|
+
if (!Number.isFinite(n) || n < min || n > max) return def;
|
|
791
|
+
return n;
|
|
792
|
+
}
|
|
793
|
+
function resolvePoolTuning() {
|
|
794
|
+
return {
|
|
795
|
+
// Teto 100: bem abaixo do `max_client_conn = 500` do PgBouncer, com folga
|
|
796
|
+
// para múltiplas instâncias do app dividirem o mesmo front-door.
|
|
797
|
+
max: readEnvInt("NEETRU_DB_POOL_MAX", DEFAULT_POOL_MAX, 1, 100),
|
|
798
|
+
// Teto 15min = janela do cert; piso 1s.
|
|
799
|
+
idleTimeoutMillis: readEnvInt("NEETRU_DB_POOL_IDLE_MS", DEFAULT_POOL_IDLE_MS, 1e3, 9e5),
|
|
800
|
+
connectionTimeoutMillis: readEnvInt(
|
|
801
|
+
"NEETRU_DB_POOL_CONNECT_TIMEOUT_MS",
|
|
802
|
+
DEFAULT_POOL_CONNECT_TIMEOUT_MS,
|
|
803
|
+
1e3,
|
|
804
|
+
12e4
|
|
805
|
+
),
|
|
806
|
+
keepAlive: true,
|
|
807
|
+
keepAliveInitialDelayMillis: 1e4
|
|
808
|
+
};
|
|
809
|
+
}
|
|
782
810
|
async function createPgPool(lease) {
|
|
783
811
|
const { Pool } = await import(
|
|
784
812
|
/* webpackIgnore: true */
|
|
@@ -797,9 +825,7 @@ async function createPgPool(lease) {
|
|
|
797
825
|
user: lease.user,
|
|
798
826
|
password: lease.password,
|
|
799
827
|
ssl,
|
|
800
|
-
|
|
801
|
-
idleTimeoutMillis: 3e4,
|
|
802
|
-
connectionTimeoutMillis: 1e4
|
|
828
|
+
...resolvePoolTuning()
|
|
803
829
|
});
|
|
804
830
|
}
|
|
805
831
|
async function createDrizzleHandle(pool, schema) {
|
|
@@ -934,9 +960,9 @@ async function createSqlClientFromConfig(config, schema, options) {
|
|
|
934
960
|
user: lease.user,
|
|
935
961
|
password: lease.password,
|
|
936
962
|
ssl,
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
963
|
+
// Tuning centralizado (env-tunável) — ver resolvePoolTuning + auditoria
|
|
964
|
+
// docs/_review/AUDITORIA_DB_CONN_PERF_2026-09-06.md no Core.
|
|
965
|
+
...resolvePoolTuning()
|
|
940
966
|
});
|
|
941
967
|
};
|
|
942
968
|
const createDrizzle = (pool, s) => {
|
|
@@ -959,7 +985,7 @@ async function createSqlClientFromConfig(config, schema, options) {
|
|
|
959
985
|
options
|
|
960
986
|
);
|
|
961
987
|
}
|
|
962
|
-
var LEASE_ENDPOINT, LEASE_RENEW_ENDPOINT;
|
|
988
|
+
var LEASE_ENDPOINT, LEASE_RENEW_ENDPOINT, DEFAULT_POOL_MAX, DEFAULT_POOL_IDLE_MS, DEFAULT_POOL_CONNECT_TIMEOUT_MS;
|
|
963
989
|
var init_sql_client = __esm({
|
|
964
990
|
"src/db/sql/sql-client.ts"() {
|
|
965
991
|
init_db_errors();
|
|
@@ -968,6 +994,9 @@ var init_sql_client = __esm({
|
|
|
968
994
|
init_resilient_pool();
|
|
969
995
|
LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
|
|
970
996
|
LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
|
|
997
|
+
DEFAULT_POOL_MAX = 10;
|
|
998
|
+
DEFAULT_POOL_IDLE_MS = 6e4;
|
|
999
|
+
DEFAULT_POOL_CONNECT_TIMEOUT_MS = 1e4;
|
|
971
1000
|
}
|
|
972
1001
|
});
|
|
973
1002
|
|
|
@@ -2543,8 +2572,10 @@ var MutationQueue = class {
|
|
|
2543
2572
|
await this.store.deleteMutation(mutationId);
|
|
2544
2573
|
}
|
|
2545
2574
|
/**
|
|
2546
|
-
* Marca uma mutação como `failed` e registra o erro.
|
|
2547
|
-
* Incrementa `attempts`. Chamado pelo SyncEngine
|
|
2575
|
+
* Marca uma mutação como `failed` (dead-letter) e registra o erro.
|
|
2576
|
+
* Incrementa `attempts` e carimba `lastAttemptAt`. Chamado pelo SyncEngine
|
|
2577
|
+
* quando a mutação esgota o teto de tentativas (mutação-veneno): sai do
|
|
2578
|
+
* conjunto `queued`, então `drain()` para de devolvê-la e a fila DESTRAVA.
|
|
2548
2579
|
*/
|
|
2549
2580
|
async markFailed(mutationId, error) {
|
|
2550
2581
|
const mut = await this.store.getMutation(mutationId);
|
|
@@ -2553,12 +2584,14 @@ var MutationQueue = class {
|
|
|
2553
2584
|
...mut,
|
|
2554
2585
|
status: "failed",
|
|
2555
2586
|
lastError: error,
|
|
2556
|
-
attempts: mut.attempts + 1
|
|
2587
|
+
attempts: mut.attempts + 1,
|
|
2588
|
+
lastAttemptAt: Date.now()
|
|
2557
2589
|
});
|
|
2558
2590
|
}
|
|
2559
2591
|
/**
|
|
2560
|
-
* Incrementa `attempts` e volta para `queued`
|
|
2561
|
-
* Chamado pelo SyncEngine em falha
|
|
2592
|
+
* Incrementa `attempts`, carimba `lastAttemptAt` e volta para `queued`
|
|
2593
|
+
* (falha transiente com backoff). Chamado pelo SyncEngine em falha
|
|
2594
|
+
* transiente (5xx, timeout) da mutação que REALMENTE foi tentada.
|
|
2562
2595
|
*/
|
|
2563
2596
|
async markRetry(mutationId, error) {
|
|
2564
2597
|
const mut = await this.store.getMutation(mutationId);
|
|
@@ -2567,9 +2600,24 @@ var MutationQueue = class {
|
|
|
2567
2600
|
...mut,
|
|
2568
2601
|
status: "queued",
|
|
2569
2602
|
lastError: error,
|
|
2570
|
-
attempts: mut.attempts + 1
|
|
2603
|
+
attempts: mut.attempts + 1,
|
|
2604
|
+
lastAttemptAt: Date.now()
|
|
2571
2605
|
});
|
|
2572
2606
|
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Reverte uma mutação `inflight` de volta a `queued` SEM tocar em `attempts`
|
|
2609
|
+
* nem `lastAttemptAt`. Usado para mutações que foram marcadas inflight mas
|
|
2610
|
+
* NUNCA chegaram a ser enviadas (estavam atrás da head no FIFO e o push
|
|
2611
|
+
* parou antes delas): contá-las como tentativa as empurraria pro dead-letter
|
|
2612
|
+
* por engano, e adiaria seu envio via backoff sem que tenham falhado.
|
|
2613
|
+
* No-op se a mutação não está mais `inflight`.
|
|
2614
|
+
*/
|
|
2615
|
+
async requeueInflight(mutationId) {
|
|
2616
|
+
const mut = await this.store.getMutation(mutationId);
|
|
2617
|
+
if (!mut) return;
|
|
2618
|
+
if (mut.status !== "inflight") return;
|
|
2619
|
+
await this.store.putMutation({ ...mut, status: "queued" });
|
|
2620
|
+
}
|
|
2573
2621
|
};
|
|
2574
2622
|
|
|
2575
2623
|
// src/db/field-value.ts
|
|
@@ -2614,6 +2662,10 @@ function hasFieldSentinel(data) {
|
|
|
2614
2662
|
}
|
|
2615
2663
|
|
|
2616
2664
|
// src/db/offline/sync-engine.ts
|
|
2665
|
+
var MAX_MUTATION_ATTEMPTS = 8;
|
|
2666
|
+
var BACKOFF_BASE_MS = 1e3;
|
|
2667
|
+
var BACKOFF_CAP_MS = 5 * 60 * 1e3;
|
|
2668
|
+
var TRUNCATED_COLLECTIONS_META_KEY = "truncatedCollections";
|
|
2617
2669
|
function hasSentinels(payload) {
|
|
2618
2670
|
return payload !== null && hasFieldSentinel(payload);
|
|
2619
2671
|
}
|
|
@@ -2648,6 +2700,8 @@ var SyncEngine = class {
|
|
|
2648
2700
|
_state;
|
|
2649
2701
|
/** Listeners de mudança de SyncState. */
|
|
2650
2702
|
_stateListeners = /* @__PURE__ */ new Set();
|
|
2703
|
+
/** Listeners de erro de ciclo de sync (P2-C — não mais engolido em silêncio). */
|
|
2704
|
+
_errorListeners = /* @__PURE__ */ new Set();
|
|
2651
2705
|
/** Cleanup functions dos listeners externos. */
|
|
2652
2706
|
_cleanups = [];
|
|
2653
2707
|
/** Timer de resync periódico. */
|
|
@@ -2706,6 +2760,19 @@ var SyncEngine = class {
|
|
|
2706
2760
|
this._stateListeners.delete(cb);
|
|
2707
2761
|
};
|
|
2708
2762
|
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Registra um hook de erro de sync (P2-C). Disparado quando um ciclo falha
|
|
2765
|
+
* (exceção inesperada nas fases, falha de pull/resync ou dead-letter de uma
|
|
2766
|
+
* mutação-veneno) — o que antes era engolido com "log silencioso".
|
|
2767
|
+
* O mesmo diagnóstico também aparece em `SyncState.lastError`.
|
|
2768
|
+
* Retorna uma função de unsubscribe.
|
|
2769
|
+
*/
|
|
2770
|
+
onError(cb) {
|
|
2771
|
+
this._errorListeners.add(cb);
|
|
2772
|
+
return () => {
|
|
2773
|
+
this._errorListeners.delete(cb);
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2709
2776
|
/**
|
|
2710
2777
|
* Força um sync imediato (I3 §10.3 `flush()`).
|
|
2711
2778
|
* Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
|
|
@@ -2738,8 +2805,10 @@ var SyncEngine = class {
|
|
|
2738
2805
|
}
|
|
2739
2806
|
await this._phase2Pull();
|
|
2740
2807
|
this._phase3Realtime();
|
|
2741
|
-
this._updateState({ status: "idle", lastSyncedAt: Date.now() });
|
|
2808
|
+
this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
|
|
2742
2809
|
} catch (err) {
|
|
2810
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2811
|
+
this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
|
|
2743
2812
|
this._updateState({ status: "idle" });
|
|
2744
2813
|
} finally {
|
|
2745
2814
|
this._syncing = false;
|
|
@@ -2774,6 +2843,9 @@ var SyncEngine = class {
|
|
|
2774
2843
|
async _phase1Push() {
|
|
2775
2844
|
const mutations = await this._queue.drain();
|
|
2776
2845
|
if (mutations.length === 0) return true;
|
|
2846
|
+
if (this._isInBackoff(mutations[0])) {
|
|
2847
|
+
return true;
|
|
2848
|
+
}
|
|
2777
2849
|
for (const mut of mutations) {
|
|
2778
2850
|
await this._queue.markInflight(mut.mutationId);
|
|
2779
2851
|
}
|
|
@@ -2781,10 +2853,16 @@ var SyncEngine = class {
|
|
|
2781
2853
|
try {
|
|
2782
2854
|
result = await this._transport.pushMutations(mutations);
|
|
2783
2855
|
} catch (err) {
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2856
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2857
|
+
const busChangesOnThrow = [];
|
|
2858
|
+
const [head, ...behind] = mutations;
|
|
2859
|
+
await this._retryOrDeadLetter(head, errMsg, busChangesOnThrow);
|
|
2860
|
+
for (const mut of behind) {
|
|
2861
|
+
await this._queue.requeueInflight(mut.mutationId);
|
|
2862
|
+
}
|
|
2863
|
+
if (busChangesOnThrow.length > 0) this._bus.emit(busChangesOnThrow);
|
|
2864
|
+
this._refreshPendingWrites().catch(() => {
|
|
2865
|
+
});
|
|
2788
2866
|
return false;
|
|
2789
2867
|
}
|
|
2790
2868
|
const busChanges = [];
|
|
@@ -2811,8 +2889,16 @@ var SyncEngine = class {
|
|
|
2811
2889
|
}
|
|
2812
2890
|
const processedIds = new Set(result.results.map((r) => r.mutationId));
|
|
2813
2891
|
const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
|
|
2814
|
-
|
|
2815
|
-
|
|
2892
|
+
if (unprocessed.length > 0) {
|
|
2893
|
+
const [boundary, ...behind] = unprocessed;
|
|
2894
|
+
await this._retryOrDeadLetter(
|
|
2895
|
+
boundary,
|
|
2896
|
+
"partial_push: muta\xE7\xE3o n\xE3o processada no lote",
|
|
2897
|
+
busChanges
|
|
2898
|
+
);
|
|
2899
|
+
for (const mut of behind) {
|
|
2900
|
+
await this._queue.requeueInflight(mut.mutationId);
|
|
2901
|
+
}
|
|
2816
2902
|
}
|
|
2817
2903
|
if (busChanges.length > 0) {
|
|
2818
2904
|
this._bus.emit(busChanges);
|
|
@@ -3004,6 +3090,67 @@ var SyncEngine = class {
|
|
|
3004
3090
|
});
|
|
3005
3091
|
}
|
|
3006
3092
|
}
|
|
3093
|
+
// ─── Dead-letter + backoff (P1-C) ────────────────────────────────────────────
|
|
3094
|
+
/**
|
|
3095
|
+
* Trata a falha transiente de UMA mutação que foi realmente tentada.
|
|
3096
|
+
*
|
|
3097
|
+
* - Se ainda há orçamento de tentativas → `markRetry` (volta pra fila com
|
|
3098
|
+
* backoff, `attempts++`).
|
|
3099
|
+
* - Se esgotou (`attempts+1 >= MAX_MUTATION_ATTEMPTS`) → DEAD-LETTER:
|
|
3100
|
+
* `markFailed` (sai do conjunto `queued`, então `drain()` para de devolvê-la
|
|
3101
|
+
* e a fila FIFO DESTRAVA para as demais coleções) + grava um ConflictRecord
|
|
3102
|
+
* `failed_max_attempts` (o produto vê via `getConflicts()`) + limpa o estado
|
|
3103
|
+
* pending do doc no cache + registra `lastError`/dispara `onError`.
|
|
3104
|
+
*/
|
|
3105
|
+
async _retryOrDeadLetter(mut, errMsg, busChanges) {
|
|
3106
|
+
if (mut.attempts + 1 < MAX_MUTATION_ATTEMPTS) {
|
|
3107
|
+
await this._queue.markRetry(mut.mutationId, errMsg);
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
await this._queue.markFailed(mut.mutationId, errMsg);
|
|
3111
|
+
const conflictRecord = {
|
|
3112
|
+
collection: mut.collection,
|
|
3113
|
+
docId: mut.docId,
|
|
3114
|
+
mutationId: mut.mutationId,
|
|
3115
|
+
losingData: mut.payload ?? {},
|
|
3116
|
+
winningData: {},
|
|
3117
|
+
reason: "failed_max_attempts",
|
|
3118
|
+
detectedAt: Date.now(),
|
|
3119
|
+
delivered: false
|
|
3120
|
+
};
|
|
3121
|
+
await this._store.appendConflict(conflictRecord);
|
|
3122
|
+
const existing = await this._store.getDoc(mut.collection, mut.docId);
|
|
3123
|
+
if (existing) {
|
|
3124
|
+
await this._store.putDoc({
|
|
3125
|
+
...existing,
|
|
3126
|
+
meta: { ...existing.meta, state: "synced", pendingMutationIds: [] }
|
|
3127
|
+
});
|
|
3128
|
+
busChanges.push({
|
|
3129
|
+
type: "modified",
|
|
3130
|
+
collection: mut.collection,
|
|
3131
|
+
doc: { id: mut.docId, data: existing.data }
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
this._recordSyncError(
|
|
3135
|
+
"push",
|
|
3136
|
+
`muta\xE7\xE3o ${mut.mutationId} (${mut.collection}/${mut.docId}) descartada ap\xF3s ${mut.attempts + 1} tentativas: ${errMsg}`
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
/**
|
|
3140
|
+
* `true` se a mutação (head do FIFO) ainda está dentro da janela de backoff
|
|
3141
|
+
* desde a última tentativa falha. Só se aplica a mutações já tentadas
|
|
3142
|
+
* (`attempts > 0` e `lastAttemptAt` carimbado).
|
|
3143
|
+
*/
|
|
3144
|
+
_isInBackoff(mut) {
|
|
3145
|
+
if (mut.attempts <= 0) return false;
|
|
3146
|
+
const last = mut.lastAttemptAt;
|
|
3147
|
+
if (typeof last !== "number") return false;
|
|
3148
|
+
return Date.now() < last + this._backoffDelayMs(mut.attempts);
|
|
3149
|
+
}
|
|
3150
|
+
/** Backoff exponencial keyed em `attempts`, com teto `BACKOFF_CAP_MS`. */
|
|
3151
|
+
_backoffDelayMs(attempts) {
|
|
3152
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_CAP_MS);
|
|
3153
|
+
}
|
|
3007
3154
|
// ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
|
|
3008
3155
|
/**
|
|
3009
3156
|
* Busca mudanças do servidor e aplica ao cache via ConflictResolver.
|
|
@@ -3043,10 +3190,14 @@ var SyncEngine = class {
|
|
|
3043
3190
|
}
|
|
3044
3191
|
const busChanges = [];
|
|
3045
3192
|
await this._applyServerDocs(resyncResult.docs, busChanges, true);
|
|
3193
|
+
const truncatedCollections = resyncResult.truncatedCollections ?? [];
|
|
3194
|
+
const truncatedSet = new Set(truncatedCollections);
|
|
3195
|
+
await this._store.setMeta(TRUNCATED_COLLECTIONS_META_KEY, truncatedCollections);
|
|
3046
3196
|
const returnedKeys = new Set(
|
|
3047
3197
|
resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
|
|
3048
3198
|
);
|
|
3049
3199
|
for (const col of collections) {
|
|
3200
|
+
if (truncatedSet.has(col)) continue;
|
|
3050
3201
|
const queryResult = await this._store.listDocs(col, {});
|
|
3051
3202
|
for (const { id } of queryResult.docs) {
|
|
3052
3203
|
const key = `${col}::${id}`;
|
|
@@ -3206,7 +3357,8 @@ var SyncEngine = class {
|
|
|
3206
3357
|
status: isOnline ? "idle" : "offline",
|
|
3207
3358
|
pendingWrites: 0,
|
|
3208
3359
|
lastSyncedAt: null,
|
|
3209
|
-
isLeaderTab: this._tabCoordinator.isLeader()
|
|
3360
|
+
isLeaderTab: this._tabCoordinator.isLeader(),
|
|
3361
|
+
lastError: null
|
|
3210
3362
|
};
|
|
3211
3363
|
}
|
|
3212
3364
|
/**
|
|
@@ -3216,7 +3368,7 @@ var SyncEngine = class {
|
|
|
3216
3368
|
_updateState(partial) {
|
|
3217
3369
|
const prev = this._state;
|
|
3218
3370
|
const next = { ...prev, ...partial };
|
|
3219
|
-
const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
|
|
3371
|
+
const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab || (prev.lastError ?? null) !== (next.lastError ?? null);
|
|
3220
3372
|
this._state = next;
|
|
3221
3373
|
if (changed) {
|
|
3222
3374
|
this._emitState(next);
|
|
@@ -3230,6 +3382,21 @@ var SyncEngine = class {
|
|
|
3230
3382
|
}
|
|
3231
3383
|
}
|
|
3232
3384
|
}
|
|
3385
|
+
/**
|
|
3386
|
+
* P2-C: registra a falha de um ciclo de sync — antes engolida em silêncio.
|
|
3387
|
+
* Publica em `SyncState.lastError` (visível via `onSyncStateChanged`) e
|
|
3388
|
+
* dispara o hook `onError`. Nunca lança (diagnóstico é best-effort).
|
|
3389
|
+
*/
|
|
3390
|
+
_recordSyncError(phase, message) {
|
|
3391
|
+
this._updateState({ lastError: message });
|
|
3392
|
+
const event = { phase, message, at: Date.now() };
|
|
3393
|
+
for (const listener of this._errorListeners) {
|
|
3394
|
+
try {
|
|
3395
|
+
listener({ ...event });
|
|
3396
|
+
} catch {
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3233
3400
|
/**
|
|
3234
3401
|
* Atualiza `pendingWrites` no estado.
|
|
3235
3402
|
* Chamado de forma assíncrona quando necessário.
|
|
@@ -4039,7 +4206,9 @@ var DbCollectionRefImpl = class {
|
|
|
4039
4206
|
const isOnline = this._isOnline();
|
|
4040
4207
|
const syncState = this._engine.getSyncState();
|
|
4041
4208
|
const nextCursor = buildNextCursor(result.docs, effectiveLimit);
|
|
4042
|
-
const
|
|
4209
|
+
const truncatedRaw = await this._store.getMeta(TRUNCATED_COLLECTIONS_META_KEY);
|
|
4210
|
+
const truncated = Array.isArray(truncatedRaw) && truncatedRaw.includes(this._collection);
|
|
4211
|
+
const stale = !isOnline || syncState.status === "offline" || truncated;
|
|
4043
4212
|
const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
|
|
4044
4213
|
return {
|
|
4045
4214
|
docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
|
|
@@ -4047,7 +4216,8 @@ var DbCollectionRefImpl = class {
|
|
|
4047
4216
|
fromCache,
|
|
4048
4217
|
stale,
|
|
4049
4218
|
hasPendingWrites: hasPending,
|
|
4050
|
-
changes: []
|
|
4219
|
+
changes: [],
|
|
4220
|
+
truncated
|
|
4051
4221
|
};
|
|
4052
4222
|
}
|
|
4053
4223
|
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -4493,6 +4663,9 @@ var NeetruDbDocumentsImpl = class {
|
|
|
4493
4663
|
async getConflicts() {
|
|
4494
4664
|
return this._store.listConflicts();
|
|
4495
4665
|
}
|
|
4666
|
+
onError(cb) {
|
|
4667
|
+
return this._engine.onError(cb);
|
|
4668
|
+
}
|
|
4496
4669
|
};
|
|
4497
4670
|
async function createOfflineDocumentsNamespace(opts) {
|
|
4498
4671
|
const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
|
|
@@ -4968,6 +5141,7 @@ var defaultWebSocketFactory = (url) => {
|
|
|
4968
5141
|
// src/db/client-db.ts
|
|
4969
5142
|
var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
|
|
4970
5143
|
var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
|
|
5144
|
+
var DOCUMENTS_RESYNC_PAGE_CAP = 200;
|
|
4971
5145
|
function resolveTransport(transport, opts, config) {
|
|
4972
5146
|
if (opts._transport) {
|
|
4973
5147
|
return opts._transport;
|
|
@@ -5080,6 +5254,7 @@ function createRestSyncTransport(config) {
|
|
|
5080
5254
|
const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
|
|
5081
5255
|
const now = Date.now();
|
|
5082
5256
|
const docs = [];
|
|
5257
|
+
const truncatedCollections = [];
|
|
5083
5258
|
for (const collection of collections) {
|
|
5084
5259
|
let raw;
|
|
5085
5260
|
try {
|
|
@@ -5089,11 +5264,11 @@ function createRestSyncTransport(config) {
|
|
|
5089
5264
|
requireAuth: true,
|
|
5090
5265
|
retries: 2,
|
|
5091
5266
|
headers: tenantHeaders,
|
|
5092
|
-
// Teto do Core
|
|
5093
|
-
//
|
|
5094
|
-
// deleção-por-ausência
|
|
5095
|
-
//
|
|
5096
|
-
query: { limit:
|
|
5267
|
+
// Teto do Core (DOCUMENTS_RESYNC_PAGE_CAP). Coleções acima dele vêm
|
|
5268
|
+
// TRUNCADAS — reportadas em `truncatedCollections` p/ o SyncEngine
|
|
5269
|
+
// (a) NÃO rodar deleção-por-ausência e (b) sinalizar
|
|
5270
|
+
// `DbListResult.truncated`. Paginação por cursor é tarefa Core (gated).
|
|
5271
|
+
query: { limit: DOCUMENTS_RESYNC_PAGE_CAP }
|
|
5097
5272
|
});
|
|
5098
5273
|
} catch (err) {
|
|
5099
5274
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -5104,6 +5279,9 @@ function createRestSyncTransport(config) {
|
|
|
5104
5279
|
}
|
|
5105
5280
|
const resp = raw;
|
|
5106
5281
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
|
5282
|
+
if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
|
|
5283
|
+
truncatedCollections.push(collection);
|
|
5284
|
+
}
|
|
5107
5285
|
for (const item of items) {
|
|
5108
5286
|
if (!item || typeof item !== "object") continue;
|
|
5109
5287
|
const record = item;
|
|
@@ -5131,7 +5309,7 @@ function createRestSyncTransport(config) {
|
|
|
5131
5309
|
});
|
|
5132
5310
|
}
|
|
5133
5311
|
}
|
|
5134
|
-
return { docs, newWatermark: now };
|
|
5312
|
+
return { docs, newWatermark: now, truncatedCollections };
|
|
5135
5313
|
}
|
|
5136
5314
|
};
|
|
5137
5315
|
}
|
|
@@ -5193,20 +5371,20 @@ function buildTicketProvider(config, opts) {
|
|
|
5193
5371
|
return ticket;
|
|
5194
5372
|
};
|
|
5195
5373
|
}
|
|
5196
|
-
var
|
|
5197
|
-
function
|
|
5198
|
-
if (
|
|
5199
|
-
if (dbOpts.dbId) return;
|
|
5374
|
+
var _hasWarnedDbIdIgnored = false;
|
|
5375
|
+
function warnDbIdIgnoredOnce(config, dbOpts) {
|
|
5376
|
+
if (_hasWarnedDbIdIgnored) return;
|
|
5377
|
+
if (!dbOpts.dbId) return;
|
|
5200
5378
|
if (config.env === "prod") return;
|
|
5201
5379
|
if (typeof window === "undefined") return;
|
|
5202
5380
|
if (typeof console === "undefined" || typeof console.warn !== "function") return;
|
|
5203
|
-
|
|
5381
|
+
_hasWarnedDbIdIgnored = true;
|
|
5204
5382
|
console.warn(
|
|
5205
|
-
|
|
5383
|
+
'[neetru/sdk] client.db recebeu `dbId` ("' + dbOpts.dbId + '") mas ele N\xC3O roteia nem escopa nada no servidor: as escritas do mundo Documentos (add/set/update/remove/list) SEMPRE caem no datastore gen\xE9rico do Core, escopado s\xF3 por tenant/produto (header x-neetru-tenant). `dbId` apenas nomeia o cache offline LOCAL (IndexedDB). ATEN\xC7\xC3O: usar dbIds distintos no MESMO tenant/produto N\xC3O isola os dados no servidor \u2014 eles se misturam (co-mingling). Veja o JSDoc de NeetruDbOptions.dbId.'
|
|
5206
5384
|
);
|
|
5207
5385
|
}
|
|
5208
5386
|
function createNeetruDb(config, dbOpts = {}) {
|
|
5209
|
-
|
|
5387
|
+
warnDbIdIgnoredOnce(config, dbOpts);
|
|
5210
5388
|
let resolvedTransport;
|
|
5211
5389
|
if (dbOpts.transport !== void 0) {
|
|
5212
5390
|
resolvedTransport = dbOpts.transport;
|
|
@@ -5284,6 +5462,20 @@ function createNeetruDb(config, dbOpts = {}) {
|
|
|
5284
5462
|
async getConflicts() {
|
|
5285
5463
|
const docs = await getDocsPromise();
|
|
5286
5464
|
return docs.getConflicts();
|
|
5465
|
+
},
|
|
5466
|
+
onError(cb) {
|
|
5467
|
+
if (_docsResolved) return _docsResolved.onError(cb);
|
|
5468
|
+
let unsub = null;
|
|
5469
|
+
let cancelled = false;
|
|
5470
|
+
getDocsPromise().then((docs) => {
|
|
5471
|
+
if (!cancelled) {
|
|
5472
|
+
unsub = docs.onError(cb);
|
|
5473
|
+
}
|
|
5474
|
+
});
|
|
5475
|
+
return () => {
|
|
5476
|
+
cancelled = true;
|
|
5477
|
+
unsub?.();
|
|
5478
|
+
};
|
|
5287
5479
|
}
|
|
5288
5480
|
};
|
|
5289
5481
|
}
|