@neetru/sdk 3.4.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 (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/ai.d.cts +1 -1
  3. package/dist/ai.d.ts +1 -1
  4. package/dist/auth.cjs +193 -30
  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 +193 -30
  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 +193 -30
  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 +193 -30
  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 +194 -31
  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 +194 -31
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/mocks.d.cts +1 -1
  33. package/dist/mocks.d.ts +1 -1
  34. package/dist/notifications.d.cts +1 -1
  35. package/dist/notifications.d.ts +1 -1
  36. package/dist/react.d.cts +1 -1
  37. package/dist/react.d.ts +1 -1
  38. package/dist/support.d.cts +1 -1
  39. package/dist/support.d.ts +1 -1
  40. package/dist/telemetry.d.cts +1 -1
  41. package/dist/telemetry.d.ts +1 -1
  42. package/dist/{types-DdJyqBy-.d.ts → types-BlOLDMR0.d.ts} +79 -6
  43. package/dist/{types-BoFSCHF_.d.cts → types-D-QdG502.d.cts} +79 -6
  44. package/dist/usage.d.cts +1 -1
  45. package/dist/usage.d.ts +1 -1
  46. package/dist/webhooks.d.cts +1 -1
  47. package/dist/webhooks.d.ts +1 -1
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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
+
8
26
  ## [3.4.0] - 2026-08-15 — **feat(support): superfície "Product Feedback" — markResolvedSeen/recordSatisfactionAttempt/submitSatisfaction/contestTicket**
9
27
 
10
28
  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
@@ -2543,8 +2543,10 @@ var MutationQueue = class {
2543
2543
  await this.store.deleteMutation(mutationId);
2544
2544
  }
2545
2545
  /**
2546
- * Marca uma mutação como `failed` e registra o erro.
2547
- * 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.
2548
2550
  */
2549
2551
  async markFailed(mutationId, error) {
2550
2552
  const mut = await this.store.getMutation(mutationId);
@@ -2553,12 +2555,14 @@ var MutationQueue = class {
2553
2555
  ...mut,
2554
2556
  status: "failed",
2555
2557
  lastError: error,
2556
- attempts: mut.attempts + 1
2558
+ attempts: mut.attempts + 1,
2559
+ lastAttemptAt: Date.now()
2557
2560
  });
2558
2561
  }
2559
2562
  /**
2560
- * Incrementa `attempts` e volta para `queued` (falha transiente com backoff).
2561
- * 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.
2562
2566
  */
2563
2567
  async markRetry(mutationId, error) {
2564
2568
  const mut = await this.store.getMutation(mutationId);
@@ -2567,9 +2571,24 @@ var MutationQueue = class {
2567
2571
  ...mut,
2568
2572
  status: "queued",
2569
2573
  lastError: error,
2570
- attempts: mut.attempts + 1
2574
+ attempts: mut.attempts + 1,
2575
+ lastAttemptAt: Date.now()
2571
2576
  });
2572
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
+ }
2573
2592
  };
2574
2593
 
2575
2594
  // src/db/field-value.ts
@@ -2614,6 +2633,10 @@ function hasFieldSentinel(data) {
2614
2633
  }
2615
2634
 
2616
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";
2617
2640
  function hasSentinels(payload) {
2618
2641
  return payload !== null && hasFieldSentinel(payload);
2619
2642
  }
