@neetru/sdk 3.3.0 → 3.4.1

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 (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/ai.d.cts +1 -1
  3. package/dist/ai.d.ts +1 -1
  4. package/dist/auth.cjs +365 -25
  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 +365 -25
  9. package/dist/auth.mjs.map +1 -1
  10. package/dist/catalog.d.cts +1 -1
  11. package/dist/catalog.d.ts +1 -1
  12. package/dist/checkout.d.cts +1 -1
  13. package/dist/checkout.d.ts +1 -1
  14. package/dist/db-react.d.cts +1 -1
  15. package/dist/db-react.d.ts +1 -1
  16. package/dist/db.cjs +202 -22
  17. package/dist/db.cjs.map +1 -1
  18. package/dist/db.d.cts +1 -1
  19. package/dist/db.d.ts +1 -1
  20. package/dist/db.mjs +202 -23
  21. package/dist/db.mjs.map +1 -1
  22. package/dist/entitlements.d.cts +1 -1
  23. package/dist/entitlements.d.ts +1 -1
  24. package/dist/firestore-compat.d.cts +1 -1
  25. package/dist/firestore-compat.d.ts +1 -1
  26. package/dist/index.cjs +366 -26
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +2 -2
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.mjs +366 -26
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/mocks.cjs +68 -1
  33. package/dist/mocks.cjs.map +1 -1
  34. package/dist/mocks.d.cts +17 -1
  35. package/dist/mocks.d.ts +17 -1
  36. package/dist/mocks.mjs +68 -1
  37. package/dist/mocks.mjs.map +1 -1
  38. package/dist/notifications.d.cts +1 -1
  39. package/dist/notifications.d.ts +1 -1
  40. package/dist/react.d.cts +1 -1
  41. package/dist/react.d.ts +1 -1
  42. package/dist/support.cjs +98 -1
  43. package/dist/support.cjs.map +1 -1
  44. package/dist/support.d.cts +1 -1
  45. package/dist/support.d.ts +1 -1
  46. package/dist/support.mjs +98 -1
  47. package/dist/support.mjs.map +1 -1
  48. package/dist/telemetry.d.cts +1 -1
  49. package/dist/telemetry.d.ts +1 -1
  50. package/dist/{types-DslphKIM.d.ts → types-BlOLDMR0.d.ts} +192 -4
  51. package/dist/{types-DQplLXIk.d.cts → types-D-QdG502.d.cts} +192 -4
  52. package/dist/usage.d.cts +1 -1
  53. package/dist/usage.d.ts +1 -1
  54. package/dist/webhooks.d.cts +1 -1
  55. package/dist/webhooks.d.ts +1 -1
  56. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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.1] - 2026-08-20 — **fix(db offline): resiliência da fila offline + honestidade do transporte Documentos**
