feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getConnectivity, reportRequestOutcome, subscribeConnectivity } from "./connectivity";
|
||||
|
||||
function setOnLine(value: boolean) {
|
||||
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
|
||||
}
|
||||
|
||||
describe("connectivity", () => {
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("goes offline after a network error and online after the next server answer", () => {
|
||||
setOnLine(true);
|
||||
expect(getConnectivity()).toBe("online");
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeConnectivity(listener);
|
||||
|
||||
reportRequestOutcome("network-error");
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
reportRequestOutcome("reached-server");
|
||||
expect(getConnectivity()).toBe("online");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("is offline while navigator.onLine is false", () => {
|
||||
setOnLine(false);
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
reportRequestOutcome("reached-server");
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
});
|
||||
|
||||
it("notifies on browser online/offline events", () => {
|
||||
setOnLine(true);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeConnectivity(listener);
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
window.dispatchEvent(new Event("online"));
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Connectivity = "online" | "offline";
|
||||
export type RequestOutcome = "reached-server" | "network-error";
|
||||
|
||||
// Offline = the browser says so, or the last request failed at network level
|
||||
// and no request has reached the server since. navigator.onLine alone is
|
||||
// unreliable (it stays true on a captive portal or a dead uplink).
|
||||
let lastOutcome: RequestOutcome = "reached-server";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function reportRequestOutcome(outcome: RequestOutcome): void {
|
||||
if (outcome === lastOutcome) {
|
||||
return;
|
||||
}
|
||||
lastOutcome = outcome;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getConnectivity(): Connectivity {
|
||||
const browserOffline = typeof navigator !== "undefined" && navigator.onLine === false;
|
||||
return browserOffline || lastOutcome === "network-error" ? "offline" : "online";
|
||||
}
|
||||
|
||||
export function subscribeConnectivity(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
window.addEventListener("online", listener);
|
||||
window.addEventListener("offline", listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("online", listener);
|
||||
window.removeEventListener("offline", listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useConnectivity(): Connectivity {
|
||||
return useSyncExternalStore(subscribeConnectivity, getConnectivity, () => "online");
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { openDB } from "idb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type OfflineDbModule = typeof import("./db");
|
||||
|
||||
const DB_NAME = "apicore-offline";
|
||||
const VERSION_2_STORES = [
|
||||
"pending-inspections",
|
||||
"pending-treatments",
|
||||
"pending-feedings",
|
||||
"pending-varroa-measurements",
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
];
|
||||
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
|
||||
const FEEDING = {
|
||||
colonyId: "colony-1",
|
||||
date: "2026-10-01T00:00:00.000Z",
|
||||
feedType: "FEED_SYRUP" as const,
|
||||
amount: 2,
|
||||
unit: "kg",
|
||||
};
|
||||
const REJECTION = {
|
||||
status: 400,
|
||||
message: "Volk V-03 ist beendet.",
|
||||
at: "2026-10-13T09:00:00.000Z",
|
||||
};
|
||||
|
||||
function photo(id: string, inspectionIdempotencyKey: string, ownerUserId = "user-1") {
|
||||
return {
|
||||
id,
|
||||
sessionId: "session-1",
|
||||
ownerUserId,
|
||||
blob: new Blob(["jpeg"]),
|
||||
fileName: `${id}.jpg`,
|
||||
createdAt: "2026-10-13T08:00:00.000Z",
|
||||
state: "waiting-for-inspection",
|
||||
inspectionIdempotencyKey,
|
||||
uploadIdempotencyKey: `upload-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh module, so nothing is cached from the previous test's database. */
|
||||
async function loadDb(): Promise<OfflineDbModule> {
|
||||
vi.resetModules();
|
||||
return import("./db");
|
||||
}
|
||||
|
||||
/** The queue exactly as the previous release (DB version 2) left it on a phone. */
|
||||
async function createVersion2Queue(entry: object): Promise<void> {
|
||||
const db = await openDB(DB_NAME, 2, {
|
||||
upgrade(database) {
|
||||
for (const store of VERSION_2_STORES) {
|
||||
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
await db.add("pending-inspections", entry);
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("offline queue database", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
it("upgrading v2 → v3 keeps already queued inspections", async () => {
|
||||
await createVersion2Queue({
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
});
|
||||
const db = await loadDb();
|
||||
|
||||
const entries = await db.getPendingEntries("pending-inspections");
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
// Entries from before owners existed still count as the user's own.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(3);
|
||||
expect([...upgraded.objectStoreNames]).toEqual(
|
||||
expect.arrayContaining([
|
||||
...VERSION_2_STORES,
|
||||
"pending-bulk",
|
||||
"pending-photos",
|
||||
"offline-packs",
|
||||
"meta",
|
||||
]),
|
||||
);
|
||||
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
|
||||
"inspectionIdempotencyKey",
|
||||
"ownerUserId",
|
||||
"sessionId",
|
||||
]);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueTreatment(TREATMENT, "key-2", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-3", "user-2");
|
||||
const [treatment] = await db.getPendingEntries("pending-treatments");
|
||||
await db.markFailed("pending-treatments", treatment.id, REJECTION);
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 1, foreign: 1 });
|
||||
expect(await db.summarizeQueue("user-2")).toEqual({ own: 1, failed: 0, foreign: 2 });
|
||||
});
|
||||
|
||||
it("summarizeQueue counts a photo waiting on its own like an entry, never a draft or one riding with its inspection", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
const ready = { ...photo("ready", "inspection-key"), state: "ready", inspectionId: "srv-1" };
|
||||
await raw.put("pending-photos", ready);
|
||||
await raw.put("pending-photos", { ...ready, id: "rejected", lastError: REJECTION });
|
||||
await raw.put("pending-photos", { ...ready, id: "foreign", ownerUserId: "user-2" });
|
||||
await raw.put("pending-photos", photo("rides-along", "inspection-key"));
|
||||
await raw.put("pending-photos", { ...photo("draft", "unused"), state: "draft" });
|
||||
raw.close();
|
||||
|
||||
// The inspection itself, plus the ready photo; the rejected one separately.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 1, foreign: 1 });
|
||||
});
|
||||
|
||||
it("toQueueError keeps the server's status, message and body", async () => {
|
||||
const db = await loadDb();
|
||||
const { ApiError } = await import("@/lib/auth/api-client");
|
||||
const body = { statusCode: 413, message: "Datei zu groß" };
|
||||
|
||||
const error = db.toQueueError(new ApiError(413, "Datei zu groß", body));
|
||||
|
||||
expect(error).toEqual({ status: 413, message: "Datei zu groß", details: body, at: expect.any(String) });
|
||||
});
|
||||
|
||||
it("stores each entry with its key and owner", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueVarroaMeasurement(
|
||||
{ colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", method: "WASHING_METHOD" as const },
|
||||
"key-1",
|
||||
"user-1",
|
||||
);
|
||||
|
||||
const [entry] = await db.getPendingEntries("pending-varroa-measurements");
|
||||
|
||||
expect(entry).toMatchObject({ idempotencyKey: "key-1", ownerUserId: "user-1" });
|
||||
expect(Date.parse(entry.queuedAt)).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("lists only the current user's entries, oldest first, with the server's message", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-2", "user-2");
|
||||
await db.queueTreatment(TREATMENT, "key-3", "user-1");
|
||||
const [treatment] = await db.getPendingEntries("pending-treatments");
|
||||
await db.markFailed("pending-treatments", treatment.id, REJECTION);
|
||||
|
||||
const items = await db.listQueue("user-1");
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ store: "pending-inspections", label: "Durchsicht" }),
|
||||
expect.objectContaining({
|
||||
store: "pending-treatments",
|
||||
id: treatment.id,
|
||||
label: "Behandlung",
|
||||
lastError: REJECTION,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("discardQueueItem deletes exactly that entry", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-2", "user-1");
|
||||
const [first] = await db.listQueue("user-1");
|
||||
|
||||
await db.discardQueueItem(first);
|
||||
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]);
|
||||
});
|
||||
|
||||
it("discarding a queued inspection discards its photos and no others", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("pending-photos", photo("p1", "inspection-key"));
|
||||
await raw.put("pending-photos", photo("p2", "other-inspection-key"));
|
||||
const [item] = await db.listQueue("user-1");
|
||||
expect(item.photoCount).toBe(1);
|
||||
|
||||
await db.discardQueueItem(item);
|
||||
|
||||
expect(await raw.getAllKeys("pending-photos")).toEqual(["p2"]);
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
raw.close();
|
||||
});
|
||||
|
||||
it("clearQueueOf removes one owner's entries and photos and keeps everyone else's", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-2", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-3", "user-2");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("pending-photos", photo("p1", "key-1", "user-1"));
|
||||
await raw.put("pending-photos", photo("p3", "key-3", "user-2"));
|
||||
|
||||
await db.clearQueueOf("user-1");
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 0, failed: 0, foreign: 1 });
|
||||
const [left] = await db.getPendingEntries("pending-inspections");
|
||||
expect(left.idempotencyKey).toBe("key-3");
|
||||
expect(await raw.getAllKeys("pending-photos")).toEqual(["p3"]);
|
||||
raw.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
||||
import type { ApiError } from "@/lib/auth/api-client";
|
||||
import type { OfflinePackV1 } from "@/lib/apiaries/field-types";
|
||||
import { CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import { CreateTreatmentInput } from "@/lib/treatments/types";
|
||||
import { CreateFeedingInput } from "@/lib/feedings/types";
|
||||
@@ -6,49 +8,139 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
|
||||
type PendingStoreName =
|
||||
| "pending-inspections"
|
||||
| "pending-treatments"
|
||||
| "pending-feedings"
|
||||
| "pending-varroa-measurements"
|
||||
| "pending-swarm-control-checks"
|
||||
| "pending-harvests";
|
||||
/** What each single-record queue (one per quick-capture form) holds. */
|
||||
export interface RecordQueueInputs {
|
||||
"pending-inspections": CreateInspectionInput;
|
||||
"pending-treatments": CreateTreatmentInput;
|
||||
"pending-feedings": CreateFeedingInput;
|
||||
"pending-varroa-measurements": CreateVarroaMeasurementInput;
|
||||
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
|
||||
"pending-harvests": CreateHarvestInput;
|
||||
}
|
||||
export type RecordStoreName = keyof RecordQueueInputs;
|
||||
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
|
||||
export type QueueStoreName = RecordStoreName | "pending-bulk";
|
||||
|
||||
interface PendingEntry<T> {
|
||||
/** The server's answer when it rejected a queued entry. */
|
||||
export interface QueueError {
|
||||
status: number;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export function toQueueError(error: ApiError): QueueError {
|
||||
return {
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
details: error.body,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendingEntry<T> {
|
||||
id: number;
|
||||
input: T;
|
||||
queuedAt: string;
|
||||
// Missing on entries queued before keys existed - those sync as before.
|
||||
idempotencyKey?: string;
|
||||
// Missing on entries queued before owners existed (DB v2) - those sync as
|
||||
// before, under whoever is signed in.
|
||||
ownerUserId?: string;
|
||||
// Set when the server rejected the entry; it stays queued until it is sent
|
||||
// or the user discards it.
|
||||
lastError?: QueueError;
|
||||
}
|
||||
|
||||
interface OfflineDB extends DBSchema {
|
||||
"pending-inspections": { key: number; value: PendingEntry<CreateInspectionInput> };
|
||||
"pending-treatments": { key: number; value: PendingEntry<CreateTreatmentInput> };
|
||||
"pending-feedings": { key: number; value: PendingEntry<CreateFeedingInput> };
|
||||
"pending-varroa-measurements": {
|
||||
key: number;
|
||||
value: PendingEntry<CreateVarroaMeasurementInput>;
|
||||
export type BulkKind = "treatments" | "feedings" | "varroa";
|
||||
|
||||
export interface PendingBulkEntry {
|
||||
id: number;
|
||||
kind: BulkKind;
|
||||
input: unknown;
|
||||
idempotencyKey: string;
|
||||
queuedAt: string;
|
||||
ownerUserId: string;
|
||||
lastError?: QueueError;
|
||||
}
|
||||
|
||||
export interface StoredPhoto {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
ownerUserId: string;
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
state: "draft" | "waiting-for-inspection" | "ready";
|
||||
inspectionIdempotencyKey?: string;
|
||||
inspectionId?: string;
|
||||
uploadIdempotencyKey: string;
|
||||
lastError?: QueueError;
|
||||
}
|
||||
|
||||
export interface StoredPack {
|
||||
apiaryId: string;
|
||||
ownerUserId: string;
|
||||
savedAt: string;
|
||||
pack: OfflinePackV1;
|
||||
}
|
||||
|
||||
type RecordStores = {
|
||||
[Store in RecordStoreName]: { key: number; value: PendingEntry<RecordQueueInputs[Store]> };
|
||||
};
|
||||
|
||||
export interface OfflineDB extends DBSchema, RecordStores {
|
||||
"pending-bulk": { key: number; value: PendingBulkEntry };
|
||||
"pending-photos": {
|
||||
key: string;
|
||||
value: StoredPhoto;
|
||||
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
|
||||
};
|
||||
"pending-swarm-control-checks": {
|
||||
key: number;
|
||||
value: PendingEntry<CreateSwarmControlCheckInput>;
|
||||
};
|
||||
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
|
||||
"offline-packs": { key: string; value: StoredPack };
|
||||
meta: { key: string; value: unknown };
|
||||
}
|
||||
|
||||
const DB_NAME = "apicore-offline";
|
||||
// v1 only had "pending-inspections". v2 adds the other five quick-capture
|
||||
// types - existing users' already-queued inspections must survive the
|
||||
// upgrade, so each store is only created if it doesn't exist yet (guarded by
|
||||
// oldVersion, per idb's upgrade() convention), never recreated.
|
||||
const DB_VERSION = 2;
|
||||
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
||||
// types, v3 adds bulk entries, photos, offline packs and meta. Entries already
|
||||
// queued on a phone must survive every upgrade, so each store is only created
|
||||
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
|
||||
// convention), never recreated.
|
||||
const DB_VERSION = 3;
|
||||
|
||||
const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-inspections",
|
||||
"pending-treatments",
|
||||
"pending-feedings",
|
||||
"pending-varroa-measurements",
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
];
|
||||
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
|
||||
|
||||
const RECORD_LABELS: Record<RecordStoreName, string> = {
|
||||
"pending-inspections": "Durchsicht",
|
||||
"pending-treatments": "Behandlung",
|
||||
"pending-feedings": "Fütterung",
|
||||
"pending-varroa-measurements": "Varroamessung",
|
||||
"pending-swarm-control-checks": "Schwarmkontrolle",
|
||||
"pending-harvests": "Ernte",
|
||||
};
|
||||
const BULK_LABELS: Record<BulkKind, string> = {
|
||||
treatments: "Behandlung für mehrere Völker",
|
||||
feedings: "Fütterung für mehrere Völker",
|
||||
varroa: "Varroamessung für mehrere Völker",
|
||||
};
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
|
||||
|
||||
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
/**
|
||||
* The one shared connection to this device's offline database. Exported for
|
||||
* the photo store (lib/photos/store.ts), which keeps its own records here.
|
||||
*/
|
||||
export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
|
||||
const opening = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
|
||||
@@ -66,128 +158,297 @@ function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
});
|
||||
db.createObjectStore("pending-harvests", { keyPath: "id", autoIncrement: true });
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
db.createObjectStore("pending-bulk", { keyPath: "id", autoIncrement: true });
|
||||
const photos = db.createObjectStore("pending-photos", { keyPath: "id" });
|
||||
photos.createIndex("sessionId", "sessionId");
|
||||
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
|
||||
photos.createIndex("ownerUserId", "ownerUserId");
|
||||
db.createObjectStore("offline-packs", { keyPath: "apiaryId" });
|
||||
db.createObjectStore("meta");
|
||||
}
|
||||
},
|
||||
// A newer app version in another tab wants to upgrade: step aside
|
||||
// instead of blocking it. The next call here opens the new version.
|
||||
blocking() {
|
||||
void opening.then((db) => db.close());
|
||||
forget(opening);
|
||||
},
|
||||
terminated() {
|
||||
forget(opening);
|
||||
},
|
||||
});
|
||||
// A failed open (storage blocked, private mode) is retried next time.
|
||||
opening.catch(() => forget(opening));
|
||||
dbPromise = opening;
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function queue<T>(
|
||||
store: PendingStoreName,
|
||||
input: T,
|
||||
idempotencyKey?: string,
|
||||
function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
|
||||
if (dbPromise === connection) {
|
||||
dbPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queue<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
input: RecordQueueInputs[Store],
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.add(store, { input, idempotencyKey, queuedAt: new Date().toISOString() } as never);
|
||||
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add(store, entry as never);
|
||||
}
|
||||
|
||||
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
|
||||
export function queueInspection(
|
||||
input: CreateInspectionInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-inspections", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueTreatment(
|
||||
input: CreateTreatmentInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-treatments", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueFeeding(
|
||||
input: CreateFeedingInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-feedings", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueVarroaMeasurement(
|
||||
input: CreateVarroaMeasurementInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-varroa-measurements", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueSwarmControlCheck(
|
||||
input: CreateSwarmControlCheckInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-swarm-control-checks", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueHarvest(
|
||||
input: CreateHarvestInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
/** Every entry of one record queue, oldest first, of every owner. */
|
||||
export async function getPendingEntries<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
): Promise<PendingEntry<RecordQueueInputs[Store]>[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll(store) as Promise<PendingEntry<T>[]>;
|
||||
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
|
||||
}
|
||||
|
||||
async function removePending(store: PendingStoreName, id: number): Promise<void> {
|
||||
/** One entry as it is stored right now, or undefined once it is gone. */
|
||||
export async function getPendingEntry<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
id: number,
|
||||
): Promise<PendingEntry<RecordQueueInputs[Store]> | undefined> {
|
||||
const db = await getDb();
|
||||
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
|
||||
}
|
||||
|
||||
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(store, id);
|
||||
}
|
||||
|
||||
async function countPending(store: PendingStoreName): Promise<number> {
|
||||
/** Keeps a rejected entry queued and remembers why the server refused it. */
|
||||
export async function markFailed(
|
||||
store: QueueStoreName,
|
||||
id: number,
|
||||
error: QueueError,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
return db.count(store);
|
||||
const tx = db.transaction(store, "readwrite");
|
||||
const entry = await tx.store.get(id);
|
||||
if (entry) {
|
||||
await tx.store.put({ ...entry, lastError: error });
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export type PendingInspection = PendingEntry<CreateInspectionInput>;
|
||||
export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-inspections", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingInspections(): Promise<PendingInspection[]> {
|
||||
return getPending("pending-inspections");
|
||||
}
|
||||
export function removePendingInspection(id: number): Promise<void> {
|
||||
return removePending("pending-inspections", id);
|
||||
}
|
||||
export function countPendingInspections(): Promise<number> {
|
||||
return countPending("pending-inspections");
|
||||
/**
|
||||
* Entries from before owners existed (DB v2) count as the signed-in user's
|
||||
* own. Anything queued under another account is never sent by this one.
|
||||
*/
|
||||
export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: string): boolean {
|
||||
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
|
||||
}
|
||||
|
||||
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
|
||||
export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-treatments", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingTreatments(): Promise<PendingTreatment[]> {
|
||||
return getPending("pending-treatments");
|
||||
}
|
||||
export function removePendingTreatment(id: number): Promise<void> {
|
||||
return removePending("pending-treatments", id);
|
||||
}
|
||||
export function countPendingTreatments(): Promise<number> {
|
||||
return countPending("pending-treatments");
|
||||
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
|
||||
|
||||
interface QueuedRecord {
|
||||
store: QueueStoreName;
|
||||
entry: QueuedValue;
|
||||
}
|
||||
|
||||
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
|
||||
export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-feedings", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingFeedings(): Promise<PendingFeeding[]> {
|
||||
return getPending("pending-feedings");
|
||||
}
|
||||
export function removePendingFeeding(id: number): Promise<void> {
|
||||
return removePending("pending-feedings", id);
|
||||
}
|
||||
export function countPendingFeedings(): Promise<number> {
|
||||
return countPending("pending-feedings");
|
||||
async function readQueue(): Promise<QueuedRecord[]> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction([...QUEUE_STORES], "readonly");
|
||||
const perStore = await Promise.all(
|
||||
QUEUE_STORES.map(async (store) => {
|
||||
const entries: QueuedValue[] = await tx.objectStore(store).getAll();
|
||||
return entries.map((entry) => ({ store, entry }));
|
||||
}),
|
||||
);
|
||||
await tx.done;
|
||||
return perStore.flat();
|
||||
}
|
||||
|
||||
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
|
||||
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-varroa-measurements", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
|
||||
return getPending("pending-varroa-measurements");
|
||||
}
|
||||
export function removePendingVarroaMeasurement(id: number): Promise<void> {
|
||||
return removePending("pending-varroa-measurements", id);
|
||||
}
|
||||
export function countPendingVarroaMeasurements(): Promise<number> {
|
||||
return countPending("pending-varroa-measurements");
|
||||
export interface QueueSummary {
|
||||
/** The user's entries still waiting to be sent. */
|
||||
own: number;
|
||||
/** The user's entries the server rejected. */
|
||||
failed: number;
|
||||
/** Entries of another account on this device - never sent from here. */
|
||||
foreign: number;
|
||||
}
|
||||
|
||||
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
|
||||
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-swarm-control-checks", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
|
||||
return getPending("pending-swarm-control-checks");
|
||||
}
|
||||
export function removePendingSwarmControlCheck(id: number): Promise<void> {
|
||||
return removePending("pending-swarm-control-checks", id);
|
||||
}
|
||||
export function countPendingSwarmControlChecks(): Promise<number> {
|
||||
return countPending("pending-swarm-control-checks");
|
||||
/**
|
||||
* A photo whose inspection is on the server waits on its own and counts like
|
||||
* an entry. Drafts (form not saved yet) and photos still waiting for their
|
||||
* queued inspection are not counted: they ride along with that inspection.
|
||||
*/
|
||||
async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
|
||||
const db = await getDb();
|
||||
const photos = await db.getAll("pending-photos");
|
||||
return photos.filter((photo) => photo.state === "ready");
|
||||
}
|
||||
|
||||
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
|
||||
export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-harvests", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingHarvests(): Promise<PendingHarvest[]> {
|
||||
return getPending("pending-harvests");
|
||||
}
|
||||
export function removePendingHarvest(id: number): Promise<void> {
|
||||
return removePending("pending-harvests", id);
|
||||
}
|
||||
export function countPendingHarvests(): Promise<number> {
|
||||
return countPending("pending-harvests");
|
||||
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
|
||||
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
|
||||
...(await readQueue()).map(({ entry }) => entry),
|
||||
...(await readPhotosWaitingOnTheirOwn()),
|
||||
];
|
||||
return waiting.reduce<QueueSummary>(
|
||||
(summary, entry) => {
|
||||
if (isForeignEntry(entry, currentUserId)) {
|
||||
return { ...summary, foreign: summary.foreign + 1 };
|
||||
}
|
||||
if (entry.lastError) {
|
||||
return { ...summary, failed: summary.failed + 1 };
|
||||
}
|
||||
return { ...summary, own: summary.own + 1 };
|
||||
},
|
||||
{ own: 0, failed: 0, foreign: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function countAllPending(): Promise<number> {
|
||||
const counts = await Promise.all([
|
||||
countPendingInspections(),
|
||||
countPendingTreatments(),
|
||||
countPendingFeedings(),
|
||||
countPendingVarroaMeasurements(),
|
||||
countPendingSwarmControlChecks(),
|
||||
countPendingHarvests(),
|
||||
]);
|
||||
return counts.reduce((sum, count) => sum + count, 0);
|
||||
export interface QueueItem {
|
||||
store: string;
|
||||
id: number | string;
|
||||
label: string;
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
}
|
||||
|
||||
function labelOf({ store, entry }: QueuedRecord): string {
|
||||
if (store === "pending-bulk") {
|
||||
return BULK_LABELS[(entry as PendingBulkEntry).kind];
|
||||
}
|
||||
return RECORD_LABELS[store];
|
||||
}
|
||||
|
||||
/** The signed-in user's waiting and rejected entries, oldest first. */
|
||||
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
const db = await getDb();
|
||||
const own = (await readQueue()).filter(({ entry }) => !isForeignEntry(entry, currentUserId));
|
||||
const items = await Promise.all(
|
||||
own.map(async (record): Promise<QueueItem> => {
|
||||
const { store, entry } = record;
|
||||
const item: QueueItem = {
|
||||
store,
|
||||
id: entry.id,
|
||||
label: labelOf(record),
|
||||
queuedAt: entry.queuedAt,
|
||||
...(entry.lastError ? { lastError: entry.lastError } : {}),
|
||||
};
|
||||
if (store !== "pending-inspections" || !entry.idempotencyKey) {
|
||||
return item;
|
||||
}
|
||||
const photoCount = await db.countFromIndex(
|
||||
"pending-photos",
|
||||
"inspectionIdempotencyKey",
|
||||
entry.idempotencyKey,
|
||||
);
|
||||
return photoCount > 0 ? { ...item, photoCount } : item;
|
||||
}),
|
||||
);
|
||||
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
|
||||
}
|
||||
|
||||
function asQueueStore(store: string): QueueStoreName {
|
||||
const known = QUEUE_STORES.find((name) => name === store);
|
||||
if (!known) {
|
||||
throw new Error(`Unknown offline queue: ${store}`);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one queued entry without sending it. A queued inspection takes its
|
||||
* photos with it - they must never be uploaded to another record.
|
||||
*/
|
||||
export async function discardQueueItem(item: QueueItem): Promise<void> {
|
||||
const store = asQueueStore(item.store);
|
||||
if (typeof item.id !== "number") {
|
||||
throw new Error(`Invalid queue entry id: ${String(item.id)}`);
|
||||
}
|
||||
const db = await getDb();
|
||||
const tx = db.transaction([store, "pending-photos"], "readwrite");
|
||||
const entry = await tx.objectStore(store).get(item.id);
|
||||
if (store === "pending-inspections" && entry?.idempotencyKey) {
|
||||
const photos = tx.objectStore("pending-photos");
|
||||
const photoIds = await photos
|
||||
.index("inspectionIdempotencyKey")
|
||||
.getAllKeys(entry.idempotencyKey);
|
||||
await Promise.all(photoIds.map((photoId) => photos.delete(photoId)));
|
||||
}
|
||||
await tx.objectStore(store).delete(item.id);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** Deletes everything one account queued on this device, photos included. */
|
||||
export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite");
|
||||
const queues = QUEUE_STORES.map(async (store) => {
|
||||
const objectStore = tx.objectStore(store);
|
||||
const entries: QueuedValue[] = await objectStore.getAll();
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.ownerUserId === ownerUserId)
|
||||
.map((entry) => objectStore.delete(entry.id)),
|
||||
);
|
||||
});
|
||||
const photos = tx.objectStore("pending-photos");
|
||||
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
|
||||
await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]);
|
||||
await tx.done;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
toTreatmentInput,
|
||||
} from "@/app/(app)/treatments/new/treatment-form-values";
|
||||
import { queueFeeding, queueInspection, queueTreatment, queueVarroaMeasurement } from "./db";
|
||||
import { syncPendingInspections } from "./sync";
|
||||
import { syncPendingEntries } from "./sync";
|
||||
|
||||
/**
|
||||
* Stand-in for IndexedDB that stores entries the way the browser does - as a
|
||||
@@ -31,6 +31,7 @@ vi.mock("idb", () => {
|
||||
stores.set(store, entries);
|
||||
return id;
|
||||
},
|
||||
get: async (store: string, id: number) => structuredClone(stores.get(store)?.get(id)),
|
||||
getAll: async (store: string) =>
|
||||
[...(stores.get(store)?.values() ?? [])].map((entry) => structuredClone(entry)),
|
||||
delete: async (store: string, id: number) => {
|
||||
@@ -41,6 +42,12 @@ vi.mock("idb", () => {
|
||||
return { openDB: vi.fn(async () => db) };
|
||||
});
|
||||
|
||||
// The photos that follow a queued inspection are covered in lib/photos/store.spec.ts.
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
resolveQueuedInspection: vi.fn(async () => undefined),
|
||||
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(async () => ({ id: "saved" })),
|
||||
@@ -72,13 +79,13 @@ describe("offline queue keeps 'not filled in' as null from form to server", () =
|
||||
product: "Oxuvar",
|
||||
});
|
||||
|
||||
await queueInspection(inspection, "key-inspection");
|
||||
await queueVarroaMeasurement(varroa, "key-varroa");
|
||||
await queueFeeding(feeding, "key-feeding");
|
||||
await queueTreatment(treatment, "key-treatment");
|
||||
const result = await syncPendingInspections();
|
||||
await queueInspection(inspection, "key-inspection", "user-1");
|
||||
await queueVarroaMeasurement(varroa, "key-varroa", "user-1");
|
||||
await queueFeeding(feeding, "key-feeding", "user-1");
|
||||
await queueTreatment(treatment, "key-treatment", "user-1");
|
||||
const result = await syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 4, failed: 0 });
|
||||
expect(result).toEqual({ synced: 4, failed: 0, skippedForeign: 0 });
|
||||
expect(sentBody("/inspections")).toEqual(asSentOnline(inspection));
|
||||
expect(sentBody("/varroa-measurements")).toEqual(asSentOnline(varroa));
|
||||
expect(sentBody("/feedings")).toEqual(asSentOnline(feeding));
|
||||
@@ -86,8 +93,8 @@ describe("offline queue keeps 'not filled in' as null from form to server", () =
|
||||
});
|
||||
|
||||
it("sends an untouched inspection observation as null, never as a finding", async () => {
|
||||
await queueInspection(toInspectionInput(initialInspectionValues("colony-1")), "key-2");
|
||||
await syncPendingInspections();
|
||||
await queueInspection(toInspectionInput(initialInspectionValues("colony-1")), "key-2", "user-1");
|
||||
await syncPendingEntries("user-1");
|
||||
|
||||
expect(sentBody("/inspections")).toMatchObject({
|
||||
queenSeen: null,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
entriesRejected,
|
||||
entriesSynced,
|
||||
entriesWaiting,
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
} from "./queue-texts";
|
||||
|
||||
describe("queue texts", () => {
|
||||
it("uses the singular for exactly one entry", () => {
|
||||
expect(entriesWaiting(1)).toBe("1 Eintrag wartet");
|
||||
expect(entriesRejected(1)).toBe("1 Eintrag abgelehnt");
|
||||
expect(foreignEntriesWaiting(1)).toBe("1 Eintrag eines anderen Kontos wartet");
|
||||
expect(entriesSynced(1)).toBe("1 Eintrag synchronisiert");
|
||||
expect(photosAttached(1)).toBe("+ 1 Foto");
|
||||
});
|
||||
|
||||
it("uses the plural for two entries", () => {
|
||||
expect(entriesWaiting(2)).toBe("2 Einträge warten");
|
||||
expect(entriesRejected(2)).toBe("2 Einträge abgelehnt");
|
||||
expect(foreignEntriesWaiting(2)).toBe("2 Einträge eines anderen Kontos warten");
|
||||
expect(entriesSynced(2)).toBe("2 Einträge synchronisiert");
|
||||
expect(photosAttached(2)).toBe("+ 2 Fotos");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
||||
}
|
||||
|
||||
export function entriesWaiting(n: number): string {
|
||||
return count(n, "Eintrag wartet", "Einträge warten");
|
||||
}
|
||||
|
||||
export function entriesRejected(n: number): string {
|
||||
return count(n, "Eintrag abgelehnt", "Einträge abgelehnt");
|
||||
}
|
||||
|
||||
export function foreignEntriesWaiting(n: number): string {
|
||||
return count(n, "Eintrag eines anderen Kontos wartet", "Einträge eines anderen Kontos warten");
|
||||
}
|
||||
|
||||
export function entriesSynced(n: number): string {
|
||||
return count(n, "Eintrag synchronisiert", "Einträge synchronisiert");
|
||||
}
|
||||
|
||||
/** Photos that wait together with a queued inspection: "+ 2 Fotos". */
|
||||
export function photosAttached(n: number): string {
|
||||
return `+ ${count(n, "Foto", "Fotos")}`;
|
||||
}
|
||||
@@ -1,111 +1,427 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { createInspection } from "@/lib/inspections/api";
|
||||
import { getPendingInspections, removePendingInspection } from "./db";
|
||||
import { syncPendingInspections } from "./sync";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { openDB } from "idb";
|
||||
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
|
||||
vi.mock("@/lib/inspections/api", () => ({
|
||||
createInspection: vi.fn(),
|
||||
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
|
||||
// What the inspection drain hands over to the photos, seen at that moment.
|
||||
const photoHandOver = vi.hoisted(() => ({
|
||||
calls: [] as { key: string; inspectionId: string; entryStillQueued: boolean }[],
|
||||
failFor: null as string | null,
|
||||
// Set per test: reads the queue of the test's fresh database.
|
||||
isQueued: (() => Promise.resolve(false)) as (key: string) => Promise<boolean>,
|
||||
}));
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getPendingInspections: vi.fn(),
|
||||
removePendingInspection: vi.fn(),
|
||||
// The other five queues are exercised by their own dedicated tests
|
||||
// elsewhere; here they just need to resolve empty so runSync()'s
|
||||
// Promise.all doesn't touch their (unmocked, real) create functions.
|
||||
getPendingTreatments: vi.fn().mockResolvedValue([]),
|
||||
removePendingTreatment: vi.fn(),
|
||||
getPendingFeedings: vi.fn().mockResolvedValue([]),
|
||||
removePendingFeeding: vi.fn(),
|
||||
getPendingVarroaMeasurements: vi.fn().mockResolvedValue([]),
|
||||
removePendingVarroaMeasurement: vi.fn(),
|
||||
getPendingSwarmControlChecks: vi.fn().mockResolvedValue([]),
|
||||
removePendingSwarmControlCheck: vi.fn(),
|
||||
getPendingHarvests: vi.fn().mockResolvedValue([]),
|
||||
removePendingHarvest: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedCreateInspection = vi.mocked(createInspection);
|
||||
const mockedGetPending = vi.mocked(getPendingInspections);
|
||||
const mockedRemovePending = vi.mocked(removePendingInspection);
|
||||
|
||||
function pendingEntry(id: number) {
|
||||
vi.mock("@/lib/photos/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/photos/store")>();
|
||||
return {
|
||||
id,
|
||||
input: { colonyId: `colony-${id}`, date: "2026-01-01T00:00:00.000Z" },
|
||||
queuedAt: "2026-01-01T00:00:00.000Z",
|
||||
...actual,
|
||||
resolveQueuedInspection: async (key: string, inspectionId: string) => {
|
||||
photoHandOver.calls.push({
|
||||
key,
|
||||
inspectionId,
|
||||
entryStillQueued: await photoHandOver.isQueued(key),
|
||||
});
|
||||
if (photoHandOver.failFor === key) {
|
||||
throw new Error("Speicher voll");
|
||||
}
|
||||
return actual.resolveQueuedInspection(key, inspectionId);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type OfflineDbModule = typeof import("./db");
|
||||
type SyncModule = typeof import("./sync");
|
||||
type PhotoStoreModule = typeof import("@/lib/photos/store");
|
||||
type ApiErrorClass = typeof import("@/lib/auth/api-client").ApiError;
|
||||
|
||||
let db: OfflineDbModule;
|
||||
let sync: SyncModule;
|
||||
let photos: PhotoStoreModule;
|
||||
let ApiError: ApiErrorClass;
|
||||
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
|
||||
let createTreatment: MockedFunction<typeof import("@/lib/treatments/api").createTreatment>;
|
||||
let uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
|
||||
|
||||
function inspection(colony: number) {
|
||||
return { colonyId: `colony-${colony}`, date: "2026-10-01T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
describe("syncPendingInspections", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
/** A request that stays open until the test answers it. */
|
||||
function openRequest() {
|
||||
let answer: (saved: { id: string }) => void = () => undefined;
|
||||
const promise = new Promise<{ id: string }>((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
return { promise: promise as never, answer };
|
||||
}
|
||||
|
||||
/** Lets every already-runnable promise callback run. */
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function sentColonies(): string[] {
|
||||
return createInspection.mock.calls.map(([input]) => input.colonyId);
|
||||
}
|
||||
|
||||
function takePhoto(sessionId: string) {
|
||||
return photos.addDraftPhoto({
|
||||
sessionId,
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
|
||||
date: "2026-10-01",
|
||||
});
|
||||
}
|
||||
|
||||
describe("syncPendingEntries", () => {
|
||||
beforeEach(async () => {
|
||||
// A fresh database and fresh modules: nothing (no open connection, no
|
||||
// in-flight run) carries over from the previous test.
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
db = await import("./db");
|
||||
sync = await import("./sync");
|
||||
photos = await import("@/lib/photos/store");
|
||||
ApiError = (await import("@/lib/auth/api-client")).ApiError;
|
||||
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
|
||||
createTreatment = vi.mocked((await import("@/lib/treatments/api")).createTreatment);
|
||||
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
|
||||
// The mocks themselves outlive resetModules - start them from scratch too.
|
||||
createInspection.mockReset();
|
||||
createTreatment.mockReset();
|
||||
uploadDocument.mockReset();
|
||||
photoHandOver.calls.length = 0;
|
||||
photoHandOver.failFor = null;
|
||||
photoHandOver.isQueued = async (key) =>
|
||||
(await db.getPendingEntries("pending-inspections")).some(
|
||||
(entry) => entry.idempotencyKey === key,
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs every queued entry and removes it from the queue", async () => {
|
||||
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
const result = await syncPendingInspections();
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 2, failed: 0 });
|
||||
expect(mockedRemovePending).toHaveBeenCalledWith(1);
|
||||
expect(mockedRemovePending).toHaveBeenCalledWith(2);
|
||||
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("resends a queued entry with the idempotency key it was queued with", async () => {
|
||||
// The server may already have saved the first attempt; the same key lets
|
||||
// it answer with that record instead of storing a duplicate.
|
||||
mockedGetPending.mockResolvedValue([
|
||||
{ ...pendingEntry(1), idempotencyKey: "key-from-the-form-0001" },
|
||||
pendingEntry(2),
|
||||
]);
|
||||
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
|
||||
await db.queueInspection(inspection(1), "key-from-the-form-0001", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
await syncPendingInspections();
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(mockedCreateInspection).toHaveBeenNthCalledWith(1, pendingEntry(1).input, {
|
||||
expect(createInspection).toHaveBeenCalledWith(inspection(1), {
|
||||
idempotencyKey: "key-from-the-form-0001",
|
||||
});
|
||||
// Entries queued before keys existed still sync, just without one.
|
||||
expect(mockedCreateInspection).toHaveBeenNthCalledWith(2, pendingEntry(2).input, {
|
||||
idempotencyKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a rejected entry queued and continues with the rest on a server-side ApiError", async () => {
|
||||
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||
mockedCreateInspection
|
||||
it("still sends entries queued before keys and owners existed", async () => {
|
||||
await db.summarizeQueue("user-1"); // creates the database at the current version
|
||||
const raw = await openDB("apicore-offline");
|
||||
await raw.add("pending-inspections", {
|
||||
input: inspection(1),
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
});
|
||||
raw.close();
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(createInspection).toHaveBeenCalledWith(inspection(1), { idempotencyKey: undefined });
|
||||
});
|
||||
|
||||
it("stores the server rejection on the entry and keeps it", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
const body = { statusCode: 400, message: "Volk V-03 ist beendet." };
|
||||
createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.", body));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
const [kept] = await db.getPendingEntries("pending-inspections");
|
||||
expect(kept.idempotencyKey).toBe("key-1");
|
||||
expect(kept.lastError).toEqual({
|
||||
status: 400,
|
||||
message: "Volk V-03 ist beendet.",
|
||||
details: body,
|
||||
at: expect.any(String),
|
||||
});
|
||||
expect(kept.lastError?.status).toBe(400);
|
||||
});
|
||||
|
||||
it("continues with the rest of the queue after a server rejection", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
createInspection
|
||||
.mockRejectedValueOnce(new ApiError(400, "invalid colony"))
|
||||
.mockResolvedValueOnce({ id: "x" } as never);
|
||||
|
||||
const result = await syncPendingInspections();
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 1 });
|
||||
expect(mockedRemovePending).not.toHaveBeenCalledWith(1);
|
||||
expect(mockedRemovePending).toHaveBeenCalledWith(2);
|
||||
expect(result).toEqual({ synced: 1, failed: 1, skippedForeign: 0 });
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-1"]);
|
||||
});
|
||||
|
||||
it("stops immediately on a network failure, leaving the rest queued", async () => {
|
||||
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||
mockedCreateInspection.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
it("stops immediately on a network failure, leaving the rest queued untouched", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
createInspection.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
|
||||
const result = await syncPendingInspections();
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1 });
|
||||
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRemovePending).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
expect(createInspection).toHaveBeenCalledTimes(1);
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining).toHaveLength(2);
|
||||
expect(remaining.every((entry) => entry.lastError === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("never sends entries queued by another user", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-2");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
|
||||
expect(createInspection).not.toHaveBeenCalled();
|
||||
expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shares a single in-flight run across concurrent callers instead of double-processing the queue", async () => {
|
||||
mockedGetPending.mockResolvedValue([pendingEntry(1)]);
|
||||
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
const [first, second] = await Promise.all([syncPendingInspections(), syncPendingInspections()]);
|
||||
const [first, second] = await Promise.all([
|
||||
sync.syncPendingEntries("user-1"),
|
||||
sync.syncPendingEntries("user-1"),
|
||||
]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(mockedGetPending).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
|
||||
expect(createInspection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never sends a user's entries after cancelSync() during a running sync", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
const firstRequest = openRequest();
|
||||
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Logout: stop the run before the access token changes.
|
||||
const cancelled = sync.cancelSync();
|
||||
firstRequest.answer({ id: "saved-1" });
|
||||
await cancelled;
|
||||
|
||||
// The request already on its way is finished properly, nothing after it is sent.
|
||||
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(sentColonies()).toEqual(["colony-1"]);
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]);
|
||||
});
|
||||
|
||||
it("starts another user's run only after the current one finished", async () => {
|
||||
await db.queueInspection(inspection(1), "key-a1", "user-a");
|
||||
await db.queueInspection(inspection(2), "key-b1", "user-b");
|
||||
const firstRequest = openRequest();
|
||||
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
|
||||
const runA = sync.syncPendingEntries("user-a");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
const runB = sync.syncPendingEntries("user-b");
|
||||
await settle();
|
||||
expect(createInspection).toHaveBeenCalledTimes(1);
|
||||
firstRequest.answer({ id: "saved-a1" });
|
||||
|
||||
expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
|
||||
expect(await runB).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(sentColonies()).toEqual(["colony-1", "colony-2"]);
|
||||
});
|
||||
|
||||
it("after a logout and the next login, never sends the first user's entries", async () => {
|
||||
await db.queueInspection(inspection(1), "key-a1", "user-a");
|
||||
await db.queueInspection(inspection(2), "key-a2", "user-a");
|
||||
await db.queueInspection(inspection(3), "key-b1", "user-b");
|
||||
const firstRequest = openRequest();
|
||||
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
|
||||
const runA = sync.syncPendingEntries("user-a");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
// A signs out (the cancel is awaited before the token changes) ...
|
||||
const cancelled = sync.cancelSync();
|
||||
firstRequest.answer({ id: "saved-a1" });
|
||||
await cancelled;
|
||||
// ... and B signs in on the same device: B's token is set, sync opens again.
|
||||
sync.resumeSync();
|
||||
const runB = await sync.syncPendingEntries("user-b");
|
||||
|
||||
expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
|
||||
expect(runB).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
|
||||
expect(sentColonies()).toEqual(["colony-1", "colony-3"]);
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-a2"]);
|
||||
});
|
||||
|
||||
it("sends nothing between cancelSync() and resumeSync(), not even for the same user", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
const firstRequest = openRequest();
|
||||
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
|
||||
void sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
const cancelled = sync.cancelSync();
|
||||
// E.g. an `online` event while the logout still waits for the cancel.
|
||||
const late = sync.syncPendingEntries("user-1");
|
||||
firstRequest.answer({ id: "saved-1" });
|
||||
await cancelled;
|
||||
|
||||
expect(await late).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
expect(await sync.syncPendingEntries("user-1")).toEqual({
|
||||
synced: 0,
|
||||
failed: 0,
|
||||
skippedForeign: 0,
|
||||
});
|
||||
expect(sentColonies()).toEqual(["colony-1"]);
|
||||
|
||||
sync.resumeSync();
|
||||
|
||||
expect(await sync.syncPendingEntries("user-1")).toEqual({
|
||||
synced: 1,
|
||||
failed: 0,
|
||||
skippedForeign: 0,
|
||||
});
|
||||
expect(sentColonies()).toEqual(["colony-1", "colony-2"]);
|
||||
});
|
||||
|
||||
it("re-checks each entry right before sending it", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
await db.queueInspection(inspection(3), "key-3", "user-1");
|
||||
const firstRequest = openRequest();
|
||||
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
// While the first request is open: the second entry is discarded in the
|
||||
// queue sheet, and the third no longer belongs to this user.
|
||||
const [, second, third] = await db.getPendingEntries("pending-inspections");
|
||||
await db.discardQueueItem({
|
||||
store: "pending-inspections",
|
||||
id: second.id,
|
||||
label: "Durchsicht",
|
||||
queuedAt: second.queuedAt,
|
||||
});
|
||||
const raw = await openDB("apicore-offline");
|
||||
await raw.put("pending-inspections", { ...third, ownerUserId: "user-2" });
|
||||
raw.close();
|
||||
firstRequest.answer({ id: "saved-1" });
|
||||
|
||||
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
|
||||
expect(sentColonies()).toEqual(["colony-1"]);
|
||||
});
|
||||
|
||||
it("uploads photos only after their inspection synced in the same run", async () => {
|
||||
await db.queueInspection(inspection(1), "inspection-key", "user-1");
|
||||
await db.queueTreatment(
|
||||
{ colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" },
|
||||
"treatment-key",
|
||||
"user-1",
|
||||
);
|
||||
const photo = await takePhoto("session-1");
|
||||
await photos.attachSessionToQueuedInspection("session-1", "inspection-key");
|
||||
createInspection.mockResolvedValue({ id: "srv-1" } as never);
|
||||
const treatmentRequest = openRequest();
|
||||
createTreatment.mockReturnValueOnce(treatmentRequest.promise);
|
||||
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
|
||||
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(async () =>
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]),
|
||||
);
|
||||
// The inspection is on the server, another record drain still runs: no photo yet.
|
||||
await settle();
|
||||
expect(uploadDocument).not.toHaveBeenCalled();
|
||||
treatmentRequest.answer({ id: "srv-t1" });
|
||||
|
||||
expect(await run).toEqual({ synced: 3, failed: 0, skippedForeign: 0 });
|
||||
// The photos got the server id while the inspection was still queued.
|
||||
expect(photoHandOver.calls).toEqual([
|
||||
{ key: "inspection-key", inspectionId: "srv-1", entryStillQueued: true },
|
||||
]);
|
||||
expect(uploadDocument).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), {
|
||||
idempotencyKey: photo.uploadIdempotencyKey,
|
||||
fileName: photo.fileName,
|
||||
});
|
||||
expect(await photos.listSessionPhotos("session-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an inspection whose photo hand-over failed and still finishes the run", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
const photo = await takePhoto("session-1");
|
||||
await photos.attachSessionToQueuedInspection("session-1", "key-1");
|
||||
createInspection
|
||||
.mockResolvedValueOnce({ id: "srv-1" } as never)
|
||||
.mockResolvedValueOnce({ id: "srv-2" } as never);
|
||||
photoHandOver.failFor = "key-1";
|
||||
|
||||
const first = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(first).toEqual({ synced: 1, failed: 1, skippedForeign: 0 });
|
||||
const [kept] = await db.getPendingEntries("pending-inspections");
|
||||
expect(kept.idempotencyKey).toBe("key-1");
|
||||
expect(kept.lastError).toBeUndefined();
|
||||
expect(uploadDocument).not.toHaveBeenCalled();
|
||||
|
||||
// Next run: the same key brings back the same server record, the photo follows it.
|
||||
photoHandOver.failFor = null;
|
||||
createInspection.mockResolvedValue({ id: "srv-1" } as never);
|
||||
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
|
||||
const second = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(second).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
|
||||
expect(createInspection).toHaveBeenLastCalledWith(inspection(1), { idempotencyKey: "key-1" });
|
||||
expect(uploadDocument).toHaveBeenCalledWith(
|
||||
"INSPECTION",
|
||||
"srv-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ idempotencyKey: photo.uploadIdempotencyKey }),
|
||||
);
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("never uploads photos after cancelSync()", async () => {
|
||||
await db.queueInspection(inspection(1), "inspection-key", "user-1");
|
||||
await takePhoto("session-1");
|
||||
await photos.attachSessionToQueuedInspection("session-1", "inspection-key");
|
||||
const request = openRequest();
|
||||
createInspection.mockReturnValueOnce(request.promise);
|
||||
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
|
||||
const cancelled = sync.cancelSync();
|
||||
request.answer({ id: "srv-1" });
|
||||
await cancelled;
|
||||
|
||||
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(uploadDocument).not.toHaveBeenCalled();
|
||||
const [photo] = await photos.listSessionPhotos("session-1");
|
||||
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
|
||||
});
|
||||
});
|
||||
@@ -6,94 +6,200 @@ import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
|
||||
import { createHarvest } from "@/lib/harvests/api";
|
||||
import { uploadDocument } from "@/lib/documents/api";
|
||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
||||
import { resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
|
||||
import {
|
||||
getPendingInspections,
|
||||
removePendingInspection,
|
||||
getPendingTreatments,
|
||||
removePendingTreatment,
|
||||
getPendingFeedings,
|
||||
removePendingFeeding,
|
||||
getPendingVarroaMeasurements,
|
||||
removePendingVarroaMeasurement,
|
||||
getPendingSwarmControlChecks,
|
||||
removePendingSwarmControlCheck,
|
||||
getPendingHarvests,
|
||||
removePendingHarvest,
|
||||
PendingEntry,
|
||||
RecordQueueInputs,
|
||||
RecordStoreName,
|
||||
getPendingEntries,
|
||||
getPendingEntry,
|
||||
isForeignEntry,
|
||||
markFailed,
|
||||
removeQueueEntry,
|
||||
toQueueError,
|
||||
} from "./db";
|
||||
|
||||
export interface SyncResult {
|
||||
synced: number;
|
||||
failed: number;
|
||||
/** Entries of another account on this device - left alone, never sent. */
|
||||
skippedForeign: number;
|
||||
}
|
||||
|
||||
let inFlightSync: Promise<SyncResult> | null = null;
|
||||
/** One sync run: whose entries it sends, and whether it was cancelled since. */
|
||||
interface SyncRun {
|
||||
userId: string;
|
||||
isCancelled: () => boolean;
|
||||
}
|
||||
|
||||
// Drains all six offline queues in one pass (not just inspections) - a
|
||||
// beekeeper working a row of hives with no signal may have queued several
|
||||
// different record types before connectivity returns.
|
||||
export function syncPendingInspections(): Promise<SyncResult> {
|
||||
if (!inFlightSync) {
|
||||
inFlightSync = runSync().finally(() => {
|
||||
inFlightSync = null;
|
||||
});
|
||||
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
|
||||
|
||||
let inFlight: { userId: string; generation: number; run: Promise<SyncResult> } | null = null;
|
||||
// Raised by cancelSync(): every run requested before stops sending.
|
||||
let generation = 0;
|
||||
// Closed by cancelSync() until resumeSync(): between a logout and the next
|
||||
// signed-in user's token nothing is sent, whoever asks.
|
||||
let isOpen = true;
|
||||
|
||||
/**
|
||||
* Drains all six offline queues in one pass (not just inspections) - a
|
||||
* beekeeper working a row of hives with no signal may have queued several
|
||||
* different record types before connectivity returns. Only entries of
|
||||
* `currentUserId` (and ownerless ones from before owners existed) are sent.
|
||||
*
|
||||
* Callers for the same user share one run; a run for another user starts
|
||||
* once the current one is done, so two accounts never send at the same time.
|
||||
* Every drain runs inside such a run, so cancelSync() always reaches it.
|
||||
*/
|
||||
export function syncPendingEntries(currentUserId: string): Promise<SyncResult> {
|
||||
if (!isOpen) {
|
||||
return Promise.resolve(NOTHING_SYNCED);
|
||||
}
|
||||
return inFlightSync;
|
||||
if (inFlight?.userId === currentUserId && inFlight.generation === generation) {
|
||||
return inFlight.run;
|
||||
}
|
||||
const requestedIn = generation;
|
||||
const syncRun: SyncRun = {
|
||||
userId: currentUserId,
|
||||
isCancelled: () => generation !== requestedIn,
|
||||
};
|
||||
const previous = inFlight?.run ?? Promise.resolve(NOTHING_SYNCED);
|
||||
const run = previous
|
||||
.catch(() => NOTHING_SYNCED)
|
||||
.then(() => runSync(syncRun))
|
||||
.finally(() => {
|
||||
if (inFlight?.run === run) {
|
||||
inFlight = null;
|
||||
}
|
||||
});
|
||||
inFlight = { userId: currentUserId, generation: requestedIn, run };
|
||||
return run;
|
||||
}
|
||||
|
||||
// Generic drain: tries each queued entry in order, removes it on success,
|
||||
// stops at the first non-ApiError (real network failure, still offline) so
|
||||
// the rest of the queue is left untouched for the next sync attempt. An
|
||||
// ApiError (the server actively rejected it, e.g. validation) is counted as
|
||||
// failed and left in the queue too, rather than silently dropping data the
|
||||
// user entered - the sync badge stays visible so it's not forgotten.
|
||||
async function drain<T>(
|
||||
getPending: () => Promise<{ id: number; input: T; idempotencyKey?: string }[]>,
|
||||
removePending: (id: number) => Promise<void>,
|
||||
create: (input: T, options: CreateOptions) => Promise<unknown>,
|
||||
/**
|
||||
* Stops every sync run requested so far - the request already on its way is
|
||||
* finished and booked, nothing after it is sent - and keeps sync closed until
|
||||
* resumeSync(). Resolves once the runs have stopped: await it before the
|
||||
* access token changes (logout).
|
||||
*/
|
||||
export async function cancelSync(): Promise<void> {
|
||||
isOpen = false;
|
||||
generation += 1;
|
||||
await inFlight?.run.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens sync again after cancelSync(). Call it only once the signed-in user's
|
||||
* access token is set - the offline band does so when it mounts for a user.
|
||||
*/
|
||||
export function resumeSync(): void {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries each of the run user's queued entries in order and removes it on
|
||||
* success, after `onCreated` has seen the server's record. Right before each
|
||||
* request the entry is read again: one discarded meanwhile is not sent, one
|
||||
* that belongs to another account is not sent, and a cancelled run stops.
|
||||
*
|
||||
* A real network failure (still offline) stops the drain so the rest stays
|
||||
* untouched for the next attempt. An ApiError (the server actively rejected
|
||||
* it, e.g. validation) is stored on the entry, which stays queued rather than
|
||||
* silently dropping data the user entered - the band and the queue sheet
|
||||
* show it with the server's message until it is sent or discarded.
|
||||
*
|
||||
* If `onCreated` fails, only that entry is affected: it stays queued and
|
||||
* counts as failed, the drain goes on. The next run sends it again with the
|
||||
* same idempotency key, gets the same server record back and retries
|
||||
* `onCreated` - nothing that depends on it (the photos) is lost.
|
||||
*
|
||||
* Internal on purpose: new drains are added in runSync(), never called from
|
||||
* outside, so cancelSync() always tracks and stops them.
|
||||
*/
|
||||
async function drain<Store extends RecordStoreName, Result>(
|
||||
store: Store,
|
||||
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
|
||||
run: SyncRun,
|
||||
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
|
||||
): Promise<SyncResult> {
|
||||
const pending = await getPending();
|
||||
const queued = await getPendingEntries(store);
|
||||
const ownIds = queued.filter((entry) => !isForeignEntry(entry, run.userId)).map(({ id }) => id);
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let skippedForeign = queued.length - ownIds.length;
|
||||
|
||||
for (const entry of pending) {
|
||||
for (const id of ownIds) {
|
||||
const entry = await getPendingEntry(store, id);
|
||||
if (run.isCancelled()) {
|
||||
break;
|
||||
}
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (isForeignEntry(entry, run.userId)) {
|
||||
skippedForeign += 1;
|
||||
continue;
|
||||
}
|
||||
let result: Result;
|
||||
try {
|
||||
await create(entry.input, { idempotencyKey: entry.idempotencyKey });
|
||||
await removePending(entry.id);
|
||||
synced += 1;
|
||||
result = await create(entry.input, { idempotencyKey: entry.idempotencyKey });
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
if (!(error instanceof ApiError)) {
|
||||
break;
|
||||
}
|
||||
await markFailed(store, entry.id, toQueueError(error));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await onCreated?.(entry, result);
|
||||
} catch {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
await removeQueueEntry(store, entry.id);
|
||||
synced += 1;
|
||||
}
|
||||
|
||||
return { synced, failed };
|
||||
return { synced, failed, skippedForeign };
|
||||
}
|
||||
|
||||
async function runSync(): Promise<SyncResult> {
|
||||
const results = await Promise.all([
|
||||
drain(getPendingInspections, removePendingInspection, createInspection),
|
||||
drain(getPendingTreatments, removePendingTreatment, createTreatment),
|
||||
drain(getPendingFeedings, removePendingFeeding, createFeeding),
|
||||
drain(
|
||||
getPendingVarroaMeasurements,
|
||||
removePendingVarroaMeasurement,
|
||||
createVarroaMeasurement,
|
||||
),
|
||||
drain(
|
||||
getPendingSwarmControlChecks,
|
||||
removePendingSwarmControlCheck,
|
||||
createSwarmControlCheck,
|
||||
),
|
||||
drain(getPendingHarvests, removePendingHarvest, createHarvest),
|
||||
/**
|
||||
* A queued inspection is on the server now: its photos learn the server id,
|
||||
* while the entry - the only place that ties them to it - is still queued.
|
||||
*/
|
||||
function handOverPhotos(
|
||||
entry: PendingEntry<CreateInspectionInput>,
|
||||
created: Inspection,
|
||||
): Promise<void> {
|
||||
// Entries from before keys existed were queued before photos existed too.
|
||||
if (!entry.idempotencyKey) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return resolveQueuedInspection(entry.idempotencyKey, created.id);
|
||||
}
|
||||
|
||||
function addResults(total: SyncResult, result: SyncResult): SyncResult {
|
||||
return {
|
||||
synced: total.synced + result.synced,
|
||||
failed: total.failed + result.failed,
|
||||
skippedForeign: total.skippedForeign + result.skippedForeign,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
const records = await Promise.all([
|
||||
drain("pending-inspections", createInspection, run, handOverPhotos),
|
||||
drain("pending-treatments", createTreatment, run),
|
||||
drain("pending-feedings", createFeeding, run),
|
||||
drain("pending-varroa-measurements", createVarroaMeasurement, run),
|
||||
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
|
||||
drain("pending-harvests", createHarvest, run),
|
||||
]);
|
||||
|
||||
return results.reduce(
|
||||
(total, result) => ({
|
||||
synced: total.synced + result.synced,
|
||||
failed: total.failed + result.failed,
|
||||
}),
|
||||
{ synced: 0, failed: 0 },
|
||||
);
|
||||
// Photos last: the drains above have just given them their inspection's
|
||||
// server id, and records come first on a weak connection.
|
||||
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
|
||||
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { useCaptureMutation } from "./use-capture-mutation";
|
||||
|
||||
const signedIn = vi.hoisted(() => ({ userId: "user-1" as string | null }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => signedIn.userId,
|
||||
}));
|
||||
|
||||
interface Input {
|
||||
colonyId: string;
|
||||
}
|
||||
interface Saved {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
}
|
||||
|
||||
const INPUT: Input = { colonyId: "colony-1" };
|
||||
const FAILURE = "Durchsicht konnte nicht gespeichert werden.";
|
||||
|
||||
function setup(create: (input: Input) => Promise<Saved>) {
|
||||
const queue = vi.fn().mockResolvedValue(undefined);
|
||||
const onSaved = vi.fn();
|
||||
// A plain client on purpose: the hook must not rely on the app's defaults.
|
||||
const client = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const createMock = vi.fn(create);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCaptureMutation<Input, Saved>({
|
||||
create: createMock,
|
||||
queue,
|
||||
failureMessage: FAILURE,
|
||||
onSaved,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
return { result, create: createMock, queue, onSaved };
|
||||
}
|
||||
|
||||
describe("useCaptureMutation", () => {
|
||||
beforeEach(() => {
|
||||
signedIn.userId = "user-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// TanStack's onlineManager tracks the browser events, so restore it too.
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
});
|
||||
|
||||
it("saves online with the form's key and reports the server's record", async () => {
|
||||
const saved = { id: "i-1", colonyId: "colony-1" };
|
||||
const { result, create, queue, onSaved } = setup(async () => saved);
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({ via: "online", result: saved }, INPUT));
|
||||
expect(create).toHaveBeenCalledWith(INPUT, { idempotencyKey: result.current.idempotencyKey });
|
||||
expect(queue).not.toHaveBeenCalled();
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
|
||||
it("queues on a network error with the form's key and the current user", async () => {
|
||||
const { result, queue, onSaved } = setup(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const key = result.current.idempotencyKey;
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(queue).toHaveBeenCalledWith(INPUT, key, "user-1"));
|
||||
await waitFor(() =>
|
||||
expect(onSaved).toHaveBeenCalledWith({ via: "queued", idempotencyKey: key }, INPUT),
|
||||
);
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the ApiError message and does not queue", async () => {
|
||||
const { result, queue, onSaved } = setup(async () => {
|
||||
throw new ApiError(400, "Volk V-03 ist beendet.");
|
||||
});
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(result.current.submitError).toBe("Volk V-03 ist beendet."));
|
||||
expect(queue).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues even while navigator.onLine is false", async () => {
|
||||
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
});
|
||||
const { result, queue } = setup(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queue).toHaveBeenCalledWith(INPUT, result.current.idempotencyKey, "user-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("never queues an entry without an owner and shows the failure message instead", async () => {
|
||||
signedIn.userId = null;
|
||||
const { result, queue, onSaved } = setup(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(result.current.submitError).toBe(FAILURE));
|
||||
expect(queue).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the failure message when the device cannot store the entry", async () => {
|
||||
const { result, queue, onSaved } = setup(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
queue.mockRejectedValue(new DOMException("Quota exceeded", "QuotaExceededError"));
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(result.current.submitError).toBe(FAILURE));
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears an earlier error on the next submit", async () => {
|
||||
let attempt = 0;
|
||||
const { result, onSaved } = setup(async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new ApiError(400, "Volk V-03 ist beendet.");
|
||||
return { id: "i-1", colonyId: "colony-1" };
|
||||
});
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
await waitFor(() => expect(result.current.submitError).toBe("Volk V-03 ist beendet."));
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { CreateOptions, newIdempotencyKey } from "./idempotency";
|
||||
|
||||
/** Prefix of every query that reads the offline queue (band, queue sheet). */
|
||||
export const OFFLINE_QUEUE_QUERY_KEY = ["offline-queue"] as const;
|
||||
|
||||
export type CaptureOutcome<R> =
|
||||
| { via: "online"; result: R }
|
||||
| { via: "queued"; idempotencyKey: string };
|
||||
|
||||
export interface CaptureMutationOptions<I, R> {
|
||||
create: (input: I, options: CreateOptions) => Promise<R>;
|
||||
queue: (input: I, idempotencyKey: string, ownerUserId: string) => Promise<void>;
|
||||
/** Shown when nothing could be saved and the server gave no reason. */
|
||||
failureMessage: string;
|
||||
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface CaptureMutation<I> {
|
||||
submit: (input: I) => void;
|
||||
isPending: boolean;
|
||||
submitError: string | null;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a quick-capture form: online first; without a network the entry goes
|
||||
* into the offline queue under the signed-in user. A server rejection
|
||||
* (ApiError) is shown at the form and never queued.
|
||||
*/
|
||||
export function useCaptureMutation<I, R>({
|
||||
create,
|
||||
queue,
|
||||
failureMessage,
|
||||
onSaved,
|
||||
}: CaptureMutationOptions<I, R>): CaptureMutation<I> {
|
||||
const ownerUserId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
// One key per opened form: a double tap or an offline retry of the
|
||||
// same entry is recognised by the server instead of saved twice.
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
// Runs while the browser reports offline too: the request fails fast and
|
||||
// the entry is queued, instead of the form waiting for a network.
|
||||
networkMode: "always",
|
||||
retry: false,
|
||||
mutationFn: async (input: I): Promise<CaptureOutcome<R>> => {
|
||||
try {
|
||||
return { via: "online", result: await create(input, { idempotencyKey }) };
|
||||
} catch (error) {
|
||||
// Never queue without an owner: such an entry would be sent under
|
||||
// whoever signs in next on this device.
|
||||
if (error instanceof ApiError || !ownerUserId) {
|
||||
throw error;
|
||||
}
|
||||
await queue(input, idempotencyKey, ownerUserId);
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
return { via: "queued", idempotencyKey };
|
||||
}
|
||||
},
|
||||
onSuccess: (outcome, input) => onSaved(outcome, input),
|
||||
onError: (error) => setSubmitError(errorMessage(error, failureMessage)),
|
||||
});
|
||||
|
||||
function submit(input: I): void {
|
||||
setSubmitError(null);
|
||||
mutation.mutate(input);
|
||||
}
|
||||
|
||||
return { submit, isPending: mutation.isPending, submitError, idempotencyKey };
|
||||
}
|
||||
Reference in new issue
Block a user