From 095f08808dcbd04ab201f97fa2a226fed1bd50f4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:00:36 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix(ingestion):=20R5=20=E2=80=94=20deep-m?= =?UTF-8?q?erge=20document=20metadata=20instead=20of=20full=20replace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop worker/commit paths from clobbering concurrent renames and agent patches by merging worker-owned metadata deltas onto live JSONB. KNOWN GAP: supabase/drift-manifest.json not regenerated — Docker Desktop cannot start on this host. Do not merge until npm run drift:manifest is green. Co-authored-by: Cursor --- src/lib/supabase/database.types.ts | 14 + ...60708310000_r5_document_metadata_merge.sql | 305 ++++++++++++++++++ supabase/schema.sql | 61 +++- tests/document-metadata-merge.test.ts | 121 +++++++ worker/main.ts | 24 +- 5 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20260708310000_r5_document_metadata_merge.sql create mode 100644 tests/document-metadata-merge.test.ts diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 84252d0e3..76f550118 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2048,6 +2048,13 @@ export type Database = { Args: { p_document_id?: string | null; p_dry_run?: boolean; p_limit?: number }; Returns: Json; }; + apply_document_metadata_patch: { + Args: { + p_document_id: string; + p_metadata_patch?: Json; + }; + Returns: undefined; + }; commit_document_index_generation: { Args: { p_chunk_count?: number; @@ -2062,6 +2069,13 @@ export type Database = { }; Returns: Json; }; + jsonb_merge_deep: { + Args: { + patch_obj?: Json; + target_obj?: Json; + }; + Returns: Json; + }; complete_ingestion_job: { Args: { p_batch_id?: string | null; diff --git a/supabase/migrations/20260708310000_r5_document_metadata_merge.sql b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql new file mode 100644 index 000000000..bb17e101e --- /dev/null +++ b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql @@ -0,0 +1,305 @@ +-- R5: deep-merge documents.metadata on worker/commit writes. +-- +-- Root cause: commit_document_index_generation and worker updateDocument paths +-- full-replaced documents.metadata, erasing concurrent renames / bulk-metadata +-- / agent-state patches under reclaim races (docs/ingestion-concurrency-fix-workorder.md). +-- +-- Expand/contract: new helpers are additive; commit RPC keeps the same signature +-- and only changes the metadata assignment from replace to deep-merge. Safe to +-- apply before the worker deploy. Old workers that still send a full object still +-- benefit because merge preserves keys absent from the patch; the paired worker +-- change sends worker-owned key deltas only. + +set search_path = public, extensions, pg_temp; + +create or replace function public.jsonb_merge_deep(target_obj jsonb, patch_obj jsonb) +returns jsonb +language plpgsql +immutable +set search_path = public, extensions, pg_temp +as $$ +declare + merged jsonb := coalesce(target_obj, '{}'::jsonb); + key text; + incoming_value jsonb; +begin + for key, incoming_value in + select j.key, j.value + from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j + loop + if jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + merged := jsonb_set( + merged, + array[key], + public.jsonb_merge_deep(merged -> key, incoming_value), + true + ); + else + merged := jsonb_set(merged, array[key], incoming_value, true); + end if; + end loop; + return merged; +end; +$$; + +create or replace function public.apply_document_metadata_patch( + p_document_id uuid, + p_metadata_patch jsonb +) +returns void +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + update public.documents + set + metadata = public.jsonb_merge_deep( + coalesce(metadata, '{}'::jsonb), + coalesce(p_metadata_patch, '{}'::jsonb) + ), + updated_at = now() + where id = p_document_id; +end; +$$; + +create or replace function public.commit_document_index_generation( + p_document_id uuid, + p_index_generation_id uuid, + p_status text default 'indexed', + p_page_count integer default 0, + p_chunk_count integer default 0, + p_image_count integer default 0, + p_metadata jsonb default '{}'::jsonb, + p_pages jsonb default null, + p_quality jsonb default null +) +returns jsonb +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + perform set_config('statement_timeout', '180000', true); + + update public.documents + set + status = p_status, + page_count = p_page_count, + chunk_count = p_chunk_count, + image_count = p_image_count, + error_message = null, + updated_at = now() + where id = p_document_id; + + -- R5: merge worker-owned keys onto live metadata instead of full-replace. + perform public.apply_document_metadata_patch( + p_document_id, + coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id) + ); + + if p_pages is not null then + delete from public.document_pages + where document_id = p_document_id; + + insert into public.document_pages (document_id, page_number, text, ocr_used, metadata) + select + p_document_id, + page_row.page_number, + coalesce(page_row.text, ''), + coalesce(page_row.ocr_used, false), + coalesce(page_row.metadata, '{}'::jsonb) + from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row( + page_number integer, + text text, + ocr_used boolean, + metadata jsonb + ) + where page_row.page_number is not null; + end if; + + if p_quality is not null then + insert into public.document_index_quality ( + document_id, + owner_id, + quality_score, + extraction_quality, + metrics, + issues, + updated_at + ) + values ( + p_document_id, + nullif(p_quality->>'owner_id', '')::uuid, + coalesce((p_quality->>'quality_score')::real, 0), + coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'), + coalesce(p_quality->'metrics', '{}'::jsonb), + coalesce( + array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))), + '{}'::text[] + ), + now() + ) + on conflict on constraint document_index_quality_pkey + do update set + owner_id = excluded.owner_id, + quality_score = excluded.quality_score, + extraction_quality = excluded.extraction_quality, + metrics = excluded.metrics, + issues = excluded.issues, + updated_at = excluded.updated_at; + end if; + + -- Preserve legacy NULL-generation rows unless this generation wrote replacements. + delete from public.document_chunks + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and exists ( + select 1 + from public.document_chunks replacement + where replacement.document_id = p_document_id + and replacement.index_generation_id = p_index_generation_id + ) + ) + ); + + -- artifact tables: use typed column where set; fall back to metadata when typed is NULL + -- because the writer still populates metadata.index_generation_id rather than the typed + -- column. Without the metadata fallback, stale null-typed rows from a prior run would + -- never be cleaned up (the typed-column EXISTS guard would always be false), allowing + -- artifact rows to accumulate across re-indexes. + delete from public.document_images + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_images replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_table_facts + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_table_facts replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_embedding_fields + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_embedding_fields replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_index_units + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_index_units replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_memory_cards + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_memory_cards replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_sections + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_sections replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'document_id', p_document_id, + 'index_generation_id', p_index_generation_id + ); +end; +$$; + +revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.jsonb_merge_deep(jsonb, jsonb) to service_role; +revoke execute on function public.apply_document_metadata_patch(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role; +revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 5c5a5ddea..cfe456124 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1241,6 +1241,56 @@ begin end; $$; +create or replace function public.jsonb_merge_deep(target_obj jsonb, patch_obj jsonb) +returns jsonb +language plpgsql +immutable +set search_path = public, extensions, pg_temp +as $$ +declare + merged jsonb := coalesce(target_obj, '{}'::jsonb); + key text; + incoming_value jsonb; +begin + for key, incoming_value in + select j.key, j.value + from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j + loop + if jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + merged := jsonb_set( + merged, + array[key], + public.jsonb_merge_deep(merged -> key, incoming_value), + true + ); + else + merged := jsonb_set(merged, array[key], incoming_value, true); + end if; + end loop; + return merged; +end; +$$; + +create or replace function public.apply_document_metadata_patch( + p_document_id uuid, + p_metadata_patch jsonb +) +returns void +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + update public.documents + set + metadata = public.jsonb_merge_deep( + coalesce(metadata, '{}'::jsonb), + coalesce(p_metadata_patch, '{}'::jsonb) + ), + updated_at = now() + where id = p_document_id; +end; +$$; + create or replace function public.commit_document_index_generation( p_document_id uuid, p_index_generation_id uuid, @@ -1266,10 +1316,15 @@ begin chunk_count = p_chunk_count, image_count = p_image_count, error_message = null, - metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id), updated_at = now() where id = p_document_id; + -- R5: merge worker-owned keys onto live metadata instead of full-replace. + perform public.apply_document_metadata_patch( + p_document_id, + coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id) + ); + if p_pages is not null then delete from public.document_pages where document_id = p_document_id; @@ -4614,6 +4669,10 @@ grant select, insert, update, delete on table public.document_index_units to ser grant select on table public.document_index_units to authenticated; revoke execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) from public, anon, authenticated; grant execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) to service_role; +revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.jsonb_merge_deep(jsonb, jsonb) to service_role; +revoke execute on function public.apply_document_metadata_patch(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role; revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated; grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; revoke execute on function public.cleanup_abandoned_document_index_generations(uuid, integer, boolean) from public, anon, authenticated; diff --git a/tests/document-metadata-merge.test.ts b/tests/document-metadata-merge.test.ts new file mode 100644 index 000000000..53b79fea9 --- /dev/null +++ b/tests/document-metadata-merge.test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workerMain = readFileSync(new URL("../worker/main.ts", import.meta.url), "utf8"); +const schemaSql = readFileSync(new URL("../supabase/schema.sql", import.meta.url), "utf8"); +const r5Migration = readFileSync( + new URL("../supabase/migrations/20260708310000_r5_document_metadata_merge.sql", import.meta.url), + "utf8", +); + +/** + * Mirrors public.jsonb_merge_deep so the worker-owned-delta contract stays + * covered offline (the SQL helper is exercise-checked via migration + live + * apply). Nested objects merge recursively; scalars/arrays/null overwrite. + */ +function jsonbMergeDeep( + targetObj: Record | null | undefined, + patchObj: Record | null | undefined, +): Record { + const merged: Record = { ...(targetObj ?? {}) }; + for (const [key, incoming] of Object.entries(patchObj ?? {})) { + const existing = merged[key]; + if ( + existing !== null && + typeof existing === "object" && + !Array.isArray(existing) && + incoming !== null && + typeof incoming === "object" && + !Array.isArray(incoming) + ) { + merged[key] = jsonbMergeDeep( + existing as Record, + incoming as Record, + ); + } else { + merged[key] = incoming; + } + } + return merged; +} + +describe("R5 document metadata deep-merge", () => { + it("preserves concurrent rename/agent keys when worker sends owned deltas only", () => { + const live = { + title_override: "Renamed while reindexing", + agent_state: { pass: "indexing-v3-agent", note: "keep me" }, + bulk_tag: "operator", + enrichment_status: "completed", + }; + const workerDelta = { + indexed_at: "2026-07-08T12:00:00.000Z", + index_generation_id: "gen-2", + enrichment_status: "pending", + indexing_v3_agent_status: "pending", + page_count: 12, + }; + + expect(jsonbMergeDeep(live, workerDelta)).toEqual({ + title_override: "Renamed while reindexing", + agent_state: { pass: "indexing-v3-agent", note: "keep me" }, + bulk_tag: "operator", + indexed_at: "2026-07-08T12:00:00.000Z", + index_generation_id: "gen-2", + enrichment_status: "pending", + indexing_v3_agent_status: "pending", + page_count: 12, + }); + }); + + it("deep-merges nested objects without dropping sibling keys", () => { + const live = { + index_quality_metrics: { text_character_count: 100, page_count: 3 }, + labels: ["keep"], + }; + const workerDelta = { + index_quality_metrics: { text_character_count: 400, ocr_page_count: 1 }, + }; + + expect(jsonbMergeDeep(live, workerDelta)).toEqual({ + index_quality_metrics: { + text_character_count: 400, + page_count: 3, + ocr_page_count: 1, + }, + labels: ["keep"], + }); + }); + + it("overwrites scalar and array keys from the patch", () => { + expect( + jsonbMergeDeep( + { enrichment_status: "completed", issues: ["a"], note: null }, + { enrichment_status: "failed", issues: ["b"], note: "set" }, + ), + ).toEqual({ + enrichment_status: "failed", + issues: ["b"], + note: "set", + }); + }); + + it("keeps the SQL merge helpers + grant posture in schema and the R5 migration", () => { + for (const sql of [schemaSql, r5Migration]) { + expect(sql).toContain("create or replace function public.jsonb_merge_deep"); + expect(sql).toContain("create or replace function public.apply_document_metadata_patch"); + expect(sql).toContain("perform public.apply_document_metadata_patch("); + expect(sql).toContain( + "revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated", + ); + expect(sql).toContain( + "grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role", + ); + } + }); + + it("routes worker document metadata updates through the merge RPC with owned deltas only", () => { + expect(workerMain).toContain('rpc("apply_document_metadata_patch"'); + expect(workerMain).toContain("send only worker-owned key deltas"); + expect(workerMain).not.toContain("...(job.documents.metadata"); + }); +}); diff --git a/worker/main.ts b/worker/main.ts index 55c339601..1ccf34216 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -167,9 +167,23 @@ async function updateJobProgress(jobId: string, patch: { stage: string; progress } 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); + const { metadata, ...remainingPatch } = patch as TablesUpdate<"documents">; + const updatePayload = remainingPatch; + const hasUpdatePayload = Object.keys(updatePayload).length > 0; + if (hasUpdatePayload) { + const { error } = await supabase.from("documents").update(updatePayload).eq("id", documentId); + if (error) throw supabaseStageError("update document", error); + } + + // R5: deep-merge worker-owned metadata keys onto live documents.metadata so + // concurrent renames / bulk-metadata / agent patches survive reclaim races. + if (typeof metadata !== "undefined") { + const { error } = await supabase.rpc("apply_document_metadata_patch", { + p_document_id: documentId, + p_metadata_patch: sanitizeJsonbRecord(metadata), + }); + if (error) throw supabaseStageError("apply document metadata patch", error); + } } async function markSupersededSiblingJobs(job: JobRow) { @@ -1677,8 +1691,10 @@ async function processJob(job: JobRow) { const indexedAt = new Date().toISOString(); const coreAgentMessage = "Core index committed; enrichment pending."; + // R5: send only worker-owned key deltas. apply_document_metadata_patch / + // commit_document_index_generation deep-merge onto live metadata so + // concurrent renames and agent patches are not clobbered by a stale job snapshot. const committedCoreMetadata = { - ...(job.documents.metadata ?? {}), indexed_at: indexedAt, index_generation_id: indexGenerationId, rag_enrichment_version: ragEnrichmentVersion, From 70512544421799af11b09951e38b26394d225ac2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:08:14 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(ingestion):=20R5=20=E2=80=94=20null-k?= =?UTF-8?q?ey=20deletes,=20merge=20RPC=20fallback,=20and=20contract=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON null in jsonb_merge_deep now clears sticky error/gate fields that full-replace used to wipe. Worker falls back to a shallow live merge when the R5 RPC is missing, and tests lock the owned-delta contract. Co-authored-by: Cursor --- src/lib/supabase/database.types.ts | 28 +++++++++---------- ...60708310000_r5_document_metadata_merge.sql | 8 ++++-- supabase/schema.sql | 6 +++- tests/document-metadata-merge.test.ts | 26 +++++++++++++++-- tests/supabase-schema.test.ts | 5 ++++ worker/main.ts | 28 +++++++++++++++++-- 6 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 76f550118..e8d2f8797 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -1983,6 +1983,13 @@ export type Database = { }; Functions: { analyze_rag_tables: { Args: never; Returns: undefined }; + apply_document_metadata_patch: { + Args: { + p_document_id: string; + p_metadata_patch?: Json; + }; + Returns: undefined; + }; consume_api_subject_rate_limit: { Args: { p_subject_key: string; @@ -2048,13 +2055,6 @@ export type Database = { Args: { p_document_id?: string | null; p_dry_run?: boolean; p_limit?: number }; Returns: Json; }; - apply_document_metadata_patch: { - Args: { - p_document_id: string; - p_metadata_patch?: Json; - }; - Returns: undefined; - }; commit_document_index_generation: { Args: { p_chunk_count?: number; @@ -2069,13 +2069,6 @@ export type Database = { }; Returns: Json; }; - jsonb_merge_deep: { - Args: { - patch_obj?: Json; - target_obj?: Json; - }; - Returns: Json; - }; complete_ingestion_job: { Args: { p_batch_id?: string | null; @@ -2200,6 +2193,13 @@ export type Database = { Args: { document_metadata: Json; row_generation: string }; Returns: boolean; }; + jsonb_merge_deep: { + Args: { + patch_obj?: Json; + target_obj?: Json; + }; + Returns: Json; + }; match_document_chunks: { Args: { document_filter?: string | null; diff --git a/supabase/migrations/20260708310000_r5_document_metadata_merge.sql b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql index bb17e101e..f4e89a323 100644 --- a/supabase/migrations/20260708310000_r5_document_metadata_merge.sql +++ b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql @@ -10,8 +10,6 @@ -- benefit because merge preserves keys absent from the patch; the paired worker -- change sends worker-owned key deltas only. -set search_path = public, extensions, pg_temp; - create or replace function public.jsonb_merge_deep(target_obj jsonb, patch_obj jsonb) returns jsonb language plpgsql @@ -27,7 +25,11 @@ begin select j.key, j.value from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j loop - if jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + -- JSON null means "delete this key" so worker deltas can clear sticky + -- error/gate fields that the old full-replace used to wipe implicitly. + if incoming_value = 'null'::jsonb then + merged := merged - key; + elsif jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then merged := jsonb_set( merged, array[key], diff --git a/supabase/schema.sql b/supabase/schema.sql index cfe456124..d41664325 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1256,7 +1256,11 @@ begin select j.key, j.value from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j loop - if jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + -- JSON null means "delete this key" so worker deltas can clear sticky + -- error/gate fields that the old full-replace used to wipe implicitly. + if incoming_value = 'null'::jsonb then + merged := merged - key; + elsif jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then merged := jsonb_set( merged, array[key], diff --git a/tests/document-metadata-merge.test.ts b/tests/document-metadata-merge.test.ts index 53b79fea9..fcc8d1cc8 100644 --- a/tests/document-metadata-merge.test.ts +++ b/tests/document-metadata-merge.test.ts @@ -11,7 +11,8 @@ const r5Migration = readFileSync( /** * Mirrors public.jsonb_merge_deep so the worker-owned-delta contract stays * covered offline (the SQL helper is exercise-checked via migration + live - * apply). Nested objects merge recursively; scalars/arrays/null overwrite. + * apply). Nested objects merge recursively; scalars/arrays overwrite; JSON + * null deletes the key (same as `merged - key` in SQL). */ function jsonbMergeDeep( targetObj: Record | null | undefined, @@ -19,12 +20,15 @@ function jsonbMergeDeep( ): Record { const merged: Record = { ...(targetObj ?? {}) }; for (const [key, incoming] of Object.entries(patchObj ?? {})) { + if (incoming === null) { + delete merged[key]; + continue; + } const existing = merged[key]; if ( existing !== null && typeof existing === "object" && !Array.isArray(existing) && - incoming !== null && typeof incoming === "object" && !Array.isArray(incoming) ) { @@ -89,7 +93,7 @@ describe("R5 document metadata deep-merge", () => { it("overwrites scalar and array keys from the patch", () => { expect( jsonbMergeDeep( - { enrichment_status: "completed", issues: ["a"], note: null }, + { enrichment_status: "completed", issues: ["a"], note: "old" }, { enrichment_status: "failed", issues: ["b"], note: "set" }, ), ).toEqual({ @@ -99,6 +103,22 @@ describe("R5 document metadata deep-merge", () => { }); }); + it("deletes sticky keys when the patch sends JSON null", () => { + expect( + jsonbMergeDeep( + { + indexing_v3_agent_last_error: "old", + completion_gate_missing: ["image_caption"], + keep: true, + }, + { + indexing_v3_agent_last_error: null, + completion_gate_missing: null, + }, + ), + ).toEqual({ keep: true }); + }); + it("keeps the SQL merge helpers + grant posture in schema and the R5 migration", () => { for (const sql of [schemaSql, r5Migration]) { expect(sql).toContain("create or replace function public.jsonb_merge_deep"); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index a10a8fdd9..1c3efdfa3 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -210,6 +210,11 @@ describe("Supabase schema Data API grants", () => { expect(sql).toContain("insert into public.document_pages"); expect(sql).toContain("insert into public.document_index_quality"); } + // R5 helpers live only in schema.sql (+ the dedicated migration), not in the + // original atomic-reindex migration snapshot. + expect(schema).toContain("create or replace function public.jsonb_merge_deep"); + expect(schema).toContain("create or replace function public.apply_document_metadata_patch"); + expect(schema).toContain("perform public.apply_document_metadata_patch"); expect(schema).toContain("public.is_committed_document_generation(c.index_generation_id, d.metadata)"); expect(schema).toContain("public.is_committed_artifact_generation(m.metadata, d.metadata)"); expect(schema).toContain("public.is_committed_artifact_generation(f.metadata, d.metadata)"); diff --git a/worker/main.ts b/worker/main.ts index 1ccf34216..e705e071d 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -178,11 +178,33 @@ async function updateDocument(documentId: string, patch: TablesUpdate<"documents // R5: deep-merge worker-owned metadata keys onto live documents.metadata so // concurrent renames / bulk-metadata / agent patches survive reclaim races. if (typeof metadata !== "undefined") { + const metadataPatch = sanitizeJsonbRecord(metadata); const { error } = await supabase.rpc("apply_document_metadata_patch", { p_document_id: documentId, - p_metadata_patch: sanitizeJsonbRecord(metadata), + p_metadata_patch: metadataPatch, }); - if (error) throw supabaseStageError("apply document metadata patch", error); + if (!error) return; + if (!isMissingSchemaError(error)) throw supabaseStageError("apply document metadata patch", error); + + // Expand/contract fallback before the R5 migration is applied: best-effort + // shallow merge against the current row (still races under reclaim, same as + // the pre-R5 path). Prefer the RPC once live has the migration. + const { data: current, error: readError } = await supabase + .from("documents") + .select("metadata") + .eq("id", documentId) + .maybeSingle(); + if (readError) throw supabaseStageError("read document metadata for merge fallback", readError); + const { error: fallbackError } = await supabase + .from("documents") + .update({ + metadata: sanitizeJsonbRecord({ + ...((current?.metadata as Record | null) ?? {}), + ...metadataPatch, + }), + }) + .eq("id", documentId); + if (fallbackError) throw supabaseStageError("fallback document metadata merge", fallbackError); } } @@ -1827,8 +1849,10 @@ async function processJob(job: JobRow) { } : { indexing_v3_agent_status: "completed", + // JSON null deletes sticky keys via jsonb_merge_deep (R5). indexing_v3_agent_last_error: null, indexing_v3_agent_repair_reason: null, + completion_gate_missing: null, indexing_v3_agent_updated_at: enrichmentUpdatedAt ?? new Date().toISOString(), }), embedding_model: env.OPENAI_EMBEDDING_MODEL, From 2cd7c67911c539a5fdda07437839e2d149069f31 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:22:52 +0800 Subject: [PATCH 03/10] fix(ingestion): prettier-format R5 metadata merge contract test CI verify failed solely on format:check for the new R5 test file. Co-authored-by: Cursor --- tests/document-metadata-merge.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/document-metadata-merge.test.ts b/tests/document-metadata-merge.test.ts index fcc8d1cc8..c15dd5a26 100644 --- a/tests/document-metadata-merge.test.ts +++ b/tests/document-metadata-merge.test.ts @@ -32,10 +32,7 @@ function jsonbMergeDeep( typeof incoming === "object" && !Array.isArray(incoming) ) { - merged[key] = jsonbMergeDeep( - existing as Record, - incoming as Record, - ); + merged[key] = jsonbMergeDeep(existing as Record, incoming as Record); } else { merged[key] = incoming; } From 376e8611318b860864ed65875072e03289974a28 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:31:03 +0800 Subject: [PATCH 04/10] ci: add one-shot drift-manifest regen workflow Local Docker Desktop/WSL cannot start on this host, so regenerate supabase/drift-manifest.json on ubuntu-latest and download the artifact. Co-authored-by: Cursor --- .github/workflows/regen-drift-manifest.yml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/regen-drift-manifest.yml diff --git a/.github/workflows/regen-drift-manifest.yml b/.github/workflows/regen-drift-manifest.yml new file mode 100644 index 000000000..5603693d9 --- /dev/null +++ b/.github/workflows/regen-drift-manifest.yml @@ -0,0 +1,46 @@ +name: Regen drift manifest + +# One-shot helper for hosts where Docker Desktop / WSL cannot start. +# Dispatches on a branch, regenerates supabase/drift-manifest.json from +# schema.sql in a scratch postgres container, and uploads it as an artifact +# for the operator to commit. Does not push. + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: regen-drift-manifest-${{ github.ref }} + cancel-in-progress: true + +jobs: + regen: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: ".nvmrc" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Regenerate drift manifest + run: npm run drift:manifest + + - name: Upload drift-manifest.json + uses: actions/upload-artifact@v4 + with: + name: drift-manifest + path: supabase/drift-manifest.json + if-no-files-found: error From e236d40bdbad3e54dbde696b959ef175c8cba5bd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:31:35 +0800 Subject: [PATCH 05/10] ci: trigger remote drift-manifest regen for R5 Local Docker Desktop cannot start (WSL service unavailable). Push a path-filtered regen workflow and marker file so ubuntu-latest can produce supabase/drift-manifest.json as an artifact. Co-authored-by: Cursor --- .github/triggers/regen-drift-manifest | 1 + .github/workflows/regen-drift-manifest.yml | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .github/triggers/regen-drift-manifest diff --git a/.github/triggers/regen-drift-manifest b/.github/triggers/regen-drift-manifest new file mode 100644 index 000000000..ce9c1507f --- /dev/null +++ b/.github/triggers/regen-drift-manifest @@ -0,0 +1 @@ +regen-r5 2026-07-08T16:35:00Z diff --git a/.github/workflows/regen-drift-manifest.yml b/.github/workflows/regen-drift-manifest.yml index 5603693d9..c8dec0763 100644 --- a/.github/workflows/regen-drift-manifest.yml +++ b/.github/workflows/regen-drift-manifest.yml @@ -1,11 +1,15 @@ name: Regen drift manifest -# One-shot helper for hosts where Docker Desktop / WSL cannot start. -# Dispatches on a branch, regenerates supabase/drift-manifest.json from -# schema.sql in a scratch postgres container, and uploads it as an artifact -# for the operator to commit. Does not push. +# Regenerates supabase/drift-manifest.json when local Docker Desktop / WSL +# cannot start. Triggered by pushing the marker file below (workflow_dispatch +# is also supported once this file exists on the default branch). Uploads the +# manifest as an artifact for the operator to commit — does not push. on: + push: + paths: + - ".github/triggers/regen-drift-manifest" + - ".github/workflows/regen-drift-manifest.yml" workflow_dispatch: permissions: From 6fc056d853e99f7cf70b208eec4deebd6370c914 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:33:45 +0800 Subject: [PATCH 06/10] chore(supabase): refresh drift-manifest for R5 metadata merge Regenerated via CI (ubuntu Docker) because local Docker Desktop/WSL cannot start. schema_sha256 now matches supabase/schema.sql after the jsonb_merge_deep / apply_document_metadata_patch additions. Co-authored-by: Cursor --- .github/triggers/regen-drift-manifest | 3 ++- supabase/drift-manifest.json | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/triggers/regen-drift-manifest b/.github/triggers/regen-drift-manifest index ce9c1507f..59499f909 100644 --- a/.github/triggers/regen-drift-manifest +++ b/.github/triggers/regen-drift-manifest @@ -1 +1,2 @@ -regen-r5 2026-07-08T16:35:00Z +regen-r5 applied 2026-07-08T16:33:00Z + diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index f37e0b762..4f11c84bc 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-08T06:45:01.039Z", + "generated_at": "2026-07-08T16:32:42.312Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "f7ca50611471d4ced647bfc7731d72f87856bd03890f03eaca8e0ccb8affc6b0", - "replay_seconds": 13, + "schema_sha256": "ddfcafedafe439332ba494b506969c0facadb465d6869b2561de7266f8d3c20f", + "replay_seconds": 30, "snapshot": { "views": [ { @@ -5629,6 +5629,14 @@ "def_hash": "fc2b64914e236c259c192c7527a32af7", "signature": "public.analyze_rag_tables()" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "91318c428667d073d12d42e2f7bf5505", + "signature": "public.apply_document_metadata_patch(uuid,jsonb)" + }, { "acl": [ "postgres=X/postgres", @@ -5666,7 +5674,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "b9b0836c354240f45f61ccfe40392317", + "def_hash": "594b1f715fbbfa1df1b1f1183a7fef5a", "signature": "public.commit_document_index_generation(uuid,uuid,text,integer,integer,integer,jsonb,jsonb,jsonb)" }, { @@ -5805,6 +5813,14 @@ "def_hash": "d4913081cb28b11030c854deaa55c816", "signature": "public.is_committed_document_generation(uuid,jsonb)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "69c0b6affcaeb9a0e23a8673d4bbe7c0", + "signature": "public.jsonb_merge_deep(jsonb,jsonb)" + }, { "acl": [ "postgres=X/postgres", From 7d3895a7b43db64462734c30ba93d895c90cf47f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:57:59 +0800 Subject: [PATCH 07/10] ci: retrigger PR checks after drift-manifest refresh Co-authored-by: Cursor From 31faa598a2fdad3c9001d26bcdf35999bda476fe Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:06:08 +0800 Subject: [PATCH 08/10] chore: retrigger PR CI checks Co-authored-by: Cursor --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a1da1c115..a1c3cb960 100644 --- a/README.md +++ b/README.md @@ -242,3 +242,6 @@ synthetic and must not be used as clinical guidance. by Git. The smaller `public/demo-documents/` set is tracked because the app uses it for demo-mode source and image rendering when live Supabase setup is unavailable. + + + From e50e0aa39afb3aeb46dd3db8cd2085bf291d2f9f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:08:07 +0800 Subject: [PATCH 09/10] chore(ci): drop temporary drift-regen workflow from R5 branch Drift manifest is refreshed; remove the path-triggered helper workflow so standard pull_request CI runs again on this PR. Co-authored-by: Cursor --- .github/triggers/regen-drift-manifest | 2 - .github/workflows/regen-drift-manifest.yml | 50 ---------------------- README.md | 3 -- 3 files changed, 55 deletions(-) delete mode 100644 .github/triggers/regen-drift-manifest delete mode 100644 .github/workflows/regen-drift-manifest.yml diff --git a/.github/triggers/regen-drift-manifest b/.github/triggers/regen-drift-manifest deleted file mode 100644 index 59499f909..000000000 --- a/.github/triggers/regen-drift-manifest +++ /dev/null @@ -1,2 +0,0 @@ -regen-r5 applied 2026-07-08T16:33:00Z - diff --git a/.github/workflows/regen-drift-manifest.yml b/.github/workflows/regen-drift-manifest.yml deleted file mode 100644 index c8dec0763..000000000 --- a/.github/workflows/regen-drift-manifest.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Regen drift manifest - -# Regenerates supabase/drift-manifest.json when local Docker Desktop / WSL -# cannot start. Triggered by pushing the marker file below (workflow_dispatch -# is also supported once this file exists on the default branch). Uploads the -# manifest as an artifact for the operator to commit — does not push. - -on: - push: - paths: - - ".github/triggers/regen-drift-manifest" - - ".github/workflows/regen-drift-manifest.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: regen-drift-manifest-${{ github.ref }} - cancel-in-progress: true - -jobs: - regen: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version-file: ".nvmrc" - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Regenerate drift manifest - run: npm run drift:manifest - - - name: Upload drift-manifest.json - uses: actions/upload-artifact@v4 - with: - name: drift-manifest - path: supabase/drift-manifest.json - if-no-files-found: error diff --git a/README.md b/README.md index a1c3cb960..a1da1c115 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,3 @@ synthetic and must not be used as clinical guidance. by Git. The smaller `public/demo-documents/` set is tracked because the app uses it for demo-mode source and image rendering when live Supabase setup is unavailable. - - - From b1396c5cc710eb21661b07402af70195f526a885 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:13:25 +0800 Subject: [PATCH 10/10] chore(supabase): refresh drift-manifest after main merge Regenerated from merged schema.sql (R5 metadata merge + R17/R406 main). Co-authored-by: Cursor --- .github/triggers/regen-drift-manifest | 2 -- .github/workflows/regen-drift-manifest.yml | 32 ---------------------- supabase/drift-manifest.json | 14 +++++++--- 3 files changed, 10 insertions(+), 38 deletions(-) delete mode 100644 .github/triggers/regen-drift-manifest delete mode 100644 .github/workflows/regen-drift-manifest.yml diff --git a/.github/triggers/regen-drift-manifest b/.github/triggers/regen-drift-manifest deleted file mode 100644 index 6d8b807f6..000000000 --- a/.github/triggers/regen-drift-manifest +++ /dev/null @@ -1,2 +0,0 @@ -regen-post-merge 2026-07-09T01:11:27.9272841+08:00 - diff --git a/.github/workflows/regen-drift-manifest.yml b/.github/workflows/regen-drift-manifest.yml deleted file mode 100644 index eade0772c..000000000 --- a/.github/workflows/regen-drift-manifest.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Regen drift manifest -on: - push: - paths: - - ".github/triggers/regen-drift-manifest" - - ".github/workflows/regen-drift-manifest.yml" - workflow_dispatch: -permissions: - contents: read -concurrency: - group: regen-drift-manifest-${{ github.ref }} - cancel-in-progress: true -jobs: - regen: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: actions/setup-node@v5 - with: - node-version-file: ".nvmrc" - cache: npm - cache-dependency-path: package-lock.json - - run: npm ci - - run: npm run drift:manifest - - uses: actions/upload-artifact@v4 - with: - name: drift-manifest - path: supabase/drift-manifest.json - if-no-files-found: error diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 4f11c84bc..d1978e577 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-08T16:32:42.312Z", + "generated_at": "2026-07-08T17:13:03.597Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "ddfcafedafe439332ba494b506969c0facadb465d6869b2561de7266f8d3c20f", - "replay_seconds": 30, + "schema_sha256": "c004ebf92005475495394533bed3880f5372f16b90aab24f1a790e4d748d052c", + "replay_seconds": 31, "snapshot": { "views": [ { @@ -4869,6 +4869,12 @@ "table": "ingestion_jobs", "def_hash": "a59d61034f2cb9337c13283574b698d9" }, + { + "def": "CREATE UNIQUE INDEX ingestion_jobs_one_open_per_document_uidx ON public.ingestion_jobs USING btree (document_id) WHERE (status = ANY (ARRAY['pending'::text, 'processing'::text]))", + "name": "ingestion_jobs_one_open_per_document_uidx", + "table": "ingestion_jobs", + "def_hash": "2bf0e17b1b46c2b2b225128eaf6dbf0e" + }, { "def": "CREATE UNIQUE INDEX ingestion_jobs_pkey ON public.ingestion_jobs USING btree (id)", "name": "ingestion_jobs_pkey", @@ -5946,7 +5952,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "1d88b539bded5aa40393125f672b8cab", + "def_hash": "ca2877374851b432c4ca971e1e15f746", "signature": "public.retrieval_owner_matches(uuid,uuid)" }, {