@neetru/sdk 3.4.0 → 3.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/ai.d.cts +1 -1
  3. package/dist/ai.d.ts +1 -1
  4. package/dist/auth.cjs +230 -38
  5. package/dist/auth.cjs.map +1 -1
  6. package/dist/auth.d.cts +1 -1
  7. package/dist/auth.d.ts +1 -1
  8. package/dist/auth.mjs +230 -38
  9. package/dist/auth.mjs.map +1 -1
  10. package/dist/build-guard.cjs +41 -6
  11. package/dist/build-guard.cjs.map +1 -1
  12. package/dist/build-guard.d.cts +24 -1
  13. package/dist/build-guard.d.ts +24 -1
  14. package/dist/build-guard.mjs +40 -7
  15. package/dist/build-guard.mjs.map +1 -1
  16. package/dist/catalog.d.cts +1 -1
  17. package/dist/catalog.d.ts +1 -1
  18. package/dist/checkout.d.cts +1 -1
  19. package/dist/checkout.d.ts +1 -1
  20. package/dist/db-react.d.cts +1 -1
  21. package/dist/db-react.d.ts +1 -1
  22. package/dist/db.cjs +230 -38
  23. package/dist/db.cjs.map +1 -1
  24. package/dist/db.d.cts +1 -1
  25. package/dist/db.d.ts +1 -1
  26. package/dist/db.mjs +230 -38
  27. package/dist/db.mjs.map +1 -1
  28. package/dist/entitlements.d.cts +1 -1
  29. package/dist/entitlements.d.ts +1 -1
  30. package/dist/firestore-compat.d.cts +1 -1
  31. package/dist/firestore-compat.d.ts +1 -1
  32. package/dist/index.cjs +231 -39
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +2 -2
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.mjs +231 -39
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/mocks.d.cts +1 -1
  39. package/dist/mocks.d.ts +1 -1
  40. package/dist/notifications.d.cts +1 -1
  41. package/dist/notifications.d.ts +1 -1
  42. package/dist/react.cjs +593 -31
  43. package/dist/react.cjs.map +1 -1
  44. package/dist/react.d.cts +97 -2
  45. package/dist/react.d.ts +97 -2
  46. package/dist/react.mjs +590 -31
  47. package/dist/react.mjs.map +1 -1
  48. package/dist/support.d.cts +1 -1
  49. package/dist/support.d.ts +1 -1
  50. package/dist/telemetry.d.cts +1 -1
  51. package/dist/telemetry.d.ts +1 -1
  52. package/dist/{types-DdJyqBy-.d.ts → types-BlOLDMR0.d.ts} +79 -6
  53. package/dist/{types-BoFSCHF_.d.cts → types-D-QdG502.d.cts} +79 -6
  54. package/dist/usage.d.cts +1 -1
  55. package/dist/usage.d.ts +1 -1
  56. package/dist/webhooks.d.cts +1 -1
  57. package/dist/webhooks.d.ts +1 -1
  58. package/package.json +1 -1