9
+
10
+ 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).
11
+
12
+ ### Corrigido
13
+
14
+ - **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.
15
+ - **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.
16
+
17
+ ### Adicionado (aditivo, opcional)
18
+
19
+ - **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.
20
+ - `DbListResult.truncated?: boolean` e `Mutation.lastAttemptAt?: number` (interno) — campos opcionais aditivos.
21
+
22
+ ### Alterado
23
+
24
+ - **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.
25
+
26
+ ## [3.4.0] - 2026-08-15 — **feat(support): superfície "Product Feedback" — markResolvedSeen/recordSatisfactionAttempt/submitSatisfaction/contestTicket**
27
+
28
+ Release **minor** (API pública aditiva no namespace `client.support`: 4 métodos novos + 9 campos novos em `SupportTicket` + 3 opcionais em `CreateTicketInput`).
29
+
30
+ ### Contexto
31
+
32
+ Camada de dados do fluxo "Product Feedback" (report → resolvido → satisfação → contestação), contrato compartilhado entre Core (endpoints+schema), SDK (`@neetru/sdk` namespace `support`) e `@neetru/product-feedback` (neetru-libs). Ver `CONTRATO_PRODUCT_FEEDBACK_2026-08-15.md`. Decisões do owner: rating CSAT 1-5 (`number | null`); contestação incluída no v1 (4 estados).
33
+
34
+ ### Adicionado
35
+
36
+ - **`createTicket(input)`**: aceita opcionais `pageUrl?: string`, `screenshotBase64?: string`, `consoleLogs?: string` — propagados no body do POST quando presentes.
37
+ - **`SupportTicket`**: campos novos (todos nullable, mapeados defensivamente em `toTicket()`) — `pageUrl`, `screenshotUrl` (= `attachments[0] ?? null`), `resolvedAt`, `resolvedNotifiedAt`, `satisfactionAttempts` (default `0`), `lastSatisfactionAttemptAt`, `satisfiedAt`, `rating`, `satisfactionComment`.
38
+ - **`markResolvedSeen(ticketId)`**: `POST /api/sdk/v1/support/tickets/{id}/resolved-seen` — marca a notificação de resolução como vista (idempotente no Core).
39
+ - **`recordSatisfactionAttempt(ticketId)`**: `POST .../satisfaction-attempt` — registra exibição do modal de satisfação (regra-chave do fluxo: exibir já conta como tentativa, evita reprompt infinito).
40
+ - **`submitSatisfaction({ ticketId, rating, comment?, skipped })`**: `POST .../satisfaction` — envia (ou pula) a avaliação CSAT.
41
+ - **`contestTicket(ticketId, { comment? })`**: `POST .../contest` — reabre um ticket resolvido e zera o ciclo de satisfação/notificação.
42
+ - Validação client-side antes de cada request: `ticketId` non-empty (`validation_failed`); em `submitSatisfaction`, `rating` inteiro 1-5 quando não-`null`; `comment` ≤ 2000 chars.
43
+ - `MockSupport` espelha os 4 métodos novos in-memory (FSM coerente com o contrato) — modo dev/test sem fetch real.
44
+ - Testes novos cobrindo cada método (path, `requireAuth`, validação client-side), propagação dos campos novos em `createTicket`, e mapeamento defensivo em `toTicket()`.
45
+
46
+ ### Corrigido (incluído neste minor — `bug_58811a43`)
47
+
48
+ - **JSDoc de `NeetruDbOptions.dbId`** (`src/db/client-db.ts`): reescrito pra descrever o comportamento real (nomeia o cache offline LOCAL + filtro de subscription `nosql-vm`); explicita o que NÃO faz (não escopa as escritas REST — o escopo é `x-neetru-tenant`; não é usado pelo lease SQL, que usa `SqlOptions.database`). O texto antigo ("necessário para scoping das coleções") enganava.
49
+ - **Aviso dev-only one-time** `warnMissingDbIdOnce` (`createNeetruDb`): `console.warn` único em contexto browser + fora de `prod` quando `dbId` está ausente. **Sem mudança de comportamento de wire.**
50
+
8
51
  ## [3.3.0] - 2026-08-09 — **feat(db-sql): motor de ambientes "suspenso" — `db_suspended` não-retryável + lease inicial LAZY**
9
52
 
10
53
  Release **minor** (API pública aditiva: novo `NeetruDbErrorCode` `db_suspended` + `NeetruError.reason`; `client.db.sql()` deixa de explodir o import quando o ambiente está fora no boot).
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, aH as createAiNamespace } from './types-DQplLXIk.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, aH as createAiNamespace } from './types-DslphKIM.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
@@ -1584,6 +1584,7 @@ function toTicket(raw) {
1584
1584
  if (typeof r.id !== "string") {
1585
1585
  throw new NeetruError("invalid_response", "Ticket missing id");
1586
1586
  }
1587
+ const firstAttachment = Array.isArray(r.attachments) && typeof r.attachments[0] === "string" ? r.attachments[0] : void 0;
1587
1588
  return {
1588
1589
  id: r.id,
1589
1590
  subject: typeof r.subject === "string" ? r.subject : "",
@@ -1592,9 +1593,28 @@ function toTicket(raw) {
1592
1593
  status: VALID_STATUSES.includes(r.status) ? r.status : "open",
1593
1594
  createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
1594
1595
  updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : void 0,
1595
- productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0
1596
+ productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0,
1597
+ pageUrl: typeof r.pageUrl === "string" ? r.pageUrl : null,
1598
+ screenshotUrl: typeof r.screenshotUrl === "string" ? r.screenshotUrl : firstAttachment ?? null,
1599
+ resolvedAt: typeof r.resolvedAt === "string" ? r.resolvedAt : null,
1600
+ resolvedNotifiedAt: typeof r.resolvedNotifiedAt === "string" ? r.resolvedNotifiedAt : null,
1601
+ satisfactionAttempts: typeof r.satisfactionAttempts === "number" && Number.isFinite(r.satisfactionAttempts) ? r.satisfactionAttempts : 0,
1602
+ lastSatisfactionAttemptAt: typeof r.lastSatisfactionAttemptAt === "string" ? r.lastSatisfactionAttemptAt : null,
1603
+ satisfiedAt: typeof r.satisfiedAt === "string" ? r.satisfiedAt : null,
1604
+ rating: typeof r.rating === "number" && Number.isFinite(r.rating) ? r.rating : null,
1605
+ satisfactionComment: typeof r.satisfactionComment === "string" ? r.satisfactionComment : null
1596
1606
  };
1597
1607
  }
1608
+ function assertTicketId(ticketId) {
1609
+ if (!ticketId || typeof ticketId !== "string") {
1610
+ throw new NeetruError("validation_failed", "ticketId is required");
1611
+ }
1612
+ }
1613
+ function assertValidComment(comment) {
1614
+ if (typeof comment === "string" && comment.length > 2e3) {
1615
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
1616
+ }
1617
+ }
1598
1618
  function resolveProductId(config, ...candidates) {
1599
1619
  for (const c of candidates) {
1600
1620
  if (typeof c === "string" && c.length > 0) {
@@ -1651,6 +1671,15 @@ function createSupportNamespace(config) {
1651
1671
  if (input.metadata && typeof input.metadata === "object") {
1652
1672
  body.metadata = input.metadata;
1653
1673
  }
1674
+ if (typeof input.pageUrl === "string") {
1675
+ body.pageUrl = input.pageUrl;
1676
+ }
1677
+ if (typeof input.screenshotBase64 === "string") {
1678
+ body.screenshotBase64 = input.screenshotBase64;
1679
+ }
1680
+ if (typeof input.consoleLogs === "string") {
1681
+ body.consoleLogs = input.consoleLogs;
1682
+ }
1654
1683
  const raw = await httpRequest(config, {
1655
1684
  method: "POST",
1656
1685
  path: "/api/sdk/v1/support/tickets",
@@ -1676,6 +1705,74 @@ function createSupportNamespace(config) {
1676
1705
  });
1677
1706
  const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && "tickets" in raw ? raw.tickets ?? [] : [];
1678
1707
  return list.map(toTicket);
1708
+ },
1709
+ /**
1710
+ * 3.4: marca a notificação de resolução como vista (`resolvedNotifiedAt`,
1711
+ * idempotente no Core). Endpoint canonical:
1712
+ * `POST /api/sdk/v1/support/tickets/{id}/resolved-seen`.
1713
+ */
1714
+ async markResolvedSeen(ticketId) {
1715
+ assertTicketId(ticketId);
1716
+ await httpRequest(config, {
1717
+ method: "POST",
1718
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/resolved-seen`,
1719
+ requireAuth: true
1720
+ });
1721
+ },
1722
+ /**
1723
+ * 3.4: registra uma tentativa de exibição do modal de satisfação
1724
+ * (`satisfactionAttempts += 1`). Chamar no SHOW do modal, não na
1725
+ * resposta. Endpoint canonical:
1726
+ * `POST /api/sdk/v1/support/tickets/{id}/satisfaction-attempt`.
1727
+ */
1728
+ async recordSatisfactionAttempt(ticketId) {
1729
+ assertTicketId(ticketId);
1730
+ await httpRequest(config, {
1731
+ method: "POST",
1732
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/satisfaction-attempt`,
1733
+ requireAuth: true
1734
+ });
1735
+ },
1736
+ /**
1737
+ * 3.4: envia (ou pula) a avaliação de satisfação (CSAT 1-5). Endpoint
1738
+ * canonical: `POST /api/sdk/v1/support/tickets/{id}/satisfaction`.
1739
+ */
1740
+ async submitSatisfaction(input) {
1741
+ if (!input || typeof input !== "object") {
1742
+ throw new NeetruError("validation_failed", "input is required");
1743
+ }
1744
+ assertTicketId(input.ticketId);
1745
+ if (input.rating !== null && input.rating !== void 0) {
1746
+ if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
1747
+ throw new NeetruError("validation_failed", "rating must be an integer 1-5 or null");
1748
+ }
1749
+ }
1750
+ assertValidComment(input.comment);
1751
+ await httpRequest(config, {
1752
+ method: "POST",
1753
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(input.ticketId)}/satisfaction`,
1754
+ body: {
1755
+ rating: input.rating ?? null,
1756
+ comment: input.comment ?? null,
1757
+ skipped: input.skipped
1758
+ },
1759
+ requireAuth: true
1760
+ });
1761
+ },
1762
+ /**
1763
+ * 3.4: contesta um ticket resolvido — reabre e zera o ciclo de
1764
+ * satisfação/notificação. Endpoint canonical:
1765
+ * `POST /api/sdk/v1/support/tickets/{id}/contest`.
1766
+ */
1767
+ async contestTicket(ticketId, opts) {
1768
+ assertTicketId(ticketId);
1769
+ assertValidComment(opts?.comment);
1770
+ await httpRequest(config, {
1771
+ method: "POST",
1772
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/contest`,
1773
+ body: { comment: opts?.comment ?? void 0 },
1774
+ requireAuth: true
1775
+ });
1679
1776
  }
1680
1777
  };
1681
1778
  }
@@ -2446,8 +2543,10 @@ var MutationQueue = class {
2446
2543
  await this.store.deleteMutation(mutationId);
2447
2544
  }
2448
2545
  /**
2449
- * Marca uma mutação como `failed` e registra o erro.
2450
- * Incrementa `attempts`. Chamado pelo SyncEngine em falha permanente.
2546
+ * Marca uma mutação como `failed` (dead-letter) e registra o erro.
2547
+ * Incrementa `attempts` e carimba `lastAttemptAt`. Chamado pelo SyncEngine
2548
+ * quando a mutação esgota o teto de tentativas (mutação-veneno): sai do
2549
+ * conjunto `queued`, então `drain()` para de devolvê-la e a fila DESTRAVA.
2451
2550
  */
2452
2551
  async markFailed(mutationId, error) {
2453
2552
  const mut = await this.store.getMutation(mutationId);
@@ -2456,12 +2555,14 @@ var MutationQueue = class {
2456
2555
  ...mut,
2457
2556
  status: "failed",
2458
2557
  lastError: error,
2459
- attempts: mut.attempts + 1
2558
+ attempts: mut.attempts + 1,
2559
+ lastAttemptAt: Date.now()
2460
2560
  });
2461
2561
  }
