@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/dist/auth.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { X as NeetruClientConfig, W as NeetruClient } from './types-DQplLXIk.cjs';
1
+ import { X as NeetruClientConfig, W as NeetruClient } from './types-D-QdG502.cjs';
2
2
  import 'drizzle-orm/node-postgres';
3
3
  import './errors.cjs';
4
4
 
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { X as NeetruClientConfig, W as NeetruClient } from './types-DslphKIM.js';
1
+ import { X as NeetruClientConfig, W as NeetruClient } from './types-BlOLDMR0.js';
2
2
  import 'drizzle-orm/node-postgres';
3
3
  import './errors.js';
4
4
 
package/dist/auth.mjs CHANGED
@@ -1582,6 +1582,7 @@ function toTicket(raw) {
1582
1582
  if (typeof r.id !== "string") {
1583
1583
  throw new NeetruError("invalid_response", "Ticket missing id");
1584
1584
  }
1585
+ const firstAttachment = Array.isArray(r.attachments) && typeof r.attachments[0] === "string" ? r.attachments[0] : void 0;
1585
1586
  return {
1586
1587
  id: r.id,
1587
1588
  subject: typeof r.subject === "string" ? r.subject : "",
@@ -1590,9 +1591,28 @@ function toTicket(raw) {
1590
1591
  status: VALID_STATUSES.includes(r.status) ? r.status : "open",
1591
1592
  createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
1592
1593
  updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : void 0,
1593
- productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0
1594
+ productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0,
1595
+ pageUrl: typeof r.pageUrl === "string" ? r.pageUrl : null,
1596
+ screenshotUrl: typeof r.screenshotUrl === "string" ? r.screenshotUrl : firstAttachment ?? null,
1597
+ resolvedAt: typeof r.resolvedAt === "string" ? r.resolvedAt : null,
1598
+ resolvedNotifiedAt: typeof r.resolvedNotifiedAt === "string" ? r.resolvedNotifiedAt : null,
1599
+ satisfactionAttempts: typeof r.satisfactionAttempts === "number" && Number.isFinite(r.satisfactionAttempts) ? r.satisfactionAttempts : 0,
1600
+ lastSatisfactionAttemptAt: typeof r.lastSatisfactionAttemptAt === "string" ? r.lastSatisfactionAttemptAt : null,
1601
+ satisfiedAt: typeof r.satisfiedAt === "string" ? r.satisfiedAt : null,
1602
+ rating: typeof r.rating === "number" && Number.isFinite(r.rating) ? r.rating : null,
1603
+ satisfactionComment: typeof r.satisfactionComment === "string" ? r.satisfactionComment : null
1594
1604
  };
1595
1605
  }
1606
+ function assertTicketId(ticketId) {
1607
+ if (!ticketId || typeof ticketId !== "string") {
1608
+ throw new NeetruError("validation_failed", "ticketId is required");
1609
+ }
1610
+ }
1611
+ function assertValidComment(comment) {
1612
+ if (typeof comment === "string" && comment.length > 2e3) {
1613
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
1614
+ }
1615
+ }
1596
1616
  function resolveProductId(config, ...candidates) {
1597
1617
  for (const c of candidates) {
1598
1618
  if (typeof c === "string" && c.length > 0) {
@@ -1649,6 +1669,15 @@ function createSupportNamespace(config) {
1649
1669
  if (input.metadata && typeof input.metadata === "object") {
1650
1670
  body.metadata = input.metadata;
1651
1671
  }
1672
+ if (typeof input.pageUrl === "string") {
1673
+ body.pageUrl = input.pageUrl;
1674
+ }
1675
+ if (typeof input.screenshotBase64 === "string") {
1676
+ body.screenshotBase64 = input.screenshotBase64;
1677
+ }
1678
+ if (typeof input.consoleLogs === "string") {
1679
+ body.consoleLogs = input.consoleLogs;
1680
+ }
1652
1681
  const raw = await httpRequest(config, {
1653
1682
  method: "POST",
1654
1683
  path: "/api/sdk/v1/support/tickets",
@@ -1674,6 +1703,74 @@ function createSupportNamespace(config) {
1674
1703
  });
1675
1704
  const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && "tickets" in raw ? raw.tickets ?? [] : [];
1676
1705
  return list.map(toTicket);
1706
+ },
1707
+ /**
1708
+ * 3.4: marca a notificação de resolução como vista (`resolvedNotifiedAt`,
1709
+ * idempotente no Core). Endpoint canonical:
1710
+ * `POST /api/sdk/v1/support/tickets/{id}/resolved-seen`.
1711
+ */
1712
+ async markResolvedSeen(ticketId) {
1713
+ assertTicketId(ticketId);
1714
+ await httpRequest(config, {
1715
+ method: "POST",
1716
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/resolved-seen`,
1717
+ requireAuth: true
1718
+ });
1719
+ },
1720
+ /**
1721
+ * 3.4: registra uma tentativa de exibição do modal de satisfação
1722
+ * (`satisfactionAttempts += 1`). Chamar no SHOW do modal, não na
1723
+ * resposta. Endpoint canonical:
1724
+ * `POST /api/sdk/v1/support/tickets/{id}/satisfaction-attempt`.
1725
+ */
1726
+ async recordSatisfactionAttempt(ticketId) {
1727
+ assertTicketId(ticketId);
1728
+ await httpRequest(config, {
1729
+ method: "POST",
1730
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/satisfaction-attempt`,
1731
+ requireAuth: true
1732
+ });
1733
+ },
1734
+ /**
1735
+ * 3.4: envia (ou pula) a avaliação de satisfação (CSAT 1-5). Endpoint
1736
+ * canonical: `POST /api/sdk/v1/support/tickets/{id}/satisfaction`.
1737
+ */
1738
+ async submitSatisfaction(input) {
1739
+ if (!input || typeof input !== "object") {
1740
+ throw new NeetruError("validation_failed", "input is required");
1741
+ }
1742
+ assertTicketId(input.ticketId);
1743
+ if (input.rating !== null && input.rating !== void 0) {
1744
+ if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
1745
+ throw new NeetruError("validation_failed", "rating must be an integer 1-5 or null");
1746
+ }
1747
+ }
1748
+ assertValidComment(input.comment);
1749
+ await httpRequest(config, {
1750
+ method: "POST",
1751
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(input.ticketId)}/satisfaction`,
1752
+ body: {
1753
+ rating: input.rating ?? null,
1754
+ comment: input.comment ?? null,
1755
+ skipped: input.skipped
1756
+ },
1757
+ requireAuth: true
1758
+ });
1759
+ },
1760
+ /**
1761
+ * 3.4: contesta um ticket resolvido — reabre e zera o ciclo de
1762
+ * satisfação/notificação. Endpoint canonical:
1763
+ * `POST /api/sdk/v1/support/tickets/{id}/contest`.
1764
+ */
1765
+ async contestTicket(ticketId, opts) {
1766
+ assertTicketId(ticketId);
1767
+ assertValidComment(opts?.comment);
1768
+ await httpRequest(config, {
1769
+ method: "POST",
1770
+ path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/contest`,
1771
+ body: { comment: opts?.comment ?? void 0 },
1772
+ requireAuth: true
1773
+ });
1677
1774
  }
1678
1775
  };
1679
1776
  }
@@ -2444,8 +2541,10 @@ var MutationQueue = class {
2444
2541
  await this.store.deleteMutation(mutationId);
2445
2542
  }
2446
2543
  /**
2447
- * Marca uma mutação como `failed` e registra o erro.
2448
- * Incrementa `attempts`. Chamado pelo SyncEngine em falha permanente.
2544
+ * Marca uma mutação como `failed` (dead-letter) e registra o erro.
2545
+ * Incrementa `attempts` e carimba `lastAttemptAt`. Chamado pelo SyncEngine
2546
+ * quando a mutação esgota o teto de tentativas (mutação-veneno): sai do
2547
+ * conjunto `queued`, então `drain()` para de devolvê-la e a fila DESTRAVA.
2449
2548
  */
2450
2549
  async markFailed(mutationId, error) {
2451
2550
  const mut = await this.store.getMutation(mutationId);
@@ -2454,12 +2553,14 @@ var MutationQueue = class {
2454
2553
  ...mut,
2455
2554
  status: "failed",
2456
2555
  lastError: error,
2457
- attempts: mut.attempts + 1
2556
+ attempts: mut.attempts + 1,
2557
+ lastAttemptAt: Date.now()
2458
2558
  });
2459
2559
  }
2460
2560
  /**
2461
- * Incrementa `attempts` e volta para `queued` (falha transiente com backoff).
2462
- * Chamado pelo SyncEngine em falha transiente (5xx, timeout).
2561
+ * Incrementa `attempts`, carimba `lastAttemptAt` e volta para `queued`
2562
+ * (falha transiente com backoff). Chamado pelo SyncEngine em falha
2563
+ * transiente (5xx, timeout) da mutação que REALMENTE foi tentada.
2463
2564
  */
2464
2565
  async markRetry(mutationId, error) {
2465
2566
  const mut = await this.store.getMutation(mutationId);
@@ -2468,9 +2569,24 @@ var MutationQueue = class {
2468
2569
  ...mut,
2469
2570
  status: "queued",
2470
2571
  lastError: error,
2471
- attempts: mut.attempts + 1
2572
+ attempts: mut.attempts + 1,
2573
+ lastAttemptAt: Date.now()
2472
2574
  });
2473
2575
  }
2576
+ /**
2577
+ * Reverte uma mutação `inflight` de volta a `queued` SEM tocar em `attempts`
2578
+ * nem `lastAttemptAt`. Usado para mutações que foram marcadas inflight mas
2579
+ * NUNCA chegaram a ser enviadas (estavam atrás da head no FIFO e o push
2580
+ * parou antes delas): contá-las como tentativa as empurraria pro dead-letter
2581
+ * por engano, e adiaria seu envio via backoff sem que tenham falhado.
2582
+ * No-op se a mutação não está mais `inflight`.
2583
+ */
2584
+ async requeueInflight(mutationId) {
2585
+ const mut = await this.store.getMutation(mutationId);
2586
+ if (!mut) return;
2587
+ if (mut.status !== "inflight") return;
2588
+ await this.store.putMutation({ ...mut, status: "queued" });
2589
+ }
2474
2590
  };
2475
2591
 
2476
2592
  // src/db/field-value.ts
@@ -2515,6 +2631,10 @@ function hasFieldSentinel(data) {
2515
2631
  }
2516
2632
 
2517
2633
  // src/db/offline/sync-engine.ts
2634
+ var MAX_MUTATION_ATTEMPTS = 8;
2635
+ var BACKOFF_BASE_MS = 1e3;
2636
+ var BACKOFF_CAP_MS = 5 * 60 * 1e3;
2637
+ var TRUNCATED_COLLECTIONS_META_KEY = "truncatedCollections";
2518
2638
  function hasSentinels(payload) {
2519
2639
  return payload !== null && hasFieldSentinel(payload);
2520
2640
  }
@@ -2549,6 +2669,8 @@ var SyncEngine = class {
2549
2669
  _state;
2550
2670
  /** Listeners de mudança de SyncState. */
2551
2671
  _stateListeners = /* @__PURE__ */ new Set();
2672
+ /** Listeners de erro de ciclo de sync (P2-C — não mais engolido em silêncio). */
2673
+ _errorListeners = /* @__PURE__ */ new Set();
2552
2674
  /** Cleanup functions dos listeners externos. */
2553
2675
  _cleanups = [];
2554
2676
  /** Timer de resync periódico. */
@@ -2607,6 +2729,19 @@ var SyncEngine = class {
2607
2729
  this._stateListeners.delete(cb);
2608
2730
  };
2609
2731
  }
