feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+148
View File
@@ -227,3 +227,151 @@ describe("offline queue database", () => {
raw.close();
});
});
describe("bulk entries in the offline queue", () => {
const BULK_TREATMENT = {
date: "2026-10-14T00:00:00.000Z",
product: "Oxuvar",
batchNumber: "Ch-123",
colonyIds: ["c-1", "c-2", "c-3"],
};
const BULK_VARROA = {
date: "2026-10-14T00:00:00.000Z",
method: "NATURAL_MITE_FALL",
entries: [
{ colonyId: "c-1", miteCount: 4 },
{ colonyId: "c-3", miteCount: 9 },
],
};
const INVALID_COLONIES = {
status: 400,
message: "Einige Völker sind nicht (mehr) erfassbar",
details: { invalidColonyIds: ["c-3"] },
at: "2026-10-14T09:00:00.000Z",
};
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
async function onlyBulkEntry() {
const raw = await openDB(DB_NAME);
const [entry] = await raw.getAll("pending-bulk");
raw.close();
return entry;
}
it("queueBulk stores exactly one entry with kind, input, key and owner", async () => {
const db = await loadDb();
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
expect(await onlyBulkEntry()).toEqual({
id: 1,
kind: "treatments",
input: BULK_TREATMENT,
idempotencyKey: "bulk-key-0000000001",
ownerUserId: "user-1",
queuedAt: expect.any(String),
});
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
});
it("lists a bulk entry under its own name", async () => {
const db = await loadDb();
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
const [item] = await db.listQueue("user-1");
expect(item).toMatchObject({
store: "pending-bulk",
label: "Varroamessung für mehrere Völker",
});
expect(item.resendWithout).toBeUndefined();
});
it("names the rejected colonies by code from the loaded packs", async () => {
const db = await loadDb();
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
const raw = await openDB(DB_NAME);
await raw.put("offline-packs", {
apiaryId: "apiary-1",
ownerUserId: "user-1",
savedAt: "2026-10-14T08:00:00.000Z",
pack: {
apiary: { id: "apiary-1", name: "Waldrand" },
colonies: [
{ id: "c-1", code: "V-1", apiaryId: "apiary-1", status: "ACTIVE" },
{ id: "c-3", code: "V-3", apiaryId: "apiary-1", status: "DISSOLVED" },
],
},
});
raw.close();
const entry = await onlyBulkEntry();
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
const [item] = await db.listQueue("user-1");
expect(item.resendWithout).toEqual({ colonyIds: ["c-3"], codes: ["V-3"] });
});
it("offers no resend when every colony of the entry was rejected", async () => {
const db = await loadDb();
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
const entry = await onlyBulkEntry();
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
const [item] = await db.listQueue("user-1");
expect(item.resendWithout).toBeUndefined();
});
it("retryBulkWithout resends the remaining colonies with the same key and clears the rejection", async () => {
const db = await loadDb();
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
const entry = await onlyBulkEntry();
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
await db.retryBulkWithout(entry.id, ["c-3"]);
expect(await onlyBulkEntry()).toEqual({
...entry,
input: { ...BULK_TREATMENT, colonyIds: ["c-1", "c-2"] },
lastError: undefined,
});
expect((await onlyBulkEntry()).idempotencyKey).toBe("bulk-key-0000000001");
});
it("retryBulkWithout drops a varroa colony with its own count", async () => {
const db = await loadDb();
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
const entry = await onlyBulkEntry();
await db.retryBulkWithout(entry.id, ["c-3"]);
expect((await onlyBulkEntry()).input).toEqual({
...BULK_VARROA,
entries: [{ colonyId: "c-1", miteCount: 4 }],
});
});
it("retryBulkWithout removes an entry that would be left without colonies", async () => {
const db = await loadDb();
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
const entry = await onlyBulkEntry();
await db.retryBulkWithout(entry.id, ["c-3"]);
expect(await onlyBulkEntry()).toBeUndefined();
});
it("discardQueueItem deletes a bulk entry", async () => {
const db = await loadDb();
await db.queueBulk("feedings", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
const [item] = await db.listQueue("user-1");
await db.discardQueueItem(item);
expect(await onlyBulkEntry()).toBeUndefined();
});
});
+104 -7
View File
@@ -7,6 +7,11 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
@@ -191,6 +196,15 @@ function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
}
}
/** A new queue entry; the store gives it its id when it is added. */
export function newPendingEntry<T>(
input: T,
idempotencyKey: string,
ownerUserId: string,
): Omit<PendingEntry<T>, "id"> {
return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() };
}
async function queue<Store extends RecordStoreName>(
store: Store,
input: RecordQueueInputs[Store],
@@ -198,13 +212,7 @@ async function queue<Store extends RecordStoreName>(
ownerUserId: string,
): Promise<void> {
const db = await getDb();
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add(store, entry as never);
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
}
export function queueInspection(
@@ -255,6 +263,27 @@ export function queueHarvest(
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
*/
export async function queueBulk(
kind: BulkKind,
input: unknown,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
const db = await getDb();
const entry: Omit<PendingBulkEntry, "id"> = {
kind,
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add("pending-bulk", entry as PendingBulkEntry);
}
/** Every entry of one record queue, oldest first, of every owner. */
export async function getPendingEntries<Store extends RecordStoreName>(
store: Store,
@@ -272,6 +301,18 @@ export async function getPendingEntry<Store extends RecordStoreName>(
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
}
/** Every bulk entry, oldest first, of every owner. */
export async function getBulkEntries(): Promise<PendingBulkEntry[]> {
const db = await getDb();
return db.getAll("pending-bulk");
}
/** One bulk entry as it is stored right now, or undefined once it is gone. */
export async function getBulkEntry(id: number): Promise<PendingBulkEntry | undefined> {
const db = await getDb();
return db.get("pending-bulk", id);
}
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
const db = await getDb();
await db.delete(store, id);
@@ -300,6 +341,32 @@ export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: s
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
}
/**
* „Ohne diese Völker erneut senden“: takes the colonies out of a queued bulk
* entry and clears its rejection, so the next sync run sends the rest. The
* key stays: the server stored nothing for the rejected try and released it.
* An entry left without any colony is removed.
*/
export async function retryBulkWithout(
entryId: number,
colonyIds: readonly string[],
): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-bulk", "readwrite");
const entry = await tx.store.get(entryId);
if (entry) {
const input = withoutColonies(entry.kind, entry.input, colonyIds);
if (colonyIdsOfBulkInput(entry.kind, input).length === 0) {
await tx.store.delete(entryId);
} else {
const cleared: PendingBulkEntry = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
}
await tx.done;
}
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
interface QueuedRecord {
@@ -366,6 +433,11 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
*/
resendWithout?: { colonyIds: string[]; codes: string[] };
}
function labelOf({ store, entry }: QueuedRecord): string {
@@ -375,6 +447,27 @@ function labelOf({ store, entry }: QueuedRecord): string {
return RECORD_LABELS[store];
}
async function resendWithoutOf(
db: IDBPDatabase<OfflineDB>,
entry: PendingBulkEntry,
): Promise<QueueItem["resendWithout"]> {
const all = colonyIdsOfBulkInput(entry.kind, entry.input);
const rejected = invalidColonyIdsOfBody(entry.lastError?.details).filter((id) =>
all.includes(id),
);
if (rejected.length === 0 || rejected.length === all.length) {
return undefined;
}
const packs = await db.getAll("offline-packs");
const codeOf = new Map(
packs.flatMap((stored) => stored.pack.colonies.map((colony) => [colony.id, colony.code])),
);
return {
colonyIds: rejected,
codes: rejected.flatMap((id) => codeOf.get(id) ?? []),
};
}
/** The signed-in user's waiting and rejected entries, oldest first. */
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
const db = await getDb();
@@ -389,6 +482,10 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections" || !entry.idempotencyKey) {
return item;
}
@@ -45,6 +45,7 @@ vi.mock("idb", () => {
// 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),
finishHandOvers: vi.fn(async () => 0),
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
}));
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { withPackFallback } from "./pack-fallback";
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
const PACKS = [storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" }))];
const apiaryNames = vi.fn((packs: typeof PACKS) => packs.map((pack) => pack.pack.apiary.name));
describe("withPackFallback", () => {
beforeEach(() => {
vi.mocked(listPacks).mockReset().mockResolvedValue(PACKS);
apiaryNames.mockClear();
});
it("answers from the server whenever the server answers", async () => {
const result = await withPackFallback(async () => ["vom Server"], apiaryNames, "user-1");
expect(result).toEqual(["vom Server"]);
expect(listPacks).not.toHaveBeenCalled();
});
it("falls back to the pack on a network error", async () => {
const result = await withPackFallback(
() => Promise.reject(new TypeError("Failed to fetch")),
apiaryNames,
"user-1",
);
expect(result).toEqual(["Obstwiese"]);
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("falls back to the pack when the request timed out", async () => {
const timeout = new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError");
const result = await withPackFallback(() => Promise.reject(timeout), apiaryNames, "user-1");
expect(result).toEqual(["Obstwiese"]);
});
it("rethrows an ApiError instead of showing pack data", async () => {
for (const status of [403, 404, 500]) {
const rejection = new ApiError(status, "Nein");
await expect(
withPackFallback(() => Promise.reject(rejection), apiaryNames, "user-1"),
).rejects.toBe(rejection);
}
expect(listPacks).not.toHaveBeenCalled();
expect(apiaryNames).not.toHaveBeenCalled();
});
it("rethrows when the pack has nothing", async () => {
const offline = new TypeError("Failed to fetch");
await expect(
withPackFallback(() => Promise.reject(offline), () => null, "user-1"),
).rejects.toBe(offline);
});
it("rethrows the network error when the packs cannot be read", async () => {
const offline = new TypeError("Failed to fetch");
vi.mocked(listPacks).mockRejectedValue(new Error("IndexedDB gesperrt"));
await expect(
withPackFallback(() => Promise.reject(offline), apiaryNames, "user-1"),
).rejects.toBe(offline);
});
});
@@ -0,0 +1,32 @@
import { listPacks } from "@/lib/apiaries/offline-pack";
import type { StoredPack } from "./db";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
/**
* The server's answer - or, only when the network failed, what the user's
* offline packs say. A server refusal (ApiError: 403, 404, 400 ...) is never
* covered up with pack data: the server knows better than an old pack.
* When the packs have nothing (`fromPacks` returns null) or cannot be read,
* the network error is passed on, so the caller shows "not loaded".
*
* Queries built on this read local storage when offline: give them
* `networkMode: "always"`, or a paused retry would hide the fallback.
*/
export async function withPackFallback<T>(
fetcher: () => Promise<T>,
fromPacks: (packs: StoredPack[]) => T | null,
ownerUserId: string,
): Promise<T> {
try {
return await fetcher();
} catch (error) {
if (!isNetworkError(error)) {
throw error;
}
const fallback = await listPacks(ownerUserId).then(fromPacks, () => null);
if (fallback === null) {
throw error;
}
return fallback;
}
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import {
entriesNotSent,
entriesRejected,
entriesSynced,
entriesWaiting,
foreignEntriesWaiting,
photosAttached,
photosNotTakenOver,
sessionExpiredWithEntries,
} from "./queue-texts";
describe("queue texts", () => {
@@ -14,6 +17,7 @@ describe("queue texts", () => {
expect(foreignEntriesWaiting(1)).toBe("1 Eintrag eines anderen Kontos wartet");
expect(entriesSynced(1)).toBe("1 Eintrag synchronisiert");
expect(photosAttached(1)).toBe("+ 1 Foto");
expect(photosNotTakenOver(1)).toBe("1 Foto wurde nicht übernommen und verworfen");
});
it("uses the plural for two entries", () => {
@@ -22,5 +26,21 @@ describe("queue texts", () => {
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");
expect(photosNotTakenOver(2)).toBe("2 Fotos wurden nicht übernommen und verworfen");
});
it("says how many entries are not sent yet when signing out", () => {
expect(entriesNotSent(1)).toBe("1 Eintrag ist noch nicht gesendet.");
expect(entriesNotSent(3)).toBe("3 Einträge sind noch nicht gesendet.");
});
it("tells an expired session apart from the entries that still wait", () => {
expect(sessionExpiredWithEntries(0)).toBe("Sitzung abgelaufen – bitte neu anmelden.");
expect(sessionExpiredWithEntries(1)).toBe(
"Sitzung abgelaufen – bitte neu anmelden. 1 Eintrag wartet und wird danach gesendet.",
);
expect(sessionExpiredWithEntries(4)).toBe(
"Sitzung abgelaufen – bitte neu anmelden. 4 Einträge warten und werden danach gesendet.",
);
});
});
@@ -24,3 +24,23 @@ export function entriesSynced(n: number): string {
export function photosAttached(n: number): string {
return `+ ${count(n, "Foto", "Fotos")}`;
}
/** Photos of a fresh form that did not fit into a restored draft and were deleted. */
export function photosNotTakenOver(n: number): string {
return `${count(n, "Foto wurde", "Fotos wurden")} nicht übernommen und verworfen`;
}
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
export function entriesNotSent(n: number): string {
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
}
/** The refresh found the session over: the entries wait for the next sign-in. */
export function sessionExpiredWithEntries(n: number): string {
const expired = "Sitzung abgelaufen – bitte neu anmelden.";
if (n === 0) {
return expired;
}
const rest = n === 1 ? "1 Eintrag wartet und wird danach gesendet." : `${n} Einträge warten und werden danach gesendet.`;
return `${expired} ${rest}`;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { safeReturnPath } from "./return-path";
describe("safeReturnPath", () => {
it("keeps a path of this app, with its query", () => {
expect(safeReturnPath("/colonies/abc")).toBe("/colonies/abc");
expect(safeReturnPath("/statistics?year=2026")).toBe("/statistics?year=2026");
expect(safeReturnPath("/colonies/a%20b")).toBe("/colonies/a%20b");
});
it("never leads to another site", () => {
for (const unsafe of [
"https://evil.example/colonies/abc",
"//evil.example/colonies",
"/\\evil.example",
"\\\\evil.example",
"javascript:alert(1)",
" /colonies/abc",
"colonies/abc",
]) {
expect(safeReturnPath(unsafe)).toBeNull();
}
});
it("never leads back into the offline pages, the API or nowhere", () => {
expect(safeReturnPath("/offline")).toBeNull();
expect(safeReturnPath("/offline/colony?id=abc")).toBeNull();
expect(safeReturnPath("/api/v1/colonies")).toBeNull();
expect(safeReturnPath("")).toBeNull();
expect(safeReturnPath(null)).toBeNull();
});
});
@@ -0,0 +1,27 @@
// Any origin works as the base: only a result on this same origin is accepted.
const BASE = "https://apicore.invalid";
/**
* The `from` the service worker hands to the offline pages - the page the
* user wanted - as a path of this app, or null. Never another site (no open
* redirect), never back into the offline pages or the API.
*/
export function safeReturnPath(raw: string | null): string | null {
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
return null;
}
let url: URL;
try {
url = new URL(raw, BASE);
} catch {
return null;
}
if (url.origin !== BASE) {
return null;
}
const isOffline = url.pathname === "/offline" || url.pathname.startsWith("/offline/");
if (isOffline || url.pathname === "/api" || url.pathname.startsWith("/api/")) {
return null;
}
return url.pathname + url.search + url.hash;
}
@@ -0,0 +1,272 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
type OfflineFallback = { kind: "redirect"; to: string } | { kind: "cached-path"; path: string };
type NavigationPlan =
| { kind: "browser" }
| { kind: "cache-first" }
| { kind: "network-first"; timeoutMs: number; redirectOnTimeout: boolean };
interface SwRoutes {
SHELL_ROUTES: readonly string[];
offlineFallbackFor: (url: URL) => OfflineFallback;
offlineHomeFor: (url: URL) => string;
navigationPlanFor: (url: URL) => NavigationPlan;
isShellRoute: (pathname: string) => boolean;
extractAssetUrls: (html: string) => string[];
isCacheableApiPath: (pathname: string) => boolean;
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
apiOriginFrom: (config: unknown) => string | null;
isRoutePayload: (url: URL) => boolean;
buildAssetsToPrune: (
cached: { url: string; date: number | null }[],
current: readonly string[],
now: number,
) => string[];
}
// The service worker's routing rules are a plain script (public/), loaded by
// the worker with importScripts - here as CommonJS.
const require = createRequire(import.meta.url);
// Vitest runs from the frontend root.
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
const routes = require(path.join(PUBLIC_DIR, "sw-routes.js")) as SwRoutes;
const APP = "https://apicore.example";
const API = "https://apicorebackend.example";
const DAY_MS = 24 * 60 * 60 * 1000;
function fallbackFor(path: string): OfflineFallback {
return routes.offlineFallbackFor(new URL(path, APP));
}
function planFor(path: string): NavigationPlan {
return routes.navigationPlanFor(new URL(path, APP));
}
function cachesApi(path: string, origin = API): boolean {
return routes.shouldCacheApi(new URL(path, origin), API);
}
describe("service worker routes", () => {
it("maps /colonies/abc to /offline/colony?id=abc and encodes ids", () => {
expect(fallbackFor("/colonies/abc")).toEqual({
kind: "redirect",
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc",
});
expect(fallbackFor("/colonies/abc/")).toEqual({
kind: "redirect",
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc%2F",
});
expect(fallbackFor("/apiaries/xyz")).toEqual({
kind: "redirect",
to: "/offline/apiary?id=xyz&from=%2Fapiaries%2Fxyz",
});
expect(fallbackFor("/colonies/a%20b%26c%3Dd")).toEqual({
kind: "redirect",
to: "/offline/colony?id=a%20b%26c%3Dd&from=%2Fcolonies%2Fa%2520b%2526c%253Dd",
});
expect(fallbackFor("/colonies/a&b=c#x")).toEqual({
kind: "redirect",
to: "/offline/colony?id=a%26b%3Dc&from=%2Fcolonies%2Fa%26b%3Dc",
});
});
it("serves /inspections/new?colonyId=x from the cached shell path", () => {
expect(fallbackFor("/inspections/new?colonyId=x&round=apiary-1")).toEqual({
kind: "cached-path",
path: "/inspections/new",
});
expect(fallbackFor("/offline/colony?id=abc")).toEqual({
kind: "cached-path",
path: "/offline/colony",
});
expect(fallbackFor("/dashboard/")).toEqual({ kind: "cached-path", path: "/dashboard" });
for (const route of [
"/",
"/login",
"/dashboard",
"/offline",
"/offline/apiary",
"/offline/colony",
"/inspections/new",
"/varroa/new",
"/treatments/new",
"/feedings/new",
"/swarm-control/new",
]) {
expect(routes.isShellRoute(route)).toBe(true);
}
expect(routes.SHELL_ROUTES).toHaveLength(11);
});
it("unknown routes go to /offline and remember where the user wanted to go", () => {
expect(fallbackFor("/statistics?year=2026")).toEqual({
kind: "redirect",
to: "/offline?from=%2Fstatistics%3Fyear%3D2026",
});
for (const path of [
"/colonies",
"/colonies/new",
"/apiaries/new",
"/apiaries/map",
"/colonies/abc/edit",
"/colonies/%E0%A4%A",
"/harvests/new",
]) {
const fallback = fallbackFor(path);
expect(fallback.kind).toBe("redirect");
expect(fallback.kind === "redirect" && fallback.to.startsWith("/offline?from=")).toBe(true);
}
expect(routes.isShellRoute("/statistics")).toBe(false);
expect(routes.offlineHomeFor(new URL("/queens/q-1", APP))).toBe("/offline?from=%2Fqueens%2Fq-1");
});
it("plans each navigation by its route", () => {
expect(planFor("/offline")).toEqual({ kind: "cache-first" });
expect(planFor("/offline/apiary?id=a-1")).toEqual({ kind: "cache-first" });
expect(planFor("/offline/colony?id=c-1")).toEqual({ kind: "cache-first" });
// Shell pages answer from the cache after 4 s - without one, the network
// keeps its chance (no dead end on a slow line).
expect(planFor("/inspections/new?colonyId=c-1")).toEqual({
kind: "network-first",
timeoutMs: 4000,
redirectOnTimeout: false,
});
expect(planFor("/")).toEqual({ kind: "network-first", timeoutMs: 4000, redirectOnTimeout: false });
// A scanned QR code at the apiary: the offline view after 10 s.
expect(planFor("/colonies/c-1")).toEqual({
kind: "network-first",
timeoutMs: 10000,
redirectOnTimeout: true,
});
expect(planFor("/apiaries/a-1")).toEqual({
kind: "network-first",
timeoutMs: 10000,
redirectOnTimeout: true,
});
expect(planFor("/statistics")).toEqual({
kind: "network-first",
timeoutMs: 15000,
redirectOnTimeout: true,
});
});
it("leaves navigations to /api/ to the browser", () => {
expect(planFor("/api/runtime-config")).toEqual({ kind: "browser" });
expect(planFor("/api/v1/documents/d-1/download")).toEqual({ kind: "browser" });
});
it("never caches /api/v1/auth/*", () => {
for (const path of [
"/api/v1/auth/me",
"/api/v1/auth/refresh",
"/api/v1/auth/sso/status",
"/api/v1/auth",
"/api/v1/Auth/me",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/authors")).toBe(true);
});
it("never caches what the offline packs answer, so the pack answers offline", () => {
for (const path of [
"/api/v1/colonies",
"/api/v1/colonies/",
"/api/v1/colonies?status=ACTIVE",
"/api/v1/colonies/c-1/inspections",
"/api/v1/apiaries/a-1/colony-overview",
"/api/v1/apiaries/a-1/offline-pack",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/colonies/c-1")).toBe(true);
expect(cachesApi("/api/v1/tasks")).toBe(true);
});
it("never caches apiaries themselves - they carry coordinates and the address", () => {
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/apiaries/a-1/weather")).toBe(true);
});
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
for (const path of [
"/api/v1/export/apiaries.json",
"/api/v1/export/treatment-log.pdf",
"/api/v1/export/colonies.csv",
"/api/v1/Export/apiaries.json",
"/api/v1/backup/export",
"/api/v1/backup",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/exporters")).toBe(true);
});
it("caches only answers of the configured backend", () => {
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/runtime-config", APP), APP)).toBe(false);
expect(routes.shouldCacheApi(new URL("/_next/static/chunks/main.js", APP), APP)).toBe(false);
expect(routes.apiOriginFrom({ apiUrl: `${API}/api/v1` })).toBe(API);
expect(routes.apiOriginFrom({ apiUrl: "kein url" })).toBeNull();
expect(routes.apiOriginFrom(null)).toBeNull();
expect(routes.isCacheableApiPath("/api/v1/apiaries/a-1")).toBe(false);
expect(routes.isCacheableApiPath("/api/v1/tasks")).toBe(true);
});
it("recognises route payloads, which are never kept", () => {
expect(routes.isRoutePayload(new URL("/dashboard?_rsc=1x2y", APP))).toBe(true);
expect(routes.isRoutePayload(new URL("/dashboard", APP))).toBe(false);
});
it("extracts _next/static asset urls", () => {
const html = `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/_next/static/css/app-1a2b.css" data-precedence="next"/>
<link rel="preload" href="/_next/static/media/inter-3c4d.p.woff2" as="font" crossorigin=""/>
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
<script src='/_next/static/chunks/app/page-7a8b.js?dpl=abc' async></script>
<script src="https://cdn.example/_next/static/chunks/foreign.js"></script>
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
</head><body><script>self.__next_f.push([1,"0:[\\"/_next/static/chunks/lazy-9c0d.js\\"]"])</script></body></html>`;
expect(routes.extractAssetUrls(html).sort()).toEqual([
"/_next/static/chunks/app/page-7a8b.js?dpl=abc",
"/_next/static/chunks/lazy-9c0d.js",
"/_next/static/chunks/webpack-5e6f.js",
"/_next/static/css/app-1a2b.css",
"/_next/static/media/inter-3c4d.p.woff2",
]);
expect(routes.extractAssetUrls("<html><body>Keine Assets</body></html>")).toEqual([]);
});
it("prunes build assets of old builds, keeping current and recently used ones", () => {
const now = Date.parse("2026-10-14T12:00:00.000Z");
const cached = [
{ url: `${APP}/_next/static/chunks/current.js`, date: now - 30 * DAY_MS },
{ url: `${APP}/_next/static/chunks/old.js`, date: now - 8 * DAY_MS },
{ url: `${APP}/_next/static/chunks/recent-lazy.js`, date: now - DAY_MS },
{ url: `${APP}/_next/static/chunks/undated.js`, date: null },
{ url: `${APP}/icon.svg`, date: now - 90 * DAY_MS },
];
expect(
routes.buildAssetsToPrune(cached, ["/_next/static/chunks/current.js"], now).sort(),
).toEqual([`${APP}/_next/static/chunks/old.js`, `${APP}/_next/static/chunks/undated.js`]);
});
it("sw.js imports sw-routes.js under its content version, so every change reaches the phones", () => {
const worker = readFileSync(path.join(PUBLIC_DIR, "sw.js"), "utf8");
const rules = readFileSync(path.join(PUBLIC_DIR, "sw-routes.js"), "utf8").replace(/\r\n/g, "\n");
const version = createHash("sha256").update(rules).digest("hex").slice(0, 12);
expect(worker).toContain(`const SW_ROUTES_VERSION = "${version}";`);
expect(worker).toContain("importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);");
});
});
+338
View File
@@ -0,0 +1,338 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { beforeEach, describe, expect, it, vi } from "vitest";
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
const APP = "https://apicore.example";
const API = "https://apicorebackend.example";
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
interface MatchOptions {
ignoreSearch?: boolean;
}
function keyOf(request: RequestInfo | URL): string {
const raw = typeof request === "string" || request instanceof URL ? String(request) : request.url;
return new URL(raw, APP).href;
}
function withoutSearch(href: string): string {
const url = new URL(href);
return url.origin + url.pathname;
}
class FakeCache {
readonly entries = new Map<string, Response>();
constructor(private readonly log: string[], private readonly name: string) {}
async match(request: RequestInfo | URL, options: MatchOptions = {}): Promise<Response | undefined> {
const key = keyOf(request);
const found = options.ignoreSearch
? [...this.entries.entries()].find(([stored]) => withoutSearch(stored) === withoutSearch(key))?.[1]
: this.entries.get(key);
return found?.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const key = keyOf(request);
// Like the real Cache API: the body is read completely before it is stored.
const body = await response.arrayBuffer();
this.entries.set(key, new Response(body, { status: response.status, headers: response.headers }));
this.log.push(`${this.name} ${new URL(key).pathname}`);
}
async delete(request: RequestInfo | URL): Promise<boolean> {
return this.entries.delete(keyOf(request));
}
async keys(): Promise<Request[]> {
return [...this.entries.keys()].map((key) => new Request(key));
}
}
class FakeCacheStorage {
readonly stores = new Map<string, FakeCache>();
readonly log: string[] = [];
async open(name: string): Promise<FakeCache> {
if (!this.stores.has(name)) {
this.stores.set(name, new FakeCache(this.log, name));
}
return this.stores.get(name) as FakeCache;
}
async keys(): Promise<string[]> {
return [...this.stores.keys()];
}
async delete(name: string): Promise<boolean> {
return this.stores.delete(name);
}
}
type Handler = (event: unknown) => void;
interface Worker {
caches: FakeCacheStorage;
network: ReturnType<typeof vi.fn>;
handlers: Map<string, Handler>;
}
function loadWorker(caches: FakeCacheStorage, network: ReturnType<typeof vi.fn>): Worker {
const handlers = new Map<string, Handler>();
const self = {
location: new URL(`${APP}/sw.js`),
addEventListener: (type: string, handler: Handler) => handlers.set(type, handler),
skipWaiting: () => Promise.resolve(),
clients: { claim: () => Promise.resolve() },
};
const context = vm.createContext({
self,
caches,
fetch: network,
URL,
Request,
Response,
Headers,
setTimeout,
clearTimeout,
console,
importScripts: () => {
vm.runInContext(readFileSync(path.join(PUBLIC_DIR, "sw-routes.js"), "utf8"), context);
},
});
vm.runInContext(readFileSync(path.join(PUBLIC_DIR, "sw.js"), "utf8"), context);
return { caches, network, handlers };
}
/** Dispatches an event and waits for its answer and everything it keeps alive. */
async function dispatch(worker: Worker, type: string, fields: Record<string, unknown>) {
let answer: Promise<Response> | undefined;
const pending: Promise<unknown>[] = [];
const event = {
...fields,
respondWith: (promise: Promise<Response>) => {
answer = Promise.resolve(promise);
},
waitUntil: (promise: Promise<unknown>) => {
pending.push(promise);
},
};
worker.handlers.get(type)?.(event);
const response = await answer?.catch(() => undefined);
for (let settled = 0; settled < pending.length; settled = pending.length) {
await Promise.allSettled(pending.slice(settled));
}
return { responded: answer !== undefined, response };
}
function apiGet(worker: Worker, url: string) {
return dispatch(worker, "fetch", { request: new Request(url) });
}
function navigate(worker: Worker, url: string) {
const request = { url, method: "GET", mode: "navigate", headers: new Headers() };
return dispatch(worker, "fetch", { request });
}
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json; charset=utf-8" } });
}
function html(body: string): Response {
return new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
function page(asset: string, marker: string): string {
return `<!doctype html><html><head><script src="${asset}" async=""></script></head><body>${marker}</body></html>`;
}
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
const worker = loadWorker(caches, network);
// Let the worker read the backend origin from the cached config.
await new Promise((resolve) => setTimeout(resolve, 0));
return worker;
}
async function dataKeys(worker: Worker): Promise<string[]> {
const data = worker.caches.stores.get("apicore-data-v2");
return data ? [...data.entries.keys()].map((key) => new URL(key).pathname) : [];
}
describe("service worker", () => {
let network: ReturnType<typeof vi.fn>;
beforeEach(() => {
network = vi.fn();
});
it("keeps JSON answers of the backend, never downloads, photos or other non-JSON answers", async () => {
const worker = await workerWithBackend(network);
network.mockImplementation(async (request: Request) => {
const { pathname } = new URL(request.url);
if (pathname.endsWith("/download")) {
return new Response("%PDF-1.7", { headers: { "Content-Type": "application/pdf" } });
}
if (pathname.endsWith("/avatar")) {
return new Response("jpeg", { headers: { "Content-Type": "image/jpeg" } });
}
return json([{ id: "t-1" }]);
});
await apiGet(worker, `${API}/api/v1/tasks`);
const download = await apiGet(worker, `${API}/api/v1/documents/d-1/download`);
await apiGet(worker, `${API}/api/v1/profile/avatar`);
expect(await download.response?.text()).toBe("%PDF-1.7");
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
});
it("leaves exports and backups to the network entirely", async () => {
const worker = await workerWithBackend(network);
network.mockResolvedValue(json([{ latitude: 52.1, address: "Feldweg 3" }]));
const exported = await apiGet(worker, `${API}/api/v1/export/apiaries.json`);
const backup = await apiGet(worker, `${API}/api/v1/backup/export`);
expect(exported.responded).toBe(false);
expect(backup.responded).toBe(false);
expect(await dataKeys(worker)).toEqual([]);
});
it("drops the data cache of earlier versions on activation", async () => {
const caches = new FakeCacheStorage();
await (await caches.open("apicore-data-v1")).put(`${API}/api/v1/apiaries/a-1`, json({ latitude: 52.1 }));
const worker = loadWorker(caches, network);
await dispatch(worker, "activate", {});
expect(await caches.keys()).not.toContain("apicore-data-v1");
});
it("after WIPE_CACHES keeps nothing until the backend is known again", async () => {
const worker = await workerWithBackend(network);
network.mockImplementation(async () => json([{ id: "t-1" }]));
await apiGet(worker, `${API}/api/v1/tasks`);
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
await dispatch(worker, "message", { data: { type: "WIPE_CACHES" }, ports: [] });
await apiGet(worker, `${API}/api/v1/tasks`);
expect(await dataKeys(worker)).toEqual([]);
});
describe("WIPE_CACHES while answers are on their way", () => {
/** Sends WIPE_CACHES and returns the promise that settles when it is done. */
function startWipe(worker: Worker): Promise<unknown> {
let done: Promise<unknown> = Promise.resolve();
worker.handlers.get("message")?.({
data: { type: "WIPE_CACHES" },
ports: [],
waitUntil: (promise: Promise<unknown>) => {
done = promise;
},
});
return done;
}
it("never stores an answer whose request began before the wipe", async () => {
const worker = await workerWithBackend(network);
let answer: (response: Response) => void = () => undefined;
network.mockImplementation(
() => new Promise<Response>((resolve) => (answer = resolve)),
);
const inFlight = apiGet(worker, `${API}/api/v1/tasks`);
await new Promise((resolve) => setTimeout(resolve, 0));
await startWipe(worker);
answer(json([{ id: "c-1", name: "Volk des Vorgängers" }]));
await inFlight;
expect(await dataKeys(worker)).toEqual([]);
});
it("does not rebuild the data cache for a request that arrives during the wipe", async () => {
const worker = await workerWithBackend(network);
network.mockImplementation(async () => json([{ id: "c-1" }]));
await apiGet(worker, `${API}/api/v1/tasks`);
const wiping = startWipe(worker);
const during = apiGet(worker, `${API}/api/v1/tasks`);
await wiping;
await during;
expect(await dataKeys(worker)).toEqual([]);
});
it("keeps storing answers of requests that began after the wipe once the backend is known", async () => {
const worker = await workerWithBackend(network);
network.mockImplementation(async () => json({ apiUrl: `${API}/api/v1` }));
await startWipe(worker);
await dispatch(worker, "fetch", { request: new Request(`${APP}/api/runtime-config`) });
await new Promise((resolve) => setTimeout(resolve, 0));
network.mockImplementation(async () => json([{ id: "c-2" }]));
await apiGet(worker, `${API}/api/v1/tasks`);
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
});
});
describe("background refresh of a cached offline page", () => {
async function workerWithOldOfflinePage(): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
caches.log.length = 0;
return loadWorker(caches, network);
}
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
const shell = await worker.caches.open("apicore-shell-v4");
return (await shell.match("/offline/colony"))?.text();
}
it("keeps the new page only once its assets are cached", async () => {
const worker = await workerWithOldOfflinePage();
network.mockImplementation(async (request: RequestInfo) => {
const { pathname } = new URL(keyOf(request));
if (pathname === "/offline/colony") return html(page("/_next/static/chunks/new.js", "neu"));
if (pathname === "/_next/static/chunks/new.js") return new Response("neu()");
throw new TypeError("Failed to fetch");
});
const { response } = await navigate(worker, `${APP}/offline/colony?id=c-1`);
// The cached page answers at once ...
expect(await response?.text()).toContain("alt");
// ... and the new one is kept with its assets, the assets first.
expect(await cachedOfflinePage(worker)).toContain("neu");
expect(worker.caches.log).toEqual([
"apicore-shell-v4 /_next/static/chunks/new.js",
"apicore-shell-v4 /offline/colony",
]);
});
it("keeps the old page when an asset of the new one cannot be fetched", async () => {
const worker = await workerWithOldOfflinePage();
network.mockImplementation(async (request: RequestInfo) => {
const { pathname } = new URL(keyOf(request));
if (pathname === "/offline/colony") return html(page("/_next/static/chunks/new.js", "neu"));
throw new TypeError("Failed to fetch");
});
await navigate(worker, `${APP}/offline/colony?id=c-1`);
expect(await cachedOfflinePage(worker)).toContain("alt");
});
});
});
@@ -0,0 +1,286 @@
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/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// Only the network is faked: the bulk endpoints and their error helpers are real.
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
type OfflineDbModule = typeof import("./db");
type SyncModule = typeof import("./sync");
let db: OfflineDbModule;
let sync: SyncModule;
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
let apiFetch: MockedFunction<typeof import("@/lib/auth/api-client").apiFetch>;
let refreshPacksAfterSync: MockedFunction<
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
>;
const TREATMENT_BULK = {
date: "2026-10-14T00:00:00.000Z",
product: "Oxuvar",
colonyIds: ["colony-1", "colony-2", "colony-3"],
};
const VARROA_BULK = {
date: "2026-10-14T00:00:00.000Z",
method: "NATURAL_MITE_FALL" as const,
entries: [
{ colonyId: "colony-1", miteCount: 4 },
{ colonyId: "colony-2", miteCount: 9 },
],
};
const SAVED = { bulkId: "b-1", records: [] };
const KEY_1 = "bulk-key-0000000001";
const KEY_2 = "bulk-key-0000000002";
/** What went out to one bulk endpoint: the body and the Idempotency-Key. */
function sentTo(path: string) {
return apiFetch.mock.calls
.filter(([sentPath]) => sentPath === path)
.map(([, init]) => ({
body: JSON.parse(String(init?.body)) as unknown,
key: (init?.headers as Record<string, string>)["Idempotency-Key"],
}));
}
async function queuedBulk() {
const raw = await openDB("apicore-offline");
const entries = await raw.getAll("pending-bulk");
raw.close();
return entries;
}
describe("bulk entries in syncPendingEntries", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
window.localStorage.clear();
vi.resetModules();
db = await import("./db");
sync = await import("./sync");
const apiClient = await import("@/lib/auth/api-client");
ApiError = apiClient.ApiError;
apiFetch = vi.mocked(apiClient.apiFetch).mockReset();
refreshPacksAfterSync = vi.mocked(
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
);
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
});
it("drains a bulk entry in the run, sending the key it was queued with", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
await db.queueBulk("varroa", VARROA_BULK, KEY_2, "user-1");
apiFetch.mockResolvedValue(SAVED);
apiFetch.mockResolvedValue(SAVED);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
expect(sentTo("/treatments/bulk")).toEqual([{ body: TREATMENT_BULK, key: KEY_1 }]);
expect(sentTo("/varroa-measurements/bulk")).toEqual([{ body: VARROA_BULK, key: KEY_2 }]);
expect(await queuedBulk()).toEqual([]);
});
it("keeps a rejected bulk entry with the server answer and reloads the packs of the named colonies only", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
const body = {
message: "Einige Völker sind nicht (mehr) erfassbar",
invalidColonyIds: ["colony-3"],
};
apiFetch.mockRejectedValue(new ApiError(400, body.message, body));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [kept] = await queuedBulk();
expect(kept.idempotencyKey).toBe(KEY_1);
expect(kept.lastError).toMatchObject({ status: 400, details: body });
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
"user-1",
expect.any(Number),
expect.any(Function),
{ force: true, colonyIds: ["colony-3"] },
);
});
it("does not force a pack reload for a rejection that names no colony", async () => {
await db.queueBulk("feedings", TREATMENT_BULK, KEY_1, "user-1");
apiFetch.mockRejectedValue(new ApiError(400, "Menge fehlt"));
await sync.syncPendingEntries("user-1");
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
});
it("treats wird bereits verarbeitet as later again: no rejection, no failure", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
apiFetch.mockRejectedValueOnce(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
);
const first = await sync.syncPendingEntries("user-1");
expect(first).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
const [kept] = await queuedBulk();
expect(kept.lastError).toBeUndefined();
apiFetch.mockResolvedValue(SAVED);
const second = await sync.syncPendingEntries("user-1");
expect(second.synced).toBe(1);
expect(sentTo("/treatments/bulk").at(-1)).toEqual({ body: TREATMENT_BULK, key: KEY_1 });
});
it("stops at a network failure and leaves the entry untouched", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [kept] = await queuedBulk();
expect(kept.lastError).toBeUndefined();
});
it("never sends another user's bulk entry", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-2");
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(apiFetch).not.toHaveBeenCalled();
});
it("sends nothing after cancelSync()", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
await sync.cancelSync();
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(apiFetch).not.toHaveBeenCalled();
});
it("after Ohne diese Voelker neu senden resends the remaining colonies with the same key", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
apiFetch.mockRejectedValueOnce(
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
invalidColonyIds: ["colony-2"],
}),
);
await sync.syncPendingEntries("user-1");
const [rejected] = await queuedBulk();
await db.retryBulkWithout(rejected.id, ["colony-2"]);
apiFetch.mockResolvedValue(SAVED);
const result = await sync.syncPendingEntries("user-1", { afterChange: true });
expect(result.synced).toBe(1);
expect(sentTo("/treatments/bulk").at(-1)).toEqual({
body: { ...TREATMENT_BULK, colonyIds: ["colony-1", "colony-3"] },
key: KEY_1,
});
expect(await queuedBulk()).toEqual([]);
});
describe("a session or server problem is not a rejection of the entry", () => {
const TREATMENT = { colonyId: "colony-1", date: "2026-10-14T00:00:00.000Z", product: "Oxuvar" };
async function queuedTreatments() {
const raw = await openDB("apicore-offline");
const entries = await raw.getAll("pending-treatments");
raw.close();
return entries;
}
it("a 401 stops the run: bulk entries stay waiting, none is marked rejected", async () => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
await db.queueBulk("varroa", VARROA_BULK, KEY_2, "user-1");
apiFetch.mockRejectedValue(new ApiError(401, "Unauthorized"));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect((await queuedBulk()).map((entry) => entry.lastError)).toEqual([undefined, undefined]);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("a 401 on a single entry leaves it and the rest of that queue untouched", async () => {
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
await db.queueTreatment(TREATMENT, "treatment-key-00000002", "user-1");
apiFetch.mockRejectedValue(new ApiError(401, "Unauthorized"));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect((await queuedTreatments()).map((entry) => entry.lastError)).toEqual([
undefined,
undefined,
]);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it.each([408, 429, 502, 503, 504])(
"a %i leaves a bulk entry waiting and stops like a network failure",
async (status) => {
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
await db.queueBulk("treatments", TREATMENT_BULK, KEY_2, "user-1");
apiFetch.mockRejectedValue(new ApiError(status, "Bad Gateway"));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
expect((await queuedBulk()).map((entry) => entry.lastError)).toEqual([
undefined,
undefined,
]);
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
},
);
it("a 503 leaves a single entry waiting, not rejected", async () => {
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
apiFetch.mockRejectedValue(new ApiError(503, "Service Unavailable"));
await sync.syncPendingEntries("user-1");
const [kept] = await queuedTreatments();
expect(kept.lastError).toBeUndefined();
});
it("a real rejection (400) is still stored on the entry", async () => {
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
apiFetch.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet."));
await sync.syncPendingEntries("user-1");
const [kept] = await queuedTreatments();
expect(kept.lastError).toMatchObject({ status: 400 });
});
it("a single entry whose key is still being processed (409) is skipped, the next one is sent", async () => {
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
await db.queueTreatment(TREATMENT, "treatment-key-00000002", "user-1");
apiFetch
.mockRejectedValueOnce(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
)
.mockResolvedValue({ id: "t-2" });
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
const [kept, ...rest] = await queuedTreatments();
expect(rest).toEqual([]);
expect(kept.idempotencyKey).toBe("treatment-key-00000001");
expect(kept.lastError).toBeUndefined();
});
});
});
@@ -2,10 +2,12 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { openDB } from "idb";
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
import { readHandOvers, rememberHandOver } from "@/lib/photos/handover";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
const photoHandOver = vi.hoisted(() => ({
@@ -44,6 +46,9 @@ 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>;
let refreshPacksAfterSync: MockedFunction<
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
>;
function inspection(colony: number) {
return { colonyId: `colony-${colony}`, date: "2026-10-01T00:00:00.000Z" };
@@ -81,6 +86,7 @@ describe("syncPendingEntries", () => {
// A fresh database and fresh modules: nothing (no open connection, no
// in-flight run) carries over from the previous test.
globalThis.indexedDB = new IDBFactory();
window.localStorage.clear();
vi.resetModules();
db = await import("./db");
sync = await import("./sync");
@@ -89,10 +95,14 @@ describe("syncPendingEntries", () => {
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);
refreshPacksAfterSync = vi.mocked(
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
);
// The mocks themselves outlive resetModules - start them from scratch too.
createInspection.mockReset();
createTreatment.mockReset();
uploadDocument.mockReset();
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
photoHandOver.calls.length = 0;
photoHandOver.failFor = null;
photoHandOver.isQueued = async (key) =>
@@ -160,6 +170,81 @@ describe("syncPendingEntries", () => {
expect(kept.lastError?.status).toBe(400);
});
it("an inspection for a colony ended since the pack was loaded stays queued with the server message and the packs are refreshed", async () => {
// Review Focus 1: the pack still listed V-03 as living when the
// inspection was recorded offline; the server knows better.
await db.queueInspection(inspection(3), "key-3", "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.lastError?.message).toBe("Volk V-03 ist beendet.");
expect(kept.lastError?.status).toBe(400);
// The packs holding V-03 are reloaded at once, not only after 1 h.
expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1);
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
"user-1",
expect.any(Number),
expect.any(Function),
{ force: true, colonyIds: ["colony-3"] },
);
});
it("refreshes the packs by age only when nothing was rejected", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
await sync.syncPendingEntries("user-1");
expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1);
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
});
it("does not force a pack reload after a network failure", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
createInspection.mockRejectedValue(new TypeError("Failed to fetch"));
await sync.syncPendingEntries("user-1");
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
});
it("forces the reload for the colonies of every rejected entry, each once", async () => {
await db.queueInspection(inspection(3), "key-3", "user-1");
await db.queueInspection(inspection(3), "key-3b", "user-1");
await db.queueTreatment(
{ colonyId: "colony-5", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" },
"key-t",
"user-1",
);
createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet."));
createTreatment.mockRejectedValue(new ApiError(400, "Volk V-05 ist beendet."));
await sync.syncPendingEntries("user-1");
const options = refreshPacksAfterSync.mock.calls[0][3];
expect(options?.force).toBe(true);
expect([...(options?.colonyIds ?? [])].sort()).toEqual(["colony-3", "colony-5"]);
});
it("refreshes the packs only for the run's user and never after cancelSync()", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
const request = openRequest();
createInspection.mockReturnValueOnce(request.promise);
const run = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
const cancelled = sync.cancelSync();
request.answer({ id: "server-1" });
await run;
await cancelled;
expect(refreshPacksAfterSync).not.toHaveBeenCalled();
});
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");
@@ -212,6 +297,51 @@ describe("syncPendingEntries", () => {
expect(createInspection).toHaveBeenCalledTimes(1);
});
it("a caller that changed nothing shares the running run, even once it has begun", async () => {
await takePhoto("session-1");
await photos.attachSessionToInspection("session-1", "srv-1");
const upload = openRequest();
uploadDocument.mockReturnValueOnce(upload.promise);
const running = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
// The band's triggers on a reconnect (`online` event, connectivity check).
const reconnect = sync.syncPendingEntries("user-1");
upload.answer({ id: "doc-1" });
expect(reconnect).toBe(running);
expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadDocument).toHaveBeenCalledTimes(1);
});
it("runs exactly once more for callers that changed something after the run began, so it does not wait", async () => {
await takePhoto("session-1");
await photos.attachSessionToInspection("session-1", "srv-1");
const firstUpload = openRequest();
uploadDocument
.mockReturnValueOnce(firstUpload.promise)
.mockResolvedValue({ id: "doc-2" } as never);
const running = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
// An online save while the running sync uploads: its photo is ready only now.
await takePhoto("session-2");
await photos.attachSessionToInspection("session-2", "srv-2");
const afterSave = sync.syncPendingEntries("user-1", { afterChange: true });
// e.g. „Erneut senden“
const afterRetry = sync.syncPendingEntries("user-1", { afterChange: true });
firstUpload.answer({ id: "doc-1" });
expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(afterRetry).toBe(afterSave);
expect(await afterSave).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadDocument.mock.calls.map(([, inspectionId]) => inspectionId)).toEqual([
"srv-1",
"srv-2",
]);
expect(await photos.listSessionPhotos("session-2")).toEqual([]);
});
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");
@@ -370,6 +500,34 @@ describe("syncPendingEntries", () => {
expect(await photos.listSessionPhotos("session-1")).toEqual([]);
});
it("finishes the user's open photo hand-overs before uploading, never another user's", async () => {
const own = await takePhoto("session-1");
await photos.addDraftPhoto({
sessionId: "session-2",
ownerUserId: "user-2",
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
date: "2026-10-01",
});
// Saved online, but the photos could not be tied to the inspection then.
rememberHandOver({ userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" });
rememberHandOver({ userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" });
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadDocument).toHaveBeenCalledTimes(1);
expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), {
idempotencyKey: own.uploadIdempotencyKey,
fileName: own.fileName,
});
expect(readHandOvers()).toEqual([
{ userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" },
]);
const [foreign] = await photos.listSessionPhotos("session-2");
expect(foreign.state).toBe("draft");
});
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");
+176 -22
View File
@@ -7,12 +7,25 @@ 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 {
createFeedingsBulk,
createTreatmentsBulk,
createVarroaBulk,
invalidColonyIdsOf,
isRetryLaterError,
} from "@/lib/bulk/api";
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { finishHandOvers, resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
import {
BulkKind,
PendingBulkEntry,
PendingEntry,
QueueStoreName,
RecordQueueInputs,
RecordStoreName,
getBulkEntries,
getBulkEntry,
getPendingEntries,
getPendingEntry,
isForeignEntry,
@@ -28,15 +41,38 @@ export interface SyncResult {
skippedForeign: number;
}
export interface SyncRequest {
/** The caller has just made something sendable that a running run may have passed. */
afterChange?: boolean;
}
/** One sync run: whose entries it sends, and whether it was cancelled since. */
interface SyncRun {
userId: string;
isCancelled: () => boolean;
}
const UNAUTHORIZED = 401;
/** Answers that say „not now“, not „this entry is wrong“. */
const TRANSIENT_STATUSES: readonly number[] = [408, 429, 502, 503, 504];
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
let inFlight: { userId: string; generation: number; run: Promise<SyncResult> } | null = null;
/** One queue's drain: its counts and the colonies the server rejected entries for. */
interface DrainResult extends SyncResult {
rejectedColonyIds: string[];
}
/** A requested run; runs are chained, so this is the last one in line. */
interface ScheduledRun {
userId: string;
generation: number;
run: Promise<SyncResult>;
/** True once it reads the queues: what changes after that, it may miss. */
hasStarted: () => boolean;
}
let lastScheduled: ScheduledRun | 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
@@ -49,32 +85,53 @@ let isOpen = true;
* 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.
* Callers for the same user share one run (a reconnect fires several
* triggers at once). A caller that has just changed what is to be sent (a
* photo made ready, a rejection reset) says so with `afterChange`: if the
* run has already begun, it may have passed that change, so the caller gets
* one more run after it - every further caller shares that one until it
* begins. 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> {
export function syncPendingEntries(
currentUserId: string,
{ afterChange = false }: SyncRequest = {},
): Promise<SyncResult> {
if (!isOpen) {
return Promise.resolve(NOTHING_SYNCED);
}
if (inFlight?.userId === currentUserId && inFlight.generation === generation) {
return inFlight.run;
if (
lastScheduled?.userId === currentUserId &&
lastScheduled.generation === generation &&
!(afterChange && lastScheduled.hasStarted())
) {
return lastScheduled.run;
}
const requestedIn = generation;
const syncRun: SyncRun = {
userId: currentUserId,
isCancelled: () => generation !== requestedIn,
};
const previous = inFlight?.run ?? Promise.resolve(NOTHING_SYNCED);
let started = false;
const previous = lastScheduled?.run ?? Promise.resolve(NOTHING_SYNCED);
const run = previous
.catch(() => NOTHING_SYNCED)
.then(() => runSync(syncRun))
.then(() => {
started = true;
return runSync(syncRun);
})
.finally(() => {
if (inFlight?.run === run) {
inFlight = null;
if (lastScheduled?.run === run) {
lastScheduled = null;
}
});
inFlight = { userId: currentUserId, generation: requestedIn, run };
lastScheduled = {
userId: currentUserId,
generation: requestedIn,
run,
hasStarted: () => started,
};
return run;
}
@@ -87,7 +144,8 @@ export function syncPendingEntries(currentUserId: string): Promise<SyncResult> {
export async function cancelSync(): Promise<void> {
isOpen = false;
generation += 1;
await inFlight?.run.catch(() => undefined);
// Runs are chained: once the last one is done, all are.
await lastScheduled?.run.catch(() => undefined);
}
/**
@@ -123,15 +181,51 @@ async function drain<Store extends RecordStoreName, Result>(
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
run: SyncRun,
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
): Promise<SyncResult> {
const queued = await getPendingEntries(store);
): Promise<DrainResult> {
return drainQueue<PendingEntry<RecordQueueInputs[Store]>, Result>({
store,
list: () => getPendingEntries(store),
read: (id) => getPendingEntry(store, id),
send: (entry) => create(entry.input, { idempotencyKey: entry.idempotencyKey }),
rejectedColonyIds: (entry) => {
const colonyId = colonyIdOf(entry.input);
return colonyId ? [colonyId] : [];
},
onCreated,
run,
});
}
/** What the drain needs to know about one queue; record queues and bulk differ only here. */
interface QueueDrain<Entry extends { id: number; ownerUserId?: string }, Result> {
store: QueueStoreName;
list: () => Promise<Entry[]>;
read: (id: number) => Promise<Entry | undefined>;
send: (entry: Entry) => Promise<Result>;
/** The colonies the server refused, so their packs are reloaded at once. */
rejectedColonyIds: (entry: Entry, error: ApiError) => string[];
onCreated?: (entry: Entry, result: Result) => Promise<void>;
run: SyncRun;
}
async function drainQueue<Entry extends { id: number; ownerUserId?: string }, Result>({
store,
list,
read,
send,
rejectedColonyIds: rejectedColonies,
onCreated,
run,
}: QueueDrain<Entry, Result>): Promise<DrainResult> {
const queued = await list();
const ownIds = queued.filter((entry) => !isForeignEntry(entry, run.userId)).map(({ id }) => id);
let synced = 0;
let failed = 0;
let skippedForeign = queued.length - ownIds.length;
const rejectedColonyIds: string[] = [];
for (const id of ownIds) {
const entry = await getPendingEntry(store, id);
const entry = await read(id);
if (run.isCancelled()) {
break;
}
@@ -144,13 +238,26 @@ async function drain<Store extends RecordStoreName, Result>(
}
let result: Result;
try {
result = await create(entry.input, { idempotencyKey: entry.idempotencyKey });
result = await send(entry);
} catch (error) {
// The same key is still being processed (409): neither sent nor
// rejected, the next run asks again.
if (isRetryLaterError(error)) {
continue;
}
// The session ended (401): the entry is fine. The run stops and the
// auth layer deals with the expired session.
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
break;
}
failed += 1;
if (!(error instanceof ApiError)) {
// A network failure or a server that is only unavailable for now
// (timeout, overload, proxy errors): the entry waits, the run stops.
if (!(error instanceof ApiError) || TRANSIENT_STATUSES.includes(error.status)) {
break;
}
await markFailed(store, entry.id, toQueueError(error));
rejectedColonyIds.push(...rejectedColonies(entry, error));
continue;
}
try {
@@ -163,7 +270,33 @@ async function drain<Store extends RecordStoreName, Result>(
synced += 1;
}
return { synced, failed, skippedForeign };
return { synced, failed, skippedForeign, rejectedColonyIds };
}
const BULK_CREATORS: Record<
BulkKind,
(input: never, options: CreateOptions) => Promise<unknown>
> = {
treatments: createTreatmentsBulk,
feedings: createFeedingsBulk,
varroa: createVarroaBulk,
};
/**
* The entries for several colonies. Each goes out as one request with the key
* it was queued with; a 400 names the colonies the server refused
* (`invalidColonyIds`), whose packs are then reloaded at once.
*/
function drainBulk(run: SyncRun): Promise<DrainResult> {
return drainQueue<PendingBulkEntry, unknown>({
store: "pending-bulk",
list: getBulkEntries,
read: getBulkEntry,
send: (entry) =>
BULK_CREATORS[entry.kind](entry.input as never, { idempotencyKey: entry.idempotencyKey }),
rejectedColonyIds: (_entry, error) => invalidColonyIdsOf(error),
run,
});
}
/**
@@ -181,6 +314,12 @@ function handOverPhotos(
return resolveQueuedInspection(entry.idempotencyKey, created.id);
}
/** The colony a queued record belongs to, if it has one. */
function colonyIdOf(input: unknown): string | null {
const colonyId = (input as { colonyId?: unknown } | null)?.colonyId;
return typeof colonyId === "string" ? colonyId : null;
}
function addResults(total: SyncResult, result: SyncResult): SyncResult {
return {
synced: total.synced + result.synced,
@@ -197,9 +336,24 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
drain("pending-varroa-measurements", createVarroaMeasurement, run),
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
drain("pending-harvests", createHarvest, run),
drainBulk(run),
]);
// Photos last: the drains above have just given them their inspection's
// server id, and records come first on a weak connection.
// server id, and records come first on a weak connection. Hand-overs that
// failed after an online save are finished first, so those go up too.
if (!run.isCancelled()) {
await finishHandOvers(run.userId);
}
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
// Packs last, quietly and only with the run user's token: what the server
// has just accepted or rejected (a colony ended since the pack was loaded)
// shows up offline too - the packs of rejected colonies at once, not only
// after 1 h (Review Focus 1). Inside the run, so cancelSync() waits for it.
if (!run.isCancelled()) {
const colonyIds = [...new Set(records.flatMap((record) => record.rejectedColonyIds))];
await (colonyIds.length > 0
? refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled, { force: true, colonyIds })
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
}
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
}
@@ -152,3 +152,69 @@ describe("useCaptureMutation", () => {
expect(result.current.submitError).toBeNull();
});
});
describe("useCaptureMutation with a key per submission", () => {
function setupPerSubmission() {
const create = vi.fn<(input: Input, options: { idempotencyKey?: string }) => Promise<Saved>>(
async (input) => ({ id: "i-1", ...input }),
); const queue = vi.fn().mockResolvedValue(undefined);
const client = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const { result } = renderHook(
() =>
useCaptureMutation<Input, Saved>({
create,
queue,
failureMessage: FAILURE,
onSaved: vi.fn(),
keyPerSubmission: true,
}),
{ wrapper },
);
return { result, create };
}
const keyOfCall = (create: ReturnType<typeof setupPerSubmission>["create"], index: number) =>
create.mock.calls[index][1].idempotencyKey;
it("repeats the key for the same submission and renews it for a different one", async () => {
const { result, create } = setupPerSubmission();
act(() => result.current.submit({ colonyId: "colony-1" }));
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
act(() => result.current.submit({ colonyId: "colony-1" }));
await waitFor(() => expect(create).toHaveBeenCalledTimes(2));
act(() => result.current.submit({ colonyId: "colony-2" }));
await waitFor(() => expect(create).toHaveBeenCalledTimes(3));
expect(keyOfCall(create, 0)).toBeTruthy();
expect(keyOfCall(create, 1)).toBe(keyOfCall(create, 0));
expect(keyOfCall(create, 2)).not.toBe(keyOfCall(create, 0));
});
it("exposes the error of the last submit", async () => {
const error = new ApiError(409, "Diese Anfrage wird bereits verarbeitet");
const create = vi.fn().mockRejectedValue(error);
const client = new QueryClient();
const { result } = renderHook(
() =>
useCaptureMutation<Input, Saved>({
create,
queue: vi.fn(),
failureMessage: FAILURE,
onSaved: vi.fn(),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
act(() => result.current.submit(INPUT));
await waitFor(() => expect(result.current.error).toBe(error));
});
});
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { useCurrentUserId } from "@/lib/auth/auth-context";
@@ -20,12 +20,21 @@ export interface CaptureMutationOptions<I, R> {
/** Shown when nothing could be saved and the server gave no reason. */
failureMessage: string;
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
/**
* One key per distinct submission instead of one per opened form: the server
* binds a key to (user, key, scope), not to the body, so an entry for
* another selection of colonies must never reuse the key of an earlier one.
* The same input again (double tap, retry) still sends the same key.
*/
keyPerSubmission?: boolean;
}
export interface CaptureMutation<I> {
submit: (input: I) => void;
isPending: boolean;
submitError: string | null;
/** What the last submit failed with (an ApiError, or a storage failure), if it did. */
error: unknown;
idempotencyKey: string;
}
@@ -39,12 +48,25 @@ export function useCaptureMutation<I, R>({
queue,
failureMessage,
onSaved,
keyPerSubmission = false,
}: 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 lastSubmission = useRef<{ fingerprint: string; key: string } | null>(null);
function keyFor(input: I): string {
if (!keyPerSubmission) {
return idempotencyKey;
}
const fingerprint = JSON.stringify(input);
if (lastSubmission.current?.fingerprint !== fingerprint) {
lastSubmission.current = { fingerprint, key: newIdempotencyKey() };
}
return lastSubmission.current.key;
}
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({
@@ -53,17 +75,18 @@ export function useCaptureMutation<I, R>({
networkMode: "always",
retry: false,
mutationFn: async (input: I): Promise<CaptureOutcome<R>> => {
const key = keyFor(input);
try {
return { via: "online", result: await create(input, { idempotencyKey }) };
return { via: "online", result: await create(input, { idempotencyKey: key }) };
} 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);
await queue(input, key, ownerUserId);
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return { via: "queued", idempotencyKey };
return { via: "queued", idempotencyKey: key };
}
},
onSuccess: (outcome, input) => onSaved(outcome, input),
@@ -75,5 +98,11 @@ export function useCaptureMutation<I, R>({
mutation.mutate(input);
}
return { submit, isPending: mutation.isPending, submitError, idempotencyKey };
return {
submit,
isPending: mutation.isPending,
submitError,
error: mutation.error,
idempotencyKey,
};
}