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.
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer