perf: separate blob storage for registratura attachments

Root cause: even with SQL-level stripping, PostgreSQL must TOAST-decompress
entire multi-MB JSONB values from disk before any processing. For 5 entries
with PDF attachments (25-50MB total), this takes several seconds.

Fix: store base64 attachment data in separate namespace 'registratura-blobs'.
Main entries are inherently small (~1-2KB). List queries never touch heavy data.

Changes:
- registry-service.ts: extractBlobs/mergeBlobs split base64 on save/load,
  migrateEntryBlobs() one-time migration for existing entries
- use-registry.ts: dual namespace (registratura + registratura-blobs),
  migration runs on first mount
- registratura-module.tsx: removed useContacts/useTags hooks that triggered
  2 unnecessary API fetches on page load (write-only ops use direct storage)

Before: 3 API calls on mount, one reading 25-50MB from PostgreSQL
After: 1 API call on mount, reading ~5-10KB total
This commit is contained in:
AI Assistant
2026-02-27 23:35:04 +02:00
parent 8385041bb0
commit f8c19bb5b4
3 changed files with 227 additions and 53 deletions
+35 -34
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type {
@@ -17,6 +17,7 @@ import {
saveEntry,
deleteEntry,
generateRegistryNumber,
migrateEntryBlobs,
} from "../services/registry-service";
import {
createTrackedDeadline,
@@ -34,6 +35,7 @@ export interface RegistryFilters {
export function useRegistry() {
const storage = useStorage("registratura");
const blobStorage = useStorage("registratura-blobs");
const [entries, setEntries] = useState<RegistryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<RegistryFilters>({
@@ -43,6 +45,7 @@ export function useRegistry() {
documentType: "all",
company: "all",
});
const migrationRan = useRef(false);
const refresh = useCallback(async () => {
setLoading(true);
@@ -51,17 +54,23 @@ export function useRegistry() {
setLoading(false);
}, [storage]);
// On mount: run migration (once), then load entries
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => {
refresh();
}, [refresh]);
const init = async () => {
if (!migrationRan.current) {
migrationRan.current = true;
await migrateEntryBlobs(storage, blobStorage);
}
await refresh();
};
init();
}, [refresh, storage, blobStorage]);
const addEntry = useCallback(
async (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => {
// Fetch fresh entries to prevent duplicate numbers from stale state.
// This single call replaces what was previously list() + N×get().
const freshEntries = await getAllEntries(storage);
const now = new Date().toISOString();
const number = generateRegistryNumber(
@@ -77,12 +86,11 @@ export function useRegistry() {
createdAt: now,
updatedAt: now,
};
await saveEntry(storage, entry);
// Update local state directly to avoid a second full fetch
await saveEntry(storage, blobStorage, entry);
setEntries((prev) => [entry, ...prev]);
return entry;
},
[storage],
[storage, blobStorage],
);
const updateEntry = useCallback(
@@ -97,50 +105,48 @@ export function useRegistry() {
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await saveEntry(storage, blobStorage, updated);
await refresh();
},
[storage, refresh, entries],
[storage, blobStorage, refresh, entries],
);
const removeEntry = useCallback(
async (id: string) => {
await deleteEntry(storage, id);
await deleteEntry(storage, blobStorage, id);
await refresh();
},
[storage, refresh],
[storage, blobStorage, refresh],
);
/** Close an entry and optionally its linked entries.
* Batches all saves, then does a single refresh at the end.
*/
const closeEntry = useCallback(
async (id: string, closeLinked: boolean) => {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
// Save main entry as closed
const now = new Date().toISOString();
const closedMain: RegistryEntry = {
...entry,
status: "inchis",
updatedAt: now,
};
await saveEntry(storage, closedMain);
// Close linked entries in parallel
await saveEntry(storage, blobStorage, closedMain);
const linked = entry.linkedEntryIds ?? [];
if (closeLinked && linked.length > 0) {
const saves = linked
.map((linkedId) => entries.find((e) => e.id === linkedId))
.filter((e): e is RegistryEntry => !!e && e.status !== "inchis")
.map((e) =>
saveEntry(storage, { ...e, status: "inchis", updatedAt: now }),
saveEntry(storage, blobStorage, {
...e,
status: "inchis",
updatedAt: now,
}),
);
await Promise.all(saves);
}
// Single refresh at the end
await refresh();
},
[entries, storage, refresh],
[entries, storage, blobStorage, refresh],
);
const updateFilter = useCallback(
@@ -169,11 +175,11 @@ export function useRegistry() {
trackedDeadlines: [...existing, tracked],
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await saveEntry(storage, blobStorage, updated);
await refresh();
return tracked;
},
[entries, storage, refresh],
[entries, storage, blobStorage, refresh],
);
const resolveDeadline = useCallback(
@@ -198,9 +204,8 @@ export function useRegistry() {
trackedDeadlines: updatedDeadlines,
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await saveEntry(storage, blobStorage, updated);
// If the resolved deadline has a chain, automatically check for the next type
const def = getDeadlineType(dl.typeId);
await refresh();
@@ -213,7 +218,7 @@ export function useRegistry() {
return resolved;
},
[entries, storage, refresh],
[entries, storage, blobStorage, refresh],
);
const removeDeadline = useCallback(
@@ -226,10 +231,10 @@ export function useRegistry() {
trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId),
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await saveEntry(storage, blobStorage, updated);
await refresh();
},
[entries, storage, refresh],
[entries, storage, blobStorage, refresh],
);
const filteredEntries = entries.filter((entry) => {
@@ -256,15 +261,11 @@ export function useRegistry() {
return true;
});
/**
* Load a single entry WITH full attachment data (for editing).
* The list uses lightweight mode that strips base64 data.
*/
const loadFullEntry = useCallback(
async (id: string): Promise<RegistryEntry | null> => {
return getFullEntry(storage, id);
return getFullEntry(storage, blobStorage, id);
},
[storage],
[storage, blobStorage],
);
return {