@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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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
|
+
|
|
26
|
+
## [3.4.0] - 2026-08-15 — **feat(support): superfície "Product Feedback" — markResolvedSeen/recordSatisfactionAttempt/submitSatisfaction/contestTicket**
|
|
27
|
+
|
|
28
|
+
Release **minor** (API pública aditiva no namespace `client.support`: 4 métodos novos + 9 campos novos em `SupportTicket` + 3 opcionais em `CreateTicketInput`).
|
|
29
|
+
|
|
30
|
+
### Contexto
|
|
31
|
+
|
|
32
|
+
Camada de dados do fluxo "Product Feedback" (report → resolvido → satisfação → contestação), contrato compartilhado entre Core (endpoints+schema), SDK (`@neetru/sdk` namespace `support`) e `@neetru/product-feedback` (neetru-libs). Ver `CONTRATO_PRODUCT_FEEDBACK_2026-08-15.md`. Decisões do owner: rating CSAT 1-5 (`number | null`); contestação incluída no v1 (4 estados).
|
|
33
|
+
|
|
34
|
+
### Adicionado
|
|
35
|
+
|
|
36
|
+
- **`createTicket(input)`**: aceita opcionais `pageUrl?: string`, `screenshotBase64?: string`, `consoleLogs?: string` — propagados no body do POST quando presentes.
|
|
37
|
+
- **`SupportTicket`**: campos novos (todos nullable, mapeados defensivamente em `toTicket()`) — `pageUrl`, `screenshotUrl` (= `attachments[0] ?? null`), `resolvedAt`, `resolvedNotifiedAt`, `satisfactionAttempts` (default `0`), `lastSatisfactionAttemptAt`, `satisfiedAt`, `rating`, `satisfactionComment`.
|
|
38
|
+
- **`markResolvedSeen(ticketId)`**: `POST /api/sdk/v1/support/tickets/{id}/resolved-seen` — marca a notificação de resolução como vista (idempotente no Core).
|
|
39
|
+
- **`recordSatisfactionAttempt(ticketId)`**: `POST .../satisfaction-attempt` — registra exibição do modal de satisfação (regra-chave do fluxo: exibir já conta como tentativa, evita reprompt infinito).
|
|
40
|
+
- **`submitSatisfaction({ ticketId, rating, comment?, skipped })`**: `POST .../satisfaction` — envia (ou pula) a avaliação CSAT.
|
|
41
|
+
- **`contestTicket(ticketId, { comment? })`**: `POST .../contest` — reabre um ticket resolvido e zera o ciclo de satisfação/notificação.
|
|
42
|
+
- Validação client-side antes de cada request: `ticketId` non-empty (`validation_failed`); em `submitSatisfaction`, `rating` inteiro 1-5 quando não-`null`; `comment` ≤ 2000 chars.
|
|
43
|
+
- `MockSupport` espelha os 4 métodos novos in-memory (FSM coerente com o contrato) — modo dev/test sem fetch real.
|
|
44
|
+
- Testes novos cobrindo cada método (path, `requireAuth`, validação client-side), propagação dos campos novos em `createTicket`, e mapeamento defensivo em `toTicket()`.
|
|
45
|
+
|
|
46
|
+
### Corrigido (incluído neste minor — `bug_58811a43`)
|
|
47
|
+
|
|
48
|
+
- **JSDoc de `NeetruDbOptions.dbId`** (`src/db/client-db.ts`): reescrito pra descrever o comportamento real (nomeia o cache offline LOCAL + filtro de subscription `nosql-vm`); explicita o que NÃO faz (não escopa as escritas REST — o escopo é `x-neetru-tenant`; não é usado pelo lease SQL, que usa `SqlOptions.database`). O texto antigo ("necessário para scoping das coleções") enganava.
|
|
49
|
+
- **Aviso dev-only one-time** `warnMissingDbIdOnce` (`createNeetruDb`): `console.warn` único em contexto browser + fora de `prod` quando `dbId` está ausente. **Sem mudança de comportamento de wire.**
|
|
50
|
+
|
|
8
51
|
## [3.3.0] - 2026-08-09 — **feat(db-sql): motor de ambientes "suspenso" — `db_suspended` não-retryável + lease inicial LAZY**
|
|
9
52
|
|
|
10
53
|
Release **minor** (API pública aditiva: novo `NeetruDbErrorCode` `db_suspended` + `NeetruError.reason`; `client.db.sql()` deixa de explodir o import quando o ambiente está fora no boot).
|
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
|
@@ -1584,6 +1584,7 @@ function toTicket(raw) {
|
|
|
1584
1584
|
if (typeof r.id !== "string") {
|
|
1585
1585
|
throw new 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 NeetruError("validation_failed", "ticketId is required");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function assertValidComment(comment) {
|
|
1614
|
+
if (typeof comment === "string" && comment.length > 2e3) {
|
|
1615
|
+
throw new 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 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 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
|
|
@@ -2517,6 +2633,10 @@ function hasFieldSentinel(data) {
|
|
|
2517
2633
|
}
|
|
2518
2634
|
|
|
2519
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";
|
|
2520
2640
|
function hasSentinels(payload) {
|
|
2521
2641
|
return payload !== null && hasFieldSentinel(payload);
|
|
2522
2642
|
}
|
|
@@ -2551,6 +2671,8 @@ var SyncEngine = class {
|
|
|
2551
2671
|
_state;
|
|
2552
2672
|
/** Listeners de mudança de SyncState. */
|
|
2553
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();
|
|
2554
2676
|
/** Cleanup functions dos listeners externos. */
|
|
2555
2677
|
_cleanups = [];
|
|
2556
2678
|
/** Timer de resync periódico. */
|
|
@@ -2609,6 +2731,19 @@ var SyncEngine = class {
|
|
|
2609
2731
|
this._stateListeners.delete(cb);
|
|
2610
2732
|
};
|
|
2611
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
|
+
}
|
|
2612
2747
|
/**
|
|
2613
2748
|
* Força um sync imediato (I3 §10.3 `flush()`).
|
|
2614
2749
|
* Resolve quando a fila esvazia OU rejeita se o engine estiver destruído.
|
|
@@ -2641,8 +2776,10 @@ var SyncEngine = class {
|
|
|
2641
2776
|
}
|
|
2642
2777
|
await this._phase2Pull();
|
|
2643
2778
|
this._phase3Realtime();
|
|
2644
|
-
this._updateState({ status: "idle", lastSyncedAt: Date.now() });
|
|
2779
|
+
this._updateState({ status: "idle", lastSyncedAt: Date.now(), lastError: null });
|
|
2645
2780
|
} catch (err) {
|
|
2781
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2782
|
+
this._recordSyncError("cycle", `ciclo de sync falhou: ${msg}`);
|
|
2646
2783
|
this._updateState({ status: "idle" });
|
|
2647
2784
|
} finally {
|
|
2648
2785
|
this._syncing = false;
|
|
@@ -2677,6 +2814,9 @@ var SyncEngine = class {
|
|
|
2677
2814
|
async _phase1Push() {
|
|
2678
2815
|
const mutations = await this._queue.drain();
|
|
2679
2816
|
if (mutations.length === 0) return true;
|
|
2817
|
+
if (this._isInBackoff(mutations[0])) {
|
|
2818
|
+
return true;
|
|
2819
|
+
}
|
|
2680
2820
|
for (const mut of mutations) {
|
|
2681
2821
|
await this._queue.markInflight(mut.mutationId);
|
|
2682
2822
|
}
|
|
@@ -2684,10 +2824,16 @@ var SyncEngine = class {
|
|
|
2684
2824
|
try {
|
|
2685
2825
|
result = await this._transport.pushMutations(mutations);
|
|
2686
2826
|
} catch (err) {
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
+
});
|
|
2691
2837
|
return false;
|
|
2692
2838
|
}
|
|
2693
2839
|
const busChanges = [];
|
|
@@ -2714,8 +2860,16 @@ var SyncEngine = class {
|
|
|
2714
2860
|
}
|
|
2715
2861
|
const processedIds = new Set(result.results.map((r) => r.mutationId));
|
|
2716
2862
|
const unprocessed = mutations.filter((m) => !processedIds.has(m.mutationId));
|
|
2717
|
-
|
|
2718
|
-
|
|
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
|
+
}
|
|
2719
2873
|
}
|
|
2720
2874
|
if (busChanges.length > 0) {
|
|
2721
2875
|
this._bus.emit(busChanges);
|
|
@@ -2907,6 +3061,67 @@ var SyncEngine = class {
|
|
|
2907
3061
|
});
|
|
2908
3062
|
}
|
|
2909
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
|
+
}
|
|
2910
3125
|
// ─── FASE 2 — pull (reconciliar o cache) ─────────────────────────────────────
|
|
2911
3126
|
/**
|
|
2912
3127
|
* Busca mudanças do servidor e aplica ao cache via ConflictResolver.
|
|
@@ -2946,10 +3161,14 @@ var SyncEngine = class {
|
|
|
2946
3161
|
}
|
|
2947
3162
|
const busChanges = [];
|
|
2948
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);
|
|
2949
3167
|
const returnedKeys = new Set(
|
|
2950
3168
|
resyncResult.docs.map((d) => `${d.collection}::${d.id}`)
|
|
2951
3169
|
);
|
|
2952
3170
|
for (const col of collections) {
|
|
3171
|
+
if (truncatedSet.has(col)) continue;
|
|
2953
3172
|
const queryResult = await this._store.listDocs(col, {});
|
|
2954
3173
|
for (const { id } of queryResult.docs) {
|
|
2955
3174
|
const key = `${col}::${id}`;
|
|
@@ -3109,7 +3328,8 @@ var SyncEngine = class {
|
|
|
3109
3328
|
status: isOnline ? "idle" : "offline",
|
|
3110
3329
|
pendingWrites: 0,
|
|
3111
3330
|
lastSyncedAt: null,
|
|
3112
|
-
isLeaderTab: this._tabCoordinator.isLeader()
|
|
3331
|
+
isLeaderTab: this._tabCoordinator.isLeader(),
|
|
3332
|
+
lastError: null
|
|
3113
3333
|
};
|
|
3114
3334
|
}
|
|
3115
3335
|
/**
|
|
@@ -3119,7 +3339,7 @@ var SyncEngine = class {
|
|
|
3119
3339
|
_updateState(partial) {
|
|
3120
3340
|
const prev = this._state;
|
|
3121
3341
|
const next = { ...prev, ...partial };
|
|
3122
|
-
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);
|
|
3123
3343
|
this._state = next;
|
|
3124
3344
|
if (changed) {
|
|
3125
3345
|
this._emitState(next);
|
|
@@ -3133,6 +3353,21 @@ var SyncEngine = class {
|
|
|
3133
3353
|
}
|
|
3134
3354
|
}
|
|
3135
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
|
+
}
|
|
3136
3371
|
/**
|
|
3137
3372
|
* Atualiza `pendingWrites` no estado.
|
|
3138
3373
|
* Chamado de forma assíncrona quando necessário.
|
|
@@ -3942,7 +4177,9 @@ var DbCollectionRefImpl = class {
|
|
|
3942
4177
|
const isOnline = this._isOnline();
|
|
3943
4178
|
const syncState = this._engine.getSyncState();
|
|
3944
4179
|
const nextCursor = buildNextCursor(result.docs, effectiveLimit);
|
|
3945
|
-
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;
|
|
3946
4183
|
const fromCache = hasPending || syncState.lastSyncedAt === null || stale;
|
|
3947
4184
|
return {
|
|
3948
4185
|
docs: result.docs.map((d) => ({ id: d.id, data: d.data })),
|
|
@@ -3950,7 +4187,8 @@ var DbCollectionRefImpl = class {
|
|
|
3950
4187
|
fromCache,
|
|
3951
4188
|
stale,
|
|
3952
4189
|
hasPendingWrites: hasPending,
|
|
3953
|
-
changes: []
|
|
4190
|
+
changes: [],
|
|
4191
|
+
truncated
|
|
3954
4192
|
};
|
|
3955
4193
|
}
|
|
3956
4194
|
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -4396,6 +4634,9 @@ var NeetruDbDocumentsImpl = class {
|
|
|
4396
4634
|
async getConflicts() {
|
|
4397
4635
|
return this._store.listConflicts();
|
|
4398
4636
|
}
|
|
4637
|
+
onError(cb) {
|
|
4638
|
+
return this._engine.onError(cb);
|
|
4639
|
+
}
|
|
4399
4640
|
};
|
|
4400
4641
|
async function createOfflineDocumentsNamespace(opts) {
|
|
4401
4642
|
const store = isIndexedDBUnavailable() ? new MemoryStore() : new LocalStore(opts.dbName);
|
|
@@ -4871,6 +5112,7 @@ var defaultWebSocketFactory = (url) => {
|
|
|
4871
5112
|
// src/db/client-db.ts
|
|
4872
5113
|
var DATASTORE_COLLECTION_ENDPOINT = (collection) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}`;
|
|
4873
5114
|
var DATASTORE_DOC_ENDPOINT = (collection, docId) => `/api/sdk/v1/datastore/${encodeURIComponent(collection)}/${encodeURIComponent(docId)}`;
|
|
5115
|
+
var DOCUMENTS_RESYNC_PAGE_CAP = 200;
|
|
4874
5116
|
function resolveTransport(transport, opts, config) {
|
|
4875
5117
|
if (opts._transport) {
|
|
4876
5118
|
return opts._transport;
|
|
@@ -4983,6 +5225,7 @@ function createRestSyncTransport(config) {
|
|
|
4983
5225
|
const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
|
|
4984
5226
|
const now = Date.now();
|
|
4985
5227
|
const docs = [];
|
|
5228
|
+
const truncatedCollections = [];
|
|
4986
5229
|
for (const collection of collections) {
|
|
4987
5230
|
let raw;
|
|
4988
5231
|
try {
|
|
@@ -4992,11 +5235,11 @@ function createRestSyncTransport(config) {
|
|
|
4992
5235
|
requireAuth: true,
|
|
4993
5236
|
retries: 2,
|
|
4994
5237
|
headers: tenantHeaders,
|
|
4995
|
-
// Teto do Core
|
|
4996
|
-
//
|
|
4997
|
-
// deleção-por-ausência
|
|
4998
|
-
//
|
|
4999
|
-
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 }
|
|
5000
5243
|
});
|
|
5001
5244
|
} catch (err) {
|
|
5002
5245
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -5007,6 +5250,9 @@ function createRestSyncTransport(config) {
|
|
|
5007
5250
|
}
|
|
5008
5251
|
const resp = raw;
|
|
5009
5252
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
|
5253
|
+
if (items.length >= DOCUMENTS_RESYNC_PAGE_CAP) {
|
|
5254
|
+
truncatedCollections.push(collection);
|
|
5255
|
+
}
|
|
5010
5256
|
for (const item of items) {
|
|
5011
5257
|
if (!item || typeof item !== "object") continue;
|
|
5012
5258
|
const record = item;
|
|
@@ -5034,7 +5280,7 @@ function createRestSyncTransport(config) {
|
|
|
5034
5280
|
});
|
|
5035
5281
|
}
|
|
5036
5282
|
}
|
|
5037
|
-
return { docs, newWatermark: now };
|
|
5283
|
+
return { docs, newWatermark: now, truncatedCollections };
|
|
5038
5284
|
}
|
|
5039
5285
|
};
|
|
5040
5286
|
}
|
|
@@ -5096,7 +5342,20 @@ function buildTicketProvider(config, opts) {
|
|
|
5096
5342
|
return ticket;
|
|
5097
5343
|
};
|
|
5098
5344
|
}
|
|
5345
|
+
var _hasWarnedDbIdIgnored = false;
|
|
5346
|
+
function warnDbIdIgnoredOnce(config, dbOpts) {
|
|
5347
|
+
if (_hasWarnedDbIdIgnored) return;
|
|
5348
|
+
if (!dbOpts.dbId) return;
|
|
5349
|
+
if (config.env === "prod") return;
|
|
5350
|
+
if (typeof window === "undefined") return;
|
|
5351
|
+
if (typeof console === "undefined" || typeof console.warn !== "function") return;
|
|
5352
|
+
_hasWarnedDbIdIgnored = true;
|
|
5353
|
+
console.warn(
|
|
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.'
|
|
5355
|
+
);
|
|
5356
|
+
}
|
|
5099
5357
|
function createNeetruDb(config, dbOpts = {}) {
|
|
5358
|
+
warnDbIdIgnoredOnce(config, dbOpts);
|
|
5100
5359
|
let resolvedTransport;
|
|
5101
5360
|
if (dbOpts.transport !== void 0) {
|
|
5102
5361
|
resolvedTransport = dbOpts.transport;
|
|
@@ -5174,6 +5433,20 @@ function createNeetruDb(config, dbOpts = {}) {
|
|
|
5174
5433
|
async getConflicts() {
|
|
5175
5434
|
const docs = await getDocsPromise();
|
|
5176
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
|
+
};
|
|
5177
5450
|
}
|
|
5178
5451
|
};
|
|
5179
5452
|
}
|
|
@@ -6519,7 +6792,18 @@ var MockSupport = class {
|
|
|
6519
6792
|
severity: input.severity ?? "normal",
|
|
6520
6793
|
status: "open",
|
|
6521
6794
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6522
|
-
productSlug: input.productSlug
|
|
6795
|
+
productSlug: input.productSlug,
|
|
6796
|
+
// 3.4 (product-feedback): espelha o contrato — campos novos nullable,
|
|
6797
|
+
// populados a partir do input quando fornecidos.
|
|
6798
|
+
pageUrl: typeof input.pageUrl === "string" ? input.pageUrl : null,
|
|
6799
|
+
screenshotUrl: typeof input.screenshotBase64 === "string" ? input.screenshotBase64 : null,
|
|
6800
|
+
resolvedAt: null,
|
|
6801
|
+
resolvedNotifiedAt: null,
|
|
6802
|
+
satisfactionAttempts: 0,
|
|
6803
|
+
lastSatisfactionAttemptAt: null,
|
|
6804
|
+
satisfiedAt: null,
|
|
6805
|
+
rating: null,
|
|
6806
|
+
satisfactionComment: null
|
|
6523
6807
|
};
|
|
6524
6808
|
this._tickets.push(ticket);
|
|
6525
6809
|
return ticket;
|
|
@@ -6530,6 +6814,62 @@ var MockSupport = class {
|
|
|
6530
6814
|
}
|
|
6531
6815
|
return [...this._tickets];
|
|
6532
6816
|
}
|
|
6817
|
+
_find(ticketId) {
|
|
6818
|
+
if (!ticketId || typeof ticketId !== "string") {
|
|
6819
|
+
throw new NeetruError("validation_failed", "ticketId is required");
|
|
6820
|
+
}
|
|
6821
|
+
const ticket = this._tickets.find((t) => t.id === ticketId);
|
|
6822
|
+
if (!ticket) {
|
|
6823
|
+
throw new NeetruError("not_found", `ticket ${ticketId} not found`);
|
|
6824
|
+
}
|
|
6825
|
+
return ticket;
|
|
6826
|
+
}
|
|
6827
|
+
/** 3.4: espelha `markResolvedSeen` — idempotente (só seta se ainda null). */
|
|
6828
|
+
async markResolvedSeen(ticketId) {
|
|
6829
|
+
const ticket = this._find(ticketId);
|
|
6830
|
+
if (ticket.resolvedNotifiedAt === null) {
|
|
6831
|
+
ticket.resolvedNotifiedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6832
|
+
}
|
|
6833
|
+
}
|
|
6834
|
+
/** 3.4: espelha `recordSatisfactionAttempt` — incrementa contador. */
|
|
6835
|
+
async recordSatisfactionAttempt(ticketId) {
|
|
6836
|
+
const ticket = this._find(ticketId);
|
|
6837
|
+
ticket.satisfactionAttempts += 1;
|
|
6838
|
+
ticket.lastSatisfactionAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6839
|
+
}
|
|
6840
|
+
/** 3.4: espelha `submitSatisfaction` — terminal, seta `satisfiedAt`. */
|
|
6841
|
+
async submitSatisfaction(input) {
|
|
6842
|
+
if (!input || typeof input !== "object") {
|
|
6843
|
+
throw new NeetruError("validation_failed", "input is required");
|
|
6844
|
+
}
|
|
6845
|
+
if (input.rating !== null && input.rating !== void 0) {
|
|
6846
|
+
if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) {
|
|
6847
|
+
throw new NeetruError("validation_failed", "rating must be an integer 1-5 or null");
|
|
6848
|
+
}
|
|
6849
|
+
}
|
|
6850
|
+
if (typeof input.comment === "string" && input.comment.length > 2e3) {
|
|
6851
|
+
throw new NeetruError("validation_failed", "comment max 2000 chars");
|
|
6852
|
+
}
|
|
6853
|
+
const ticket = this._find(input.ticketId);
|
|
6854
|
+
ticket.satisfiedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6855
|
+
ticket.rating = input.rating ?? null;
|
|
6856
|
+
ticket.satisfactionComment = input.comment ?? null;
|
|
6857
|
+
}
|
|
6858
|
+
/** 3.4: espelha `contestTicket` — reabre e zera o ciclo de satisfação. */
|
|
6859
|
+
async contestTicket(ticketId, opts) {
|
|
6860
|
+
if (typeof opts?.comment === "string" && opts.comment.length > 2e3) {
|
|
6861
|
+
throw new NeetruError("validation_failed", "comment max 2000 chars");
|
|
6862
|
+
}
|
|
6863
|
+
const ticket = this._find(ticketId);
|
|
6864
|
+
ticket.status = "open";
|
|
6865
|
+
ticket.resolvedAt = null;
|
|
6866
|
+
ticket.resolvedNotifiedAt = null;
|
|
6867
|
+
ticket.satisfactionAttempts = 0;
|
|
6868
|
+
ticket.lastSatisfactionAttemptAt = null;
|
|
6869
|
+
ticket.satisfiedAt = null;
|
|
6870
|
+
ticket.rating = null;
|
|
6871
|
+
ticket.satisfactionComment = null;
|
|
6872
|
+
}
|
|
6533
6873
|
/** Test helper. */
|
|
6534
6874
|
__reset() {
|
|
6535
6875
|
this._tickets = [];
|