From c22848b471afaf5fa5555e1442caf1565a589be1 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 27 Feb 2026 22:37:39 +0200 Subject: [PATCH] perf(registratura): lightweight API mode strips base64 attachments from list ROOT CAUSE: RegistryEntry stores file attachments as base64 strings in JSON. A single 5MB PDF becomes ~6.7MB of base64. With 6 entries, the exportAll() endpoint was sending 30-60MB of JSON on every page load taking 2+ minutes. Fix: Added ?lightweight=true parameter to /api/storage GET endpoint. When enabled, stripHeavyFields() recursively removes large 'data' and 'fileData' string fields (>1KB) from JSON values, replacing with '__stripped__'. Changes: - /api/storage route.ts: stripHeavyFields() + lightweight query param - StorageService.export(): accepts { lightweight?: boolean } option - DatabaseStorageAdapter.export(): passes lightweight flag to API - LocalStorageAdapter.export(): accepts option (no-op, localStorage is fast) - useStorage.exportAll(): passes options through - registry-service.ts: getAllEntries() uses lightweight=true by default - registry-service.ts: new getFullEntry() loads single entry with full data - use-registry.ts: exports loadFullEntry() for on-demand full loading - registratura-module.tsx: handleEdit/handleNavigateEntry load full entry Result: List loading transfers ~100KB instead of 30-60MB. Editing loads full data for a single entry on demand (~5-10MB for one entry vs all). --- src/app/api/storage/route.ts | 36 ++++++++++++++++- src/core/storage/adapters/database-adapter.ts | 13 +++++-- src/core/storage/adapters/local-storage.ts | 27 ++++++++----- src/core/storage/types.ts | 5 ++- src/core/storage/use-storage.ts | 39 ++++++++++++------- .../components/registratura-module.tsx | 12 ++++-- .../registratura/hooks/use-registry.ts | 13 +++++++ .../registratura/services/registry-service.ts | 22 ++++++++--- 8 files changed, 128 insertions(+), 39 deletions(-) diff --git a/src/app/api/storage/route.ts b/src/app/api/storage/route.ts index 4aec8c2..8cab220 100644 --- a/src/app/api/storage/route.ts +++ b/src/app/api/storage/route.ts @@ -1,10 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; +/** + * Strip heavy base64 data from JSON values to enable fast list loading. + * Targets: attachments[].data, versions[].fileData, and any top-level `data` + * field that looks like base64 (>1KB). Replaces with "__stripped__" marker. + */ +function stripHeavyFields(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(stripHeavyFields); + + const obj = value as Record; + const result: Record = {}; + + for (const [k, v] of Object.entries(obj)) { + if (k === "data" && typeof v === "string" && v.length > 1024) { + // Strip large base64 data fields, keep a marker + result[k] = "__stripped__"; + } else if (k === "fileData" && typeof v === "string" && v.length > 1024) { + result[k] = "__stripped__"; + } else if (Array.isArray(v)) { + result[k] = v.map(stripHeavyFields); + } else if (v && typeof v === "object") { + result[k] = stripHeavyFields(v); + } else { + result[k] = v; + } + } + return result; +} + export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const namespace = searchParams.get("namespace"); const key = searchParams.get("key"); + const lightweight = searchParams.get("lightweight") === "true"; if (!namespace) { return NextResponse.json( @@ -34,7 +64,11 @@ export async function GET(request: NextRequest) { // Return as a record { [key]: value } const result: Record = {}; for (const item of items) { - result[item.key] = item.value; + if (lightweight) { + result[item.key] = stripHeavyFields(item.value); + } else { + result[item.key] = item.value; + } } return NextResponse.json({ items: result }); } diff --git a/src/core/storage/adapters/database-adapter.ts b/src/core/storage/adapters/database-adapter.ts index 8a2d5c2..dd68cd6 100644 --- a/src/core/storage/adapters/database-adapter.ts +++ b/src/core/storage/adapters/database-adapter.ts @@ -82,11 +82,16 @@ export class DatabaseStorageAdapter implements StorageService { } } - async export(namespace: string): Promise> { + async export( + namespace: string, + options?: { lightweight?: boolean }, + ): Promise> { try { - const res = await fetch( - `/api/storage?namespace=${encodeURIComponent(namespace)}`, - ); + let url = `/api/storage?namespace=${encodeURIComponent(namespace)}`; + if (options?.lightweight) { + url += "&lightweight=true"; + } + const res = await fetch(url); if (!res.ok) return {}; const data = await res.json(); return data.items || {}; diff --git a/src/core/storage/adapters/local-storage.ts b/src/core/storage/adapters/local-storage.ts index f5b8be6..8bef7de 100644 --- a/src/core/storage/adapters/local-storage.ts +++ b/src/core/storage/adapters/local-storage.ts @@ -1,4 +1,4 @@ -import type { StorageService } from '../types'; +import type { StorageService } from "../types"; function nsKey(namespace: string, key: string): string { return `architools:${namespace}:${key}`; @@ -10,7 +10,7 @@ function nsPrefix(namespace: string): string { export class LocalStorageAdapter implements StorageService { async get(namespace: string, key: string): Promise { - if (typeof window === 'undefined') return null; + if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(nsKey(namespace, key)); if (raw === null) return null; @@ -21,17 +21,17 @@ export class LocalStorageAdapter implements StorageService { } async set(namespace: string, key: string, value: T): Promise { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; window.localStorage.setItem(nsKey(namespace, key), JSON.stringify(value)); } async delete(namespace: string, key: string): Promise { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; window.localStorage.removeItem(nsKey(namespace, key)); } async list(namespace: string): Promise { - if (typeof window === 'undefined') return []; + if (typeof window === "undefined") return []; const prefix = nsPrefix(namespace); const keys: string[] = []; for (let i = 0; i < window.localStorage.length; i++) { @@ -43,7 +43,10 @@ export class LocalStorageAdapter implements StorageService { return keys; } - async query(namespace: string, predicate: (item: T) => boolean): Promise { + async query( + namespace: string, + predicate: (item: T) => boolean, + ): Promise { const keys = await this.list(namespace); const results: T[] = []; for (const key of keys) { @@ -56,14 +59,17 @@ export class LocalStorageAdapter implements StorageService { } async clear(namespace: string): Promise { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; const keys = await this.list(namespace); for (const key of keys) { window.localStorage.removeItem(nsKey(namespace, key)); } } - async export(namespace: string): Promise> { + async export( + namespace: string, + _options?: { lightweight?: boolean }, + ): Promise> { const keys = await this.list(namespace); const data: Record = {}; for (const key of keys) { @@ -72,7 +78,10 @@ export class LocalStorageAdapter implements StorageService { return data; } - async import(namespace: string, data: Record): Promise { + async import( + namespace: string, + data: Record, + ): Promise { for (const [key, value] of Object.entries(data)) { await this.set(namespace, key, value); } diff --git a/src/core/storage/types.ts b/src/core/storage/types.ts index bb2c4a7..057fb60 100644 --- a/src/core/storage/types.ts +++ b/src/core/storage/types.ts @@ -5,6 +5,9 @@ export interface StorageService { list(namespace: string): Promise; query(namespace: string, predicate: (item: T) => boolean): Promise; clear(namespace: string): Promise; - export(namespace: string): Promise>; + export( + namespace: string, + options?: { lightweight?: boolean }, + ): Promise>; import(namespace: string, data: Record): Promise; } diff --git a/src/core/storage/use-storage.ts b/src/core/storage/use-storage.ts index fbf1a5a..58c6e2d 100644 --- a/src/core/storage/use-storage.ts +++ b/src/core/storage/use-storage.ts @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useCallback, useMemo } from 'react'; -import { useStorageService } from './storage-provider'; +import { useCallback, useMemo } from "react"; +import { useStorageService } from "./storage-provider"; export interface NamespacedStorage { get: (key: string) => Promise; @@ -10,7 +10,9 @@ export interface NamespacedStorage { list: () => Promise; query: (predicate: (item: T) => boolean) => Promise; clear: () => Promise; - exportAll: () => Promise>; + exportAll: (options?: { + lightweight?: boolean; + }) => Promise>; importAll: (data: Record) => Promise; } @@ -18,38 +20,45 @@ export function useStorage(namespace: string): NamespacedStorage { const service = useStorageService(); const get = useCallback( - (key: string) => service.get(namespace, key), - [service, namespace] + (key: string) => service.get(namespace, key), + [service, namespace], ); const set = useCallback( - (key: string, value: T) => service.set(namespace, key, value), - [service, namespace] + (key: string, value: T) => service.set(namespace, key, value), + [service, namespace], ); const del = useCallback( (key: string) => service.delete(namespace, key), - [service, namespace] + [service, namespace], ); const list = useCallback(() => service.list(namespace), [service, namespace]); const query = useCallback( - (predicate: (item: T) => boolean) => service.query(namespace, predicate), - [service, namespace] + (predicate: (item: T) => boolean) => + service.query(namespace, predicate), + [service, namespace], ); - const clear = useCallback(() => service.clear(namespace), [service, namespace]); + const clear = useCallback( + () => service.clear(namespace), + [service, namespace], + ); - const exportAll = useCallback(() => service.export(namespace), [service, namespace]); + const exportAll = useCallback( + (options?: { lightweight?: boolean }) => service.export(namespace, options), + [service, namespace], + ); const importAll = useCallback( (data: Record) => service.import(namespace, data), - [service, namespace] + [service, namespace], ); return useMemo( () => ({ get, set, delete: del, list, query, clear, exportAll, importAll }), - [get, set, del, list, query, clear, exportAll, importAll] + [get, set, del, list, query, clear, exportAll, importAll], ); } diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index 6da4b74..e21ef3b 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -49,6 +49,7 @@ export function RegistraturaModule() { updateEntry, removeEntry, closeEntry, + loadFullEntry, addDeadline, resolveDeadline, removeDeadline, @@ -120,13 +121,16 @@ export function RegistraturaModule() { setViewMode("list"); }; - const handleEdit = (entry: RegistryEntry) => { - setEditingEntry(entry); + const handleEdit = async (entry: RegistryEntry) => { + // Load full entry with attachment data (list mode strips base64) + const full = await loadFullEntry(entry.id); + setEditingEntry(full ?? entry); setViewMode("edit"); }; - const handleNavigateEntry = (entry: RegistryEntry) => { - setEditingEntry(entry); + const handleNavigateEntry = async (entry: RegistryEntry) => { + const full = await loadFullEntry(entry.id); + setEditingEntry(full ?? entry); setViewMode("edit"); }; diff --git a/src/modules/registratura/hooks/use-registry.ts b/src/modules/registratura/hooks/use-registry.ts index 89d1bf3..af57a34 100644 --- a/src/modules/registratura/hooks/use-registry.ts +++ b/src/modules/registratura/hooks/use-registry.ts @@ -13,6 +13,7 @@ import type { } from "../types"; import { getAllEntries, + getFullEntry, saveEntry, deleteEntry, generateRegistryNumber, @@ -255,6 +256,17 @@ 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 => { + return getFullEntry(storage, id); + }, + [storage], + ); + return { entries: filteredEntries, allEntries: entries, @@ -265,6 +277,7 @@ export function useRegistry() { updateEntry, removeEntry, closeEntry, + loadFullEntry, addDeadline, resolveDeadline, removeDeadline, diff --git a/src/modules/registratura/services/registry-service.ts b/src/modules/registratura/services/registry-service.ts index c642e44..6897e71 100644 --- a/src/modules/registratura/services/registry-service.ts +++ b/src/modules/registratura/services/registry-service.ts @@ -8,18 +8,20 @@ export interface RegistryStorage { set(key: string, value: T): Promise; delete(key: string): Promise; list(): Promise; - exportAll(): Promise>; + exportAll(options?: { + lightweight?: boolean; + }): Promise>; } /** - * Load all registry entries in a SINGLE request. - * Uses exportAll() which fetches namespace data in one HTTP call, - * avoiding the N+1 pattern (list keys → get each one individually). + * Load all registry entries in a SINGLE lightweight request. + * Uses exportAll({ lightweight: true }) which strips base64 attachment data + * server-side, reducing payload from potentially 30-60MB to <100KB. */ export async function getAllEntries( storage: RegistryStorage, ): Promise { - const all = await storage.exportAll(); + const all = await storage.exportAll({ lightweight: true }); const entries: RegistryEntry[] = []; for (const [key, value] of Object.entries(all)) { if (key.startsWith(STORAGE_PREFIX) && value) { @@ -30,6 +32,16 @@ export async function getAllEntries( return entries; } +/** + * Load a single full entry (with attachment data) for editing. + */ +export async function getFullEntry( + storage: RegistryStorage, + id: string, +): Promise { + return storage.get(`${STORAGE_PREFIX}${id}`); +} + export async function saveEntry( storage: RegistryStorage, entry: RegistryEntry,