@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/entitlements.d.cts
CHANGED
package/dist/entitlements.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Y as NeetruDb, E as DbCollectionRef, P as FieldSentinel, K as DbQuery } from './types-
|
|
1
|
+
import { Y as NeetruDb, E as DbCollectionRef, P as FieldSentinel, K as DbQuery } from './types-D-QdG502.cjs';
|
|
2
2
|
import 'drizzle-orm/node-postgres';
|
|
3
3
|
import './errors.cjs';
|
|
4
4
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Y as NeetruDb, E as DbCollectionRef, P as FieldSentinel, K as DbQuery } from './types-
|
|
1
|
+
import { Y as NeetruDb, E as DbCollectionRef, P as FieldSentinel, K as DbQuery } from './types-BlOLDMR0.js';
|
|
2
2
|
import 'drizzle-orm/node-postgres';
|
|
3
3
|
import './errors.js';
|
|
4
4
|
|
package/dist/index.cjs
CHANGED
|
@@ -1584,6 +1584,7 @@ function toTicket(raw) {
|
|
|
1584
1584
|
if (typeof r.id !== "string") {
|
|
1585
1585
|
throw new exports.NeetruError("invalid_response", "Ticket missing id");
|
|
1586
1586
|
}
|
|
1587
|
+
const firstAttachment = Array.isArray(r.attachments) && typeof r.attachments[0] === "string" ? r.attachments[0] : void 0;
|
|
1587
1588
|
return {
|
|
1588
1589
|
id: r.id,
|
|
1589
1590
|
subject: typeof r.subject === "string" ? r.subject : "",
|
|
@@ -1592,9 +1593,28 @@ function toTicket(raw) {
|
|
|
1592
1593
|
status: VALID_STATUSES.includes(r.status) ? r.status : "open",
|
|
1593
1594
|
createdAt: typeof r.createdAt === "string" ? r.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
1594
1595
|
updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : void 0,
|
|
1595
|
-
productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0
|
|
1596
|
+
productSlug: typeof r.productId === "string" ? r.productId : typeof r.productSlug === "string" ? r.productSlug : void 0,
|
|
1597
|
+
pageUrl: typeof r.pageUrl === "string" ? r.pageUrl : null,
|
|
1598
|
+
screenshotUrl: typeof r.screenshotUrl === "string" ? r.screenshotUrl : firstAttachment ?? null,
|
|
1599
|
+
resolvedAt: typeof r.resolvedAt === "string" ? r.resolvedAt : null,
|
|
1600
|
+
resolvedNotifiedAt: typeof r.resolvedNotifiedAt === "string" ? r.resolvedNotifiedAt : null,
|
|
1601
|
+
satisfactionAttempts: typeof r.satisfactionAttempts === "number" && Number.isFinite(r.satisfactionAttempts) ? r.satisfactionAttempts : 0,
|
|
1602
|
+
lastSatisfactionAttemptAt: typeof r.lastSatisfactionAttemptAt === "string" ? r.lastSatisfactionAttemptAt : null,
|
|
1603
|
+
satisfiedAt: typeof r.satisfiedAt === "string" ? r.satisfiedAt : null,
|
|
1604
|
+
rating: typeof r.rating === "number" && Number.isFinite(r.rating) ? r.rating : null,
|
|
1605
|
+
satisfactionComment: typeof r.satisfactionComment === "string" ? r.satisfactionComment : null
|
|
1596
1606
|
};
|
|
1597
1607
|
}
|
|
1608
|
+
function assertTicketId(ticketId) {
|
|
1609
|
+
if (!ticketId || typeof ticketId !== "string") {
|
|
1610
|
+
throw new exports.NeetruError("validation_failed", "ticketId is required");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function assertValidComment(comment) {
|
|
1614
|
+
if (typeof comment === "string" && comment.length > 2e3) {
|
|
1615
|
+
throw new exports.NeetruError("validation_failed", "comment max 2000 chars");
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1598
1618
|
function resolveProductId(config, ...candidates) {
|
|
1599
1619
|
for (const c of candidates) {
|
|
1600
1620
|
if (typeof c === "string" && c.length > 0) {
|
|
@@ -1651,6 +1671,15 @@ function createSupportNamespace(config) {
|
|
|
1651
1671
|
if (input.metadata && typeof input.metadata === "object") {
|
|
1652
1672
|
body.metadata = input.metadata;
|
|
1653
1673
|
}
|
|
1674
|
+
if (typeof input.pageUrl === "string") {
|
|
1675
|
+
body.pageUrl = input.pageUrl;
|
|
1676
|
+
}
|
|
1677
|
+
if (typeof input.screenshotBase64 === "string") {
|
|
1678
|
+
body.screenshotBase64 = input.screenshotBase64;
|
|
1679
|
+
}
|
|
1680
|
+
if (typeof input.consoleLogs === "string") {
|
|
1681
|
+
body.consoleLogs = input.consoleLogs;
|
|
1682
|
+
}
|
|
1654
1683
|
const raw = await httpRequest(config, {
|
|
1655
1684
|
method: "POST",
|
|
1656
1685
|
path: "/api/sdk/v1/support/tickets",
|
|
@@ -1676,6 +1705,74 @@ function createSupportNamespace(config) {
|
|
|
1676
1705
|
});
|
|
1677
1706
|
const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && "tickets" in raw ? raw.tickets ?? [] : [];
|
|
1678
1707
|
return list.map(toTicket);
|
|
1708
|
+
},
|
|
1709
|
+
/**
|
|
1710
|
+
* 3.4: marca a notificação de resolução como vista (`resolvedNotifiedAt`,
|
|
1711
|
+
* idempotente no Core). Endpoint canonical:
|
|
1712
|
+
* `POST /api/sdk/v1/support/tickets/{id}/resolved-seen`.
|
|
1713
|
+
*/
|
|
1714
|
+
async markResolvedSeen(ticketId) {
|
|
1715
|
+
assertTicketId(ticketId);
|
|
1716
|
+
await httpRequest(config, {
|
|
1717
|
+
method: "POST",
|
|
1718
|
+
path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/resolved-seen`,
|
|
1719
|
+
requireAuth: true
|
|
1720
|
+
});
|
|
1721
|
+
},
|
|
1722
|
+
/**
|
|
1723
|
+
* 3.4: registra uma tentativa de exibição do modal de satisfação
|
|
1724
|
+
* (`satisfactionAttempts += 1`). Chamar no SHOW do modal, não na
|
|
1725
|
+
* resposta. Endpoint canonical:
|
|
1726
|
+
* `POST /api/sdk/v1/support/tickets/{id}/satisfaction-attempt`.
|
|
1727
|
+
*/
|
|
1728
|
+
async recordSatisfactionAttempt(ticketId) {
|
|
1729
|
+
assertTicketId(ticketId);
|
|
1730
|
+
await httpRequest(config, {
|
|
1731
|
+
method: "POST",
|
|
1732
|
+
path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/satisfaction-attempt`,
|
|
1733
|
+
requireAuth: true
|
|
1734
|
+
});
|
|
1735
|
+
},
|
|
1736
|
+
/**
|
|
1737
|
+
* 3.4: envia (ou pula) a avaliação de satisfação (CSAT 1-5). Endpoint
|
|
1738
|
+
* canonical: `POST /api/sdk/v1/support/tickets/{id}/satisfaction`.
|
|
1739
|
+
*/
|
|
1740
|
+
async submitSatisfaction(input) {
|
|
1741
|
+
if (!input || typeof input !== "object") {
|
|
1742
|
+
throw new exports.NeetruError("validation_failed", "input is required");
|
|
1743
|
+
}
|
|
1744
|
+
assertTicketId(input.ticketId);
|
|
1745
|
+
if (input.rating !== null && input.rating !== void 0) {
|
|
1746
|
+
if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
|
|
1747
|
+
throw new exports.NeetruError("validation_failed", "rating must be an integer 1-5 or null");
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
assertValidComment(input.comment);
|
|
1751
|
+
await httpRequest(config, {
|
|
1752
|
+
method: "POST",
|
|
1753
|
+
path: `/api/sdk/v1/support/tickets/${encodeURIComponent(input.ticketId)}/satisfaction`,
|
|
1754
|
+
body: {
|
|
1755
|
+
rating: input.rating ?? null,
|
|
1756
|
+
comment: input.comment ?? null,
|
|
1757
|
+
skipped: input.skipped
|
|
1758
|
+
},
|
|
1759
|
+
requireAuth: true
|
|
1760
|
+
});
|
|
1761
|
+
},
|
|
1762
|
+
/**
|
|
1763
|
+
* 3.4: contesta um ticket resolvido — reabre e zera o ciclo de
|
|
1764
|
+
* satisfação/notificação. Endpoint canonical:
|
|
1765
|
+
* `POST /api/sdk/v1/support/tickets/{id}/contest`.
|
|
1766
|
+
*/
|
|
1767
|
+
async contestTicket(ticketId, opts) {
|
|
1768
|
+
assertTicketId(ticketId);
|
|
1769
|
+
assertValidComment(opts?.comment);
|
|
1770
|
+
await httpRequest(config, {
|
|
1771
|
+
method: "POST",
|
|
1772
|
+
path: `/api/sdk/v1/support/tickets/${encodeURIComponent(ticketId)}/contest`,
|
|
1773
|
+
body: { comment: opts?.comment ?? void 0 },
|
|
1774
|
+
requireAuth: true
|
|
1775
|
+
});
|
|
1679
1776
|
}
|
|
1680
1777
|
};
|
|
1681
1778
|
}
|
|
@@ -2446,8 +2543,10 @@ var MutationQueue = class {
|
|
|
2446
2543
|
await this.store.deleteMutation(mutationId);
|
|
2447
2544
|
}
|
|
2448
2545
|
/**
|
|
2449
|
-
* Marca uma mutação como `failed` e registra o erro.
|
|
2450
|
-
* 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.
|
|
2451
2550
|
*/
|
|
2452
2551
|
async markFailed(mutationId, error) {
|
|
2453
2552
|
const mut = await this.store.getMutation(mutationId);
|
|
@@ -2456,12 +2555,14 @@ var MutationQueue = class {
|
|
|
2456
2555
|
...mut,
|
|
2457
2556
|
status: "failed",
|
|
2458
2557
|
lastError: error,
|
|
2459
|
-
attempts: mut.attempts + 1
|
|
2558
|
+
attempts: mut.attempts + 1,
|
|
2559
|
+
lastAttemptAt: Date.now()
|
|
2460
2560
|
});
|
|
2461
2561
|
}
|
|
2462
2562
|
/**
|
|
2463
|
-
* Incrementa `attempts` e volta para `queued`
|
|
2464
|
-
* 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.
|
|
2465
2566
|
*/
|
|
2466
2567
|
async markRetry(mutationId, error) {
|
|
2467
2568
|
const mut = await this.store.getMutation(mutationId);
|
|
@@ -2470,9 +2571,24 @@ var MutationQueue = class {
|
|
|
2470
2571
|
...mut,
|
|
2471
2572
|
status: "queued",
|
|
2472
2573
|
lastError: error,
|
|
2473
|
-
attempts: mut.attempts + 1
|
|
2574
|
+
attempts: mut.attempts + 1,
|
|
2575
|
+
lastAttemptAt: Date.now()
|
|
2474
2576
|
});
|
|
2475
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
|
+
}
|
|
2476
2592
|
};
|
|
2477
2593
|
|
|
2478
2594
|
// src/db/field-value.ts
|
|
@@ -2528,6 +2644,10 @@ function hasFieldSentinel(data) {
|
|
|
2528
2644
|
}
|
|
2529
2645
|
|
|
2530
2646
|
// src/db/offline/sync-engine.ts
|
|
2647
|
+
var MAX_MUTATION_ATTEMPTS = 8;
|
|
2648
|
+
var BACKOFF_BASE_MS = 1e3;
|
|
2649
|
+
var BACKOFF_CAP_MS = 5 * 60 * 1e3;
|
|
2650
|
+
var TRUNCATED_COLLECTIONS_META_KEY = "truncatedCollections";
|
|
2531
2651
|
function hasSentinels(payload) {
|
|
2532
2652
|
return payload !== null && hasFieldSentinel(payload);
|
|
2533
2653
|
}
|
|
@@ -2562,6 +2682,8 @@ var SyncEngine = class {
|
|
|
2562
2682
|
_state;
|
|
2563
2683
|
/** Listeners de mudança de SyncState. */
|
|
2564
2684
|
_stateListeners = /* @__PURE__ */ new Set();
|
|
2685
|
+
/** Listeners de erro de ciclo de sync (P2-C — não mais engolido em silêncio). */
|
|
2686
|
+
_errorListeners = /* @__PURE__ */ new Set();
|
|
2565
2687
|
/** Cleanup functions dos listeners externos. */
|
|
2566
2688
|
_cleanups = [];
|
|
2567
2689
|
/** Timer de resync periódico. */
|
|
@@ -2620,6 +2742,19 @@ var SyncEngine = class {
|
|
|
2620
2742
|
this._stateListeners.delete(cb);
|
|
2621
2743
|
};
|
|
2622
2744
|
}
|
|
2745
|
+
/**
|
|
2746
|
+
* Registra um hook de erro de sync (P2-C). Disparado quando um ciclo falha
|
|
2747
|
+
* (exceção inesperada nas fases, falha de pull/resync ou dead-letter de uma
|
|
2748
|
+
* mutação-veneno) — o que antes era engolido com "log silencioso".
|
|
2749
|
+
* O mesmo diagnóstico também aparece em `SyncState.lastError`.
|
|
2750
|
+
* Retorna uma função de unsubscribe.
|
|
2751
|
+
*/
|
|
2752
|
+
onError(cb) {
|
|
2753
|
+
this._errorListeners.add(cb);
|
|
2754
|
+
return () => {
|
|
2755
|
+
this._errorListeners.delete(cb);
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2623
2758
|
/**
|
|
2624
2759
|
* Força um sync imediato (I3 §10.3 `flush()`).
|
|
2625
2760
|
* Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
|
|
@@ -2652,8 +2787,10 @@ var SyncEngine = class {
|
|
|
2652
2787
|
}
|
|
2653
2788
|
await this._phase2Pull();
|
|
2654
2789
|
this._phase3Realtime();
|
|
2655
|
-
this._updateState({ status: "idle", lastSyncedAt: Date.now() });
|
|
2790
|
+
this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
|
|
2656
2791
|
} catch (err) {
|
|
2792
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2793
|
+
this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
|
|
2657
2794
|
this._updateState({ status: "idle" });
|
|
2658
2795
|
} finally {
|
|
2659
2796
|
this._syncing = false;
|
|
@@ -2688,6 +2825,9 @@ var SyncEngine = class {
|
|
|
2688
2825
|
async _phase1Push() {
|
|
2689
2826
|
const mutations = await this._queue.drain();
|
|
2690
2827
|
if (mutations.length === 0) return true;
|
|
2828
|
+
if (this._isInBackoff(mutations[0])) {
|
|
2829
|
+
return true;
|
|
2830
|
+
}
|
|
2691
2831
|
for (const mut of mutations) {
|
|
2692
2832
|
await this._queue.markInflight(mut.mutationId);
|
|
2693
2833
|
}
|
|
@@ -2695,10 +2835,16 @@ var SyncEngine = class {
|
|
|
2695
2835
|
try {
|
|
2696
2836
|
result = await this._transport.pushMutations(mutations);
|
|
2697
2837
|
} catch (err) {
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2838
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2839
|
+
const busChangesOnThrow = [];
|
|
2840
|
+
const [head, ...behind] = mutations;
|
|
2841
|
+
await this._retryOrDeadLetter(head, errMsg, busChangesOnThrow);
|
|
2842
|
+
for (const mut of behind) {
|
|
2843
|
+
await this._queue.requeueInflight(mut.mutationId);
|
|
2844
|
+
}
|
|
2845
|
+
if (busChangesOnThrow.length > 0) this._bus.emit(busChangesOnThrow);
|
|
2846
|
+
this._refreshPendingWrites().catch(() => {
|
|
2847
|
+
});
|
|
2702
2848
|
return false;
|
|
2703
2849
|
}
|
|
2704
2850
|
const busChanges = [];
|
|
@@ -2725,8 +2871,16 @@ var SyncEngine = class {
|
|
|
2725
2871
|
}
|
|
2726
2872
|
const processedIds = new Set(result.results.map((r) => r.mutationId));
|
|
2727
2873
|
const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
|
|
2728
|
-
|
|
2729
|
-
|
|
2874
|
+
if (unprocessed.length > 0) {
|
|
2875
|
+
const [boundary, ...behind] = unprocessed;
|
|
2876
|
+
await this._retryOrDeadLetter(
|
|
2877
|
+
boundary,
|
|
2878
|
+
"partial_push: muta\xE7\xE3o n\xE3o processada no lote",
|
|
2879
|
+
busChanges
|
|
2880
|
+
);
|
|
2881
|
+
for (const mut of behind) {
|
|
2882
|
+
await this._queue.requeueInflight(mut.mutationId);
|
|
2883
|
+
}
|
|
2730
2884
|
}
|
|
2731
2885
|
if (busChanges.length > 0) {
|
|
2732
2886
|
this._bus.emit(busChanges);
|
|
@@ -2918,6 +3072,67 @@ var SyncEngine = class {
|
|
|
2918
3072
|
});
|
|
2919
3073
|
}
|
|
2920
3074
|
}
|
|
3075
|
+
// ─── Dead-letter + backoff (P1-C) ────────────────────────────────────────────
|
|
3076
|
+
/**
|
|
3077
|
+
* Trata a falha transiente de UMA mutação que foi realmente tentada.
|
|
3078
|
+
*
|
|
3079
|
+
* - Se ainda há orçamento de tentativas → `markRetry` (volta pra fila com
|
|
3080
|
+
* backoff, `attempts++`).
|
|
3081
|
+
* - Se esgotou (`attempts+1 >= MAX_MUTATION_ATTEMPTS`) → DEAD-LETTER:
|
|
3082
|
+
* `markFailed` (sai do conjunto `queued`, então `drain()` para de devolvê-la
|
|
3083
|
+
* e a fila FIFO DESTRAVA para as demais coleções) + grava um ConflictRecord
|
|
3084
|
+
* `failed_max_attempts` (o produto vê via `getConflicts()`) + limpa o estado
|
|
3085
|
+
* pending do doc no cache + registra `lastError`/dispara `onError`.
|
|
3086
|
+
*/
|
|
3087
|
+
async _retryOrDeadLetter(mut, errMsg, busChanges) {
|
|
3088
|
+
if (mut.attempts + 1 < MAX_MUTATION_ATTEMPTS) {
|
|
3089
|
+
await this._queue.markRetry(mut.mutationId, errMsg);
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
await this._queue.markFailed(mut.mutationId, errMsg);
|
|
3093
|
+
const conflictRecord = {
|
|
3094
|
+
collection: mut.collection,
|
|
3095
|
+
docId: mut.docId,
|
|
3096
|
+
mutationId: mut.mutationId,
|
|
3097
|
+
losingData: mut.payload ?? {},
|
|
3098
|
+
winningData: {},
|
|
3099
|
+
reason: "failed_max_attempts",
|
|
3100
|
+
detectedAt: Date.now(),
|
|
3101
|
+
delivered: false
|
|
3102
|
+
};
|
|
3103
|
+
await this._store.appendConflict(conflictRecord);
|
|
3104
|
+
const existing = await this._store.getDoc(mut.collection, mut.docId);
|
|
3105
|
+
if (existing) {
|
|
3106
|
+
await this._store.putDoc({
|
|
3107
|
+
...existing,
|
|
3108
|
+
meta: { ...existing.meta, state: "synced", pendingMutationIds: [] }
|
|
3109
|
+
});
|
|
3110
|
+
busChanges.push({
|
|
3111
|
+
type: "modified",
|
|
3112
|
+
collection: mut.collection,
|
|
3113
|
+
doc: { id: mut.docId, data: existing.data }
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
this._recordSyncError(
|
|
3117
|
+
"push",
|
|
3118
|
+
`muta\xE7\xE3o ${mut.mutationId} (${mut.collection}/${mut.docId}) descartada ap\xF3s ${mut.attempts + 1} tentativas: ${errMsg}`
|
|
3119
|
+
);
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* `true` se a mutação (head do FIFO) ainda está dentro da janela de backoff
|
|
3123
|
+
* desde a última tentativa falha. Só se aplica a mutações já tentadas
|
|
3124
|
+
* (`attempts > 0` e `lastAttemptAt` carimbado).
|
|
3125
|
+
*/
|
|
3126
|
+
_isInBackoff(mut) {
|
|
3127
|
+
if (mut.attempts <= 0) return false;
|
|
3128
|
+
const last = mut.lastAttemptAt;
|
|
3129
|
+
if (typeof last !== "number") return false;
|
|
3130
|
+
return Date.now() < last + this._backoffDelayMs(mut.attempts);
|
|
3131
|
+
}
|
|
3132
|
+
/** Backoff exponencial keyed em `attempts`, com teto `BACKOFF_CAP_MS`. */
|
|
3133
|
+
_backoffDelayMs(attempts) {
|
|
3134
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_CAP_MS);
|
|
3135
|
+
}
|
|
2921
3136
|
// ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
|
|
2922
3137
|
/**
|
|
2923
3138
|
* Busca mudanças do servidor e aplica ao cache via ConflictResolver.
|
|
@@ -2957,10 +3172,14 @@ var SyncEngine = class {
|
|
|
2957
3172
|
}
|
|
2958
3173
|
const busChanges = [];
|
|
2959
3174
|
await this._applyServerDocs(resyncResult.docs, busChanges, true);
|
|
3175
|
+
const truncatedCollections = resyncResult.truncatedCollections ?? [];
|
|
3176
|
+
const truncatedSet = new Set(truncatedCollections);
|
|
3177
|
+
await this._store.setMeta(TRUNCATED_COLLECTIONS_META_KEY, truncatedCollections);
|
|
2960
3178
|
const returnedKeys = new Set(
|
|
2961
3179
|
resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
|
|
2962
3180
|
);
|
|
2963
3181
|
for (const col of collections) {
|
|
3182
|
+
if (truncatedSet.has(col)) continue;
|
|
2964
3183
|
const queryResult = await this._store.listDocs(col, {});
|
|
2965
3184
|
for (const { id } of queryResult.docs) {
|
|
2966
3185
|
const key = `${col}::${id}`;
|
|
@@ -3120,7 +3339,8 @@ var SyncEngine = class {
|
|
|
3120
3339
|
status: isOnline ? "idle" : "offline",
|
|
3121
3340
|
pendingWrites: 0,
|
|
3122
3341
|
lastSyncedAt: null,
|
|
3123
|
-
isLeaderTab: this._tabCoordinator.isLeader()
|
|
3342
|
+
isLeaderTab: this._tabCoordinator.isLeader(),
|
|
3343
|
+
lastError: null
|
|
3124
3344
|
};
|
|
3125
3345
|
}
|
|
3126
3346
|
/**
|
|
@@ -3130,7 +3350,7 @@ var SyncEngine = class {
|
|
|
3130
3350
|
_updateState(partial) {
|
|
3131
3351
|
const prev = this._state;
|
|
3132
3352
|
const next = { ...prev, ...partial };
|
|
3133
|
-
const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab;
|
|
3353
|
+
const changed = prev.status !== next.status || prev.pendingWrites !== next.pendingWrites || prev.lastSyncedAt !== next.lastSyncedAt || prev.isLeaderTab !== next.isLeaderTab || (prev.lastError ?? null) !== (next.lastError ?? null);
|
|
3134
3354
|
this._state = next;
|
|
3135
3355
|
if (changed) {
|
|
3136
3356
|
this._emitState(next);
|
|
@@ -3144,6 +3364,21 @@ var SyncEngine = class {
|
|
|
3144
3364
|
}
|
|
3145
3365
|
}
|
|
3146
3366
|
}
|
|
3367
|
+
/**
|
|
3368
|
+
* P2-C: registra a falha de um ciclo de sync — antes engolida em silêncio.
|
|
3369
|
+
* Publica em `SyncState.lastError` (visível via `onSyncStateChanged`) e
|
|
3370
|
+
* dispara o hook `onError`. Nunca lança (diagnóstico é best-effort).
|
|
3371
|
+
*/
|
|
3372
|
+
_recordSyncError(phase, message) {
|
|
3373
|
+
this._updateState({ lastError: message });
|
|
3374
|
+
const event = { phase, message, at: Date.now() };
|
|
3375
|
+
for (const listener of this._errorListeners) {
|
|
3376
|
+
try {
|
|
3377
|
+
listener({ ...event });
|
|
3378
|
+
} catch {
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3147
3382
|
/**
|
|
3148
3383
|
* Atualiza `pendingWrites` no estado.
|
|
3149
3384
|
* Chamado de forma assíncrona quando necessário.
|
|
@@ -3953,7 +4188,9 @@ var DbCollectionRefImpl = class {
|
|
|
3953
4188
|
const isOnline = this._isOnline();
|
|
3954
4189
|
const syncState = this._engine.getSyncState();
|
|
3955
4190
|
const nextCursor = buildNextCursor(result.docs, effectiveLimit);
|
|
3956
|
-
const
|
|
4191
|
+
const truncatedRaw = await this._store.getMeta(TRUNCATED_COLLECTIONS_META_KEY);
|
|
4192
|
+
const truncated = Array.isArray(truncatedRaw) && truncatedRaw.includes(this._collection);
|
|
4193
|
+
const stale = !isOnline || syncState.status === "offline" || truncated;
|
|
3957
4194
|
const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
|
|
3958
4195
|
return {
|
|
3959
4196
|
docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
|
|
@@ -3961,7 +4198,8 @@ var DbCollectionRefImpl = class {
|
|
|
3961
4198
|
fromCache,
|
|
3962
4199
|
stale,
|
|
3963
4200
|
hasPendingWrites: hasPending,
|
|
3964
|
-
changes: []
|
|
4201
|
+
changes: [],
|
|
4202
|
+
truncated
|
|
3965
4203
|
};
|
|
3966
4204
|
}
|
|
3967
4205
|
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -4407,6 +4645,9 @@ var NeetruDbDocumentsImpl = class {
|
|
|
4407
4645
|
async getConflicts() {
|
|
4408
4646
|
return this._store.listConflicts();
|
|
4409
4647
|
}
|
|
4648
|
+
onError(cb) {
|
|
4649
|
+
return this._engine.onError(cb);
|
|
4650
|
+
}
|
|
4410
4651
|
};
|
|
4411
4652
|
async function createOfflineDocumentsNamespace(opts) {
|
|
4412
4653
|
const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
|
|
@@ -4882,6 +5123,7 @@ var defaultWebSocketFactory = (url) => {
|
|
|
4882
5123
|
// src/db/client-db.ts
|
|
4883
5124
|
var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
|
|
4884
5125
|
var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
|
|
5126
|
+
var DOCUMENTS_RESYNC_PAGE_CAP = 200;
|
|
4885
5127
|
function resolveTransport(transport, opts, config) {
|
|
4886
5128
|
if (opts._transport) {
|
|
4887
5129
|
return opts._transport;
|
|
@@ -4994,6 +5236,7 @@ function createRestSyncTransport(config) {
|
|
|
4994
5236
|
const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
|
|
4995
5237
|
const now = Date.now();
|
|
4996
5238
|
const docs = [];
|
|
5239
|
+
const truncatedCollections = [];
|
|
4997
5240
|
for (const collection of collections) {
|
|
4998
5241
|
let raw;
|
|
4999
5242
|
try {
|
|
@@ -5003,11 +5246,11 @@ function createRestSyncTransport(config) {
|
|
|
5003
5246
|
requireAuth: true,
|
|
5004
5247
|
retries: 2,
|
|
5005
5248
|
headers: tenantHeaders,
|
|
5006
|
-
// Teto do Core
|
|
5007
|
-
//
|
|
5008
|
-
// deleção-por-ausência
|
|
5009
|
-
//
|
|
5010
|
-
query: { limit:
|
|
5249
|
+
// Teto do Core (DOCUMENTS_RESYNC_PAGE_CAP). Coleções acima dele vêm
|
|
5250
|
+
// TRUNCADAS — reportadas em `truncatedCollections` p/ o SyncEngine
|
|
5251
|
+
// (a) NÃO rodar deleção-por-ausência e (b) sinalizar
|
|
5252
|
+
// `DbListResult.truncated`. Paginação por cursor é tarefa Core (gated).
|
|
5253
|
+
query: { limit: DOCUMENTS_RESYNC_PAGE_CAP }
|
|
5011
5254
|
});
|
|
5012
5255
|
} catch (err) {
|
|
5013
5256
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -5018,6 +5261,9 @@ function createRestSyncTransport(config) {
|
|
|
5018
5261
|
}
|
|
5019
5262
|
const resp = raw;
|
|
5020
5263
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
|
5264
|
+
if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
|
|
5265
|
+
truncatedCollections.push(collection);
|
|
5266
|
+
}
|
|
5021
5267
|
for (const item of items) {
|
|
5022
5268
|
if (!item || typeof item !== "object") continue;
|
|
5023
5269
|
const record = item;
|
|
@@ -5045,7 +5291,7 @@ function createRestSyncTransport(config) {
|
|
|
5045
5291
|
});
|
|
5046
5292
|
}
|
|
5047
5293
|
}
|
|
5048
|
-
return { docs, newWatermark: now };
|
|
5294
|
+
return { docs, newWatermark: now, truncatedCollections };
|
|
5049
5295
|
}
|
|
5050
5296
|
};
|
|
5051
5297
|
}
|
|
@@ -5107,7 +5353,20 @@ function buildTicketProvider(config, opts) {
|
|
|
5107
5353
|
return ticket;
|
|
5108
5354
|
};
|
|
5109
5355
|
}
|
|
5356
|
+
var _hasWarnedDbIdIgnored = false;
|
|
5357
|
+
function warnDbIdIgnoredOnce(config, dbOpts) {
|
|
5358
|
+
if (_hasWarnedDbIdIgnored) return;
|
|
5359
|
+
if (!dbOpts.dbId) return;
|
|
5360
|
+
if (config.env === "prod") return;
|
|
5361
|
+
if (typeof window === "undefined") return;
|
|
5362
|
+
if (typeof console === "undefined" || typeof console.warn !== "function") return;
|
|
5363
|
+
_hasWarnedDbIdIgnored = true;
|
|
5364
|
+
console.warn(
|
|
5365
|
+
'[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.'
|
|
5366
|
+
);
|
|
5367
|
+
}
|
|
5110
5368
|
function createNeetruDb(config, dbOpts = {}) {
|
|
5369
|
+
warnDbIdIgnoredOnce(config, dbOpts);
|
|
5111
5370
|
let resolvedTransport;
|
|
5112
5371
|
if (dbOpts.transport !== void 0) {
|
|
5113
5372
|
resolvedTransport = dbOpts.transport;
|
|
@@ -5185,6 +5444,20 @@ function createNeetruDb(config, dbOpts = {}) {
|
|
|
5185
5444
|
async getConflicts() {
|
|
5186
5445
|
const docs = await getDocsPromise();
|
|
5187
5446
|
return docs.getConflicts();
|
|
5447
|
+
},
|
|
5448
|
+
onError(cb) {
|
|
5449
|
+
if (_docsResolved) return _docsResolved.onError(cb);
|
|
5450
|
+
let unsub = null;
|
|
5451
|
+
let cancelled = false;
|
|
5452
|
+
getDocsPromise().then((docs) => {
|
|
5453
|
+
if (!cancelled) {
|
|
5454
|
+
unsub = docs.onError(cb);
|
|
5455
|
+
}
|
|
5456
|
+
});
|
|
5457
|
+
return () => {
|
|
5458
|
+
cancelled = true;
|
|
5459
|
+
unsub?.();
|
|
5460
|
+
};
|
|
5188
5461
|
}
|
|
5189
5462
|
};
|
|
5190
5463
|
}
|
|
@@ -6617,7 +6890,18 @@ var MockSupport = class {
|
|
|
6617
6890
|
severity: input.severity ?? "normal",
|
|
6618
6891
|
status: "open",
|
|
6619
6892
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6620
|
-
productSlug: input.productSlug
|
|
6893
|
+
productSlug: input.productSlug,
|
|
6894
|
+
// 3.4 (product-feedback): espelha o contrato — campos novos nullable,
|
|
6895
|
+
// populados a partir do input quando fornecidos.
|
|
6896
|
+
pageUrl: typeof input.pageUrl === "string" ? input.pageUrl : null,
|
|
6897
|
+
screenshotUrl: typeof input.screenshotBase64 === "string" ? input.screenshotBase64 : null,
|
|
6898
|
+
resolvedAt: null,
|
|
6899
|
+
resolvedNotifiedAt: null,
|
|
6900
|
+
satisfactionAttempts: 0,
|
|
6901
|
+
lastSatisfactionAttemptAt: null,
|
|
6902
|
+
satisfiedAt: null,
|
|
6903
|
+
rating: null,
|
|
6904
|
+
satisfactionComment: null
|
|
6621
6905
|
};
|
|
6622
6906
|
this._tickets.push(ticket);
|
|
6623
6907
|
return ticket;
|
|
@@ -6628,6 +6912,62 @@ var MockSupport = class {
|
|
|
6628
6912
|
}
|
|
6629
6913
|
return [...this._tickets];
|
|
6630
6914
|
}
|
|
6915
|
+
_find(ticketId) {
|
|
6916
|
+
if (!ticketId || typeof ticketId !== "string") {
|
|
6917
|
+
throw new exports.NeetruError("validation_failed", "ticketId is required");
|
|
6918
|
+
}
|
|
6919
|
+
const ticket = this._tickets.find((t) => t.id === ticketId);
|
|
6920
|
+
if (!ticket) {
|
|
6921
|
+
throw new exports.NeetruError("not_found", `ticket ${ticketId} not found`);
|
|
6922
|
+
}
|
|
6923
|
+
return ticket;
|
|
6924
|
+
}
|
|
6925
|
+
/** 3.4: espelha `markResolvedSeen` — idempotente (só seta se ainda null). */
|
|
6926
|
+
async markResolvedSeen(ticketId) {
|
|
6927
|
+
const ticket = this._find(ticketId);
|
|
6928
|
+
if (ticket.resolvedNotifiedAt === null) {
|
|
6929
|
+
ticket.resolvedNotifiedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6932
|
+
/** 3.4: espelha `recordSatisfactionAttempt` — incrementa contador. */
|
|
6933
|
+
async recordSatisfactionAttempt(ticketId) {
|
|
6934
|
+
const ticket = this._find(ticketId);
|
|
6935
|
+
ticket.satisfactionAttempts += 1;
|
|
6936
|
+
ticket.lastSatisfactionAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6937
|
+
}
|
|
6938
|
+
/** 3.4: espelha `submitSatisfaction` — terminal, seta `satisfiedAt`. */
|
|
6939
|
+
async submitSatisfaction(input) {
|
|
6940
|
+
if (!input || typeof input !== "object") {
|
|
6941
|
+
throw new exports.NeetruError("validation_failed", "input is required");
|
|
6942
|
+
}
|
|
6943
|
+
if (input.rating !== null && input.rating !== void 0) {
|
|
6944
|
+
if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
|
|
6945
|
+
throw new exports.NeetruError("validation_failed", "rating must be an integer 1-5 or null");
|
|
6946
|
+
}
|
|
6947
|
+
}
|
|
6948
|
+
if (typeof input.comment === "string" && input.comment.length > 2e3) {
|
|
6949
|
+
throw new exports.NeetruError("validation_failed", "comment max 2000 chars");
|
|
6950
|
+
}
|
|
6951
|
+
const ticket = this._find(input.ticketId);
|
|
6952
|
+
ticket.satisfiedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6953
|
+
ticket.rating = input.rating ?? null;
|
|
6954
|
+
ticket.satisfactionComment = input.comment ?? null;
|
|
6955
|
+
}
|
|
6956
|
+
/** 3.4: espelha `contestTicket` — reabre e zera o ciclo de satisfação. */
|
|
6957
|
+
async contestTicket(ticketId, opts) {
|
|
6958
|
+
if (typeof opts?.comment === "string" && opts.comment.length > 2e3) {
|
|
6959
|
+
throw new exports.NeetruError("validation_failed", "comment max 2000 chars");
|
|
6960
|
+
}
|
|
6961
|
+
const ticket = this._find(ticketId);
|
|
6962
|
+
ticket.status = "open";
|
|
6963
|
+
ticket.resolvedAt = null;
|
|
6964
|
+
ticket.resolvedNotifiedAt = null;
|
|
6965
|
+
ticket.satisfactionAttempts = 0;
|
|
6966
|
+
ticket.lastSatisfactionAttemptAt = null;
|
|
6967
|
+
ticket.satisfiedAt = null;
|
|
6968
|
+
ticket.rating = null;
|
|
6969
|
+
ticket.satisfactionComment = null;
|
|
6970
|
+
}
|
|
6631
6971
|
/** Test helper. */
|
|
6632
6972
|
__reset() {
|
|
6633
6973
|
this._tickets = [];
|
|
@@ -7664,7 +8004,7 @@ function createNeetruClient(config = {}) {
|
|
|
7664
8004
|
init_errors();
|
|
7665
8005
|
init_db_errors();
|
|
7666
8006
|
init_http();
|
|
7667
|
-
var VERSION = "3.
|
|
8007
|
+
var VERSION = "3.4.1";
|
|
7668
8008
|
|
|
7669
8009
|
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
7670
8010
|
exports.DEV_FIXTURE_USER = DEV_FIXTURE_USER;
|