package/dist/auth.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { X as NeetruClientConfig, W as NeetruClient } from './types-BoFSCHF_.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-DdJyqBy-.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
@@ -682,13 +682,17 @@ var init_lease = __esm({
682
682
  // src/db/sql/sql-client.ts
683
683
  var sql_client_exports = {};
684
684
  __export(sql_client_exports, {
685
+ DEFAULT_POOL_CONNECT_TIMEOUT_MS: () => DEFAULT_POOL_CONNECT_TIMEOUT_MS,
686
+ DEFAULT_POOL_IDLE_MS: () => DEFAULT_POOL_IDLE_MS,
687
+ DEFAULT_POOL_MAX: () => DEFAULT_POOL_MAX,
685
688
  LEASE_ENDPOINT: () => LEASE_ENDPOINT,
686
689
  LEASE_RENEW_ENDPOINT: () => LEASE_RENEW_ENDPOINT,
687
690
  createDrizzleHandle: () => createDrizzleHandle,
688
691
  createHttpLeaseFetcher: () => createHttpLeaseFetcher,
689
692
  createPgPool: () => createPgPool,
690
693
  createSqlClientFromConfig: () => createSqlClientFromConfig,
691
- createSqlClientWithDeps: () => createSqlClientWithDeps
694
+ createSqlClientWithDeps: () => createSqlClientWithDeps,
695
+ resolvePoolTuning: () => resolvePoolTuning
692
696
  });
693
697
  function mapEnvToCore(sdkEnv) {
694
698
  if (sdkEnv === "workspace") return "staging";
@@ -777,6 +781,30 @@ function parseLease(raw) {
777
781
  expiresAt: req("expiresAt", "string")
778
782
  };
779
783
  }
784
+ function readEnvInt(name, def, min, max) {
785
+ const raw = typeof process !== "undefined" ? process.env?.[name] : void 0;
786
+ if (raw === void 0 || raw.trim() === "") return def;
787
+ const n = Number.parseInt(raw.trim(), 10);
788
+ if (!Number.isFinite(n) || n < min || n > max) return def;
789
+ return n;
790
+ }
791
+ function resolvePoolTuning() {
792
+ return {
793
+ // Teto 100: bem abaixo do `max_client_conn = 500` do PgBouncer, com folga
794
+ // para múltiplas instâncias do app dividirem o mesmo front-door.
795
+ max: readEnvInt("NEETRU_DB_POOL_MAX", DEFAULT_POOL_MAX, 1, 100),
796
+ // Teto 15min = janela do cert; piso 1s.
797
+ idleTimeoutMillis: readEnvInt("NEETRU_DB_POOL_IDLE_MS", DEFAULT_POOL_IDLE_MS, 1e3, 9e5),
798
+ connectionTimeoutMillis: readEnvInt(
799
+ "NEETRU_DB_POOL_CONNECT_TIMEOUT_MS",
800
+ DEFAULT_POOL_CONNECT_TIMEOUT_MS,
801
+ 1e3,
802
+ 12e4
803
+ ),
804
+ keepAlive: true,
805
+ keepAliveInitialDelayMillis: 1e4
806
+ };
807
+ }
780
808
  async function createPgPool(lease) {
781
809
  const { Pool } = await import(
782
810
  /* webpackIgnore: true */
@@ -795,9 +823,7 @@ async function createPgPool(lease) {
795
823
  user: lease.user,
796
824
  password: lease.password,
797
825
  ssl,
798
- max: 3,
799
- idleTimeoutMillis: 3e4,
800
- connectionTimeoutMillis: 1e4
826
+ ...resolvePoolTuning()
801
827
  });
802
828
  }
803
829
  async function createDrizzleHandle(pool, schema) {
@@ -932,9 +958,9 @@ async function createSqlClientFromConfig(config, schema, options) {
932
958
  user: lease.user,
933
959
  password: lease.password,
934
960
  ssl,
935
- max: 3,
936
- idleTimeoutMillis: 3e4,
937
- connectionTimeoutMillis: 1e4
961
+ // Tuning centralizado (env-tunável) — ver resolvePoolTuning + auditoria
962
+ // docs/_review/AUDITORIA_DB_CONN_PERF_2026-09-06.md no Core.
963
+ ...resolvePoolTuning()
938
964
  });
939
965
  };
940
966
  const createDrizzle = (pool, s) => {
@@ -957,7 +983,7 @@ async function createSqlClientFromConfig(config, schema, options) {
957
983
  options
958
984
  );
959
985
  }
960
- var LEASE_ENDPOINT, LEASE_RENEW_ENDPOINT;
986
+ var LEASE_ENDPOINT, LEASE_RENEW_ENDPOINT, DEFAULT_POOL_MAX, DEFAULT_POOL_IDLE_MS, DEFAULT_POOL_CONNECT_TIMEOUT_MS;
961
987
  var init_sql_client = __esm({
962
988
  "src/db/sql/sql-client.ts"() {
963
989
  init_db_errors();
@@ -966,6 +992,9 @@ var init_sql_client = __esm({
966
992
  init_resilient_pool();
967
993
  LEASE_ENDPOINT = "/api/sdk/v1/db/lease";
968
994
  LEASE_RENEW_ENDPOINT = "/api/sdk/v1/db/lease/renew";
995
+ DEFAULT_POOL_MAX = 10;
996
+ DEFAULT_POOL_IDLE_MS = 6e4;
997
+ DEFAULT_POOL_CONNECT_TIMEOUT_MS = 1e4;
969
998
  }
970
999
  });
971
1000
 
@@ -2541,8 +2570,10 @@ var MutationQueue = class {
2541
2570
  await this.store.deleteMutation(mutationId);
2542
2571
  }
2543
2572
  /**
2544
- * Marca uma mutação como `failed` e registra o erro.
2545
- * Incrementa `attempts`. Chamado pelo SyncEngine em falha permanente.
2573
+ * Marca uma mutação como `failed` (dead-letter) e registra o erro.
2574
+ * Incrementa `attempts` e carimba `lastAttemptAt`. Chamado pelo SyncEngine
2575
+ * quando a mutação esgota o teto de tentativas (mutação-veneno): sai do
2576
+ * conjunto `queued`, então `drain()` para de devolvê-la e a fila DESTRAVA.
2546
2577
  */
2547
2578
  async markFailed(mutationId, error) {
2548
2579
  const mut = await this.store.getMutation(mutationId);
@@ -2551,12 +2582,14 @@ var MutationQueue = class {
2551
2582
  ...mut,
2552
2583
  status: "failed",
2553
2584
  lastError: error,
2554
- attempts: mut.attempts + 1
2585
+ attempts: mut.attempts + 1,
2586
+ lastAttemptAt: Date.now()
2555
2587
  });
2556
2588
  }
2557
2589
  /**
2558
- * Incrementa `attempts` e volta para `queued` (falha transiente com backoff).
2559
- * Chamado pelo SyncEngine em falha transiente (5xx, timeout).
2590
+ * Incrementa `attempts`, carimba `lastAttemptAt` e volta para `queued`
2591
+ * (falha transiente com backoff). Chamado pelo SyncEngine em falha
2592
+ * transiente (5xx, timeout) da mutação que REALMENTE foi tentada.
2560
2593
  */
2561
2594
  async markRetry(mutationId, error) {
2562
2595
  const mut = await this.store.getMutation(mutationId);
@@ -2565,9 +2598,24 @@ var MutationQueue = class {
2565
2598
  ...mut,
2566
2599
  status: "queued",
2567
2600
  lastError: error,
2568
- attempts: mut.attempts + 1
2601
+ attempts: mut.attempts + 1,
2602
+ lastAttemptAt: Date.now()
2569
2603
  });
2570
2604
  }
2605
+ /**
2606
+ * Reverte uma mutação `inflight` de volta a `queued` SEM tocar em `attempts`
2607
+ * nem `lastAttemptAt`. Usado para mutações que foram marcadas inflight mas
2608
+ * NUNCA chegaram a ser enviadas (estavam atrás da head no FIFO e o push
2609
+ * parou antes delas): contá-las como tentativa as empurraria pro dead-letter
2610
+ * por engano, e adiaria seu envio via backoff sem que tenham falhado.
2611
+ * No-op se a mutação não está mais `inflight`.
2612
+ */
2613
+ async requeueInflight(mutationId) {
2614
+ const mut = await this.store.getMutation(mutationId);
2615
+ if (!mut) return;
2616
+ if (mut.status !== "inflight") return;
2617
+ await this.store.putMutation({ ...mut, status: "queued" });
2618
+ }
2571
2619
  };
