Files
ApiCore/ApiCore-Frontend/src/lib/offline/db.ts
T
Nick SzaunigandClaude Opus 5.5 ce472164b5 feat: Aufnahme und lokaler Speicher für Sprachnotizen (T7 L5 Task 17)
Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei
180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall
freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und
IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle
Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden
löscht nur die eigenen Notizen.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:47:45 +02:00

668 lines
22 KiB
TypeScript

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";
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
import type { HiveOp } from "@/lib/hive/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** A change of a colony's hive made on the card, waiting for network. */
export interface HiveChangeQueueInput {
colonyId: string;
occurredOn: string;
source: "CARD";
ops: HiveOp[];
}
/** 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;
"pending-phenology-reports": CreatePhenologyReportInput;
"pending-hive-changes": HiveChangeQueueInput;
}
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";
/** 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;
}
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;
}
/** A voice note recorded on this device, waiting to be uploaded (Sprachnotiz). */
export interface StoredVoiceNote {
id: string;
ownerUserId: string;
blob: Blob;
mime: string;
durationSec: number;
colonyId: string;
inspectionDate: string;
createdAt: string;
uploadIdempotencyKey: string;
state: "recorded" | "uploading";
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-voice": { key: string; value: StoredVoiceNote; indexes: { ownerUserId: string } };
"offline-packs": { key: string; value: StoredPack };
meta: { key: string; value: unknown };
}
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute), v6 adds voice
// notes (Sprache). 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 = 6;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
"pending-treatments",
"pending-feedings",
"pending-varroa-measurements",
"pending-swarm-control-checks",
"pending-harvests",
"pending-phenology-reports",
"pending-hive-changes",
];
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",
"pending-phenology-reports": "Pflanzenmeldung",
"pending-hive-changes": "Beute-Änderung",
};
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;
/**
* 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) {
const opening = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 2) {
db.createObjectStore("pending-treatments", { keyPath: "id", autoIncrement: true });
db.createObjectStore("pending-feedings", { keyPath: "id", autoIncrement: true });
db.createObjectStore("pending-varroa-measurements", {
keyPath: "id",
autoIncrement: true,
});
db.createObjectStore("pending-swarm-control-checks", {
keyPath: "id",
autoIncrement: true,
});
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");
}
if (oldVersion < 4) {
db.createObjectStore("pending-phenology-reports", {
keyPath: "id",
autoIncrement: true,
});
}
if (oldVersion < 5) {
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 6) {
const voice = db.createObjectStore("pending-voice", { keyPath: "id" });
voice.createIndex("ownerUserId", "ownerUserId");
}
},
// 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;
}
function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
if (dbPromise === connection) {
dbPromise = null;
}
}
/** 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],
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
const db = await getDb();
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
}
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);
}
export function queuePhenologyReport(
input: CreatePhenologyReportInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
}
export function queueHiveChange(
input: HiveChangeQueueInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-hive-changes", 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,
): Promise<PendingEntry<RecordQueueInputs[Store]>[]> {
const db = await getDb();
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
}
/** 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;
}
/** 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);
}
/** 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();
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;
}
/**
* 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;
}
/**
* „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 {
store: QueueStoreName;
entry: QueuedValue;
}
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 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;
}
/**
* 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 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 },
);
}
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
function isHiveRefusal(error: QueueError | undefined): boolean {
if (!error) return false;
const code = (error.details as { code?: unknown } | null | undefined)?.code;
return error.status === 403 || code === "HIVE_CONFLICT";
}
/** A queued inspection that also carries a change of the hive. */
export function hasHiveChange(input: unknown): boolean {
return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined;
}
/**
* Ohne Beute-Änderung senden: takes the hive change out of a queued
* inspection and clears its rejection, so the next sync run sends the rest.
* The key stays: the server stored nothing for the rejected try.
*/
export async function retryInspectionWithoutHiveChange(entryId: number): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-inspections", "readwrite");
const entry = await tx.store.get(entryId);
if (entry && hasHiveChange(entry.input)) {
const input = { ...entry.input };
delete input.hiveChange;
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
await tx.done;
}
export interface QueueItem {
store: string;
id: number | string;
label: string;
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
canResendWithoutHiveChange?: boolean;
/**
* 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 {
if (store === "pending-bulk") {
return BULK_LABELS[(entry as PendingBulkEntry).kind];
}
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();
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-hive-changes") {
return { ...item, hasHiveChange: true };
}
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections") {
return item;
}
const hive = hasHiveChange((entry as PendingEntry<unknown>).input)
? {
hasHiveChange: true,
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
}
: {};
if (!entry.idempotencyKey) {
return { ...item, ...hive };
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
}),
);
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 and voice notes included. */
export async function clearQueueOf(ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction([...QUEUE_STORES, "pending-photos", "pending-voice"], "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);
const voice = tx.objectStore("pending-voice");
const voiceIds = await voice.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all([
...queues,
...photoIds.map((photoId) => photos.delete(photoId)),
...voiceIds.map((voiceId) => voice.delete(voiceId)),
]);
await tx.done;
}