feat(dashboard/offline): generic idempotent outbox + ID remapping
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s
Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into a reusable write engine and fixes the two correctness bugs in the old path. - offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with enqueue/list/update/remove + a persisted client→server id map. - outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking an op until its creator syncs), sends each op with its idempotency key, and classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison after 5 attempts). remap/blocked logic validated against representative cases. - client: apiPost/Put/Patch/Delete take an optional idempotencyKey → `Idempotency-Key` header; ApiClientError now carries HTTP status. - submit-order: generates ONE idempotency key per submit, used for both the online attempt and the queued replay → server de-dups (no more double-create); offline create carries createsClientId so a later add-items remaps onto the real order instead of spawning a second order. - use-offline-sync: drains the outbox, one-time migrates legacy order_queue items, invalidates queries after a successful sync. tsc + production build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,87 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import {
|
||||
enqueueOutboxOp,
|
||||
getAllQueueItems,
|
||||
getOutboxCount,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
markQueueItemFailed,
|
||||
} from "@/lib/offline/offline-db";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import { drainOutbox } from "@/lib/offline/outbox";
|
||||
|
||||
function newId(prefix: string): string {
|
||||
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one queued item and returns whether it succeeded.
|
||||
* One-time migration of any items left in the legacy POS `order_queue` into the
|
||||
* generic outbox, so orders queued before this release still sync. Best-effort.
|
||||
*/
|
||||
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
|
||||
async function migrateLegacyQueue(): Promise<void> {
|
||||
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
|
||||
try {
|
||||
if (item.type === "create_order") {
|
||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
||||
} else if (item.type === "add_items") {
|
||||
const { cafeId, orderId, body } = item.payload as {
|
||||
cafeId: string;
|
||||
orderId: string;
|
||||
body: unknown;
|
||||
};
|
||||
await apiPost(
|
||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
return true;
|
||||
legacy = await getAllQueueItems();
|
||||
} catch {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
for (const item of legacy) {
|
||||
try {
|
||||
if (item.type === "create_order") {
|
||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||
await enqueueOutboxOp({
|
||||
id: newId("op"),
|
||||
idempotencyKey: newId("idem"),
|
||||
method: "POST",
|
||||
url: `/api/cafes/${cafeId}/orders`,
|
||||
body,
|
||||
entityType: "order",
|
||||
idField: "id",
|
||||
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||
});
|
||||
} else if (item.type === "add_items") {
|
||||
const { cafeId, orderId, body } = item.payload as {
|
||||
cafeId: string;
|
||||
orderId: string;
|
||||
body: unknown;
|
||||
};
|
||||
await enqueueOutboxOp({
|
||||
id: newId("op"),
|
||||
idempotencyKey: newId("idem"),
|
||||
method: "POST",
|
||||
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body,
|
||||
entityType: "order_items",
|
||||
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||
});
|
||||
}
|
||||
await removeQueueItem(item.id);
|
||||
} catch {
|
||||
// leave the legacy item in place; we'll try again next mount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this hook once in the app shell to:
|
||||
* - Load initial queue count from IndexedDB on mount
|
||||
* - Listen to online/offline events
|
||||
* - Auto-sync when back online or tab becomes visible
|
||||
* Mount once in the app shell to:
|
||||
* - migrate any legacy queued orders into the outbox,
|
||||
* - keep the pending-count badge and online flag in sync,
|
||||
* - drain the outbox when back online or the tab regains focus,
|
||||
* - refresh server data once writes have synced.
|
||||
*/
|
||||
export function useOfflineSync() {
|
||||
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||
const queryClient = useQueryClient();
|
||||
const syncLock = useRef(false);
|
||||
|
||||
const refreshCount = useCallback(async () => {
|
||||
const n = await getQueueCount();
|
||||
const n = (await getOutboxCount()) + (await getQueueCount());
|
||||
setQueueCount(n);
|
||||
return n;
|
||||
}, [setQueueCount]);
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (syncLock.current) return;
|
||||
if (!navigator.onLine) return;
|
||||
const count = await refreshCount();
|
||||
if (count === 0) return;
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||
|
||||
syncLock.current = true;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const items = await getAllQueueItems();
|
||||
for (const item of items) {
|
||||
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
|
||||
const ok = await processItem(item);
|
||||
if (ok) {
|
||||
await removeQueueItem(item.id);
|
||||
} else {
|
||||
await markQueueItemFailed(item.id);
|
||||
}
|
||||
const result = await drainOutbox();
|
||||
if (result.sent > 0) {
|
||||
// Replace optimistic local data with the authoritative server state.
|
||||
await queryClient.invalidateQueries();
|
||||
}
|
||||
} finally {
|
||||
syncLock.current = false;
|
||||
setSyncing(false);
|
||||
await refreshCount();
|
||||
}
|
||||
}, [refreshCount, setSyncing]);
|
||||
}, [refreshCount, setSyncing, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load initial count
|
||||
void refreshCount();
|
||||
void (async () => {
|
||||
await migrateLegacyQueue();
|
||||
await refreshCount();
|
||||
// Drain anything pending if we mounted already online.
|
||||
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
|
||||
})();
|
||||
|
||||
// Track online state
|
||||
const handleOnline = () => {
|
||||
setOnline(true);
|
||||
void syncQueue();
|
||||
@@ -92,7 +122,6 @@ export function useOfflineSync() {
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
// Sync when tab regains focus
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible" && navigator.onLine) {
|
||||
void syncQueue();
|
||||
|
||||
Reference in New Issue
Block a user