2732
+ /**
2733
+ * Registra um hook de erro de sync (P2-C). Disparado quando um ciclo falha
2734
+ * (exceção inesperada nas fases, falha de pull/resync ou dead-letter de uma
2735
+ * mutação-veneno) — o que antes era engolido com "log silencioso".
2736
+ * O mesmo diagnóstico também aparece em `SyncState.lastError`.
2737
+ * Retorna uma função de unsubscribe.
2738
+ */
2739
+ onError(cb) {
2740
+ this._errorListeners.add(cb);
2741
+ return () => {
2742
+ this._errorListeners.delete(cb);
2743
+ };
2744
+ }
2610
2745
  /**
2611
2746
  * Força um sync imediato (I3 §10.3 `flush()`).
2612
2747
  * Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
@@ -2639,8 +2774,10 @@ var SyncEngine = class {
2639
2774
  }
2640
2775
  await this._phase2Pull();
2641
2776
  this._phase3Realtime();
2642
- this._updateState({ status: "idle", lastSyncedAt: Date.now() });
2777
+ this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
2643
2778
  } catch (err) {
2779
+ const msg = err instanceof Error ? err.message : String(err);
2780
+ this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
2644
2781
  this._updateState({ status: "idle" });
2645
2782
  } finally {
2646
2783
  this._syncing = false;
@@ -2675,6 +2812,9 @@ var SyncEngine = class {
2675
2812
  async _phase1Push() {
2676
2813
  const mutations = await this._queue.drain();
2677
2814
  if (mutations.length === 0) return true;
2815
+ if (this._isInBackoff(mutations[0])) {
2816
+ return true;
2817
+ }
2678
2818
  for (const mut of mutations) {
2679
2819
  await this._queue.markInflight(mut.mutationId);
2680
2820
  }
@@ -2682,10 +2822,16 @@ var SyncEngine = class {
2682
2822
  try {
2683
2823
  result = await this._transport.pushMutations(mutations);
2684
2824
  } catch (err) {
2685
- for (const mut of mutations) {
2686
- const errMsg = err instanceof Error ? err.message : String(err);
2687
- await this._queue.markRetry(mut.mutationId, errMsg);
2688
- }
2825
+ const errMsg = err instanceof Error ? err.message : String(err);
2826
+ const busChangesOnThrow = [];
2827
+ const [head, ...behind] = mutations;
2828
+ await this._retryOrDeadLetter(head, errMsg, busChangesOnThrow);
2829
+ for (const mut of behind) {
2830
+ await this._queue.requeueInflight(mut.mutationId);
2831
+ }
2832
+ if (busChangesOnThrow.length > 0) this._bus.emit(busChangesOnThrow);
2833
+ this._refreshPendingWrites().catch(() => {
2834
+ });
2689
2835
  return false;
2690
2836
  }
2691
2837
  const busChanges = [];
@@ -2712,8 +2858,16 @@ var SyncEngine = class {
2712
2858
  }
2713
2859
  const processedIds = new Set(result.results.map((r) => r.mutationId));
2714
2860
  const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
2715
- for (const mut of unprocessed) {
2716
- await this._queue.markRetry(mut.mutationId, "partial_push: muta\xE7\xE3o n\xE3o processada no lote");
2861
+ if (unprocessed.length > 0) {
2862
+ const [boundary, ...behind] = unprocessed;
2863
+ await this._retryOrDeadLetter(
2864
+ boundary,
2865
+ "partial_push: muta\xE7\xE3o n\xE3o processada no lote",
2866
+ busChanges
2867
+ );
2868
+ for (const mut of behind) {
2869
+ await this._queue.requeueInflight(mut.mutationId);
2870
+ }
2717
2871
  }
2718
2872
  if (busChanges.length > 0) {
2719
2873
  this._bus.emit(busChanges);
@@ -2905,6 +3059,67 @@ var SyncEngine = class {
2905
3059
  });
2906
3060
  }
2907
3061
  }
3062
+ // ─── Dead-letter + backoff (P1-C) ────────────────────────────────────────────
3063
+ /**
3064
+ * Trata a falha transiente de UMA mutação que foi realmente tentada.
3065
+ *
3066
+ * - Se ainda há orçamento de tentativas → `markRetry` (volta pra fila com
3067
+ * backoff, `attempts++`).
3068
+ * - Se esgotou (`attempts+1 >= MAX_MUTATION_ATTEMPTS`) → DEAD-LETTER:
3069
+ * `markFailed` (sai do conjunto `queued`, então `drain()` para de devolvê-la
3070
+ * e a fila FIFO DESTRAVA para as demais coleções) + grava um ConflictRecord
3071
+ * `failed_max_attempts` (o produto vê via `getConflicts()`) + limpa o estado
3072
+ * pending do doc no cache + registra `lastError`/dispara `onError`.
3073
+ */
3074
+ async _retryOrDeadLetter(mut, errMsg, busChanges) {
3075
+ if (mut.attempts + 1 < MAX_MUTATION_ATTEMPTS) {
3076
+ await this._queue.markRetry(mut.mutationId, errMsg);
3077
+ return;
3078
+ }
3079
+ await this._queue.markFailed(mut.mutationId, errMsg);
3080
+ const conflictRecord = {
3081
+ collection: mut.collection,
3082
+ docId: mut.docId,
3083
+ mutationId: mut.mutationId,
3084
+ losingData: mut.payload ?? {},
3085
+ winningData: {},
3086
+ reason: "failed_max_attempts",
3087
+ detectedAt: Date.now(),
3088
+ delivered: false
3089
+ };
3090
+ await this._store.appendConflict(conflictRecord);
3091
+ const existing = await this._store.getDoc(mut.collection, mut.docId);
3092
+ if (existing) {
3093
+ await this._store.putDoc({
3094
+ ...existing,
3095
+ meta: { ...existing.meta, state: "synced", pendingMutationIds: [] }
3096
+ });
3097
+ busChanges.push({
3098
+ type: "modified",
3099
+ collection: mut.collection,
3100
+ doc: { id: mut.docId, data: existing.data }
3101
+ });
3102
+ }
3103
+ this._recordSyncError(
3104
+ "push",
3105
+ `muta\xE7\xE3o ${mut.mutationId} (${mut.collection}/${mut.docId}) descartada ap\xF3s ${mut.attempts + 1} tentativas: ${errMsg}`
3106
+ );
3107
+ }
3108
+ /**
3109
+ * `true` se a mutação (head do FIFO) ainda está dentro da janela de backoff
3110
+ * desde a última tentativa falha. Só se aplica a mutações já tentadas
3111
+ * (`attempts > 0` e `lastAttemptAt` carimbado).
3112
+ */
3113
+ _isInBackoff(mut) {
3114
+ if (mut.attempts <= 0) return false;
3115
+ const last = mut.lastAttemptAt;
3116
+ if (typeof last !== "number") return false;
3117
+ return Date.now() < last + this._backoffDelayMs(mut.attempts);
3118
+ }
3119
+ /** Backoff exponencial keyed em `attempts`, com teto `BACKOFF_CAP_MS`. */
3120
+ _backoffDelayMs(attempts) {
3121
+ return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_CAP_MS);
3122
+ }
2908
3123
  // ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
