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:
@@ -79,7 +79,7 @@ api.interceptors.response.use(
|
||||
|
||||
const apiError = error.response?.data?.error;
|
||||
if (apiError?.code) {
|
||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status));
|
||||
}
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const path = window.location.pathname;
|
||||
@@ -131,15 +131,29 @@ export class ApiClientError extends Error {
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
||||
public readonly payload?: unknown
|
||||
public readonly payload?: unknown,
|
||||
/** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */
|
||||
public readonly status?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
||||
/** Options for mutating requests. An `idempotencyKey` is sent as the
|
||||
* `Idempotency-Key` header so the server safely de-duplicates retries
|
||||
* (used by the offline outbox; harmless when omitted). */
|
||||
export interface WriteOptions {
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
function writeConfig(opts?: WriteOptions) {
|
||||
if (!opts?.idempotencyKey) return undefined;
|
||||
return { headers: { "Idempotency-Key": opts.idempotencyKey } };
|
||||
}
|
||||
|
||||
export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
||||
@@ -147,8 +161,8 @@ export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T>
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
@@ -156,8 +170,8 @@ export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T>
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
@@ -165,8 +179,8 @@ export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiDelete(url: string): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
||||
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url, writeConfig(opts));
|
||||
if (!data.success) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
|
||||
Reference in New Issue
Block a user