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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
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");
}
+229
View File
@@ -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();
});
});
+380 -119
View File
@@ -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")}`;
}
+389 -73
View File
@@ -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" });
});
});
+168 -62
View File
@@ -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 };
}