121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* Generic offline durability for the central API client. When a write happens
|
||
|
|
* while offline (or fails with a network error), it is enqueued in the outbox
|
||
|
|
* and an optimistic value is returned, so no write is ever lost — instead of the
|
||
|
|
* mutation throwing. The online path is unchanged apart from an idempotency key.
|
||
|
|
*
|
||
|
|
* A small set of endpoints are *online-only* (payments, billing, auth, SMS): these
|
||
|
|
* must never be queued — they throw {@link OfflineUnavailableError} when offline so
|
||
|
|
* the UI can tell the user to reconnect.
|
||
|
|
*/
|
||
|
|
import {
|
||
|
|
enqueueOutboxOp,
|
||
|
|
getOutboxCount,
|
||
|
|
getQueueCount,
|
||
|
|
type OutboxMethod,
|
||
|
|
} from "@/lib/offline/offline-db";
|
||
|
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||
|
|
|
||
|
|
/** Endpoints that require a live connection and must NOT be queued offline. */
|
||
|
|
const ONLINE_ONLY: RegExp[] = [
|
||
|
|
/\/api\/auth\//, // login / refresh / register / OTP
|
||
|
|
/\/api\/billing\b/, // checkout / verify / payment gateway
|
||
|
|
/\/payments?\b/, // taking payment against an order/shift
|
||
|
|
/\/api\/sms\b/, // sending SMS now / campaigns
|
||
|
|
/\/send-sms\b/,
|
||
|
|
/\/export\b/, // server-computed exports
|
||
|
|
];
|
||
|
|
|
||
|
|
export function isOnlineOnly(url: string): boolean {
|
||
|
|
return ONLINE_ONLY.some((re) => re.test(url));
|
||
|
|
}
|
||
|
|
|
||
|
|
export class OfflineUnavailableError extends Error {
|
||
|
|
readonly code = "OFFLINE_UNAVAILABLE";
|
||
|
|
constructor(message = "This action needs an internet connection.") {
|
||
|
|
super(message);
|
||
|
|
this.name = "OfflineUnavailableError";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isNetworkError(err: unknown): boolean {
|
||
|
|
if (err instanceof TypeError) {
|
||
|
|
const msg = err.message.toLowerCase();
|
||
|
|
return (
|
||
|
|
msg.includes("failed to fetch") ||
|
||
|
|
msg.includes("networkerror") ||
|
||
|
|
msg.includes("load failed") ||
|
||
|
|
msg.includes("network request failed")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const ax = err as { isAxiosError?: boolean; response?: unknown };
|
||
|
|
return !!ax?.isAxiosError && !ax.response;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function newIdempotencyKey(): string {
|
||
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||
|
|
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function newLocalId(): string {
|
||
|
|
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Best-effort entity kind from a URL (last non-id path segment). */
|
||
|
|
export function entityTypeFromUrl(url: string): string {
|
||
|
|
const path = (url.split("?")[0] ?? "").replace(/^\/api\//, "");
|
||
|
|
const segs = path.split("/").filter(Boolean);
|
||
|
|
for (let i = segs.length - 1; i >= 0; i--) {
|
||
|
|
const s = segs[i];
|
||
|
|
const looksLikeId = /^[0-9a-f]{16,}$/i.test(s) || s.startsWith("local_");
|
||
|
|
if (!looksLikeId) return s;
|
||
|
|
}
|
||
|
|
return segs[0] ?? "entity";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshBadge(): Promise<void> {
|
||
|
|
const n = (await getOutboxCount()) + (await getQueueCount());
|
||
|
|
useSyncQueueStore.getState().setQueueCount(n);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Enqueue a write to the outbox and synthesize an optimistic return value.
|
||
|
|
* POST → treated as a create (local id, remappable later); PUT/PATCH → echo the
|
||
|
|
* body; DELETE → void.
|
||
|
|
*/
|
||
|
|
export async function queueWrite(
|
||
|
|
method: OutboxMethod,
|
||
|
|
url: string,
|
||
|
|
body: unknown,
|
||
|
|
idempotencyKey: string
|
||
|
|
): Promise<unknown> {
|
||
|
|
let createsClientId: string | undefined;
|
||
|
|
let optimistic: unknown;
|
||
|
|
|
||
|
|
if (method === "POST") {
|
||
|
|
createsClientId = newLocalId();
|
||
|
|
optimistic =
|
||
|
|
body && typeof body === "object"
|
||
|
|
? { id: createsClientId, ...(body as Record<string, unknown>) }
|
||
|
|
: { id: createsClientId };
|
||
|
|
} else if (method === "DELETE") {
|
||
|
|
optimistic = undefined;
|
||
|
|
} else {
|
||
|
|
optimistic = body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : body;
|
||
|
|
}
|
||
|
|
|
||
|
|
await enqueueOutboxOp({
|
||
|
|
id: newLocalId(),
|
||
|
|
idempotencyKey,
|
||
|
|
method,
|
||
|
|
url,
|
||
|
|
body,
|
||
|
|
entityType: entityTypeFromUrl(url),
|
||
|
|
createsClientId,
|
||
|
|
idField: "id",
|
||
|
|
createdAt: Date.now(),
|
||
|
|
});
|
||
|
|
await refreshBadge();
|
||
|
|
return optimistic;
|
||
|
|
}
|