SuperAPI AI Directory

api-layer

Coding & development

View repository

Updated 10-15 17:30

About this skill

API Layer Patterns

Boundary Zone: external/

All raw Supabase access and Supabase-generated types live only in a feature's external/ directory (or shared/external/). This is lint-enforced:

  • getSupabaseClient may be imported only inside */external/**. hooks, components, and utils must call a feature's external/ fetch*/read* functions — which return domain types — never the raw client.
  • @/shared/external/database.types (generated schema types) may be imported only inside external/. UI code deals in domain types, not row/DTO shapes.

external/ is the one place a raw row shape or a boundary cast may exist; everything outside it is structurally confined to domain types. The client is typed (createClient<Database>), so .from(...).select(...) results are inferred from the schema — parse and map at the boundary instead of casting.

Canonical References

  • apps/web/src/post/external/post.ts
  • apps/web/src/shared/external/supabaseClient.ts
  • apps/web/src/comment/external/comment.ts

Function Structure

import { getSupabaseClient } from '@/shared/external/supabaseClient';
import type { Post } from '../model/Post';

export async function fetchRecentPostsFromSupabase(boardId: string, limitCount: number): Promise<Post[]> {
  const supabase = getSupabaseClient();
  const { data, error } = await supabase
    .from('posts')
    .select(FEED_POST_SELECT)
    .eq('board_id', boardId)
    .order('created_at', { ascending: false })
    .limit(limitCount);

  if (error) throw error;
  return (data || []).map(mapRowToPost);
}

Column Allowlist Pattern (FEED_*_SELECT)

export const FEED_POST_SELECT =
  'id, board_id, author_id, author_name, title, content_preview, thumbnail_image_url, visibility, count_of_comments, count_of_replies, count_of_likes, engagement_score, week_days_from_first_day, created_at, updated_at, comments(count), replies(count)';

Use explicit column strings for feed/list queries. Avoid broad * in list contexts.

Mapping Convention (mapRowToX)

function mapRowToComment(row: {
  id: string;
  user_id: string;
  user_name: string;
  user_profile_image: string | null;
  created_at: string;
  content: string;
}): Comment {
  return {
    id: row.id,
    userId: row.user_id,
    userName: row.user_name,
    userProfileImage: row.user_profile_image || '',
    content: row.content,
    createdAt: createTimestamp(new Date(row.created_at)),
  };
}

Supabase rows stay snake_case; domain models stay camelCase.

Write Error Handling (SupabaseWriteError + executeTrackedWrite)

import { executeTrackedWrite, throwOnError } from '@/shared/external/supabaseClient';

export async function createComment(postId: string, content: string, userId: string) {
  const supabase = getSupabaseClient();
  await executeTrackedWrite('createComment', () =>
    supabase.from('comments').insert({
      post_id: postId,
      user_id: userId,
      content,
      created_at: new Date().toISOString(),
    }),
  );
}

export async function deleteComment(commentId: string) {
  const supabase = getSupabaseClient();
  throwOnError(await supabase.from('comments').delete().eq('id', commentId));
}

throwOnError/executeTrackedWrite surface SupabaseWriteError and SupabaseNetworkError from one shared place.

N+1 Prevention: Batch-First Hook Convention

Expose list-context data fetchers as one batch hook taking ids: string[] and returning Set<id> or Map<id, T>. Do not create a singular wrapper that delegates to the batch hook with a single-element array.