Optimize Send Message Latency
Context
The send-message endpoint (functions/v1/api/v1/messages) is slow. Tracing the handler shows the
dominant cost is synchronous moderation against OpenAI, which blocks the message INSERT and the
201 response:
Critical path today (supabase/functions/api/index.ts:2350-2451):
Promise.all([isConversationMember, loadModerationConfig])— ~1 DB roundtrip (config cached 60s).moderateText— blocks onomni-moderation-latest(~200-500ms) and, for suspicious text, agpt-4o-minicontact-evasion call (~500ms-1.5s).moderation.ts:148-258, 285-350.resolveSendExtras—validateMentionsthenvalidateReplyTargetrun sequentially (up to 3 DB roundtrips that don’t depend on each other).messages.ts:356-407, 238-297.- INSERT message.
endUserActorLabel— a DB roundtrip that blocks the response even though it only feeds the fire-and-forget audit log (index.ts:2439).
Push and audit are already deferred (void / EdgeRuntime.waitUntil).
Decision (from the user): move moderation off the response path (optimistic insert + async moderate), plus apply the safe structural overlaps, across the text path, attachment path, and the PATCH edit handler.
Key architectural fact that shapes the design: recipient delivery is broadcast-driven, not
RLS/postgres_changes-driven. The sender’s client broadcasts MESSAGE_NEW_EVENT after it receives
the 201 (useMessages.ts:749), and recipients render from that broadcast. History/reconnect uses an
RLS-filtered SELECT. So to insert optimistically without leaking unmoderated content to recipients we
must (a) gate the broadcast on moderation passing, and (b) hide non-approved messages from non-senders
in the RLS SELECT policy (history path). The server already has a broadcast helper
(broadcastMessageNew, system.ts:101) used for system messages — reuse it.
Intended outcome: the 201 returns in ~tens of ms (membership + cheap validation + INSERT only); moderation, broadcast, and audit happen in the background; recipients still never see unmoderated content; the sender sees their own message immediately as “sending” and it either confirms or is retracted with a reason.
Approach
1. Migration — moderation status + RLS (new supabase/migrations/<ts>_messages_moderation_status.sql)
- Add to
public.messages:moderation_status text not null default 'approved' check (moderation_status in ('pending','approved','rejected'))moderation_reason text,moderation_detail text- Default
'approved'keeps all existing rows and non-moderated sends visible with no backfill.
- Replace the SELECT policy
messages_select_same_tenant(20260608130000_phase2_messaging.sql:145-150) so non-senders only see approved messages:using (tenant_id = ((auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid)and deleted_at is nulland (moderation_status = 'approved' or sender_id = auth.uid())) - No realtime-publication or
replica identitychange needed (delivery is via broadcast, and the Edge Function updates status with the service-role client, which bypasses RLS). - Then run
pnpm db:typesto regeneratepackages/contracts/src/database.types.ts.
2. Contracts (packages/contracts/src/schemas.ts)
- Add
messageModerationStatusSchema = z.enum(["pending","approved","rejected"])(export inferredMessageModerationStatus). - Add
moderationStatus: messageModerationStatusSchematomessageResponseSchema(line 242). - Add a
messageRejectedEventSchema({ messageId, localId, reason?, detail? }) for the new rejection broadcast payload. Export fromsrc/index.ts, thenpnpm --filter @tesark/contracts build.
3. Edge Function (supabase/functions/api)
index.ts — formatting & shared helper
MESSAGE_SELECT(line 689): addmoderation_status, moderation_reason, moderation_detail.formatMessage(line 692): includemoderationStatus: row.moderation_status.- Reuse a background scheduler: export the existing
scheduleAuditWritepattern fromaudit.ts:143as a genericscheduleBackground(promise)(rename/duplicate-minimally) so the send handler can defer moderation+broadcast+audit viaEdgeRuntime.waitUntil.
messages.ts — parallelize validation
- In
resolveSendExtras(line 356), runvalidateMentionsandvalidateReplyTargetwithPromise.allinstead of sequentially (they’re independent). Preserve current error precedence (mention error before reply error) when both fail.
index.ts — POST /v1/messages text path (lines 2350-2451) and attachment path (2196-2348)
- Compute
needsModeration = hasText && (config.harm_enabled || config.profanity_enabled || config.contact_id_enabled). - Keep synchronous: membership check,
resolveMentionSlicesForModeration,resolveSendExtras(these are cheap DB/CPU and produce real 400s the sender should get inline). Remove the inlineawait moderateText. - INSERT with
moderation_status: needsModeration ? 'pending' : 'approved'. Return201immediately with the formatted message (now carryingmoderationStatus). - Schedule background work via
scheduleBackground:- If
needsModeration: runmoderateText.- Passed →
UPDATE messages SET moderation_status='approved'; thenbroadcastMessageNew(supabase, conversationId, formatted-with-approved). - Failed →
UPDATE messages SET moderation_status='rejected', moderation_reason, moderation_detail, deleted_at=now(); broadcast a newMESSAGE_REJECTED_EVENT(system.ts) with{ messageId, localId, reason, detail }.
- Passed →
- If not
needsModeration:broadcastMessageNewimmediately (still in background). - In both cases, do the audit write here, looking up
endUserActorLabelinside the background task (removes it from the response path).
- If
- Apply the same structure to the attachment path: caption present →
pending+ async moderate; no caption →approved+ broadcast.
system.ts
- Add
MESSAGE_REJECTED_EVENT = "message:rejected"and abroadcastMessageRejected(...)mirroringbroadcastMessageNew(line 101). ReusebroadcastMessageNewfor the approved case.
4. SDK (packages/chat-react)
message-delivery.ts — add a "moderating" state to MessageDeliveryState
({ state: "moderating" }) so the UI can show “sending…” while a message is pending; keep
pending/failed as-is.
message-utils.ts — add export const MESSAGE_REJECTED_EVENT = "message:rejected"; (beside
line 192).
useMessages.ts — sendMessage (lines 699-785)
- On 201: upsert the returned message; set delivery to
"moderating"ifmessage.moderationStatus === "pending", elseclearDelivery. - Stop the client-side
broadcast(MESSAGE_NEW_EVENT, message)for text send (line 749) — the server now broadcasts on approval. (Same removal in the attachment send path, line 982.) MESSAGE_NEW_EVENThandler (line 640): whenincoming.senderId === userId, alsoclearDelivery(incoming.localId)so the sender’s “moderating” echo confirms on approval.- Add a
MESSAGE_REJECTED_EVENTlistener: match bylocalId→ set delivery"failed"with reason/detail andremoveMessagethe optimistic row (mirrors current 422 handling at lines 752-770).
Critical files
supabase/migrations/<ts>_messages_moderation_status.sql(new)packages/contracts/src/schemas.ts,src/index.tssupabase/functions/api/index.ts(MESSAGE_SELECT, formatMessage, POST text + attachment paths)supabase/functions/api/messages.ts(resolveSendExtrasparallelization)supabase/functions/api/system.ts(rejection broadcast)supabase/functions/api/audit.ts(expose generic background scheduler)packages/chat-react/src/useMessages.ts,message-delivery.ts,message-utils.ts
Edit (PATCH /v1/messages/:messageId) — secondary
The edit path (index.ts:2454-2600) already-approved content is visible, so async re-moderation must
snapshot the pre-edit content and revert (restore content + re-broadcast MESSAGE_UPDATED_EVENT)
on rejection rather than soft-delete. Apply the structural wins here regardless (parallelize mention
validation, defer endUserActorLabel into the background audit). Recommend: keep edit moderation
synchronous initially OR ship the async+revert variant only if the added revert complexity is wanted —
flag at implementation.
Verification
- Build:
pnpm build(contracts → chat-react → dashboard) andpnpm --filter @tesark/contracts build. - Apply migration to remote (
sknjeespbkyxzgddgfcn) via Supabase MCPapply_migration, thenpnpm db:typesand confirmmoderation_statusappears indatabase.types.ts. - Local Edge Function:
pnpm exec supabase functions serve api --no-verify-jwt.- Send a clean message → assert 201 returns in tens of ms (no OpenAI roundtrip on the response;
time it with
curl -w "%{time_total}"),moderationStatus: "pending". - Confirm a second (recipient) realtime client receives
MESSAGE_NEW_EVENTonly after moderation approves, and that a recipient history SELECT does not return a still-pendingmessage (RLS check). - Send content that trips harm/contact moderation → message inserts then is retracted; sender
receives
MESSAGE_REJECTED_EVENTwith reason/detail; recipients never receive a broadcast; the row endsmoderation_status='rejected',deleted_atset.
- Send a clean message → assert 201 returns in tens of ms (no OpenAI roundtrip on the response;
time it with
- SDK: in the dashboard (
pnpm dev), send a message and confirm the bubble shows “sending…” (moderating) then confirms; send a blocked message and confirm it flips to failed with the moderation reason and disappears for the recipient. pnpm test(chat-react vitest — add/adjust tests for the newmoderatingdelivery state and theMESSAGE_REJECTED_EVENThandler inuseMessages/message-delivery) andpnpm lint.