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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user