R-WP12 — Opaque-Json RPC inventory (Wave 5 / kh-ast-S9)
R-WP12 — Opaque-Json RPC inventory (Wave 5 / kh-ast-S9)
Section titled “R-WP12 — Opaque-Json RPC inventory (Wave 5 / kh-ast-S9)”Status: DRAFT-S9 (kh-ast-S9 Wave 1 — R-WP19) Source: R-WP12 spec triple §WP-C (type-safety-pipeline/TECH.md lines ~245-283) Companion:
R-WP12-type-safety-pipeline.md§Gap 2
Summary
Section titled “Summary”Of the 14 opaque-Json RPCs in supabase/types/database.types.ts, 5 are convertible to typed RETURNS TABLE(...) signatures with mechanical migrations, 6 require design work before conversion (nested arrays or dynamic-keyed objects that do not map cleanly to a flat TABLE), 2 have no TS callers (candidates for deletion), and 1 must remain as-is (Supabase Auth Hook — protocol constraint).
Total active TS call sites across all 14: 12 (spread across 8 source files).
Recommended migration sprint shape: A focused S10 sprint targeting the 5 convertible functions would eliminate roughly half the cast surface in one pass (~6–8h total). The 6 requires-design functions need per-function design decisions from Liam before any migration is scheduled. The 2 dead functions (get_bid_summary, get_verification_stats) should be deleted to reduce schema noise, costing ~0.5h each.
Method
Section titled “Method”Step 1 — Enumerate
Section titled “Step 1 — Enumerate”All 14 functions identified via:
grep -c 'Returns: Json' supabase/types/database.types.ts # → 14grep -n 'Returns: Json' supabase/types/database.types.tsThe grep -c 'Returns: Json$' form (with $ anchor) returned only 4 due to trailing whitespace
on some lines — use the unanchored form.
Step 2 — TS callers
Section titled “Step 2 — TS callers”Callers found by scanning the full corpus (app/, lib/, hooks/, components/) for
.rpc('<function_name>' patterns using the audit script
(scripts/audit-opaque-json-rpcs.ts). This is equivalent to bun run ast-dataflow string-literal-uses '<function_name>' --scope app/ lib/ and is re-runnable at any time.
Step 3 — PL/pgSQL function bodies
Section titled “Step 3 — PL/pgSQL function bodies”Function bodies read from the live database via pg_proc.prosrc queries (MCP
execute_sql with read-only SELECT — no DDL executed). Migration files crosschecked for
historical definitions.
Step 4 — Convertibility verdict
Section titled “Step 4 — Convertibility verdict”Four tiers per function body:
| Verdict | Definition |
|---|---|
convertible | Fixed scalar output fields; mechanical migration to RETURNS TABLE(...) |
requires-design | Contains nested json_agg arrays or dynamic-keyed objects |
no-ts-callers | Zero TS callers; candidate for deletion |
leave-as-is | JSONB contract is protocol-mandated or genuinely polymorphic |
Inventory
Section titled “Inventory”Function summary table
Section titled “Function summary table”| RPC | Location (database.types.ts) | TS callers | Return shape | Verdict |
|---|---|---|---|---|
get_author_analysis | line 3227 | 1 | Scalars + 4 nested arrays | requires-design |
get_bid_summary | line 3255 | 0 | Scalars + 4 nested arrays | no-ts-callers |
get_content_gaps | line 3263 | 1 | 3 nested arrays (different element shapes) | requires-design |
get_dashboard_attention_counts | line 3311 | 1 | 9 scalars + 1 nested object | convertible |
get_entity_list_aggregated | line 3386 | 1 | Pagination envelope {entities:[...], total:N} | requires-design |
get_filter_counts | line 3419 | 2 | 3 flat jsonb maps (label→count) | convertible |
get_reading_patterns | line 3533 | 1 | Scalars + 3 nested arrays | requires-design |
get_review_breakdown_stats | line 3534 | 1 | Scalars + 4 dynamic-keyed objects | requires-design |
get_topic_deep_dive | line 3586 | 1 | Scalars + 5 nested arrays | requires-design |
get_user_tag_counts | line 3623 | 1 | Flat jsonb map (tag→count) | convertible |
get_verification_stats | line 3624 | 0 | Scalars + 1 nested array | no-ts-callers |
get_workspace_counts | line 3625 | 0 | Flat jsonb map (name→count) | convertible |
hook_restrict_signup_to_allowed_domain | line 3636 | 0 | Auth Hook — platform protocol | leave-as-is |
merge_entities | line 3678 | 1 | 7 scalar fields | convertible |
Per-function detail
Section titled “Per-function detail”get_author_analysis
Section titled “get_author_analysis”- Location:
supabase/types/database.types.ts:3227— migrationsupabase/migrations/20260419095345_restore_stub_functions_from_production.sql - Arguments:
(p_author_name text) - TS callers:
app/api/insights/route.ts:71—const { data, error } = await supabase.rpc('get_author_analysis', { p_author_name: author })— data passed toNextResponse.json({ author: data })without casting
- Current return shape:
json_build_object('author_name', p_author_name,'total_items', (COUNT),'first_item', (MIN captured_date),'latest_item', (MAX captured_date),'avg_confidence', (AVG),'domain_breakdown', json_agg([{domain, count}]),'subtopic_breakdown', json_agg([{subtopic, count}]),'top_keywords', json_agg([{keyword, count}] LIMIT 10),'content_types', json_agg([{type, count}]))
- Convertibility verdict:
requires-design - Reason: Four nested
json_aggarrays prevent a single flatRETURNS TABLE. Top-level scalar fields (5 fields) are individually convertible. - Design path: Split into two RPCs —
get_author_stats(p_author_name)returning the 5 scalars asRETURNS TABLE(...), andget_author_breakdowns(p_author_name)returning a structured array type or keeping breakdown queries client-side. - Risk notes: Single API route caller. Insights page at
/insights?type=authorwould need route-level changes to call the split RPCs.
get_bid_summary
Section titled “get_bid_summary”- Location:
supabase/types/database.types.ts:3255— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:1029 - Arguments:
(bid_workspace_id uuid) - TS callers: none
- Current return shape:
json_build_object('workspace_id', bid_workspace_id,'total_questions', (COUNT),'status_breakdown', json_agg([{status, count}]),'confidence_breakdown', json_agg([{posture, count}]),'responses_count', (COUNT),'review_status_breakdown', json_agg([{status, count}]),'sections', json_agg([{section, question_count, completed}]))
- Convertibility verdict:
no-ts-callers - Recommendation: Candidate for deletion. Confirmed zero callers in
app/,lib/,hooks/,scripts/, and Python pipeline. Verify against Supabase Edge Function logs and direct DB clients before deleting. - Risk notes: The function summarises bid workspace data — its purpose is served by
get_bid_question_stats_batch(which has active callers) and per-question queries. If it was used historically and dropped from callers intentionally, deletion is safe.
get_content_gaps
Section titled “get_content_gaps”- Location:
supabase/types/database.types.ts:3263— migrationsupabase/migrations/20260419095345_restore_stub_functions_from_production.sql - Arguments:
(none) - TS callers:
app/api/insights/route.ts:84—const { data, error } = await supabase.rpc('get_content_gaps')— data passed toNextResponse.json({ gaps: data })without casting
- Current return shape:
json_build_object('sparse_subtopics', json_agg([{domain, subtopic, count, latest}]),'stale_subtopics', json_agg([{domain, subtopic, count, latest, days_since}]),'domain_summary', json_agg([{domain, total_items, subtopic_count, latest, avg_confidence}]))
- Convertibility verdict:
requires-design - Reason: Three sections with different element shapes. No single
RETURNS TABLEfits all three simultaneously. - Design path: Three separate structured RPCs —
get_sparse_subtopics(),get_stale_subtopics(),get_domain_summary()— each returning a typedRETURNS TABLE(...). Enables independent caching and parallel fetching. - Risk notes: Single API route caller. Insights page at
/insights?type=gapswould need route-level changes. Network round trips would increase from 1 to 3 unless combined in a single route handler.
get_dashboard_attention_counts
Section titled “get_dashboard_attention_counts”- Location:
supabase/types/database.types.ts:3311— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql(extended in subsequent migrations) - Arguments:
(p_user_id uuid, p_role text DEFAULT 'viewer') - TS callers:
lib/dashboard.ts:287—supabase.rpc('get_dashboard_attention_counts', { p_user_id: userId, p_role: effectiveRole })— result is manually cast at line 374 to a hand-authored inline type
- Current return shape:
json_build_object('governance_review_count', integer,'unverified_count', integer,'quality_flag_count', integer,'stale_content_count', integer,'expired_content_count', integer,'expiring_content_date_count', integer,'unread_notification_count', integer,'coverage_gap_count', integer,'freshness_summary', {'fresh': integer,'aging': integer,'stale': integer,'expired': integer})
- Convertibility verdict:
convertible - Migration sketch:
After migration: run-- Flatten freshness_summary into 4 separate columnsCREATE OR REPLACE FUNCTION public.get_dashboard_attention_counts(p_user_id uuid,p_role text DEFAULT 'viewer')RETURNS TABLE (governance_review_count integer,unverified_count integer,quality_flag_count integer,stale_content_count integer,expired_content_count integer,expiring_content_date_count integer,unread_notification_count integer,coverage_gap_count integer,freshness_fresh integer,freshness_aging integer,freshness_stale integer,freshness_expired integer)LANGUAGE plpgsqlSECURITY INVOKERSET search_path = public, extensionsAS $$ ... $$;
supabase gen types typescript --project-id rovrymhhffssilaftdwd > supabase/types/database.types.ts. Cast atlib/dashboard.ts:374is removed; caller accessesdata[0].freshness_freshetc. - Cast removal sweep: 1 call site (
lib/dashboard.ts:287). Inline type definition at lines 374-403 removed. Estimated ~10 lines net deletion. - Risk notes: Low. Single caller. Function body is stable (last extended in a numbered migration). The freshness sub-object flattening may require client-side re-composition of the
freshness_summaryobject if other consumers exist — none found in this scan.
get_entity_list_aggregated
Section titled “get_entity_list_aggregated”- Location:
supabase/types/database.types.ts:3386— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:1503 - Arguments:
(p_type text, p_search text, p_variants_only boolean, p_type_conflicts boolean, p_limit integer, p_offset integer) - TS callers:
app/api/entities/route.ts:40—const { data, error } = await supabase.rpc('get_entity_list_aggregated', { ... })— data passed directly asNextResponse.json(data)(line 56); no casting
- Current return shape:
json_build_object('entities', json_agg([{canonical_name, entity_type, mention_count, variant_count,variant_names (array), relationship_count,has_type_conflict, types_seen (array)}] ORDER BY mention_count DESC),'total', (COUNT from total_count CTE))
- Convertibility verdict:
requires-design - Reason: The
{ entities: [...], total: N }pagination envelope is a deliberate design pattern — data and page count returned in one round trip.RETURNS TABLEalone cannot model this without splitting into two RPCs, which would require client-side changes to theGET /api/entitieshandler and its consumers. Thetypes_seenandvariant_namesarray columns would also require composite type definitions. - Design options:
- Split RPCs:
get_entity_list(...)returningRETURNS TABLE(...)for the rows,get_entity_count(...)returninginteger. Doubles network round trips unless coalesced in the route handler. - Composite type: Define a
entity_list_resultcomposite type in Postgres and returnRETURNS entity_list_result. More structured but still opaque todatabase.types.tsgeneration. - Accept JSONB columns: Keep the
RETURNS TABLE(entity_data jsonb, total integer)pattern — partially typed (total is exact) but entities array remains jsonb. Minimal benefit.
- Split RPCs:
- Risk notes: The existing
app/api/entities/route.tspasses data directly to the client — any conversion affects the client-side API shape.
get_filter_counts
Section titled “get_filter_counts”- Location:
supabase/types/database.types.ts:3419— migrationsupabase/migrations/20260501173008_widen_get_filter_counts_publication_status.sql - Arguments:
(none) - TS callers:
hooks/browse/use-filter-data.ts:62—const { data, error } = await getSupabase().rpc('get_filter_counts')— result validated viaparseJsonb(FilterCountsSchema, data)(Zod)hooks/browse/use-top-domains.ts:51—const { data, error } = await getSupabase().rpc('get_filter_counts')— result validated viaparseJsonb(FilterCountsSchema, data)(Zod)
- Current return shape:
jsonb_build_object('domain', jsonb_object_agg(primary_domain, count),'content_type', jsonb_object_agg(content_type, count),'platform', jsonb_object_agg(platform, count))
- Convertibility verdict:
convertible - Migration sketch:
After migration: callers receive a single-row TABLE result; access viaCREATE OR REPLACE FUNCTION public.get_filter_counts()RETURNS TABLE (domain jsonb, -- e.g. {"Technology": 42, "Finance": 17}content_type jsonb,platform jsonb)LANGUAGE plpgsqlSECURITY INVOKERSET search_path = public, extensionsAS $$ ... $$;
data[0].domain(wasdata.domain). Both callers already useparseJsonb(FilterCountsSchema, data)— the Zod call site would change fromparseJsonb(schema, data)toparseJsonb(schema, data[0]). - Cast removal sweep: 2 call sites. Both already Zod-validated — cast removal is replacing
datawithdata[0]in theparseJsonbcall. Estimated ~2 lines changed. - Risk notes: Lower urgency than
get_dashboard_attention_counts— Zod already provides runtime type safety at both call sites. Thejsonbcolumn types are retained (the maps remain JSONB objects); the benefit is typed access to the three top-level keys rather than opaqueJson.
get_reading_patterns
Section titled “get_reading_patterns”- Location:
supabase/types/database.types.ts:3533— migrationsupabase/migrations/20260419095633_restore_stub_analytics_functions.sql - Arguments:
(p_days integer DEFAULT 30) - TS callers:
app/api/insights/route.ts:95—const { data, error } = await supabase.rpc('get_reading_patterns', { p_days: days })— data passed toNextResponse.json({ reading: data })without casting
- Current return shape:
json_build_object('period_days', p_days,'total_items', (COUNT),'items_read', (COUNT),'reading_velocity', (ROUND(items/days)),'domain_reading', json_agg([{domain, total, read, read_pct}]),'type_reading', json_agg([{type, total, read}]),'reading_timeline', json_agg([{date, count}]))
- Convertibility verdict:
requires-design - Reason: 4 scalar fields + 3 nested arrays. Same pattern as
get_author_analysis. Scalars are convertible; arrays are not without composite types or split RPCs. - Design path: Same as
get_author_analysis— split scalars from breakdowns, or accept composite array types. Network efficiency consideration applies since the insights page fetches this in a single round trip. - Risk notes: Single API route caller. Low urgency — route passes data through without client-side casting.
get_review_breakdown_stats
Section titled “get_review_breakdown_stats”- Location:
supabase/types/database.types.ts:3534— migrationsupabase/migrations/20260427230503_extend_review_breakdown_overdue.sql - Arguments:
(none) - TS callers:
app/api/review/stats/route.ts:45—supabase.rpc('get_review_breakdown_stats')— result explicitly cast at line 76:statsResult.data as Omit<ReviewStatsResponse, 'unverified' | 'awaiting_publication'> & { total: number; verified: number }
- Current return shape:
json_build_object('total', (COUNT),'verified', (COUNT),'flagged', (COUNT),'draft', (COUNT),'overdue', (COUNT),'by_domain', json_object_agg(domain_name, {total, verified}),'by_content_type', json_object_agg(ct, {total, verified}),'by_source_file', json_object_agg(sf, {total, verified}),'by_source_document', json_object_agg(doc_id, {total, verified, name}))
- Convertibility verdict:
requires-design - Reason: Five scalar fields are convertible. Four breakdown objects use
json_object_aggwith runtime-dynamic keys (domain names, content types, source file paths, document IDs) — these cannot be modelled asRETURNS TABLEcolumns. JSONB is the correct type for dynamic-keyed objects. - Design path (partial conversion): Highest-value option is a hybrid signature: scalar fields as typed
RETURNS TABLEcolumns, breakdown objects asjsonbcolumns.This would allow the cast atRETURNS TABLE (total integer,verified integer,flagged integer,draft integer,overdue integer,by_domain jsonb,by_content_type jsonb,by_source_file jsonb,by_source_document jsonb)app/api/review/stats/route.ts:76to be reduced —totalandverifiedwould be typed directly, removing the& { total: number; verified: number }extension. The four JSONB breakdown columns remain opaque but their presence is now compiler-verified. - Risk notes: The
ReviewStatsResponsetype intypes/review.tswould need updating after regeneratingdatabase.types.ts. Single caller. Medium complexity.
get_topic_deep_dive
Section titled “get_topic_deep_dive”- Location:
supabase/types/database.types.ts:3586— migrationsupabase/migrations/20260419095633_restore_stub_analytics_functions.sql - Arguments:
(p_keyword text) - TS callers:
app/api/insights/route.ts:52—const { data, error } = await supabase.rpc('get_topic_deep_dive', { p_keyword: keyword })— data passed toNextResponse.json({ topic: data })without casting
- Current return shape:
json_build_object('keyword', p_keyword,'total_items', (COUNT),'domain_distribution', json_agg([{domain, count}]),'top_authors', json_agg([{author, count}] LIMIT 10),'timeline', json_agg([{month, count}] LIMIT 12),'co_occurring_keywords', json_agg([{keyword, count}] LIMIT 15),'recent_items', json_agg([{...item fields}]))
- Convertibility verdict:
requires-design - Reason: 2 scalar fields + 5 nested arrays with different element shapes. Same pattern as
get_author_analysisandget_reading_patterns. - Design path: Same split-RPC approach as the other insights analytics functions.
- Risk notes: Single API route caller. Route passes data through without casting — low type-safety risk currently.
get_user_tag_counts
Section titled “get_user_tag_counts”- Location:
supabase/types/database.types.ts:3623— migrationsupabase/migrations/20260419095633_restore_stub_analytics_functions.sql - Arguments:
(none) - TS callers:
hooks/browse/use-filter-data.ts:125—const { data } = await getSupabase().rpc('get_user_tag_counts')— result cast asdata as Record<string, number>at line 127 then decomposed viaObject.entries(tagCounts)
- Current return shape:
Returns a flat JSONB object:SELECT COALESCE(jsonb_object_agg(tag, cnt), '{}'::jsonb)FROM (SELECT tag, COUNT(*) as cnt FROM content_items ci, unnest(ci.user_tags) AS tagWHERE user_tags IS NOT NULL AND user_tags != '{}' GROUP BY tag ORDER BY cnt DESC) sub;
{ "procurement": 41, "AI": 28, ... }. - Convertibility verdict:
convertible - Migration sketch:
After migration:CREATE OR REPLACE FUNCTION public.get_user_tag_counts()RETURNS TABLE (tag text, cnt bigint)LANGUAGE sqlSTABLESECURITY INVOKERSET search_path = public, extensionsAS $$SELECT tag, COUNT(*) AS cntFROM content_items ci, unnest(ci.user_tags) AS tagWHERE user_tags IS NOT NULL AND user_tags != '{}'GROUP BY tagORDER BY cnt DESC;$$;
datais a typed row array. The cast at line 127 (data as Record<string, number>) is removed. TheObject.entries(tagCounts)decomposition becomesdata.map(row => ({ tag: row.tag, count: Number(row.cnt) })). - Cast removal sweep: 1 call site. ~3 lines changed. Zero risk.
- Risk notes: Simplest conversion in the set. Single caller, no nested structures, existing logic is entirely straightforward. Recommended as the first conversion to validate the migration + gen types + cast removal workflow.
get_verification_stats
Section titled “get_verification_stats”- Location:
supabase/types/database.types.ts:3624— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:2455 - Arguments:
(none) - TS callers: none
- Current return shape:
json_build_object('total', (COUNT),'verified', (COUNT),'unverified', (COUNT),'recent_7d', (COUNT),'domains', json_agg([{domain, count}]))
- Convertibility verdict:
no-ts-callers - Recommendation: Candidate for deletion. Similar purpose to
get_review_breakdown_stats, which has an active caller and richer output. Verify via Supabase Edge Function logs or direct DB access logs before deleting. Zero sweep cost. - Risk notes: If deleted and later found to be used by a DB-level trigger or another Postgres function, the impact is low — function can be re-created from the migration history.
get_workspace_counts
Section titled “get_workspace_counts”- Location:
supabase/types/database.types.ts:3625— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:2479 - Arguments:
(none) - TS callers: none
- Current return shape:
Returns a flat JSONB object:SELECT COALESCE(jsonb_object_agg(name, cnt), '{}'::jsonb)FROM (SELECT w.name, COUNT(*) as cntFROM content_item_workspaces ciwJOIN workspaces w ON w.id = ciw.workspace_idWHERE w.is_archived = falseGROUP BY w.name ORDER BY cnt DESC) sub;
{ "Bid Management": 312, "Finance Sector": 186, ... }. - Convertibility verdict:
convertible - Migration sketch:
No active TS callers — zero sweep cost. Recommended to establish the correct pattern before any future caller is added.CREATE OR REPLACE FUNCTION public.get_workspace_counts()RETURNS TABLE (workspace_name text, item_count bigint)LANGUAGE sqlSTABLESECURITY INVOKERSET search_path = public, extensionsAS $$SELECT w.name AS workspace_name, COUNT(*) AS item_countFROM content_item_workspaces ciwJOIN workspaces w ON w.id = ciw.workspace_idWHERE w.is_archived = falseGROUP BY w.nameORDER BY item_count DESC;$$;
- Risk notes: Zero TS callers means zero breaking changes. Convert alongside
get_user_tag_countsfor efficiency.
hook_restrict_signup_to_allowed_domain
Section titled “hook_restrict_signup_to_allowed_domain”- Location:
supabase/types/database.types.ts:3636— migrationsupabase/migrations/20260424202806_capture_signup_domain_hook.sql - Arguments:
(event jsonb) - TS callers: none (by design)
- Current return shape:
-- Success (domain = 'client.example'):RETURN '{}'::jsonb;-- Rejection:RETURN jsonb_build_object('error', jsonb_build_object('message', 'Please sign up with your @client.example email address.','http_code', 403));
- Convertibility verdict:
leave-as-is - Reason: This function is a Supabase Auth Hook, registered via the
pg-functions://postgres/public/hook_restrict_signup_to_allowed_domainURI in the Supabase project settings (seedocs/audits/kh-production-readiness-phase-1/research/09-vercel-supabase-env-mapping-spec.md:724). The JSONB input/output contract is defined by the Supabase Auth Hook protocol — the platform calls this function with a structuredeventjsonb and expects a jsonb response. Converting toRETURNS TABLEwould break the hook registration. Zero TS callers is expected and correct. - Risk notes: Do not convert. Do not add TS callers. The function is platform-invoked infrastructure.
merge_entities
Section titled “merge_entities”- Location:
supabase/types/database.types.ts:3678— migrationsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:2617 - Arguments:
(p_source_names text[], p_target_name text, p_entity_type text) - TS callers:
app/api/entities/merge/route.ts:48—const { data, error } = await serviceClient.rpc('merge_entities', { ... })— result explicitly cast at line 62 to a hand-authored inline type
- Current return shape:
jsonb_build_object('merged', true,'target', p_target_name,'entity_type', p_entity_type,'mentions_updated', v_mentions_updated,'relationship_sources_updated', v_rel_sources_updated,'relationship_targets_updated', v_rel_targets_updated,'duplicates_removed', v_duplicates_removed)
- Convertibility verdict:
convertible - Migration sketch:
After migration:CREATE OR REPLACE FUNCTION public.merge_entities(p_source_names text[],p_target_name text,p_entity_type text)RETURNS TABLE (merged boolean,target text,entity_type text,mentions_updated integer,relationship_sources_updated integer,relationship_targets_updated integer,duplicates_removed integer)LANGUAGE plpgsqlSECURITY INVOKERSET search_path = public, extensionsAS $$ ... $$;
data[0]gives the typed result. The cast atapp/api/entities/merge/route.ts:62is removed entirely. - Cast removal sweep: 1 call site (~8 lines of inline type + cast removed). After
supabase gen types typescriptregeneration, the column types are compiler-enforced. - Risk notes: Function is
VOLATILE(performs DML: UPDATE, DELETE in a single transaction). Mark asLANGUAGE plpgsql SECURITY INVOKER— notSTABLE. Single caller via service client. Integration test recommended after migration (confirm row counts propagate correctly in the typed result).
Recommendations
Section titled “Recommendations”Migration sprint scope (OQ-R9 input)
Section titled “Migration sprint scope (OQ-R9 input)”| Tier | Functions | Effort estimate | Value |
|---|---|---|---|
| Tier 1 — Do now (convertible, single caller) | get_user_tag_counts, get_workspace_counts, merge_entities | ~3h total (1h per function: migration + gen types + cast sweep + test) | Removes 3 casts, validates the migration pattern |
| Tier 2 — Do next (convertible, multi-caller or nested object) | get_dashboard_attention_counts, get_filter_counts | ~4h total | Removes the dashboard inline type definition (~30 lines); Zod call sites simplified |
| Tier 3 — Design first (requires-design) | get_review_breakdown_stats (partial conversion recommended — scalars to typed columns) | ~3h for partial + 1h design | Narrows the cast at review/stats/route.ts:76 |
| Tier 3 — Design first (remaining requires-design) | get_author_analysis, get_content_gaps, get_reading_patterns, get_topic_deep_dive, get_entity_list_aggregated | ~2h design per function + ~4h implementation per split | Major sprint; requires Liam input on split-RPC vs composite type approach |
| Tier 4 — Housekeeping | get_bid_summary, get_verification_stats (delete) | ~0.5h each | Reduces schema noise |
If you migrate only Tier 1+2 (the 5 convertible functions): ~7h total. Eliminates the as Record<string, unknown> cast in use-filter-data.ts, the inline type definition in lib/dashboard.ts, and the three simple casts. All 12 active TS caller files receive typed access to the columns. This is the recommended starting scope.
If you also tackle get_review_breakdown_stats partial conversion (Tier 3 first function): ~10h total. Adds typed scalar columns to the review stats RPC, which is the highest-traffic API endpoint in the review workflow.
If you tackle all requires-design functions: ~30h total (design + implementation + client changes). Multi-sprint effort; recommend spreading across S10–S12 with Liam review at each stage.
Recommended start: get_user_tag_counts
Section titled “Recommended start: get_user_tag_counts”This is the simplest function to convert end-to-end:
- Single caller, single cast removed
- Body converts trivially from
jsonb_object_aggto aSELECT ... GROUP BYtable return - Low risk (no DML, no auth role dependency)
- Validates the full pipeline: migration →
supabase gen types→ cast removal → test
Use it as a proof-of-concept to confirm the gen types regeneration workflow is stable before tackling the higher-value functions.
Decision gate (R-WP21 / S10 follow-up)
Section titled “Decision gate (R-WP21 / S10 follow-up)”The key question for Liam at S10 planning:
Is the “requires-design” category worth a split-RPC approach, or should the insights analytics functions (
get_author_analysis,get_content_gaps,get_reading_patterns,get_topic_deep_dive) accept typed scalars + opaque JSONB arrays?
The partial conversion option (scalars typed, arrays remain JSONB) is mechanically achievable without any client-side changes. It would narrow the type gap for the scalar fields (total counts, period parameters) while accepting continued JSONB opacity for the breakdown arrays. This may be the right pragmatic call given the insight functions are read-only, low-volume analytics endpoints with no current type-safety incidents.
Out of scope
Section titled “Out of scope”- Migration execution. All migration sketches above are recommendations only — none have been run.
- Cast-removal sweep at call sites (that is R-WP18 territory, after R-WP17 ships the drift report).
- Python pipeline opaque-Json equivalents. The Python pipeline (
scripts/kb_pipeline/) makes Supabase RPC calls directly via the Pythonsupabaseclient — those call sites are a separate concern and not inventoried here. database.types.tsCI automation. Addingsupabase gen typesto CI on migration merges is aproduction-readinesstrack item (flagged in R-WP12 §Gap 2 OQ 6).
Open questions
Section titled “Open questions”| OQ | Question | For |
|---|---|---|
| OQ-1 | Are get_bid_summary and get_verification_stats safe to delete? Confirm via production Supabase function-call metrics or Edge Function logs. | Liam / S10 |
| OQ-2 | For the requires-design analytics functions (get_author_analysis, get_content_gaps, get_reading_patterns, get_topic_deep_dive): prefer split-RPC, partial conversion (scalars typed / arrays JSONB), or accept as-is? | Liam |
| OQ-3 | For get_entity_list_aggregated: the { entities: [...], total: N } pagination envelope is a deliberate design. Is a two-RPC split (rows + count) acceptable, or should the envelope be kept and the function left as requires-design? | Liam |
| OQ-4 | Is get_review_breakdown_stats partial conversion (scalars typed, breakdown objects remain JSONB columns) the right call? Or is the dynamic-keyed breakdown structure worth resolving fully? | Liam |
| OQ-5 | Tier 1+2 sprint (5 convertible functions, ~7h): schedule in S10 or defer to S11? | Liam |
Appendix — audit script
Section titled “Appendix — audit script”The machine-readable backing for this brief is scripts/audit-opaque-json-rpcs.ts.
# Re-run to verify inventory (deterministic — reads database.types.ts + corpus)bun scripts/audit-opaque-json-rpcs.ts
# Verdict summarybun scripts/audit-opaque-json-rpcs.ts 2>&1 | grep -A5 'Audit summary'
# Per-function TS caller countsbun scripts/audit-opaque-json-rpcs.ts 2>/dev/null | jq -r '[.function_name, (.ts_callers | length | tostring)] | join(": ")'Authored kh-ast-S9 Wave 1 (R-WP19). Investigation only — no migrations executed.
Redaction note (04/08/2026, id-377 D7). Client-name tokens in this file were redacted per
the ID-115 redaction map (specs/id-115-data-api-schema-isolation/CUTOVER-RUNBOOK.md step 3;
full rule set in runbooks/_archive/id68-purge-redaction-map-draft.md). Six occurrences
in the auth-hook entry: the function identifier (inventory row, §heading and the
pg-functions:// URI) now reads hook_restrict_signup_to_allowed_domain — the
client-neutral name that superseded it at canonical HEAD (ID-68 {68.21}); the migration
filename follows map rule R5 (capture_signup_domain_hook.sql); and the two domain literals
in the illustrative return-shape block — including the user-facing rejection message — follow
rule R3 (client.example). The 20260424202806 version prefix, the database.types.ts:3636
line reference and the leave-as-is verdict are unchanged. No other content was changed by
the redaction pass.