From 33bfcfc054f91cbce3c1b1934ae374aef7202c75 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:03:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(types):=20migrate=20all=20consumers=20t?= =?UTF-8?q?o=20the=20PR=20#131=20typed=20Supabase=20client=20=E2=80=94=20r?= =?UTF-8?q?estore=20green=20typecheck=20on=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #131 (bigsimmo-typescript-review) added generated Database types and a typed admin client but was merged with its verify check failing: 95 typecheck errors across worker, rag, scripts, API routes, and tests were left unmigrated (compounded by PR #130 landing first). All fixes are type-level and behavior-preserving: - Typed Update/Insert payloads (TablesUpdate/TablesInsert) for worker job/ document updates and bulk document rename. - null -> undefined at rpc argument boundaries ONLY where the generated optionality proves a SQL default and that default is null (omitting the key sends the same value the explicit null did); verified against the migration signatures for the ingestion-job, retrieval-filter, and cleanup rpcs. - pgvector embedding args cast as unknown as string: PostgREST serializes the number[] identically; only the generated column type is string. - Untyped client surface scoped to helpers that take dynamic table/select strings or metadata->> JSON-path filters (worker generation cleanup, reindex-route pagination, enrich-documents row loader) — inexpressible in the generated types. - Boundary casts where DB Row types meet domain types (SearchResult indexing_quality, MemoryChunk/MemoryImage/MemoryDocument, ChunkImage bbox/image_type, ImageEvidenceCategory) and where JSON-serializable telemetry/metadata objects are typed wider than Json. - Replaced the computed eq/is cache-invalidation call and the legacy nullableQuery fallback with explicit typed branches. - promote-query-misses filter turned into a type guard. - tsconfig: exclude scratch/ (accidentally committed local debugging files from 'Save Codex local changes') from typecheck. Verification: typecheck 0 errors; eslint clean; vitest 821 passed / 2 skipped; check:production-readiness READY; ui-smoke chromium suite run before merge. Co-Authored-By: Claude Fable 5 --- scripts/backfill-smart-index.ts | 4 +- scripts/backfill-source-metadata.ts | 6 +- scripts/backfill-visual-intelligence.ts | 10 ++- .../cleanup-abandoned-reindex-generations.ts | 4 +- scripts/enrich-documents.ts | 13 +-- scripts/profile-retrieval-rpcs.ts | 4 +- scripts/promote-query-misses.ts | 5 +- src/app/api/documents/[id]/reindex/route.ts | 11 ++- src/app/api/documents/[id]/route.ts | 4 +- src/app/api/documents/bulk/reindex/route.ts | 11 ++- src/app/api/documents/bulk/route.ts | 3 +- src/app/api/search/route.ts | 6 +- src/lib/rag.ts | 75 +++++++++--------- tests/document-naming.test.ts | 3 +- tsconfig.json | 2 +- worker/main.ts | 79 +++++++++++++------ 16 files changed, 149 insertions(+), 91 deletions(-) diff --git a/scripts/backfill-smart-index.ts b/scripts/backfill-smart-index.ts index 652b7e548e..0d08f705f7 100644 --- a/scripts/backfill-smart-index.ts +++ b/scripts/backfill-smart-index.ts @@ -602,7 +602,7 @@ async function main() { const enrichment = await upsertDocumentEnrichment({ supabase, - document, + document: document as Parameters[0]["document"], chunks: chunks as never, images: images as never, }); @@ -613,7 +613,7 @@ async function main() { }); const memory = await upsertDocumentDeepMemory({ supabase, - document, + document: document as Parameters[0]["document"], chunks: chunks as never, images: images as never, summary: enrichment.summary.summary, diff --git a/scripts/backfill-source-metadata.ts b/scripts/backfill-source-metadata.ts index 171438b2b5..68c6b84ae0 100644 --- a/scripts/backfill-source-metadata.ts +++ b/scripts/backfill-source-metadata.ts @@ -1,4 +1,5 @@ import { loadEnvConfig } from "@next/env"; +import type { Json } from "@/lib/supabase/database.types"; loadEnvConfig(process.cwd()); @@ -658,7 +659,10 @@ async function main() { if (!APPLY) return; for (const item of changed) { - const { error } = await supabase.from("documents").update({ metadata: item.metadata }).eq("id", item.document.id); + const { error } = await supabase + .from("documents") + .update({ metadata: item.metadata as Json }) + .eq("id", item.document.id); if (error) throw new Error(`Failed to update ${item.document.file_name}: ${error.message}`); } console.log(`Applied source metadata backfill to ${changed.length} documents.`); diff --git a/scripts/backfill-visual-intelligence.ts b/scripts/backfill-visual-intelligence.ts index 8523103657..84bd4a568e 100644 --- a/scripts/backfill-visual-intelligence.ts +++ b/scripts/backfill-visual-intelligence.ts @@ -1,4 +1,5 @@ import { loadEnvConfig } from "@next/env"; +import type { Json, TablesInsert } from "@/lib/supabase/database.types"; import { buildVisualDocumentIndexUnitInputs, embeddingTextForDocumentIndexUnit, @@ -241,7 +242,10 @@ async function loadChunks(documentId: string) { async function markImage(image: BackfillImageRow, patch: Record) { const metadata = { ...(image.metadata ?? {}), ...patch }; - const { error } = await supabase.from("document_images").update({ metadata }).eq("id", image.id); + const { error } = await supabase + .from("document_images") + .update({ metadata: metadata as Json }) + .eq("id", image.id); if (error) throw new Error(error.message); } @@ -286,7 +290,9 @@ async function backfillDocument(documentId: string, images: BackfillImageRow[]) ...unit, embedding: embeddings[start + index], })); - const { error } = await supabase.from("document_index_units").insert(batch); + const { error } = await supabase + .from("document_index_units") + .insert(batch as unknown as TablesInsert<"document_index_units">[]); if (error) throw new Error(error.message); } } diff --git a/scripts/cleanup-abandoned-reindex-generations.ts b/scripts/cleanup-abandoned-reindex-generations.ts index a50f779587..8b0c4d79af 100644 --- a/scripts/cleanup-abandoned-reindex-generations.ts +++ b/scripts/cleanup-abandoned-reindex-generations.ts @@ -60,7 +60,7 @@ async function main() { assertSupabaseHealthy(await probeSupabaseHealth(supabase), "Abandoned reindex generation cleanup"); const { data, error } = await supabase.rpc("cleanup_abandoned_document_index_generations", { - p_document_id: args.documentId, + p_document_id: args.documentId ?? undefined, p_limit: limit, p_dry_run: true, }); @@ -93,7 +93,7 @@ async function main() { } const applied = await supabase.rpc("cleanup_abandoned_document_index_generations", { - p_document_id: args.documentId, + p_document_id: args.documentId ?? undefined, p_limit: limit, p_dry_run: false, }); diff --git a/scripts/enrich-documents.ts b/scripts/enrich-documents.ts index cfe0de9efd..c120817cfa 100644 --- a/scripts/enrich-documents.ts +++ b/scripts/enrich-documents.ts @@ -1,4 +1,6 @@ import { loadEnvConfig } from "@next/env"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Json } from "@/lib/supabase/database.types"; import { createHash } from "node:crypto"; loadEnvConfig(process.cwd()); @@ -134,7 +136,8 @@ async function loadRowsForDocuments(supabase: SupabaseAdmin, table: string, sele for (let start = 0; start < documentIds.length; start += 5) { const ids = documentIds.slice(start, start + 5); for (let rangeStart = 0; ; rangeStart += 1000) { - const { data, error } = await supabase + // Dynamic table/select strings need the untyped client surface. + const { data, error } = await (supabase as unknown as SupabaseClient) .from(table) .select(select) .in("document_id", ids) @@ -394,7 +397,7 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin clinical_use_reason: finalAssessment.clinical_use_reason, clinical_signal_score: finalAssessment.clinical_signal_score, admin_signal_score: finalAssessment.admin_signal_score, - }); + } as Parameters[0]); const retainAsAuditTable = image.source_kind === "table_crop" && ["administrative", "reference"].includes(finalAssessment.clinical_use_class) && @@ -570,7 +573,7 @@ async function main() { .from("documents") .update({ image_count: imageStats.searchable, - metadata: imageMetadata, + metadata: imageMetadata as Json, }) .eq("id", document.id); } @@ -603,8 +606,8 @@ async function main() { const deepMemory = await upsertDocumentDeepMemory({ supabase, document: { ...document, metadata: imageMetadata }, - chunks: evidence.chunks, - images: evidence.images, + chunks: evidence.chunks as unknown as Parameters[0]["chunks"], + images: evidence.images as unknown as Parameters[0]["images"], summary: enrichmentSummary, }); const { data: latestDoc } = await supabase diff --git a/scripts/profile-retrieval-rpcs.ts b/scripts/profile-retrieval-rpcs.ts index ecdb7b76a0..1ac139d481 100644 --- a/scripts/profile-retrieval-rpcs.ts +++ b/scripts/profile-retrieval-rpcs.ts @@ -81,8 +81,8 @@ async function main() { p_rpc: rpcName, p_query_text: args.query, p_match_count: args.matchCount, - p_owner_filter: ownerId ?? null, - p_document_filters: args.documentIds ?? null, + p_owner_filter: ownerId ?? undefined, + p_document_filters: args.documentIds ?? undefined, p_analyze: args.analyze, }); if (error) { diff --git a/scripts/promote-query-misses.ts b/scripts/promote-query-misses.ts index 4084171ca8..690cf1947d 100644 --- a/scripts/promote-query-misses.ts +++ b/scripts/promote-query-misses.ts @@ -78,7 +78,10 @@ async function main() { let inserted = 0; for (const group of promotable) { - const labels = group.labels.filter((label) => label.document_id && label.label && label.label_type); + const labels = group.labels.filter( + (label): label is typeof label & { document_id: string; label: string; label_type: string } => + Boolean(label.document_id && label.label && label.label_type), + ); for (const label of labels) { const { error: labelError } = await supabase.from("document_labels").upsert( { diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 88c4b85f84..12957ff401 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; @@ -74,7 +75,11 @@ async function selectReindexRowsInPages(args: { }) { const rows: T[] = []; for (let offset = 0; ; offset += reindexPageSize) { - let query = args.supabase.from(args.table).select(args.select).eq("document_id", args.documentId); + // Dynamic table/select strings need the untyped client surface. + let query = (args.supabase as unknown as SupabaseClient) + .from(args.table) + .select(args.select) + .eq("document_id", args.documentId); if (args.searchableOnly) query = query.eq("searchable", true); const { data, error } = await query.range(offset, offset + reindexPageSize - 1); if (error) throw new Error(error.message); @@ -147,13 +152,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const enrichment = await upsertDocumentEnrichment({ supabase, - document, + document: document as Parameters[0]["document"], chunks: committedChunks, images: committedImages, }); const deepMemory = await upsertDocumentDeepMemory({ supabase, - document, + document: document as Parameters[0]["document"], chunks: committedChunks, images: committedImages, summary: enrichment.summary.summary, diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 9f68f1cb0e..55d6a15582 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; @@ -451,7 +452,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id renamed_at: new Date().toISOString(), previous_title: document.title, original_file_name: metadata.original_file_name ?? document.file_name, - }, + // JSON-serializable; the inferred literal type is wider than Json. + } as unknown as Json, }) .eq("id", id) .eq("owner_id", user.id) diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 864878678e..d7428783f7 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; @@ -66,7 +67,11 @@ async function selectRowsInPages(args: { }) { const rows: T[] = []; for (let offset = 0; ; offset += pageSize) { - let query = args.supabase.from(args.table).select(args.select).eq("document_id", args.documentId); + // Dynamic table/select strings need the untyped client surface. + let query = (args.supabase as unknown as SupabaseClient) + .from(args.table) + .select(args.select) + .eq("document_id", args.documentId); if (args.searchableOnly) query = query.eq("searchable", true); const { data, error } = await query.range(offset, offset + pageSize - 1); if (error) throw new Error(error.message); @@ -145,13 +150,13 @@ export async function POST(request: Request) { ); const enrichment = await upsertDocumentEnrichment({ supabase, - document, + document: document as Parameters[0]["document"], chunks: committedChunks, images: committedImages, }); const memory = await upsertDocumentDeepMemory({ supabase, - document, + document: document as Parameters[0]["document"], chunks: committedChunks, images: committedImages, summary: enrichment.summary.summary, diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index 1f3328fe82..40f51eed29 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { invalidateRagCachesForOwner } from "@/lib/rag"; import { createAdminClient } from "@/lib/supabase/admin"; +import type { Json, TablesUpdate } from "@/lib/supabase/database.types"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; @@ -156,7 +157,7 @@ export async function POST(request: Request) { metadata.bulk_metadata_updated_by = user.id; const nextTitle = editTitle(document.title, parsed.titleEdit); - const updatePayload: Record = { metadata }; + const updatePayload: TablesUpdate<"documents"> = { metadata: metadata as Json }; if (nextTitle && nextTitle !== document.title) updatePayload.title = nextTitle; const { error: updateError } = await supabase diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 3b61c6969a..08cf8c7510 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; import { demoSearch } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; @@ -539,7 +540,8 @@ function logRetrievalDiagnostics(args: { relevance_score: args.relevance.score, latency_bucket: latencyBucket(latencyMs), ...retrievalDecisionTelemetry(args.telemetry), - }, + // Telemetry values are JSON-serializable; some are typed wider than Json. + } as unknown as Json, }); } catch (error) { retrievalLogWriteMetrics.failures += 1; @@ -599,7 +601,7 @@ function logSearchObservation(args: { search_cache_hit: telemetry.search_cache_hit ?? null, embedding_skipped: telemetry.embedding_skipped ?? null, ...retrievalDecisionTelemetry(telemetry), - }, + } as unknown as Json, }); } catch { // Search telemetry must not affect the user-facing search path. diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 7b35c11a1d..6262c3426b 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1,5 +1,5 @@ import { createAdminClient } from "@/lib/supabase/admin"; -import type { Database } from "@/lib/supabase/database.types"; +import type { Database, Json } from "@/lib/supabase/database.types"; import { embedTextWithTelemetry, generateStructuredTextResult, @@ -1651,7 +1651,8 @@ async function replaceSharedCacheRow( normalized_query: normalizedQuery, indexing_version: indexingVersion, dependency_version: ragCacheDependencyVersion, - payload, + // JSON-serializable by contract of the response cache. + payload: payload as Json, expires_at: new Date(Date.now() + ttlMs).toISOString(), }); } catch { @@ -1716,11 +1717,11 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { } void (async () => { try { - await createAdminClient() - .from("rag_response_cache") - .delete() - [sharedCacheOwnerId ? "eq" : "is"]("owner_id", sharedCacheOwnerId) - .in("cache_kind", ["search", "answer"]); + const deletion = createAdminClient().from("rag_response_cache").delete(); + await (sharedCacheOwnerId ? deletion.eq("owner_id", sharedCacheOwnerId) : deletion.is("owner_id", null)).in( + "cache_kind", + ["search", "answer"], + ); } catch (error) { // Shared cache invalidation is best effort. console.warn("Shared cache invalidation failed for owner:", error); @@ -1769,9 +1770,7 @@ async function insertRagQuery(row: RagQueryInsert) { query: queryTextForStorage(rawQuery), metadata: { ...existingMetadata, ...queryPrivacyMetadata(rawQuery) }, }; - await supabase - .from("rag_queries") - .insert(safeRow as Database["public"]["Tables"]["rag_queries"]["Insert"]); + await supabase.from("rag_queries").insert(safeRow as Database["public"]["Tables"]["rag_queries"]["Insert"]); } async function logRagQuery(row: RagQueryInsert) { @@ -1899,12 +1898,7 @@ async function fetchEnabledRagAliases( .eq("enabled", true) .order("weight", { ascending: false }) .limit(maxRagAliasesPerScope); - const nullableQuery = query as typeof query & { is?: (column: string, value: null) => typeof query }; - query = scopeOwnerId - ? query.eq("owner_id", scopeOwnerId) - : nullableQuery.is - ? nullableQuery.is("owner_id", null) - : query.eq("owner_id", null); + query = scopeOwnerId ? query.eq("owner_id", scopeOwnerId) : query.is("owner_id", null); const { data, error } = await query; if (error) throw error; return (data ?? []) as RagAliasInput[]; @@ -2070,8 +2064,8 @@ async function searchTextChunkCandidates(args: { const { data, error } = await args.supabase.rpc("match_document_chunks_text", { query_text: queryText, match_count: matchCount, - document_filters: args.documentIds ?? null, - owner_filter: args.ownerId ?? null, + document_filters: args.documentIds ?? undefined, + owner_filter: args.ownerId ?? undefined, }); return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); }; @@ -2222,9 +2216,9 @@ async function fetchBestDocumentLookupChunks(args: { const terms = documentLookupChunkTerms(args.query); const { data: rpcChunks, error: rpcError } = await args.supabase.rpc("match_document_lookup_chunks_text", { query_text: args.query, - document_filters: args.documentIds, + document_filters: args.documentIds ?? undefined, match_count: Math.max(args.limit * 3, 24), - owner_filter: args.ownerId ?? null, + owner_filter: args.ownerId ?? undefined, }); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -2328,7 +2322,7 @@ async function searchDocumentLookupFastPath(args: { const { data, error } = await args.supabase.rpc("match_documents_for_query", { query_text: variant, match_count: index === 0 ? 12 : 8, - owner_filter: args.ownerId ?? null, + owner_filter: args.ownerId ?? undefined, }); if (error || !data?.length) return [] as DocumentLookupRow[]; return data as DocumentLookupRow[]; @@ -2681,8 +2675,8 @@ async function searchTableFactCandidates(args: { const { data, error } = await args.supabase.rpc("match_document_table_facts_text", { query_text: variant, match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24), - document_filters: args.documentIds ?? null, - owner_filter: args.ownerId ?? null, + document_filters: args.documentIds ?? undefined, + owner_filter: args.ownerId ?? undefined, }); if (error || !data?.length) return [] as TableFactRpcRow[]; return data as TableFactRpcRow[]; @@ -2725,12 +2719,12 @@ async function searchEmbeddingFieldCandidates(args: { telemetry?: SearchTelemetry; }) { const { data, error } = await args.supabase.rpc("match_document_embedding_fields_hybrid", { - query_embedding: args.queryEmbedding, + query_embedding: args.queryEmbedding as unknown as string, query_text: buildClinicalTextSearchQuery(args.query), match_count: args.matchCount, min_similarity: 0.12, - document_filters: args.documentIds ?? null, - owner_filter: args.ownerId ?? null, + document_filters: args.documentIds ?? undefined, + owner_filter: args.ownerId ?? undefined, }); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -2775,12 +2769,12 @@ async function searchIndexUnitCandidates(args: { telemetry?: SearchTelemetry; }) { const { data, error } = await args.supabase.rpc("match_document_index_units_hybrid", { - query_embedding: args.queryEmbedding, + query_embedding: args.queryEmbedding as unknown as string, query_text: buildClinicalTextSearchQuery(args.query), match_count: args.matchCount, min_similarity: 0.1, - document_filters: args.documentIds ?? null, - owner_filter: args.ownerId ?? null, + document_filters: args.documentIds ?? undefined, + owner_filter: args.ownerId ?? undefined, }); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -2903,7 +2897,7 @@ async function attachIndexQualityMetadata( supabase: ReturnType, results: SearchResult[], ownerId?: string, -) { +): Promise { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); if (documentIds.length === 0) return results; try { @@ -2917,7 +2911,10 @@ async function attachIndexQualityMetadata( const qualityByDocument = new Map(data.map((row) => [row.document_id, row])); return results.map((result) => ({ ...result, - indexing_quality: qualityByDocument.get(result.document_id) ?? result.indexing_quality ?? null, + indexing_quality: + (qualityByDocument.get(result.document_id) as SearchResult["indexing_quality"]) ?? + result.indexing_quality ?? + null, })); } catch { return results; @@ -3095,8 +3092,8 @@ async function attachPageVisualEvidence( page_number: image.page_number, storage_path: image.storage_path, caption: image.caption, - bbox: image.bbox, - image_type: image.image_type, + bbox: image.bbox as ChunkImage["bbox"], + image_type: image.image_type as ChunkImage["image_type"], searchable: image.searchable, clinical_relevance_score: image.clinical_relevance_score, source_kind: image.source_kind, @@ -5645,12 +5642,12 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { (async () => { const startedAt = Date.now(); const { data, error } = await supabase.rpc("match_document_chunks_hybrid", { - query_embedding: embedding, + query_embedding: embedding as unknown as string, query_text: textSearchQuery, match_count: candidateCount, min_similarity: minSimilarity, - document_filters: documentFilterList ?? null, - owner_filter: args.ownerId ?? null, + document_filters: documentFilterList ?? undefined, + owner_filter: args.ownerId ?? undefined, }); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -5738,11 +5735,11 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const resultSets = await Promise.all( vectorFilters.map(async (documentFilter) => { const { data, error } = await supabase.rpc("match_document_chunks", { - query_embedding: embedding, + query_embedding: embedding as unknown as string, match_count: candidateCount, min_similarity: minSimilarity, - document_filter: documentFilter, - owner_filter: args.ownerId ?? null, + document_filter: documentFilter ?? undefined, + owner_filter: args.ownerId ?? undefined, }); if (error) throw new Error(error.message); diff --git a/tests/document-naming.test.ts b/tests/document-naming.test.ts index dfce1d51a2..452ff8d334 100644 --- a/tests/document-naming.test.ts +++ b/tests/document-naming.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { documentTitleKey, planDocumentName, smartDocumentTitle } from "../src/lib/document-naming"; function supabaseWithDocuments(documents: unknown[]) { + // Minimal stub of the query chain planDocumentName uses. return { from: () => ({ select: () => ({ @@ -10,7 +11,7 @@ function supabaseWithDocuments(documents: unknown[]) { }), }), }), - }; + } as unknown as Parameters[0]["supabase"]; } describe("document naming", () => { diff --git a/tsconfig.json b/tsconfig.json index 1b9373b9fc..c5498646a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], - "exclude": ["node_modules", "supabase/functions/**"] + "exclude": ["node_modules", "supabase/functions/**", "scratch/**"] } diff --git a/worker/main.ts b/worker/main.ts index 4718f1b2ca..18d0ade2d5 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -36,6 +37,7 @@ import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai import { safeErrorLogDetails, safeIngestionJobLog } from "../src/lib/privacy"; import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline"; import { createAdminClient } from "../src/lib/supabase/admin"; +import type { Json, TablesInsert, TablesUpdate } from "../src/lib/supabase/database.types"; import { probeSupabaseHealth } from "../src/lib/supabase/health"; import type { ExtractedDocument, ImageEvidenceCategory } from "../src/lib/types"; import { buildAdditionalEmbeddingFieldInputs } from "./embedding-fields"; @@ -93,7 +95,7 @@ function supabaseStageError( return wrapped; } -async function updateJob(jobId: string, patch: Record) { +async function updateJob(jobId: string, patch: TablesUpdate<"ingestion_jobs">) { const { error } = await supabase.from("ingestion_jobs").update(patch).eq("id", jobId); if (error) throw supabaseStageError("update ingestion job", error); if (typeof patch.progress === "number" || typeof patch.stage === "string") { @@ -125,7 +127,7 @@ async function updateJobProgress(jobId: string, patch: { stage: string; progress progressUpdateState.set(jobId, { updatedAt: now, progress: patch.progress, stage: patch.stage }); } -async function updateDocument(documentId: string, patch: Record) { +async function updateDocument(documentId: string, patch: TablesUpdate<"documents">) { const sanitized = patch.metadata ? { ...patch, metadata: sanitizeJsonbRecord(patch.metadata) } : patch; const { error } = await supabase.from("documents").update(sanitized).eq("id", documentId); if (error) throw supabaseStageError("update document", error); @@ -185,7 +187,9 @@ async function completeJob(job: JobRow, stage: string) { const { error } = await supabase.rpc("complete_ingestion_job", { p_job_id: job.id, p_document_id: job.document_id, - p_batch_id: job.batch_id, + // SQL default for p_batch_id is null, so omitting the key when batch_id + // is null sends the same value the explicit null did. + p_batch_id: job.batch_id ?? undefined, p_stage: stage, }); if (!error) return; @@ -247,12 +251,13 @@ async function failOrRetryJob(args: { const { error } = await supabase.rpc("fail_or_retry_ingestion_job", { p_job_id: args.job.id, p_document_id: args.job.document_id, - p_batch_id: args.job.batch_id, + p_batch_id: args.job.batch_id ?? undefined, p_retry: args.retry, p_document_status: args.documentStatus, p_stage: args.stage, p_error_message: args.errorMessage, - p_next_run_at: args.nextRunAt ?? null, + // SQL default is null; omitting the key matches the old explicit null. + p_next_run_at: args.nextRunAt, }); if (!error) return; if (!isMissingSchemaError(error)) throw supabaseStageError("fail or retry ingestion job", error); @@ -323,7 +328,7 @@ async function claimJobs() { }); if (error) throw supabaseStageError("claim ingestion jobs", error); - return ((data ?? []) as Array & { documents: JobDocument }>).map((job) => ({ + return ((data ?? []) as unknown as Array & { documents: JobDocument }>).map((job) => ({ ...job, documents: job.documents, })) as JobRow[]; @@ -362,7 +367,7 @@ function sanitizeJsonb(val: unknown): JsonbValue { return val as JsonbValue; } -function sanitizeJsonbRecord(value: unknown): Record { +function sanitizeJsonbRecord(value: unknown): JsonbRecord { const sanitized = sanitizeJsonb(value); return typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized) ? sanitized : {}; } @@ -448,8 +453,12 @@ async function replacePageRows(documentId: string, pages: ReturnType>` JSON-path filters are outside what + // the generated Database types can express; scope an untyped client to + // these generation-cleanup helpers only. + const dynamicTables = supabase as unknown as SupabaseClient; const hasReplacementRows = async (table: string, direct: boolean) => { - let query = supabase.from(table).select("id").eq("document_id", documentId).limit(1); + let query = dynamicTables.from(table).select("id").eq("document_id", documentId).limit(1); query = direct ? query.eq("index_generation_id", indexGenerationId) : query.eq("metadata->>index_generation_id", indexGenerationId); @@ -458,25 +467,29 @@ async function deleteStaleIndexGenerationRows(documentId: string, indexGeneratio return (data ?? []).length > 0; }; const deleteDirectGenerationRows = async (table: string) => { - const stale = await supabase + const stale = await dynamicTables .from(table) .delete() .eq("document_id", documentId) .neq("index_generation_id", indexGenerationId); if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error); if (!(await hasReplacementRows(table, true))) return; - const missing = await supabase.from(table).delete().eq("document_id", documentId).is("index_generation_id", null); + const missing = await dynamicTables + .from(table) + .delete() + .eq("document_id", documentId) + .is("index_generation_id", null); if (missing.error) throw supabaseStageError(`delete generationless ${table}`, missing.error); }; const deleteMetadataGenerationRows = async (table: string) => { - const stale = await supabase + const stale = await dynamicTables .from(table) .delete() .eq("document_id", documentId) .neq("metadata->>index_generation_id", indexGenerationId); if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error); if (!(await hasReplacementRows(table, false))) return; - const missing = await supabase + const missing = await dynamicTables .from(table) .delete() .eq("document_id", documentId) @@ -494,9 +507,11 @@ async function deleteStaleIndexGenerationRows(documentId: string, indexGeneratio } async function upsertIndexQuality(quality: ReturnType) { - const { error } = await supabase.from("document_index_quality").upsert(sanitizeJsonbRecord(quality), { - onConflict: "document_id", - }); + const { error } = await supabase + .from("document_index_quality") + .upsert(sanitizeJsonbRecord(quality) as unknown as TablesInsert<"document_index_quality">, { + onConflict: "document_id", + }); if (error) throw supabaseStageError("upsert document_index_quality", error); } @@ -762,7 +777,9 @@ async function setCachedImageClassification(args: { image_caption_cache_version: imageCaptionCacheVersion, vision_classification_prompt_version: visionClassificationPromptVersion, caption_context_hash: args.contextHash, - }, + // JSON-serializable at runtime; structured_visual_profile's type is + // wider than the generated Json shape. + } as unknown as Json, updated_at: new Date().toISOString(), }, { onConflict: "owner_id,image_hash,model" }, @@ -1100,7 +1117,7 @@ async function uploadAndCaptionImages( id: data.id, caption: data.caption, pageNumber: data.page_number, - imageType: data.image_type, + imageType: data.image_type as ImageEvidenceCategory, sourceKind: image.sourceKind ?? "embedded", labels: data.labels ?? [], tableLabel: tableMetadata.tableLabel, @@ -1258,7 +1275,9 @@ async function insertDocumentLevelEmbeddingFields(args: { index_generation_id: args.chunkRows[0]?.index_generation_id ?? null, }, })); - const { error } = await supabase.from("document_embedding_fields").insert(rows); + const { error } = await supabase + .from("document_embedding_fields") + .insert(rows as unknown as TablesInsert<"document_embedding_fields">[]); if (error) throw supabaseStageError("insert document-level embedding fields", error); return rows.map((row) => row.field_type); } @@ -1323,7 +1342,9 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { })) satisfies IndexedChunkRow[]; indexedChunkRows.push(...rows); - const { error } = await supabase.from("document_chunks").insert(rows); + const { error } = await supabase + .from("document_chunks") + .insert(rows as unknown as TablesInsert<"document_chunks">[]); if (error) throw new Error(error.message); const fieldInputs = buildEmbeddingFieldInputs(job, rows); @@ -1339,7 +1360,9 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { })); for (let start = 0; start < fieldRows.length; start += 50) { const batch = fieldRows.slice(start, start + 50); - const { error: fieldsError } = await supabase.from("document_embedding_fields").insert(batch); + const { error: fieldsError } = await supabase + .from("document_embedding_fields") + .insert(batch as unknown as TablesInsert<"document_embedding_fields">[]); if (fieldsError) throw supabaseStageError("insert section-context embedding fields", fieldsError); } } catch (error) { @@ -1358,7 +1381,9 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { metadata: sanitizeJsonbRecord({ ...row.metadata, index_generation_id: indexGenerationId }), })); if (tableFacts.length > 0) { - const { error: factsError } = await supabase.from("document_table_facts").insert(tableFacts); + const { error: factsError } = await supabase + .from("document_table_facts") + .insert(tableFacts as unknown as TablesInsert<"document_table_facts">[]); if (factsError) optionalIndexWriteIssues.push( optionalIndexWriteWarning("table fact", supabaseStageError("insert table facts", factsError)), @@ -1385,7 +1410,9 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { embedding: assertEmbeddingDim(unitEmbeddings[start + index], `document_index_units.visual.${start + index}`), metadata: sanitizeJsonbRecord({ ...unit.metadata, index_generation_id: indexGenerationId }), })); - const { error: visualUnitError } = await supabase.from("document_index_units").insert(batch); + const { error: visualUnitError } = await supabase + .from("document_index_units") + .insert(batch as unknown as TablesInsert<"document_index_units">[]); if (visualUnitError) throw supabaseStageError("insert visual index units", visualUnitError); } } catch (error) { @@ -1415,7 +1442,9 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { }); for (let start = 0; start < additionalRows.length; start += 50) { const batch = additionalRows.slice(start, start + 50); - const { error: additionalFieldsError } = await supabase.from("document_embedding_fields").insert(batch); + const { error: additionalFieldsError } = await supabase + .from("document_embedding_fields") + .insert(batch as unknown as TablesInsert<"document_embedding_fields">[]); if (additionalFieldsError) throw supabaseStageError("insert supplemental embedding fields", additionalFieldsError); } @@ -1608,8 +1637,8 @@ async function processJob(job: JobRow) { const deepMemory = await upsertDocumentDeepMemory({ supabase, document: job.documents, - chunks: enrichmentRows.chunks, - images: enrichmentRows.images, + chunks: enrichmentRows.chunks as unknown as Parameters[0]["chunks"], + images: enrichmentRows.images as unknown as Parameters[0]["images"], summary: enrichment.summary.summary, }); sectionCount = deepMemory.sections.length; From d1f2de44fb9f9de3718e65027880042ecd1f9a35 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:19:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(app):=20move=20ssr:false=20dynamic=20im?= =?UTF-8?q?ports=20into=20client=20components=20=E2=80=94=20restore=20runt?= =?UTF-8?q?ime=20on=20/=20and=20/documents/[id]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #133 placed next/dynamic with { ssr: false } directly in the server components src/app/page.tsx and src/app/documents/[id]/page.tsx. The App Router forbids that ('ssr: false is not allowed with next/dynamic in Server Components'), so the home page failed to compile and every route 500'd in dev; CI never caught it because the verify job does not build or boot the app. Fix preserves #133's lazy-load intent: the dynamic(ssr:false) calls move into 'use client' wrappers (clinical-dashboard-lazy.tsx, document-viewer-lazy.tsx) that the server pages import. Verified: ui-smoke chromium 29/29 passed against a cold .next build; typecheck and prettier clean. Co-Authored-By: Claude Fable 5 --- src/app/documents/[id]/page.tsx | 7 +------ src/app/page.tsx | 7 +------ src/components/clinical-dashboard-lazy.tsx | 11 +++++++++++ src/components/document-viewer-lazy.tsx | 9 +++++++++ 4 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 src/components/clinical-dashboard-lazy.tsx create mode 100644 src/components/document-viewer-lazy.tsx diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx index b6de4af8b5..17f79250aa 100644 --- a/src/app/documents/[id]/page.tsx +++ b/src/app/documents/[id]/page.tsx @@ -1,9 +1,4 @@ -import dynamic from "next/dynamic"; - -const DocumentViewer = dynamic( - () => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), - { ssr: false }, -); +import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy"; export default async function DocumentPage({ params, diff --git a/src/app/page.tsx b/src/app/page.tsx index 73ba714477..6236f65f39 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,5 @@ -import dynamic from "next/dynamic"; import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; - -const ClinicalDashboard = dynamic( - () => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), - { ssr: false }, -); +import { ClinicalDashboardLazy as ClinicalDashboard } from "@/components/clinical-dashboard-lazy"; type HomeProps = { searchParams?: Promise<{ diff --git a/src/components/clinical-dashboard-lazy.tsx b/src/components/clinical-dashboard-lazy.tsx new file mode 100644 index 0000000000..70bd7aafc0 --- /dev/null +++ b/src/components/clinical-dashboard-lazy.tsx @@ -0,0 +1,11 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// `ssr: false` requires a Client Component in the App Router; this wrapper +// keeps the heavy dashboard bundle browser-only for the server-rendered +// home page. +export const ClinicalDashboardLazy = dynamic( + () => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), + { ssr: false }, +); diff --git a/src/components/document-viewer-lazy.tsx b/src/components/document-viewer-lazy.tsx new file mode 100644 index 0000000000..26a39fa0ef --- /dev/null +++ b/src/components/document-viewer-lazy.tsx @@ -0,0 +1,9 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// `ssr: false` requires a Client Component in the App Router; this wrapper +// keeps the viewer bundle browser-only for the server-rendered document page. +export const DocumentViewerLazy = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), { + ssr: false, +});