2572
2620
 
2573
2621
  // src/db/field-value.ts
@@ -2612,6 +2660,10 @@ function hasFieldSentinel(data) {
2612
2660
  }
2613
2661
 
2614
2662
  // src/db/offline/sync-engine.ts
2663
+ var MAX_MUTATION_ATTEMPTS = 8;
2664
+ var BACKOFF_BASE_MS = 1e3;
2665
+ var BACKOFF_CAP_MS = 5 * 60 * 1e3;
2666
+ var TRUNCATED_COLLECTIONS_META_KEY = "truncatedCollections";
2615
2667
  function hasSentinels(payload) {
2616
2668
  return payload !== null && hasFieldSentinel(payload);
2617
2669
  }
@@ -2646,6 +2698,8 @@ var SyncEngine = class {
2646
2698
  _state;
2647
2699
  /** Listeners de mudança de SyncState. */
2648
2700
  _stateListeners = /* @__PURE__ */ new Set();
2701
+ /** Listeners de erro de ciclo de sync (P2-C — não mais engolido em silêncio). */
2702
+ _errorListeners = /* @__PURE__ */ new Set();
2649
2703
  /** Cleanup functions dos listeners externos. */
2650
2704
  _cleanups = [];
2651
2705
  /** Timer de resync periódico. */
@@ -2704,6 +2758,19 @@ var SyncEngine = class {
2704
2758
  this._stateListeners.delete(cb);
2705
2759
  };
2706
2760
  }
