MAI CALL - step 12
Passo 12 completo. Build limpo, AC server-side totalmente verificado. O que foi implementado: lib/queue/ — camada de persistência offline: db.ts — Dexie 4 com tabelas pending e deadLetters broadcast.ts — BroadcastChannel helper (mai-call-sync) para comunicar entre tabs sync.ts — loop de sync com retry/backoff: signPhotoUpload → PUT MinIO → create; 409 = sucesso; 4xx = dead-letter; erros de rede = paragem + retry na próxima volta SyncProvider — React Context que: Arranca sync ao reconectar (online event + visibilitychange) Polling de 10s como fallback Regista Background Sync API quando disponível Expõe pendingCount / deadLetterCount via useSyncState() Formulário (/maintenance/new) — refatorado: ao submeter, escreve em IndexedDB e navega imediatamente para /sent sem esperar pelo servidor. O SyncProvider processa a fila em background. Feedback visual: SyncChip na home: "Tudo sincronizado" / "N pedidos por enviar" / erro dead-letter /maintenance/sent: mostra "Em fila" (Clock) ou "Enviado" (CheckCircle2) reactivamente via BroadcastChannel Workbox (@ducanh2912/next-pwa) — app shell precaching ativo, para que o app carregue mesmo sem rede depois da primeira visita.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
export const CHANNEL = 'mai-call-sync';
|
||||
|
||||
export type SyncMessage =
|
||||
| { type: 'queue-update'; pendingCount: number }
|
||||
| { type: 'synced'; clientRequestId: string }
|
||||
| { type: 'dead-letter'; clientRequestId: string };
|
||||
|
||||
let _ch: BroadcastChannel | null = null;
|
||||
|
||||
function ch(): BroadcastChannel | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (!_ch) _ch = new BroadcastChannel(CHANNEL);
|
||||
return _ch;
|
||||
}
|
||||
|
||||
export function broadcast(msg: SyncMessage): void {
|
||||
ch()?.postMessage(msg);
|
||||
}
|
||||
|
||||
export function subscribeBroadcast(handler: (msg: SyncMessage) => void): () => void {
|
||||
const c = ch();
|
||||
if (!c) return () => {};
|
||||
const listener = (e: MessageEvent<SyncMessage>) => handler(e.data);
|
||||
c.addEventListener('message', listener);
|
||||
return () => c.removeEventListener('message', listener);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
|
||||
export interface PendingRequest {
|
||||
clientRequestId: string; // primary key
|
||||
workstationId: string;
|
||||
description: string;
|
||||
photoBlob?: Blob;
|
||||
queuedAt: number;
|
||||
retries: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface DeadLetter {
|
||||
clientRequestId: string;
|
||||
error: string;
|
||||
failedAt: number;
|
||||
}
|
||||
|
||||
class MaiCallDb extends Dexie {
|
||||
pending!: Table<PendingRequest, string>;
|
||||
deadLetters!: Table<DeadLetter, string>;
|
||||
|
||||
constructor() {
|
||||
super('mai-call-v1');
|
||||
this.version(1).stores({
|
||||
pending: 'clientRequestId, queuedAt',
|
||||
deadLetters: 'clientRequestId',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Single browser-side instance. Guards against SSR import.
|
||||
function makeDb(): MaiCallDb {
|
||||
if (typeof window === 'undefined') {
|
||||
return null as unknown as MaiCallDb; // never called server-side
|
||||
}
|
||||
return new MaiCallDb();
|
||||
}
|
||||
|
||||
export const db = makeDb();
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import superjson from 'superjson';
|
||||
import type { AppRouter } from '@repo/api';
|
||||
import { db } from './db';
|
||||
import { broadcast } from './broadcast';
|
||||
|
||||
let _client: ReturnType<typeof createTRPCClient<AppRouter>> | null = null;
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
_client = createTRPCClient<AppRouter>({
|
||||
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
function httpStatus(err: unknown): number | null {
|
||||
if (typeof err === 'object' && err !== null && 'data' in err) {
|
||||
const d = (err as { data?: { httpStatus?: number } }).data;
|
||||
return d?.httpStatus ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNetworkErr(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
let _running = false;
|
||||
|
||||
export async function runSync(): Promise<void> {
|
||||
if (_running || !navigator.onLine) return;
|
||||
_running = true;
|
||||
try {
|
||||
const items = await db.pending.orderBy('queuedAt').toArray();
|
||||
if (!items.length) return;
|
||||
|
||||
const client = getClient();
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
let photoKey: string | undefined;
|
||||
|
||||
if (item.photoBlob) {
|
||||
const { uploadUrl, photoKey: key } = await client.storage.signPhotoUpload.mutate({
|
||||
contentType: 'image/jpeg',
|
||||
byteSize: item.photoBlob.size,
|
||||
});
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: item.photoBlob,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Photo PUT ${res.status}`);
|
||||
photoKey = key;
|
||||
}
|
||||
|
||||
await client.maintenanceRequest.create.mutate({
|
||||
workstationId: item.workstationId,
|
||||
description: item.description,
|
||||
photoKey,
|
||||
clientRequestId: item.clientRequestId,
|
||||
});
|
||||
|
||||
await db.pending.delete(item.clientRequestId);
|
||||
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
|
||||
} catch (err) {
|
||||
if (isNetworkErr(err)) break; // no connectivity — stop this cycle
|
||||
|
||||
const status = httpStatus(err);
|
||||
|
||||
if (status === 409) {
|
||||
// Already exists (duplicate clientRequestId) — idempotent success
|
||||
await db.pending.delete(item.clientRequestId);
|
||||
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status !== null && status >= 400 && status < 500) {
|
||||
// 4xx other than 409 — permanent failure, move to dead-letter
|
||||
await db.deadLetters.put({
|
||||
clientRequestId: item.clientRequestId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
failedAt: Date.now(),
|
||||
});
|
||||
await db.pending.delete(item.clientRequestId);
|
||||
broadcast({ type: 'dead-letter', clientRequestId: item.clientRequestId });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5xx / unknown — increment retries, leave in queue
|
||||
await db.pending.update(item.clientRequestId, {
|
||||
retries: (item.retries ?? 0) + 1,
|
||||
lastError: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await db.pending.count();
|
||||
broadcast({ type: 'queue-update', pendingCount: remaining });
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user