2909
3124
  /**
2910
3125
  * Busca mudanças do servidor e aplica ao cache via ConflictResolver.
@@ -2944,10 +3159,14 @@ var SyncEngine = class {
2944
3159
  }
2945
3160
  const busChanges = [];
2946
3161
  await this._applyServerDocs(resyncResult.docs, busChanges, true);
3162
+ const truncatedCollections = resyncResult.truncatedCollections ?? [];
3163
+ const truncatedSet = new Set(truncatedCollections);
3164
+ await this._store.setMeta(TRUNCATED_COLLECTIONS_META_KEY, truncatedCollections);
2947
3165
  const returnedKeys = new Set(
2948
3166
  resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
2949
3167
  );
2950
3168
  for (const col of collections) {
3169
+ if (truncatedSet.has(col)) continue;
2951
3170
  const queryResult = await this._store.listDocs(col, {});
2952
3171
  for (const { id } of queryResult.docs) {
2953
3172
  const key = `${col}::${id}`;
@@ -3107,7 +3326,8 @@ var SyncEngine = class {
3107
3326
  status: isOnline ? "idle" : "offline",
3108
3327
  pendingWrites: 0,
3109
3328
  lastSyncedAt: null,
3110
- isLeaderTab: this._tabCoordinator.isLeader()
3329
+ isLeaderTab: this._tabCoordinator.isLeader(),
3330
+ lastError: null
3111
3331
  };
3112
3332
  }
3113
3333
  /**
@@ -3117,7 +3337,7 @@ var SyncEngine = class {
3117
3337
  _updateState(partial) {
3118
3338
  const prev = this._state;
3119
3339
  const next = { ...prev, ...partial };
3120
- const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
3340
+ const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab || (prev.lastError ?? null) !== (next.lastError ?? null);
3121
3341
  this._state = next;
3122
3342
  if (changed) {
3123
3343
  this._emitState(next);
@@ -3131,6 +3351,21 @@ var SyncEngine = class {
3131
3351
  }
3132
3352
  }
3133
3353
  }
3354
+ /**
3355
+ * P2-C: registra a falha de um ciclo de sync — antes engolida em silêncio.
3356
+ * Publica em `SyncState.lastError` (visível via `onSyncStateChanged`) e
3357
+ * dispara o hook `onError`. Nunca lança (diagnóstico é best-effort).
3358
+ */
3359
+ _recordSyncError(phase, message) {
3360
+ this._updateState({ lastError: message });
3361
+ const event = { phase, message, at: Date.now() };
3362
+ for (const listener of this._errorListeners) {
3363
+ try {
3364
+ listener({ ...event });
3365
+ } catch {
3366
+ }
3367
+ }
3368
+ }
3134
3369
  /**
3135
3370
  * Atualiza `pendingWrites` no estado.
3136
3371
  * Chamado de forma assíncrona quando necessário.
@@ -3940,7 +4175,9 @@ var DbCollectionRefImpl = class {
3940
4175
  const isOnline = this._isOnline();
3941
4176
  const syncState = this._engine.getSyncState();
3942
4177
  const nextCursor = buildNextCursor(result.docs, effectiveLimit);
3943
- const stale = !isOnline || syncState.status === "offline";
4178
+ const truncatedRaw = await this._store.getMeta(TRUNCATED_COLLECTIONS_META_KEY);
4179
+ const truncated = Array.isArray(truncatedRaw) && truncatedRaw.includes(this._collection);
4180
+ const stale = !isOnline || syncState.status === "offline" || truncated;
3944
4181
  const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
3945
4182
  return {
3946
4183
  docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
@@ -3948,7 +4185,8 @@ var DbCollectionRefImpl = class {
3948
4185
  fromCache,
3949
4186
  stale,
3950
4187
  hasPendingWrites: hasPending,
3951
- changes: []
4188
+ changes: [],
4189
+ truncated
3952
4190
  };
3953
4191
  }
3954
4192
  // ── CRUD ────────────────────────────────────────────────────────────────────
@@ -4394,6 +4632,9 @@ var NeetruDbDocumentsImpl = class {
4394
4632
  async getConflicts() {
4395
4633
  return this._store.listConflicts();
4396
4634
  }
4635
+ onError(cb) {
4636
+ return this._engine.onError(cb);
4637
+ }
4397
4638
  };
4398
4639
  async function createOfflineDocumentsNamespace(opts) {
4399
4640
  const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
@@ -4869,6 +5110,7 @@ var defaultWebSocketFactory = (url) => {
4869
5110
  // src/db/client-db.ts
4870
5111
  var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
4871
5112
  var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
5113
+ var DOCUMENTS_RESYNC_PAGE_CAP = 200;
4872
5114
  function resolveTransport(transport, opts, config) {
4873
5115
  if (opts._transport) {
4874
5116
  return opts._transport;
@@ -4981,6 +5223,7 @@ function createRestSyncTransport(config) {
4981
5223
  const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
4982
5224
  const now = Date.now();
4983
5225
  const docs = [];
5226
+ const truncatedCollections = [];
4984
5227
  for (const collection of collections) {
4985
5228
  let raw;
4986
5229
  try {
@@ -4990,11 +5233,11 @@ function createRestSyncTransport(config) {
4990
5233
  requireAuth: true,
4991
5234
  retries: 2,
4992
5235
  headers: tenantHeaders,
4993
- // Teto do Core é 200. Coleções > 200 docs truncam silenciosamente
4994
- // aqui (paginação cursor é tarefa Core separada) — a detecção de
4995
- // deleção-por-ausência do SyncEngine NÃO deve rodar sobre truncamento,
4996
- // então mantemos o limite no máximo do servidor por ora.
4997
- query: { limit: 200 }
5236
+ // Teto do Core (DOCUMENTS_RESYNC_PAGE_CAP). Coleções acima dele vêm
5237
+ // TRUNCADAS — reportadas em `truncatedCollections` p/ o SyncEngine
5238
+ // (a) NÃO rodar deleção-por-ausência e (b) sinalizar
5239
+ // `DbListResult.truncated`. Paginação por cursor é tarefa Core (gated).
5240
+ query: { limit: DOCUMENTS_RESYNC_PAGE_CAP }
4998
5241
  });
4999
5242
  } catch (err) {
5000
5243
  const msg = err instanceof Error ? err.message : String(err);
@@ -5005,6 +5248,9 @@ function createRestSyncTransport(config) {
5005
5248
  }
5006
5249
  const resp = raw;
5007
5250
  const items = Array.isArray(resp?.items) ? resp.items : [];
5251
+ if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
5252
+ truncatedCollections.push(collection);
5253
+ }
5008
5254
  for (const item of items) {
5009
5255
  if (!item || typeof item !== "object") continue;
5010
5256
  const record = item;
@@ -5032,7 +5278,7 @@ function createRestSyncTransport(config) {
5032
5278
  });
5033
5279
  }
5034
5280
  }
5035
- return { docs, newWatermark: now };
5281
+ return { docs, newWatermark: now, truncatedCollections };
5036
5282
  }
5037
5283
  };
5038
5284
  }
@@ -5094,7 +5340,20 @@ function buildTicketProvider(config, opts) {
5094
5340
  return ticket;
5095
5341
  };
5096
5342
  }
5343
+ var _hasWarnedDbIdIgnored = false;
5344
+ function warnDbIdIgnoredOnce(config, dbOpts) {
5345
+ if (_hasWarnedDbIdIgnored) return;
5346
+ if (!dbOpts.dbId) return;
5347
+ if (config.env === "prod") return;
5348
+ if (typeof window === "undefined") return;
5349
+ if (typeof console === "undefined" || typeof console.warn !== "function") return;
5350
+ _hasWarnedDbIdIgnored = true;
5351
+ console.warn(
5352
+ '[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.'
5353
+ );
5354
+ }
5097
5355
  function createNeetruDb(config, dbOpts = {}) {
5356
+ warnDbIdIgnoredOnce(config, dbOpts);
5098
5357
  let resolvedTransport;
5099
5358
  if (dbOpts.transport !== void 0) {
5100
5359
  resolvedTransport = dbOpts.transport;
@@ -5172,6 +5431,20 @@ function createNeetruDb(config, dbOpts = {}) {
5172
5431
  async getConflicts() {
5173
5432
  const docs = await getDocsPromise();
5174
5433
  return docs.getConflicts();
5434
+ },
5435
+ onError(cb) {
5436
+ if (_docsResolved) return _docsResolved.onError(cb);
5437
+ let unsub = null;
5438
+ let cancelled = false;
5439
+ getDocsPromise().then((docs) => {
5440
+ if (!cancelled) {
5441
+ unsub = docs.onError(cb);
5442
+ }
5443
+ });
5444
+ return () => {
5445
+ cancelled = true;
5446
+ unsub?.();
5447
+ };
5175
5448
  }
5176
5449
  };
5177
5450
  }
@@ -6517,7 +6790,18 @@ var MockSupport = class {
6517
6790
  severity: input.severity ?? "normal",
6518
6791
  status: "open",
6519
6792
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6520
- productSlug: input.productSlug
6793
+ productSlug: input.productSlug,
6794
+ // 3.4 (product-feedback): espelha o contrato — campos novos nullable,
6795
+ // populados a partir do input quando fornecidos.
6796
+ pageUrl: typeof input.pageUrl === "string" ? input.pageUrl : null,
6797
+ screenshotUrl: typeof input.screenshotBase64 === "string" ? input.screenshotBase64 : null,
6798
+ resolvedAt: null,
6799
+ resolvedNotifiedAt: null,
6800
+ satisfactionAttempts: 0,
6801
+ lastSatisfactionAttemptAt: null,
6802
+ satisfiedAt: null,
6803
+ rating: null,
6804
+ satisfactionComment: null
6521
6805
  };
6522
6806
  this._tickets.push(ticket);
6523
6807
  return ticket;
@@ -6528,6 +6812,62 @@ var MockSupport = class {
6528
6812
  }
6529
6813
  return [...this._tickets];
6530
6814
  }
6815
+ _find(ticketId) {
6816
+ if (!ticketId || typeof ticketId !== "string") {
6817
+ throw new NeetruError("validation_failed", "ticketId is required");
6818
+ }
6819
+ const ticket = this._tickets.find((t) => t.id === ticketId);
6820
+ if (!ticket) {
6821
+ throw new NeetruError("not_found", `ticket ${ticketId} not found`);
6822
+ }
6823
+ return ticket;
6824
+ }
6825
+ /** 3.4: espelha `markResolvedSeen` — idempotente (só seta se ainda null). */
6826
+ async markResolvedSeen(ticketId) {
6827
+ const ticket = this._find(ticketId);
6828
+ if (ticket.resolvedNotifiedAt === null) {
6829
+ ticket.resolvedNotifiedAt = (/* @__PURE__ */ new Date()).toISOString();
6830
+ }
6831
+ }
6832
+ /** 3.4: espelha `recordSatisfactionAttempt` — incrementa contador. */
6833
+ async recordSatisfactionAttempt(ticketId) {
6834
+ const ticket = this._find(ticketId);
6835
+ ticket.satisfactionAttempts += 1;
6836
+ ticket.lastSatisfactionAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
6837
+ }
6838
+ /** 3.4: espelha `submitSatisfaction` — terminal, seta `satisfiedAt`. */
6839
+ async submitSatisfaction(input) {
6840
+ if (!input || typeof input !== "object") {
6841
+ throw new NeetruError("validation_failed", "input is required");
6842
+ }
6843
+ if (input.rating !== null && input.rating !== void 0) {
6844
+ if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
6845
+ throw new NeetruError("validation_failed", "rating must be an integer 1-5 or null");
6846
+ }
6847
+ }
6848
+ if (typeof input.comment === "string" && input.comment.length > 2e3) {
6849
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
6850
+ }
6851
+ const ticket = this._find(input.ticketId);
6852
+ ticket.satisfiedAt = (/* @__PURE__ */ new Date()).toISOString();
6853
+ ticket.rating = input.rating ?? null;
6854
+ ticket.satisfactionComment = input.comment ?? null;
6855
+ }
6856
+ /** 3.4: espelha `contestTicket` — reabre e zera o ciclo de satisfação. */
6857
+ async contestTicket(ticketId, opts) {
6858
+ if (typeof opts?.comment === "string" && opts.comment.length > 2e3) {
6859
+ throw new NeetruError("validation_failed", "comment max 2000 chars");
6860
+ }
6861
+ const ticket = this._find(ticketId);
6862
+ ticket.status = "open";
6863
+ ticket.resolvedAt = null;
6864
+ ticket.resolvedNotifiedAt = null;
6865
+ ticket.satisfactionAttempts = 0;
6866
+ ticket.lastSatisfactionAttemptAt = null;
6867
+ ticket.satisfiedAt = null;
6868
+ ticket.rating = null;
6869
+ ticket.satisfactionComment = null;
6870
+ }
6531
6871
  /** Test helper. */
6532
6872
  __reset() {
6533
6873
  this._tickets = [];