2761
+ /**
2762
+ * Registra um hook de erro de sync (P2-C). Disparado quando um ciclo falha
2763
+ * (exceção inesperada nas fases, falha de pull/resync ou dead-letter de uma
2764
+ * mutação-veneno) — o que antes era engolido com "log silencioso".
2765
+ * O mesmo diagnóstico também aparece em `SyncState.lastError`.
2766
+ * Retorna uma função de unsubscribe.
2767
+ */
2768
+ onError(cb) {
2769
+ this._errorListeners.add(cb);
2770
+ return () => {
2771
+ this._errorListeners.delete(cb);
2772
+ };
2773
+ }
2707
2774
  /**
2708
2775
  * Força um sync imediato (I3 §10.3 `flush()`).
2709
2776
  * Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
@@ -2736,8 +2803,10 @@ var SyncEngine = class {
2736
2803
  }
2737
2804
  await this._phase2Pull();
2738
2805
  this._phase3Realtime();
2739
- this._updateState({ status: "idle", lastSyncedAt: Date.now() });
2806
+ this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
2740
2807
  } catch (err) {
2808
+ const msg = err instanceof Error ? err.message : String(err);
2809
+ this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
2741
2810
  this._updateState({ status: "idle" });
2742
2811
  } finally {
2743
2812
  this._syncing = false;
@@ -2772,6 +2841,9 @@ var SyncEngine = class {
2772
2841
  async _phase1Push() {
2773
2842
  const mutations = await this._queue.drain();
2774
2843
  if (mutations.length === 0) return true;
2844
+ if (this._isInBackoff(mutations[0])) {
2845
+ return true;
2846
+ }
2775
2847
  for (const mut of mutations) {
2776
2848
  await this._queue.markInflight(mut.mutationId);
2777
2849
  }
@@ -2779,10 +2851,16 @@ var SyncEngine = class {
2779
2851
  try {
2780
2852
  result = await this._transport.pushMutations(mutations);
2781
2853
  } catch (err) {
2782
- for (const mut of mutations) {
2783
- const errMsg = err instanceof Error ? err.message : String(err);
2784
- await this._queue.markRetry(mut.mutationId, errMsg);
2785
- }
2854
+ const errMsg = err instanceof Error ? err.message : String(err);
2855
+ const busChangesOnThrow = [];
2856
+ const [head, ...behind] = mutations;
2857
+ await this._retryOrDeadLetter(head, errMsg, busChangesOnThrow);
2858
+ for (const mut of behind) {
2859
+ await this._queue.requeueInflight(mut.mutationId);
2860
+ }
2861
+ if (busChangesOnThrow.length > 0) this._bus.emit(busChangesOnThrow);
2862
+ this._refreshPendingWrites().catch(() => {
2863
+ });
2786
2864
  return false;
2787
2865
  }
2788
2866
  const busChanges = [];
@@ -2809,8 +2887,16 @@ var SyncEngine = class {
2809
2887
  }
2810
2888
  const processedIds = new Set(result.results.map((r) => r.mutationId));
2811
2889
  const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
2812
- for (const mut of unprocessed) {
2813
- await this._queue.markRetry(mut.mutationId, "partial_push: muta\xE7\xE3o n\xE3o processada no lote");
2890
+ if (unprocessed.length > 0) {
2891
+ const [boundary, ...behind] = unprocessed;
2892
+ await this._retryOrDeadLetter(
2893
+ boundary,
2894
+ "partial_push: muta\xE7\xE3o n\xE3o processada no lote",
2895
+ busChanges
2896
+ );
2897
+ for (const mut of behind) {
2898
+ await this._queue.requeueInflight(mut.mutationId);
2899
+ }
2814
2900
  }
2815
2901
  if (busChanges.length > 0) {
2816
2902
  this._bus.emit(busChanges);
@@ -3002,6 +3088,67 @@ var SyncEngine = class {
3002
3088
  });
3003
3089
  }
3004
3090
  }
3091
+ // ─── Dead-letter + backoff (P1-C) ────────────────────────────────────────────
3092
+ /**
3093
+ * Trata a falha transiente de UMA mutação que foi realmente tentada.
3094
+ *
3095
+ * - Se ainda há orçamento de tentativas → `markRetry` (volta pra fila com
3096
+ * backoff, `attempts++`).
3097
+ * - Se esgotou (`attempts+1 >= MAX_MUTATION_ATTEMPTS`) → DEAD-LETTER:
3098
+ * `markFailed` (sai do conjunto `queued`, então `drain()` para de devolvê-la
3099
+ * e a fila FIFO DESTRAVA para as demais coleções) + grava um ConflictRecord
3100
+ * `failed_max_attempts` (o produto vê via `getConflicts()`) + limpa o estado
3101
+ * pending do doc no cache + registra `lastError`/dispara `onError`.
3102
+ */
3103
+ async _retryOrDeadLetter(mut, errMsg, busChanges) {
3104
+ if (mut.attempts + 1 < MAX_MUTATION_ATTEMPTS) {
3105
+ await this._queue.markRetry(mut.mutationId, errMsg);
3106
+ return;
3107
+ }
3108
+ await this._queue.markFailed(mut.mutationId, errMsg);
3109
+ const conflictRecord = {
3110
+ collection: mut.collection,
3111
+ docId: mut.docId,
3112
+ mutationId: mut.mutationId,
3113
+ losingData: mut.payload ?? {},
3114
+ winningData: {},
3115
+ reason: "failed_max_attempts",
3116
+ detectedAt: Date.now(),
3117
+ delivered: false
3118
+ };
3119
+ await this._store.appendConflict(conflictRecord);
3120
+ const existing = await this._store.getDoc(mut.collection, mut.docId);
3121
+ if (existing) {
3122
+ await this._store.putDoc({
3123
+ ...existing,
3124
+ meta: { ...existing.meta, state: "synced", pendingMutationIds: [] }
3125
+ });
3126
+ busChanges.push({
3127
+ type: "modified",
3128
+ collection: mut.collection,
3129
+ doc: { id: mut.docId, data: existing.data }
3130
+ });
3131
+ }
3132
+ this._recordSyncError(
3133
+ "push",
3134
+ `muta\xE7\xE3o ${mut.mutationId} (${mut.collection}/${mut.docId}) descartada ap\xF3s ${mut.attempts + 1} tentativas: ${errMsg}`
3135
+ );
3136
+ }
3137
+ /**
3138
+ * `true` se a mutação (head do FIFO) ainda está dentro da janela de backoff
3139
+ * desde a última tentativa falha. Só se aplica a mutações já tentadas
3140
+ * (`attempts > 0` e `lastAttemptAt` carimbado).
3141
+ */
3142
+ _isInBackoff(mut) {
3143
+ if (mut.attempts <= 0) return false;
3144
+ const last = mut.lastAttemptAt;
3145
+ if (typeof last !== "number") return false;
3146
+ return Date.now() < last + this._backoffDelayMs(mut.attempts);
3147
+ }
3148
+ /** Backoff exponencial keyed em `attempts`, com teto `BACKOFF_CAP_MS`. */
3149
+ _backoffDelayMs(attempts) {
3150
+ return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_CAP_MS);
3151
+ }
3005
3152
  // ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
