Use when creating or modifying API functions in */api/ directories. Enforces Firestore patterns and data fetching conventions.
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.
apps/web/src/post/external/post.tsapps/web/src/shared/external/supabaseClient.tsapps/web/src/comment/external/comment.tsimport { 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);
}
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.
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.
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.
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.