@@ -2648,6 +2671,8 @@ var SyncEngine = class {
2648
2671
  _state;
2649
2672
  /** Listeners de mudança de SyncState. */
2650
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();
2651
2676
  /** Cleanup functions dos listeners externos. */
2652
2677
  _cleanups = [];
2653
2678
  /** Timer de resync periódico. */
@@ -2706,6 +2731,19 @@ var SyncEngine = class {
2706
2731
  this._stateListeners.delete(cb);
2707
2732
  };
2708
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
+ }
2709
2747
  /**
2710
2748
  * Força um sync imediato (I3 §10.3 `flush()`).
2711
2749
  * Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
@@ -2738,8 +2776,10 @@ var SyncEngine = class {
2738
2776
  }
2739
2777
  await this._phase2Pull();
2740
2778
  this._phase3Realtime();
2741
- this._updateState({ status: "idle", lastSyncedAt: Date.now() });
2779
+ this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
2742
2780
  } catch (err) {
2781
+ const msg = err instanceof Error ? err.message : String(err);
2782
+ this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
2743
2783
  this._updateState({ status: "idle" });
2744
2784
  } finally {
2745
2785
  this._syncing = false;
@@ -2774,6 +2814,9 @@ var SyncEngine = class {
2774
2814
  async _phase1Push() {
2775
2815
  const mutations = await this._queue.drain();
2776
2816
  if (mutations.length === 0) return true;
2817
+ if (this._isInBackoff(mutations[0])) {
2818
+ return true;
2819
+ }
2777
2820
  for (const mut of mutations) {
2778
2821
  await this._queue.markInflight(mut.mutationId);
2779
2822
  }
@@ -2781,10 +2824,16 @@ var SyncEngine = class {
2781
2824
  try {
2782
2825
  result = await this._transport.pushMutations(mutations);
2783
2826
  } 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
- }
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
+ });
2788
2837
  return false;
2789
2838
  }
2790
2839
  const busChanges = [];
@@ -2811,8 +2860,16 @@ var SyncEngine = class {
2811
2860
  }
2812
2861
  const processedIds = new Set(result.results.map((r) => r.mutationId));
2813
2862
  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");
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
+ }
2816
2873
  }
2817
2874
  if (busChanges.length > 0) {
2818
2875
  this._bus.emit(busChanges);
@@ -3004,6 +3061,67 @@ var SyncEngine = class {
3004
3061
  });
3005
3062
  }
3006
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
+ }
3007
3125
  // ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
3008
3126
  /**
3009
3127
  * Busca mudanças do servidor e aplica ao cache via ConflictResolver.
@@ -3043,10 +3161,14 @@ var SyncEngine = class {
3043
3161
  }
3044
3162
  const busChanges = [];
3045
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);
3046
3167
  const returnedKeys = new Set(
3047
3168
  resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
3048
3169
  );
3049
3170
  for (const col of collections) {
3171
+ if (truncatedSet.has(col)) continue;
3050
3172
  const queryResult = await this._store.listDocs(col, {});
3051
3173
  for (const { id } of queryResult.docs) {
3052
3174
  const key = `${col}::${id}`;
@@ -3206,7 +3328,8 @@ var SyncEngine = class {
3206
3328
  status: isOnline ? "idle" : "offline",
3207
3329
  pendingWrites: 0,
3208
3330
  lastSyncedAt: null,
3209
- isLeaderTab: this._tabCoordinator.isLeader()
3331
+ isLeaderTab: this._tabCoordinator.isLeader(),
3332
+ lastError: null
3210
3333
  };
3211
3334
  }
3212
3335
  /**
@@ -3216,7 +3339,7 @@ var SyncEngine = class {
3216
3339
  _updateState(partial) {
3217
3340
  const prev = this._state;
3218
3341
  const next = { ...prev, ...partial };
3219
- 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);
3220
3343
  this._state = next;
3221
3344
  if (changed) {
3222
3345
  this._emitState(next);
@@ -3230,6 +3353,21 @@ var SyncEngine = class {
3230
3353
  }
3231
3354
  }
3232
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
+ }
3233
3371
  /**
3234
3372
  * Atualiza `pendingWrites` no estado.
3235
3373
  * Chamado de forma assíncrona quando necessário.
@@ -4039,7 +4177,9 @@ var DbCollectionRefImpl = class {
4039
4177
  const isOnline = this._isOnline();
4040
4178
  const syncState = this._engine.getSyncState();
4041
4179
  const nextCursor = buildNextCursor(result.docs, effectiveLimit);
4042
- 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;
4043
4183
  const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
4044
4184
  return {
4045
4185
  docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
@@ -4047,7 +4187,8 @@ var DbCollectionRefImpl = class {
4047
4187
  fromCache,
4048
4188
  stale,
4049
4189
  hasPendingWrites: hasPending,
4050
- changes: []
4190
+ changes: [],
4191
+ truncated
4051
4192
  };
4052
4193
  }
4053
4194
  // ── CRUD ────────────────────────────────────────────────────────────────────
@@ -4493,6 +4634,9 @@ var NeetruDbDocumentsImpl = class {
4493
4634
  async getConflicts() {
4494
4635
  return this._store.listConflicts();
4495
4636
  }
4637
+ onError(cb) {
4638
+ return this._engine.onError(cb);
4639
+ }
4496
4640
  };
4497
4641
  async function createOfflineDocumentsNamespace(opts) {
4498
4642
  const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
@@ -4968,6 +5112,7 @@ var defaultWebSocketFactory = (url) => {
4968
5112
  // src/db/client-db.ts
4969
5113
  var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
4970
5114
  var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
5115
+ var DOCUMENTS_RESYNC_PAGE_CAP = 200;
4971
5116
  function resolveTransport(transport, opts, config) {
4972
5117
  if (opts._transport) {
4973
5118
  return opts._transport;
@@ -5080,6 +5225,7 @@ function createRestSyncTransport(config) {
5080
5225
  const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
5081
5226
  const now = Date.now();
5082
5227
  const docs = [];
5228
+ const truncatedCollections = [];
5083
5229
  for (const collection of collections) {
5084
5230
  let raw;
5085
5231
  try {
@@ -5089,11 +5235,11 @@ function createRestSyncTransport(config) {
5089
5235
  requireAuth: true,
5090
5236
  retries: 2,
5091
5237
  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 }
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 }
5097
5243
  });
5098
5244
  } catch (err) {
5099
5245
  const msg = err instanceof Error ? err.message : String(err);
@@ -5104,6 +5250,9 @@ function createRestSyncTransport(config) {
5104
5250
  }
5105
5251
  const resp = raw;
5106
5252
  const items = Array.isArray(resp?.items) ? resp.items : [];
5253
+ if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
5254
+ truncatedCollections.push(collection);
5255
+ }
5107
5256
  for (const item of items) {
5108
5257
  if (!item || typeof item !== "object") continue;
5109
5258
  const record = item;
@@ -5131,7 +5280,7 @@ function createRestSyncTransport(config) {
5131
5280
  });
5132
5281
  }
5133
5282
  }
5134
- return { docs, newWatermark: now };
5283
+ return { docs, newWatermark: now, truncatedCollections };
5135
5284
  }
5136
5285
  };
5137
5286
  }
@@ -5193,20 +5342,20 @@ function buildTicketProvider(config, opts) {
5193
5342
  return ticket;
5194
5343
  };
5195
5344
  }
5196
- var _hasWarnedMissingDbId = false;
5197
- function warnMissingDbIdOnce(config, dbOpts) {
5198
- if (_hasWarnedMissingDbId) return;
5199
- if (dbOpts.dbId) return;
5345
+ var _hasWarnedDbIdIgnored = false;
5346
+ function warnDbIdIgnoredOnce(config, dbOpts) {
5347
+ if (_hasWarnedDbIdIgnored) return;
5348
+ if (!dbOpts.dbId) return;
5200
5349
  if (config.env === "prod") return;
5201
5350
  if (typeof window === "undefined") return;
5202
5351
  if (typeof console === "undefined" || typeof console.warn !== "function") return;
5203
- _hasWarnedMissingDbId = true;
5352
+ _hasWarnedDbIdIgnored = true;
5204
5353
  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."
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.'
5206
5355
  );
5207
5356
  }
5208
5357
  function createNeetruDb(config, dbOpts = {}) {
5209
- warnMissingDbIdOnce(config, dbOpts);
5358
+ warnDbIdIgnoredOnce(config, dbOpts);
5210
5359
  let resolvedTransport;
5211
5360
  if (dbOpts.transport !== void 0) {
5212
5361
  resolvedTransport = dbOpts.transport;
@@ -5284,6 +5433,20 @@ function createNeetruDb(config, dbOpts = {}) {
5284
5433
  async getConflicts() {
5285
5434
  const docs = await getDocsPromise();
5286
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
+ };
5287
5450
  }
5288
5451
  };
5289
5452
  }