@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.
- package/CHANGELOG.md +43 -0
- package/dist/ai.d.cts +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/auth.cjs +365 -25
- 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 +365 -25
- 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 +202 -22
- 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 +202 -23
- 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 +366 -26
- 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 +366 -26
- package/dist/index.mjs.map +1 -1
- package/dist/mocks.cjs +68 -1
- package/dist/mocks.cjs.map +1 -1
- package/dist/mocks.d.cts +17 -1
- package/dist/mocks.d.ts +17 -1
- package/dist/mocks.mjs +68 -1
- package/dist/mocks.mjs.map +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.cjs +98 -1
- package/dist/support.cjs.map +1 -1
- package/dist/support.d.cts +1 -1
- package/dist/support.d.ts +1 -1
- package/dist/support.mjs +98 -1
- package/dist/support.mjs.map +1 -1
- package/dist/telemetry.d.cts +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/{types-DslphKIM.d.ts → types-BlOLDMR0.d.ts} +192 -4
- package/dist/{types-DQplLXIk.d.cts → types-D-QdG502.d.cts} +192 -4
- 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/dist/auth.d.cts
CHANGED
package/dist/auth.d.ts
CHANGED
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
|
|
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`
|
|
2462
|
-
* Chamado pelo SyncEngine em falha
|
|
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
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
2716
|
-
|
|
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
|
|
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
|
|
4994
|
-
//
|
|
4995
|
-
// deleção-por-ausência
|
|
4996
|
-
//
|
|
4997
|
-
query: { limit:
|
|
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 = [];
|