@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/ai.d.cts +1 -1
  3. package/dist/ai.d.ts +1 -1
  4. package/dist/auth.cjs +230 -38
  5. package/dist/auth.cjs.map +1 -1
  6. package/dist/auth.d.cts +1 -1
  7. package/dist/auth.d.ts +1 -1
  8. package/dist/auth.mjs +230 -38
  9. package/dist/auth.mjs.map +1 -1
  10. package/dist/build-guard.cjs +41 -6
  11. package/dist/build-guard.cjs.map +1 -1
  12. package/dist/build-guard.d.cts +24 -1
  13. package/dist/build-guard.d.ts +24 -1
  14. package/dist/build-guard.mjs +40 -7
  15. package/dist/build-guard.mjs.map +1 -1
  16. package/dist/catalog.d.cts +1 -1
  17. package/dist/catalog.d.ts +1 -1
  18. package/dist/checkout.d.cts +1 -1
  19. package/dist/checkout.d.ts +1 -1
  20. package/dist/db-react.d.cts +1 -1
  21. package/dist/db-react.d.ts +1 -1
  22. package/dist/db.cjs +230 -38
  23. package/dist/db.cjs.map +1 -1
  24. package/dist/db.d.cts +1 -1
  25. package/dist/db.d.ts +1 -1
  26. package/dist/db.mjs +230 -38
  27. package/dist/db.mjs.map +1 -1
  28. package/dist/entitlements.d.cts +1 -1
  29. package/dist/entitlements.d.ts +1 -1
  30. package/dist/firestore-compat.d.cts +1 -1
  31. package/dist/firestore-compat.d.ts +1 -1
  32. package/dist/index.cjs +231 -39
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +2 -2
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.mjs +231 -39
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/mocks.d.cts +1 -1
  39. package/dist/mocks.d.ts +1 -1
  40. package/dist/notifications.d.cts +1 -1
  41. package/dist/notifications.d.ts +1 -1
  42. package/dist/react.cjs +593 -31
  43. package/dist/react.cjs.map +1 -1
  44. package/dist/react.d.cts +97 -2
  45. package/dist/react.d.ts +97 -2
  46. package/dist/react.mjs +590 -31
  47. package/dist/react.mjs.map +1 -1
  48. package/dist/support.d.cts +1 -1
  49. package/dist/support.d.ts +1 -1
  50. package/dist/telemetry.d.cts +1 -1
  51. package/dist/telemetry.d.ts +1 -1
  52. package/dist/{types-DdJyqBy-.d.ts → types-BlOLDMR0.d.ts} +79 -6
  53. package/dist/{types-BoFSCHF_.d.cts → types-D-QdG502.d.cts} +79 -6
  54. package/dist/usage.d.cts +1 -1
  55. package/dist/usage.d.ts +1 -1
  56. package/dist/webhooks.d.cts +1 -1
  57. package/dist/webhooks.d.ts +1 -1
  58. 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, aI as createAiNamespace } from './types-BoFSCHF_.cjs';
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, aI as createAiNamespace } from './types-DdJyqBy-.js';
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
- max: 3,
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
- max: 3,
938
- idleTimeoutMillis: 3e4,
939
- connectionTimeoutMillis: 1e4
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 em falha permanente.
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` (falha transiente com backoff).
2561
- * Chamado pelo SyncEngine em falha transiente (5xx, timeout).
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
- for (const mut of mutations) {
2785
- const errMsg = err instanceof Error ? err.message : String(err);
2786
- await this._queue.markRetry(mut.mutationId, errMsg);
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
- for (const mut of unprocessed) {
2815
- await this._queue.markRetry(mut.mutationId, "partial_push: muta\xE7\xE3o n\xE3o processada no lote");
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 stale = !isOnline || syncState.status === "offline";
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 é 200. Coleções > 200 docs truncam silenciosamente
5093
- // aqui (paginação cursor é tarefa Core separada) — a detecção de
5094
- // deleção-por-ausência do SyncEngine NÃO deve rodar sobre truncamento,
5095
- // então mantemos o limite no máximo do servidor por ora.
5096
- query: { limit: 200 }
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 _hasWarnedMissingDbId = false;
5197
- function warnMissingDbIdOnce(config, dbOpts) {
5198
- if (_hasWarnedMissingDbId) return;
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
- _hasWarnedMissingDbId = true;
5381
+ _hasWarnedDbIdIgnored = true;
5204
5382
  console.warn(
5205
- "[neetru/sdk] client.db sem `dbId` expl\xEDcito: escritas client-side (add/set/update/remove/list) caem no datastore gen\xE9rico do Core, escopado por tenant/produto (header x-neetru-tenant) \u2014 `dbId` s\xF3 nomeia o cache offline LOCAL (IndexedDB), n\xE3o roteia nem escopa nada no servidor. Veja o JSDoc de NeetruDbOptions.dbId."
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
- warnMissingDbIdOnce(config, dbOpts);
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
  }