2462
2562
  /**
2463
- * Incrementa `attempts` e volta para `queued` (falha transiente com backoff).
2464
- * Chamado pelo SyncEngine em falha transiente (5xx, timeout).
2563
+ * Incrementa `attempts`, carimba `lastAttemptAt` e volta para `queued`
2564
+ * (falha transiente com backoff). Chamado pelo SyncEngine em falha
2565
+ * transiente (5xx, timeout) da mutação que REALMENTE foi tentada.
2465
2566
  */
2466
2567
  async markRetry(mutationId, error) {
2467
2568
  const mut = await this.store.getMutation(mutationId);
@@ -2470,9 +2571,24 @@ var MutationQueue = class {
2470
2571
  ...mut,
2471
2572
  status: "queued",
2472
2573
  lastError: error,
2473
- attempts: mut.attempts + 1
2574
+ attempts: mut.attempts + 1,
2575
+ lastAttemptAt: Date.now()
2474
2576
  });
2475
2577
  }
2578
+ /**
2579
+ * Reverte uma mutação `inflight` de volta a `queued` SEM tocar em `attempts`
2580
+ * nem `lastAttemptAt`. Usado para mutações que foram marcadas inflight mas
2581
+ * NUNCA chegaram a ser enviadas (estavam atrás da head no FIFO e o push
2582
+ * parou antes delas): contá-las como tentativa as empurraria pro dead-letter
2583
+ * por engano, e adiaria seu envio via backoff sem que tenham falhado.
2584
+ * No-op se a mutação não está mais `inflight`.
2585
+ */
2586
+ async requeueInflight(mutationId) {
2587
+ const mut = await this.store.getMutation(mutationId);
2588
+ if (!mut) return;
2589
+ if (mut.status !== "inflight") return;
2590
+ await this.store.putMutation({ ...mut, status: "queued" });
2591
+ }
2476
2592
  };
2477
2593
 
2478
2594
  // src/db/field-value.ts
@@ -2517,6 +2633,10 @@ function hasFieldSentinel(data) {
2517
2633
  }
2518
2634
 
2519
2635
  // src/db/offline/sync-engine.ts
2636
+ var MAX_MUTATION_ATTEMPTS = 8;
2637
+ var BACKOFF_BASE_MS = 1e3;
2638
+ var BACKOFF_CAP_MS = 5 * 60 * 1e3;
2639
+ var TRUNCATED_COLLECTIONS_META_KEY = "truncatedCollections";
2520
2640
  function hasSentinels(payload) {
2521
2641
  return payload !== null && hasFieldSentinel(payload);
2522
2642
  }
@@ -2551,6 +2671,8 @@ var SyncEngine = class {
2551
2671
  _state;
2552
2672
  /** Listeners de mudança de SyncState. */
2553
2673
  _stateListeners = /* @__PURE__ */ new Set();
2674
+ /** Listeners de erro de ciclo de sync (P2-C — não mais engolido em silêncio). */
2675
+ _errorListeners = /* @__PURE__ */ new Set();
2554
2676
  /** Cleanup functions dos listeners externos. */
2555
2677
  _cleanups = [];
2556
2678
  /** Timer de resync periódico. */
@@ -2609,6 +2731,19 @@ var SyncEngine = class {
2609
2731
  this._stateListeners.delete(cb);
2610
2732
  };
2611
2733
  }
