257 lines
12 KiB
PL/PgSQL
257 lines
12 KiB
PL/PgSQL
-- Add the new AI-SDK-shaped columns to messages. `parts` is a discriminated
|
|
-- union of AI SDK UI parts (`{type:'text',...}`, `{type:'tool-...'}`, etc.);
|
|
-- `metadata` carries per-message bookkeeping like the `model` that generated
|
|
-- the response.
|
|
--
|
|
-- The migration uses a CREATE-TABLE-and-swap pattern instead of an in-place
|
|
-- UPDATE. Bench on 745k rows of prod data: the compute (CASE + jsonb_build)
|
|
-- takes 1.3s but rewriting every row in place takes 80s+ because of MVCC,
|
|
-- per-row WAL, and TOAST writes on the 968 MB of artifact code being copied
|
|
-- into `parts.input`. A bulk INSERT into a fresh table writes page-level
|
|
-- WAL, has no dead tuples to clean up, and builds the indexes once at the
|
|
-- end — total runtime drops well under a minute.
|
|
|
|
-- Tune for bulk work. Plain SET (not SET LOCAL) so this file works whether
|
|
-- run inside a transaction (supabase db push wraps it implicitly) or in
|
|
-- autocommit mode (psql on the direct connection); both forms scope to the
|
|
-- current session and don't leak.
|
|
SET statement_timeout = 0;
|
|
SET maintenance_work_mem = '512MB';
|
|
SET max_parallel_maintenance_workers = 4;
|
|
|
|
-- ============================================================================
|
|
-- 1. Build a new messages table with the parts/metadata columns pre-populated.
|
|
-- No indexes, constraints, or policies yet — those slow down bulk INSERT.
|
|
-- ============================================================================
|
|
CREATE TABLE public.messages_new AS
|
|
SELECT
|
|
m.id,
|
|
m.created_at,
|
|
m.conversation_id,
|
|
m.role,
|
|
m.content,
|
|
m.parent_message_id,
|
|
m.rating,
|
|
CASE
|
|
WHEN m.content IS NULL OR jsonb_typeof(m.content) <> 'object' THEN '[]'::jsonb
|
|
WHEN m.role = 'user' THEN
|
|
-- text part
|
|
(CASE WHEN COALESCE(m.content->>'text','') <> ''
|
|
THEN jsonb_build_array(jsonb_build_object('type','text','text', m.content->>'text'))
|
|
ELSE '[]'::jsonb END)
|
|
-- file parts from images. Storage path is `${user}/${conv}/${imageId}`
|
|
-- with no extension; .png suffix is just a display label.
|
|
|| (CASE WHEN jsonb_typeof(m.content->'images') = 'array'
|
|
THEN COALESCE((
|
|
SELECT jsonb_agg(jsonb_build_object(
|
|
'type','file',
|
|
'mediaType','image/png',
|
|
'filename', img || '.png',
|
|
'url', '/storage/v1/object/public/images/' || c.user_id::text || '/' || c.id::text || '/' || img
|
|
))
|
|
FROM jsonb_array_elements_text(m.content->'images') AS img
|
|
), '[]'::jsonb)
|
|
ELSE '[]'::jsonb END)
|
|
-- data-mesh-context
|
|
|| (CASE WHEN jsonb_typeof(m.content->'mesh') = 'object'
|
|
AND (m.content->'mesh'->>'id') IS NOT NULL
|
|
THEN jsonb_build_array(jsonb_build_object(
|
|
'type','data-mesh-context',
|
|
'data', jsonb_strip_nulls(jsonb_build_object(
|
|
'meshId', m.content->'mesh'->>'id',
|
|
'fileType', COALESCE(m.content->'mesh'->>'fileType','glb'),
|
|
'filename', m.content->>'meshFilename',
|
|
'boundingBox', m.content->'meshBoundingBox'
|
|
))
|
|
))
|
|
ELSE '[]'::jsonb END)
|
|
-- data-mesh-preferences
|
|
|| (CASE WHEN (m.content ? 'meshTopology') OR (m.content ? 'polygonCount')
|
|
THEN jsonb_build_array(jsonb_build_object(
|
|
'type','data-mesh-preferences',
|
|
'data', jsonb_build_object(
|
|
'topology', COALESCE(m.content->>'meshTopology','polys'),
|
|
'polygonCount', COALESCE(NULLIF(m.content->>'polygonCount','')::int, 100000)
|
|
)
|
|
))
|
|
ELSE '[]'::jsonb END)
|
|
WHEN m.role = 'assistant' THEN
|
|
-- text part (assistant text lands with state:'done')
|
|
(CASE WHEN COALESCE(m.content->>'text','') <> ''
|
|
THEN jsonb_build_array(jsonb_build_object(
|
|
'type','text','text', m.content->>'text','state','done'))
|
|
ELSE '[]'::jsonb END)
|
|
-- tool-build_parametric_model. The legacy toolCalls array was the
|
|
-- source of the original tool call id, but ~98% of assistant rows have
|
|
-- toolCalls=[], so we synthesize a deterministic id keyed on the
|
|
-- message uuid instead. Re-runs are byte-identical for the same row.
|
|
-- The artifact object is referenced directly rather than rebuilt
|
|
-- field-by-field — same data, no re-serialization cost.
|
|
|| (CASE WHEN jsonb_typeof(m.content->'artifact') = 'object'
|
|
THEN jsonb_build_array(jsonb_build_object(
|
|
'type','tool-build_parametric_model',
|
|
'toolCallId', 'tool_legacy_bp_' || m.id::text,
|
|
'state','output-available',
|
|
'input', m.content->'artifact',
|
|
'output', jsonb_build_object('status','success','message','')
|
|
))
|
|
ELSE '[]'::jsonb END)
|
|
-- tool-create_mesh
|
|
|| (CASE WHEN jsonb_typeof(m.content->'mesh') = 'object'
|
|
AND (m.content->'mesh'->>'id') IS NOT NULL
|
|
THEN jsonb_build_array(jsonb_build_object(
|
|
'type','tool-create_mesh',
|
|
'toolCallId', m.content->'mesh'->>'id',
|
|
'state','output-available',
|
|
'input', '{}'::jsonb,
|
|
'output', jsonb_build_object(
|
|
'id', m.content->'mesh'->>'id',
|
|
'fileType', COALESCE(m.content->'mesh'->>'fileType','glb')
|
|
)
|
|
))
|
|
ELSE '[]'::jsonb END)
|
|
-- assistant-side file parts (legacy create_image tool outputs)
|
|
|| (CASE WHEN jsonb_typeof(m.content->'images') = 'array'
|
|
THEN COALESCE((
|
|
SELECT jsonb_agg(jsonb_build_object(
|
|
'type','file',
|
|
'mediaType','image/png',
|
|
'filename', img || '.png',
|
|
'url', '/storage/v1/object/public/images/' || c.user_id::text || '/' || c.id::text || '/' || img
|
|
))
|
|
FROM jsonb_array_elements_text(m.content->'images') AS img
|
|
), '[]'::jsonb)
|
|
ELSE '[]'::jsonb END)
|
|
ELSE '[]'::jsonb
|
|
END AS parts,
|
|
jsonb_strip_nulls(jsonb_build_object('model', m.content->>'model')) AS metadata
|
|
FROM public.messages m
|
|
LEFT JOIN public.conversations c ON c.id = m.conversation_id;
|
|
|
|
-- ============================================================================
|
|
-- 2. Restore the column constraints CTAS strips out. `content` becomes
|
|
-- nullable (new code writes parts only); `parts` and `metadata` get the
|
|
-- same defaults as the original ADD COLUMN form.
|
|
-- ============================================================================
|
|
ALTER TABLE public.messages_new
|
|
ALTER COLUMN id SET DEFAULT gen_random_uuid(),
|
|
ALTER COLUMN id SET NOT NULL,
|
|
ALTER COLUMN created_at SET DEFAULT now(),
|
|
ALTER COLUMN created_at SET NOT NULL,
|
|
ALTER COLUMN conversation_id SET NOT NULL,
|
|
ALTER COLUMN role SET NOT NULL,
|
|
ALTER COLUMN rating SET DEFAULT 0::smallint,
|
|
ALTER COLUMN rating SET NOT NULL,
|
|
ALTER COLUMN parts SET DEFAULT '[]'::jsonb,
|
|
ALTER COLUMN parts SET NOT NULL,
|
|
ALTER COLUMN metadata SET DEFAULT '{}'::jsonb,
|
|
ALTER COLUMN metadata SET NOT NULL;
|
|
|
|
-- ============================================================================
|
|
-- 3. Indexes. Built once at the end of the bulk load — far faster than
|
|
-- maintaining them per-row during INSERT.
|
|
-- ============================================================================
|
|
ALTER TABLE public.messages_new
|
|
ADD CONSTRAINT messages_new_pkey PRIMARY KEY (id);
|
|
|
|
CREATE INDEX messages_new_conversation_id_idx
|
|
ON public.messages_new USING btree (conversation_id);
|
|
|
|
-- ============================================================================
|
|
-- 4. Constraints. The role check matches the original schema. The
|
|
-- payload-present check is new and added NOT VALID to skip the full
|
|
-- table scan — every row trivially satisfies it (content was NOT NULL
|
|
-- in the legacy schema and every row carries either parts or content).
|
|
-- ============================================================================
|
|
ALTER TABLE public.messages_new
|
|
ADD CONSTRAINT messages_role_check
|
|
CHECK (role = ANY (ARRAY['user'::text, 'assistant'::text])) NOT VALID;
|
|
|
|
ALTER TABLE public.messages_new
|
|
ADD CONSTRAINT messages_payload_present
|
|
CHECK (jsonb_array_length(parts) > 0 OR content IS NOT NULL) NOT VALID;
|
|
|
|
ALTER TABLE public.messages_new
|
|
ADD CONSTRAINT messages_new_conversation_id_fkey
|
|
FOREIGN KEY (conversation_id) REFERENCES public.conversations(id)
|
|
ON DELETE CASCADE NOT VALID;
|
|
|
|
-- ============================================================================
|
|
-- 5. RLS — same policies as the legacy table.
|
|
-- ============================================================================
|
|
ALTER TABLE public.messages_new ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "Public conversations messages"
|
|
ON public.messages_new
|
|
FOR SELECT
|
|
TO anon, authenticated
|
|
USING (EXISTS (
|
|
SELECT 1 FROM public.conversations
|
|
WHERE conversations.id = messages_new.conversation_id
|
|
AND conversations.privacy = 'public'::privacy_type
|
|
));
|
|
|
|
CREATE POLICY "Users can manage messages in their conversations"
|
|
ON public.messages_new
|
|
USING ((SELECT auth.uid() AS uid) IN (
|
|
SELECT conversations.user_id FROM public.conversations
|
|
WHERE conversations.id = messages_new.conversation_id
|
|
));
|
|
|
|
-- ============================================================================
|
|
-- 6. Grants — mirror the legacy table's permissions for anon/authenticated/
|
|
-- service_role/postgres.
|
|
-- ============================================================================
|
|
GRANT ALL ON TABLE public.messages_new TO anon;
|
|
GRANT ALL ON TABLE public.messages_new TO authenticated;
|
|
GRANT ALL ON TABLE public.messages_new TO service_role;
|
|
GRANT ALL ON TABLE public.messages_new TO postgres;
|
|
|
|
-- ============================================================================
|
|
-- 7. Trigger — keep conversations.current_message_leaf_id pointed at the
|
|
-- latest inserted message (function is unchanged, defined in the initial
|
|
-- schema migration).
|
|
-- ============================================================================
|
|
CREATE TRIGGER update_leaf_trigger
|
|
AFTER INSERT ON public.messages_new
|
|
FOR EACH ROW EXECUTE FUNCTION public.update_conversation_leaf();
|
|
|
|
-- ============================================================================
|
|
-- 8. Atomic swap. Drop the old table, rename the new one into its place,
|
|
-- rename the indexes/constraints so future migrations and ORMs see the
|
|
-- canonical names. This whole step holds an ACCESS EXCLUSIVE lock on
|
|
-- `messages` for milliseconds.
|
|
-- ============================================================================
|
|
DROP TABLE public.messages;
|
|
|
|
ALTER TABLE public.messages_new RENAME TO messages;
|
|
ALTER INDEX public.messages_new_pkey RENAME TO messages_pkey;
|
|
ALTER INDEX public.messages_new_conversation_id_idx RENAME TO messages_conversation_id_idx;
|
|
ALTER TABLE public.messages RENAME CONSTRAINT messages_new_conversation_id_fkey TO messages_conversation_id_fkey;
|
|
|
|
-- ============================================================================
|
|
-- 9. The legacy `conversations.legacy` column is no longer needed.
|
|
-- ============================================================================
|
|
ALTER TABLE public.conversations DROP COLUMN IF EXISTS legacy;
|
|
|
|
-- ============================================================================
|
|
-- 10. Atomic write for `conversations.settings.suggestions`. The chat server
|
|
-- previously read settings, merged the new suggestions array client-side,
|
|
-- and wrote the whole object back — which under concurrent assistant
|
|
-- turns could silently drop the other update. `jsonb_set` runs in one
|
|
-- statement so only the `suggestions` key is touched.
|
|
-- ============================================================================
|
|
CREATE OR REPLACE FUNCTION public.set_conversation_suggestions(
|
|
p_conversation_id uuid,
|
|
p_suggestions jsonb
|
|
) RETURNS void LANGUAGE sql VOLATILE SECURITY INVOKER AS $$
|
|
UPDATE public.conversations
|
|
SET settings = jsonb_set(
|
|
COALESCE(settings, '{}'::jsonb),
|
|
'{suggestions}',
|
|
p_suggestions,
|
|
true
|
|
)
|
|
WHERE id = p_conversation_id;
|
|
$$;
|