@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.
- package/CHANGELOG.md +18 -0
- package/dist/ai.d.cts +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/auth.cjs +193 -30
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +1 -1
- package/dist/auth.d.ts +1 -1
- package/dist/auth.mjs +193 -30
- package/dist/auth.mjs.map +1 -1
- package/dist/catalog.d.cts +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/checkout.d.cts +1 -1
- package/dist/checkout.d.ts +1 -1
- package/dist/db-react.d.cts +1 -1
- package/dist/db-react.d.ts +1 -1
- package/dist/db.cjs +193 -30
- package/dist/db.cjs.map +1 -1
- package/dist/db.d.cts +1 -1
- package/dist/db.d.ts +1 -1
- package/dist/db.mjs +193 -30
- package/dist/db.mjs.map +1 -1
- package/dist/entitlements.d.cts +1 -1
- package/dist/entitlements.d.ts +1 -1
- package/dist/firestore-compat.d.cts +1 -1
- package/dist/firestore-compat.d.ts +1 -1
- package/dist/index.cjs +194 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +194 -31
- package/dist/index.mjs.map +1 -1
- package/dist/mocks.d.cts +1 -1
- package/dist/mocks.d.ts +1 -1
- package/dist/notifications.d.cts +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/support.d.cts +1 -1
- package/dist/support.d.ts +1 -1
- package/dist/telemetry.d.cts +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/{types-DdJyqBy-.d.ts → types-BlOLDMR0.d.ts} +79 -6
- package/dist/{types-BoFSCHF_.d.cts → types-D-QdG502.d.cts} +79 -6
- package/dist/usage.d.cts +1 -1
- package/dist/usage.d.ts +1 -1
- package/dist/webhooks.d.cts +1 -1
- package/dist/webhooks.d.ts +1 -1
- 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,
|
|
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,
|
|
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
|
|
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`
|
|
2561
|
-
* Chamado pelo SyncEngine em falha
|
|
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
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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
|
-
|
|
2815
|
-
|
|
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
|
|
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
|
|
5093
|
-
//
|
|
5094
|
-
// deleção-por-ausência
|
|
5095
|
-
//
|
|
5096
|
-
query: { limit:
|
|
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
|
|
5197
|
-
function
|
|
5198
|
-
if (
|
|
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
|
-
|
|
5352
|
+
_hasWarnedDbIdIgnored = true;
|
|
5204
5353
|
console.warn(
|
|
5205
|
-
|
|
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
|
-
|
|
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
|
}
|