2734
+ /**
2735
+ * Registra um hook de erro de sync (P2-C). Disparado quando um ciclo falha
2736
+ * (exceção inesperada nas fases, falha de pull/resync ou dead-letter de uma
2737
+ * mutação-veneno) — o que antes era engolido com "log silencioso".
2738
+ * O mesmo diagnóstico também aparece em `SyncState.lastError`.
2739
+ * Retorna uma função de unsubscribe.
2740
+ */
2741
+ onError(cb) {
2742
+ this._errorListeners.add(cb);
2743
+ return () => {
2744
+ this._errorListeners.delete(cb);
2745
+ };
2746
+ }
2612
2747
  /**
2613
2748
  * Força um sync imediato (I3 §10.3 `flush()`).
2614
2749
  * Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
@@ -2641,8 +2776,10 @@ var SyncEngine = class {
2641
2776
  }
2642
2777
  await this._phase2Pull();
2643
2778
  this._phase3Realtime();
2644
- this._updateState({ status: "idle", lastSyncedAt: Date.now() });
2779
+ this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
2645
2780
  } catch (err) {
2781
+ const msg = err instanceof Error ? err.message : String(err);
2782
+ this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
2646
2783
  this._updateState({ status: "idle" });
2647
2784
  } finally {
2648
2785
  this._syncing = false;
@@ -2677,6 +2814,9 @@ var SyncEngine = class {
2677
2814
  async _phase1Push() {
2678
2815
  const mutations = await this._queue.drain();
2679
2816
  if (mutations.length === 0) return true;
2817
+ if (this._isInBackoff(mutations[0])) {
2818
+ return true;
2819
+ }
2680
2820
  for (const mut of mutations) {
2681
2821
  await this._queue.markInflight(mut.mutationId);
2682
2822
  }
@@ -2684,10 +2824,16 @@ var SyncEngine = class {
2684
2824
  try {
2685
2825
  result = await this._transport.pushMutations(mutations);
2686
2826
  } catch (err) {
2687
- for (const mut of mutations) {
2688
- const errMsg = err instanceof Error ? err.message : String(err);
2689
- await this._queue.markRetry(mut.mutationId, errMsg);
2690
- }
2827
+ const errMsg = err instanceof Error ? err.message : String(err);
2828
+ const busChangesOnThrow = [];
2829
+ const [head, ...behind] = mutations;
2830
+ await this._retryOrDeadLetter(head, errMsg, busChangesOnThrow);
2831
+ for (const mut of behind) {
2832
+ await this._queue.requeueInflight(mut.mutationId);
2833
+ }
2834
+ if (busChangesOnThrow.length > 0) this._bus.emit(busChangesOnThrow);
2835
+ this._refreshPendingWrites().catch(() => {
2836
+ });
2691
2837
  return false;
2692
2838
  }
2693
2839
  const busChanges = [];
@@ -2714,8 +2860,16 @@ var SyncEngine = class {
2714
2860
  }
2715
2861
  const processedIds = new Set(result.results.map((r) => r.mutationId));
2716
2862
  const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
2717
- for (const mut of unprocessed) {
2718
- await this._queue.markRetry(mut.mutationId, "partial_push: muta\xE7\xE3o n\xE3o processada no lote");
2863
+ if (unprocessed.length > 0) {
2864
+ const [boundary, ...behind] = unprocessed;
2865
+ await this._retryOrDeadLetter(
2866
+ boundary,
2867
+ "partial_push: muta\xE7\xE3o n\xE3o processada no lote",
2868
+ busChanges
2869
+ );
2870
+ for (const mut of behind) {
2871
+ await this._queue.requeueInflight(mut.mutationId);
2872
+ }
2719
2873
  }
2720
2874
  if (busChanges.length > 0) {
2721
2875
  this._bus.emit(busChanges);
@@ -2907,6 +3061,67 @@ var SyncEngine = class {
2907
3061
  });
2908
3062
  }
2909
3063
  }
3064
+ // ─── Dead-letter + backoff (P1-C) ────────────────────────────────────────────
3065
+ /**
3066
+ * Trata a falha transiente de UMA mutação que foi realmente tentada.
3067
+ *
3068
+ * - Se ainda há orçamento de tentativas → `markRetry` (volta pra fila com
3069
+ * backoff, `attempts++`).
3070
+ * - Se esgotou (`attempts+1 >= MAX_MUTATION_ATTEMPTS`) → DEAD-LETTER:
3071
+ * `markFailed` (sai do conjunto `queued`, então `drain()` para de devolvê-la
3072
+ * e a fila FIFO DESTRAVA para as demais coleções) + grava um ConflictRecord
3073
+ * `failed_max_attempts` (o produto vê via `getConflicts()`) + limpa o estado
3074
+ * pending do doc no cache + registra `lastError`/dispara `onError`.
3075
+ */
3076
+ async _retryOrDeadLetter(mut, errMsg, busChanges) {
3077
+ if (mut.attempts + 1 < MAX_MUTATION_ATTEMPTS) {
3078
+ await this._queue.markRetry(mut.mutationId, errMsg);
3079
+ return;
3080
+ }
3081
+ await this._queue.markFailed(mut.mutationId, errMsg);
3082
+ const conflictRecord = {
3083
+ collection: mut.collection,
3084
+ docId: mut.docId,
3085
+ mutationId: mut.mutationId,
3086
+ losingData: mut.payload ?? {},
3087
+ winningData: {},
3088
+ reason: "failed_max_attempts",
3089
+ detectedAt: Date.now(),
3090
+ delivered: false
3091
+ };
3092
+ await this._store.appendConflict(conflictRecord);
3093
+ const existing = await this._store.getDoc(mut.collection, mut.docId);
3094
+ if (existing) {
3095
+ await this._store.putDoc({
3096
+ ...existing,
3097
+ meta: { ...existing.meta, state: "synced", pendingMutationIds: [] }
3098
+ });
3099
+ busChanges.push({
3100
+ type: "modified",
3101
+ collection: mut.collection,
3102
+ doc: { id: mut.docId, data: existing.data }
3103
+ });
3104
+ }
3105
+ this._recordSyncError(
3106
+ "push",
3107
+ `muta\xE7\xE3o ${mut.mutationId} (${mut.collection}/${mut.docId}) descartada ap\xF3s ${mut.attempts + 1} tentativas: ${errMsg}`
3108
+ );
3109
+ }
3110
+ /**
3111
+ * `true` se a mutação (head do FIFO) ainda está dentro da janela de backoff
3112
+ * desde a última tentativa falha. Só se aplica a mutações já tentadas
3113
+ * (`attempts > 0` e `lastAttemptAt` carimbado).
3114
+ */
3115
+ _isInBackoff(mut) {
3116
+ if (mut.attempts <= 0) return false;
3117
+ const last = mut.lastAttemptAt;
3118
+ if (typeof last !== "number") return false;
3119
+ return Date.now() < last + this._backoffDelayMs(mut.attempts);
3120
+ }
3121
+ /** Backoff exponencial keyed em `attempts`, com teto `BACKOFF_CAP_MS`. */
3122
+ _backoffDelayMs(attempts) {
3123
+ return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_CAP_MS);
3124
+ }
2910
3125
  // ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