3006
3153
  /**
3007
3154
  * Busca mudanças do servidor e aplica ao cache via ConflictResolver.
@@ -3041,10 +3188,14 @@ var SyncEngine = class {
3041
3188
  }
3042
3189
  const busChanges = [];
3043
3190
  await this._applyServerDocs(resyncResult.docs, busChanges, true);
3191
+ const truncatedCollections = resyncResult.truncatedCollections ?? [];
3192
+ const truncatedSet = new Set(truncatedCollections);
3193
+ await this._store.setMeta(TRUNCATED_COLLECTIONS_META_KEY, truncatedCollections);
3044
3194
  const returnedKeys = new Set(
3045
3195
  resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
3046
3196
  );
3047
3197
  for (const col of collections) {
3198
+ if (truncatedSet.has(col)) continue;
3048
3199
  const queryResult = await this._store.listDocs(col, {});
3049
3200
  for (const { id } of queryResult.docs) {
3050
3201
  const key = `${col}::${id}`;
@@ -3204,7 +3355,8 @@ var SyncEngine = class {
3204
3355
  status: isOnline ? "idle" : "offline",
3205
3356
  pendingWrites: 0,
3206
3357
  lastSyncedAt: null,
3207
- isLeaderTab: this._tabCoordinator.isLeader()
3358
+ isLeaderTab: this._tabCoordinator.isLeader(),
3359
+ lastError: null
3208
3360
  };
3209
3361
  }
3210
3362
  /**
@@ -3214,7 +3366,7 @@ var SyncEngine = class {
3214
3366
  _updateState(partial) {
3215
3367
  const prev = this._state;
3216
3368
  const next = { ...prev, ...partial };
3217
- const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
3369
+ const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab || (prev.lastError ?? null) !== (next.lastError ?? null);
3218
3370
  this._state = next;
3219
3371
  if (changed) {
3220
3372
  this._emitState(next);
@@ -3228,6 +3380,21 @@ var SyncEngine = class {
3228
3380
  }
3229
3381
  }
3230
3382
  }
3383
+ /**
3384
+ * P2-C: registra a falha de um ciclo de sync — antes engolida em silêncio.
3385
+ * Publica em `SyncState.lastError` (visível via `onSyncStateChanged`) e
3386
+ * dispara o hook `onError`. Nunca lança (diagnóstico é best-effort).
3387
+ */
3388
+ _recordSyncError(phase, message) {
3389
+ this._updateState({ lastError: message });
3390
+ const event = { phase, message, at: Date.now() };
3391
+ for (const listener of this._errorListeners) {
3392
+ try {
3393
+ listener({ ...event });
3394
+ } catch {
3395
+ }
3396
+ }
3397
+ }
3231
3398
  /**
3232
3399
  * Atualiza `pendingWrites` no estado.
3233
3400
  * Chamado de forma assíncrona quando necessário.
@@ -4037,7 +4204,9 @@ var DbCollectionRefImpl = class {
4037
4204
  const isOnline = this._isOnline();
4038
4205
  const syncState = this._engine.getSyncState();
4039
4206
  const nextCursor = buildNextCursor(result.docs, effectiveLimit);
4040
- const stale = !isOnline || syncState.status === "offline";
4207
+ const truncatedRaw = await this._store.getMeta(TRUNCATED_COLLECTIONS_META_KEY);
4208
+ const truncated = Array.isArray(truncatedRaw) && truncatedRaw.includes(this._collection);
4209
+ const stale = !isOnline || syncState.status === "offline" || truncated;
4041
4210
  const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
4042
4211
  return {
4043
4212
  docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
@@ -4045,7 +4214,8 @@ var DbCollectionRefImpl = class {
4045
4214
  fromCache,
4046
4215
  stale,
4047
4216
  hasPendingWrites: hasPending,
4048
- changes: []
4217
+ changes: [],
4218
+ truncated
4049
4219
  };
4050
4220
  }
4051
4221
  // ── CRUD ────────────────────────────────────────────────────────────────────
@@ -4491,6 +4661,9 @@ var NeetruDbDocumentsImpl = class {
4491
4661
  async getConflicts() {
4492
4662
  return this._store.listConflicts();
4493
4663
  }
4664
+ onError(cb) {
4665
+ return this._engine.onError(cb);
4666
+ }
4494
4667
  };
4495
4668
  async function createOfflineDocumentsNamespace(opts) {
4496
4669
  const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
@@ -4966,6 +5139,7 @@ var defaultWebSocketFactory = (url) => {
4966
5139
  // src/db/client-db.ts
4967
5140
  var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
4968
5141
  var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
5142
+ var DOCUMENTS_RESYNC_PAGE_CAP = 200;
4969
5143
  function resolveTransport(transport, opts, config) {
4970
5144
  if (opts._transport) {
4971
5145
  return opts._transport;
@@ -5078,6 +5252,7 @@ function createRestSyncTransport(config) {
5078
5252
  const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
5079
5253
  const now = Date.now();
5080
5254
  const docs = [];
5255
+ const truncatedCollections = [];
5081
5256
  for (const collection of collections) {
5082
5257
  let raw;
5083
5258
  try {
@@ -5087,11 +5262,11 @@ function createRestSyncTransport(config) {
5087
5262
  requireAuth: true,
5088
5263
  retries: 2,
5089
5264
  headers: tenantHeaders,
5090
- // Teto do Core é 200. Coleções > 200 docs truncam silenciosamente
5091
- // aqui (paginação cursor é tarefa Core separada) — a detecção de
5092
- // deleção-por-ausência do SyncEngine NÃO deve rodar sobre truncamento,
5093
- // então mantemos o limite no máximo do servidor por ora.
5094
- query: { limit: 200 }
5265
+ // Teto do Core (DOCUMENTS_RESYNC_PAGE_CAP). Coleções acima dele vêm
5266
+ // TRUNCADAS — reportadas em `truncatedCollections` p/ o SyncEngine
5267
+ // (a) NÃO rodar deleção-por-ausência e (b) sinalizar
5268
+ // `DbListResult.truncated`. Paginação por cursor é tarefa Core (gated).
5269
+ query: { limit: DOCUMENTS_RESYNC_PAGE_CAP }
5095
5270
  });
5096
5271
  } catch (err) {
5097
5272
  const msg = err instanceof Error ? err.message : String(err);
@@ -5102,6 +5277,9 @@ function createRestSyncTransport(config) {
5102
5277
  }
5103
5278
  const resp = raw;
5104
5279
  const items = Array.isArray(resp?.items) ? resp.items : [];
5280
+ if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
5281
+ truncatedCollections.push(collection);
5282
+ }
5105
5283
  for (const item of items) {
5106
5284
  if (!item || typeof item !== "object") continue;
5107
5285
  const record = item;
@@ -5129,7 +5307,7 @@ function createRestSyncTransport(config) {
5129
5307
  });
5130
5308
  }
5131
5309
  }
5132
- return { docs, newWatermark: now };
5310
+ return { docs, newWatermark: now, truncatedCollections };
5133
5311
  }
5134
5312
  };
5135
5313
  }
@@ -5191,20 +5369,20 @@ function buildTicketProvider(config, opts) {
5191
5369
  return ticket;
5192
5370
  };
5193
5371
  }
5194
- var _hasWarnedMissingDbId = false;
5195
- function warnMissingDbIdOnce(config, dbOpts) {
5196
- if (_hasWarnedMissingDbId) return;
5197
- if (dbOpts.dbId) return;
5372
+ var _hasWarnedDbIdIgnored = false;
5373
+ function warnDbIdIgnoredOnce(config, dbOpts) {
5374
+ if (_hasWarnedDbIdIgnored) return;
5375
+ if (!dbOpts.dbId) return;
5198
5376
  if (config.env === "prod") return;
5199
5377
  if (typeof window === "undefined") return;
5200
5378
  if (typeof console === "undefined" || typeof console.warn !== "function") return;
5201
- _hasWarnedMissingDbId = true;
5379
+ _hasWarnedDbIdIgnored = true;
5202
5380
  console.warn(
5203
- "[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."
5381
+ '[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.'
5204
5382
  );
5205
5383
  }
5206
5384
  function createNeetruDb(config, dbOpts = {}) {
5207
- warnMissingDbIdOnce(config, dbOpts);
5385
+ warnDbIdIgnoredOnce(config, dbOpts);
5208
5386
  let resolvedTransport;
5209
5387
  if (dbOpts.transport !== void 0) {
5210
5388
  resolvedTransport = dbOpts.transport;
@@ -5282,6 +5460,20 @@ function createNeetruDb(config, dbOpts = {}) {
5282
5460
  async getConflicts() {
5283
5461
  const docs = await getDocsPromise();
5284
5462
  return docs.getConflicts();
5463
+ },
5464
+ onError(cb) {
5465
+ if (_docsResolved) return _docsResolved.onError(cb);
5466
+ let unsub = null;
5467
+ let cancelled = false;
5468
+ getDocsPromise().then((docs) => {
5469
+ if (!cancelled) {
5470
+ unsub = docs.onError(cb);
5471
+ }
5472
+ });
5473
+ return () => {
5474
+ cancelled = true;
5475
+ unsub?.();
5476
+ };
5285
5477
  }
5286
5478
  };
5287
5479
  }