2911
3126
  /**
2912
3127
  * Busca mudanças do servidor e aplica ao cache via ConflictResolver.
@@ -2946,10 +3161,14 @@ var SyncEngine = class {
2946
3161
  }
2947
3162
  const busChanges = [];
2948
3163
  await this._applyServerDocs(resyncResult.docs, busChanges, true);
3164
+ const truncatedCollections = resyncResult.truncatedCollections ?? [];
3165
+ const truncatedSet = new Set(truncatedCollections);
3166
+ await this._store.setMeta(TRUNCATED_COLLECTIONS_META_KEY, truncatedCollections);
2949
3167
  const returnedKeys = new Set(
2950
3168
  resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
2951
3169
  );
2952
3170
  for (const col of collections) {
3171
+ if (truncatedSet.has(col)) continue;
2953
3172
  const queryResult = await this._store.listDocs(col, {});
2954
3173
  for (const { id } of queryResult.docs) {
2955
3174
  const key = `${col}::${id}`;
@@ -3109,7 +3328,8 @@ var SyncEngine = class {
3109
3328
  status: isOnline ? "idle" : "offline",
3110
3329
  pendingWrites: 0,
3111
3330
  lastSyncedAt: null,
3112
- isLeaderTab: this._tabCoordinator.isLeader()
3331
+ isLeaderTab: this._tabCoordinator.isLeader(),
3332
+ lastError: null
3113
3333
  };
3114
3334
  }
3115
3335
  /**
@@ -3119,7 +3339,7 @@ var SyncEngine = class {
3119
3339
  _updateState(partial) {
3120
3340
  const prev = this._state;
3121
3341
  const next = { ...prev, ...partial };
3122
- const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
3342
+ const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab || (prev.lastError ?? null) !== (next.lastError ?? null);
3123
3343
  this._state = next;
3124
3344
  if (changed) {
3125
3345
  this._emitState(next);
@@ -3133,6 +3353,21 @@ var SyncEngine = class {
3133
3353
  }
3134
3354
  }
3135
3355
  }
3356
+ /**
3357
+ * P2-C: registra a falha de um ciclo de sync — antes engolida em silêncio.
3358
+ * Publica em `SyncState.lastError` (visível via `onSyncStateChanged`) e
3359
+ * dispara o hook `onError`. Nunca lança (diagnóstico é best-effort).
3360
+ */
3361
+ _recordSyncError(phase, message) {
3362
+ this._updateState({ lastError: message });
3363
+ const event = { phase, message, at: Date.now() };
3364
+ for (const listener of this._errorListeners) {
3365
+ try {
3366
+ listener({ ...event });
3367
+ } catch {
3368
+ }
3369
+ }
3370
+ }
3136
3371
  /**
3137
3372
  * Atualiza `pendingWrites` no estado.
3138
3373
  * Chamado de forma assíncrona quando necessário.
@@ -3942,7 +4177,9 @@ var DbCollectionRefImpl = class {
3942
4177
  const isOnline = this._isOnline();
3943
4178
  const syncState = this._engine.getSyncState();
3944
4179
  const nextCursor = buildNextCursor(result.docs, effectiveLimit);
3945
- const stale = !isOnline || syncState.status === "offline";
4180
+ const truncatedRaw = await this._store.getMeta(TRUNCATED_COLLECTIONS_META_KEY);
4181
+ const truncated = Array.isArray(truncatedRaw) && truncatedRaw.includes(this._collection);
4182
+ const stale = !isOnline || syncState.status === "offline" || truncated;
3946
4183
  const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
3947
4184
  return {
3948
4185
  docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
@@ -3950,7 +4187,8 @@ var DbCollectionRefImpl = class {
3950
4187
  fromCache,
3951
4188
  stale,
3952
4189
  hasPendingWrites: hasPending,
3953
- changes: []
4190
+ changes: [],
4191
+ truncated
3954
4192
  };
3955
4193
  }
3956
4194
  // ── CRUD ────────────────────────────────────────────────────────────────────
@@ -4396,6 +4634,9 @@ var NeetruDbDocumentsImpl = class {
4396
4634
  async getConflicts() {
4397
4635
  return this._store.listConflicts();
4398
4636
  }
4637
+ onError(cb) {
4638
+ return this._engine.onError(cb);
4639
+ }
4399
4640
  };
4400
4641
  async function createOfflineDocumentsNamespace(opts) {
4401
4642
  const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
@@ -4871,6 +5112,7 @@ var defaultWebSocketFactory = (url) => {
4871
5112
  // src/db/client-db.ts
4872
5113
  var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
4873
5114
  var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
5115
+ var DOCUMENTS_RESYNC_PAGE_CAP = 200;
4874
5116
  function resolveTransport(transport, opts, config) {
4875
5117
  if (opts._transport) {
4876
5118
  return opts._transport;
@@ -4983,6 +5225,7 @@ function createRestSyncTransport(config) {
4983
5225
  const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
4984
5226
  const now = Date.now();
4985
5227
  const docs = [];
5228
+ const truncatedCollections = [];
4986
5229
  for (const collection of collections) {
4987
5230
  let raw;
4988
5231
  try {
@@ -4992,11 +5235,11 @@ function createRestSyncTransport(config) {
4992
5235
  requireAuth: true,
4993
5236
  retries: 2,
4994
5237
  headers: tenantHeaders,
4995
- // Teto do Core é 200. Coleções > 200 docs truncam silenciosamente
4996
- // aqui (paginação cursor é tarefa Core separada) — a detecção de
4997
- // deleção-por-ausência do SyncEngine NÃO deve rodar sobre truncamento,
4998
- // então mantemos o limite no máximo do servidor por ora.
4999
- query: { limit: 200 }
5238
+ // Teto do Core (DOCUMENTS_RESYNC_PAGE_CAP). Coleções acima dele vêm
5239
+ // TRUNCADAS — reportadas em `truncatedCollections` p/ o SyncEngine
5240
+ // (a) NÃO rodar deleção-por-ausência e (b) sinalizar
5241
+ // `DbListResult.truncated`. Paginação por cursor é tarefa Core (gated).
5242
+ query: { limit: DOCUMENTS_RESYNC_PAGE_CAP }
5000
5243
  });
5001
5244
  } catch (err) {
5002
5245
  const msg = err instanceof Error ? err.message : String(err);
@@ -5007,6 +5250,9 @@ function createRestSyncTransport(config) {
5007
5250
  }
5008
5251
  const resp = raw;
5009
5252
  const items = Array.isArray(resp?.items) ? resp.items : [];
5253
+ if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
5254
+ truncatedCollections.push(collection);
5255
+ }
5010
5256
  for (const item of items) {
5011
5257
  if (!item || typeof item !== "object") continue;
5012
5258
  const record = item;
@@ -5034,7 +5280,7 @@ function createRestSyncTransport(config) {
5034
5280
  });
5035
5281
  }
5036
5282
  }
5037
- return { docs, newWatermark: now };
5283
+ return { docs, newWatermark: now, truncatedCollections };
5038
5284
  }
5039
5285
  };
5040
5286
  }
@@ -5096,7 +5342,20 @@ function buildTicketProvider(config, opts) {
5096
5342
  return ticket;
5097
5343
  };
5098
5344
  }
5345
+ var _hasWarnedDbIdIgnored = false;
5346
+ function warnDbIdIgnoredOnce(config, dbOpts) {
5347
+ if (_hasWarnedDbIdIgnored) return;
5348
+ if (!dbOpts.dbId) return;
5349
+ if (config.env === "prod") return;
5350
+ if (typeof window === "undefined") return;
5351
+ if (typeof console === "undefined" || typeof console.warn !== "function") return;
5352
+ _hasWarnedDbIdIgnored = true;
5353
+ console.warn(
5354
+ '[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.'
5355
+ );
5356
+ }
5099
5357
  function createNeetruDb(config, dbOpts = {}) {
5358
+ warnDbIdIgnoredOnce(config, dbOpts);
5100
5359
  let resolvedTransport;
5101
5360
  if (dbOpts.transport !== void 0) {
5102
5361
  resolvedTransport = dbOpts.transport;
@@ -5174,6 +5433,20 @@ function createNeetruDb(config, dbOpts = {}) {
5174
5433
  async getConflicts() {
5175
5434
  const docs = await getDocsPromise();
5176
5435
  return docs.getConflicts();
5436
+ },
5437
+ onError(cb) {
5438
+ if (_docsResolved) return _docsResolved.onError(cb);
5439
+ let unsub = null;
5440
+ let cancelled = false;
5441
+ getDocsPromise().then((docs) => {
5442
+ if (!cancelled) {
5443
+ unsub = docs.onError(cb);
5444
+ }
5445
+ });
5446
+ return () => {
5447
+ cancelled = true;
5448
+ unsub?.();
5449
+ };
5177
5450
  }
5178
5451
  };
5179
5452
  }
@@ -6519,7 +6792,18 @@ var MockSupport = class {
6519
6792
  severity: input.severity ?? "normal",
6520
6793
  status: "open",
6521
6794
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6522
- productSlug: input.productSlug
6795
+ productSlug: input.productSlug,
6796
+ // 3.4 (product-feedback): espelha o contrato — campos novos nullable,
6797
+ // populados a partir do input quando fornecidos.
6798
+ pageUrl: typeof input.pageUrl === "string" ? input.pageUrl : null,
6799
+ screenshotUrl: typeof input.screenshotBase64 === "string" ? input.screenshotBase64 : null,
6800
+ resolvedAt: null,
6801
+ resolvedNotifiedAt: null,
6802
+ satisfactionAttempts: 0,
6803
+ lastSatisfactionAttemptAt: null,
6804
+ satisfiedAt: null,
6805
+ rating: null,
6806
+ satisfactionComment: null
6523
6807
  };
6524
6808
  this._tickets.push(ticket);
6525
6809
  return ticket;
@@ -6530,6 +6814,62 @@ var MockSupport = class {
6530
6814
  }
6531
6815
  return [...this._tickets];
6532
6816
  }
6817
+ _find(ticketId) {
6818
+ if (!ticketId || typeof ticketId !== "string") {
6819
+ throw new NeetruError("validation_failed", "ticketId is required");
6820
+ }
6821
+ const ticket = this._tickets.find((t) => t.id === ticketId);
6822
+ if (!ticket) {
6823
+ throw new NeetruError("not_found", `ticket ${ticketId} not found`);
6824
+ }
6825
+ return ticket;
6826
+ }
6827
+ /** 3.4: espelha `markResolvedSeen` — idempotente (só seta se ainda null). */
6828
+ async markResolvedSeen(ticketId) {
6829
+ const ticket = this._find(ticketId);
6830
+ if (ticket.resolvedNotifiedAt === null) {
6831
+ ticket.resolvedNotifiedAt = (/* @__PURE__ */ new Date()).toISOString();
6832
+ }
6833
+ }
6834
+ /** 3.4: espelha `recordSatisfactionAttempt` — incrementa contador. */
6835
+ async recordSatisfactionAttempt(ticketId) {
6836
+ const ticket = this._find(ticketId);
6837
+ ticket.satisfactionAttempts += 1;
6838
+ ticket.lastSatisfactionAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
6839
+ }
6840
+ /** 3.4: espelha `submitSatisfaction` — terminal, seta `satisfiedAt`. */
6841
+ async submitSatisfaction(input) {
6842
+ if (!input || typeof input !== "object") {
6843
+ throw new NeetruError("validation_failed", "input is required");
6844
+ }
6845
+ if (input.rating !== null && input.rating !== void 0) {
6846
+ if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
6847
+ throw new NeetruError("validation_failed", "rating must be an integer 1-5 or null");
6848
+ }
6849
+ }
6850
+ if (typeof input.comment === "string" && input.comment.length > 2e3) {
6851
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
6852
+ }
6853
+ const ticket = this._find(input.ticketId);
6854
+ ticket.satisfiedAt = (/* @__PURE__ */ new Date()).toISOString();
6855
+ ticket.rating = input.rating ?? null;
6856
+ ticket.satisfactionComment = input.comment ?? null;
6857
+ }
6858
+ /** 3.4: espelha `contestTicket` — reabre e zera o ciclo de satisfação. */
6859
+ async contestTicket(ticketId, opts) {
6860
+ if (typeof opts?.comment === "string" && opts.comment.length > 2e3) {
6861
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
6862
+ }
6863
+ const ticket = this._find(ticketId);
6864
+ ticket.status = "open";
6865
+ ticket.resolvedAt = null;
6866
+ ticket.resolvedNotifiedAt = null;
6867
+ ticket.satisfactionAttempts = 0;
6868
+ ticket.lastSatisfactionAttemptAt = null;
6869
+ ticket.satisfiedAt = null;
6870
+ ticket.rating = null;
6871
+ ticket.satisfactionComment = null;
6872
+ }
6533
6873
  /** Test helper. */
6534
6874
  __reset() {
6535
6875
  this._tickets = [];