diff --git a/.cursor/rules/dev-rules.mdc b/.cursor/rules/dev-rules.mdc new file mode 100644 index 00000000..c5e0a28c --- /dev/null +++ b/.cursor/rules/dev-rules.mdc @@ -0,0 +1,420 @@ +--- +alwaysApply: true +--- + +## Project Structure + +- next.js react tailwind frontend `/web` + - broken down into pages, components, hooks, lib +- express node api server `/backend/api` +- one off scripts, like migrations `/backend/scripts` +- supabase postgres. schema in `/backend/supabase` + - supabase-generated types in `/backend/supabase/schema.ts` +- files shared between backend directories `/backend/shared` + - anything in `/backend` can import from `shared`, but not vice versa +- files shared between the frontend and backend in `/common` + - `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa. + +## Deployment + +- The project has both dev and prod environments. +- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform. +- Project ID is `compass-130ba`. + +## Code Guidelines + +--- + +Here's an example component from web in our style: + +```tsx +import clsx from 'clsx' +import Link from 'next/link' + +import { isAdminId, isModId } from 'common/envs/constants' +import { type Headline } from 'common/news' +import { EditNewsButton } from 'web/components/news/edit-news-button' +import { Carousel } from 'web/components/widgets/carousel' +import { useUser } from 'web/hooks/use-user' +import { track } from 'web/lib/service/analytics' +import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page' +import { removeEmojis } from 'common/util/string' + +export function HeadlineTabs(props: { + headlines: Headline[] + currentSlug: string + endpoint: DashboardEndpoints + hideEmoji?: boolean + notSticky?: boolean + className?: string +}) { + const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } = + props + const user = useUser() + + return ( +
+ + {headlines.map(({ id, slug, title }) => ( + + ))} + {user && } + {user && (isAdminId(user.id) || isModId(user.id)) && ( + + )} + +
+ ) +} +``` + +--- + +We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components. + +It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find. + +Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS: + +```ts +import { api } from 'web/lib/api/api' +// More imports... + +export async function getStaticProps() { + try { + const headlines = await api('headlines', {}) + return { + props: { + headlines, + revalidate: 30 * 60, // 30 minutes + }, + } + } catch (err) { + return { props: { headlines: [] }, revalidate: 60 } + } +} + +export default function Home(props: { headlines: Headline[] }) { ... } +``` + +--- + +If we are calling the API on the client, prefer using the `useAPIGetter` hook: + +```ts +export const YourTopicsSection = (props: { + user: User + className?: string +}) => { + const { user, className } = props + const { data, refresh } = useAPIGetter('get-followed-groups', { + userId: user.id, + }) + const followedGroups = data?.groups ?? [] + ... +``` + +This stores the result in memory, and allows you to call refresh() to get an updated version. + +--- + +We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly. + +Here's the definition of usePersistentInMemoryState: + +```ts +export const usePersistentInMemoryState = (initialValue: T, key: string) => { + const [state, setState] = useStateCheckEquality( + safeJsonParse(store[key]) ?? initialValue + ) + + useEffect(() => { + const storedValue = safeJsonParse(store[key]) ?? initialValue + setState(storedValue as T) + }, [key]) + + const saveState = useEvent((newState: T | ((prevState: T) => T)) => { + setState((prevState) => { + const updatedState = isFunction(newState) ? newState(prevState) : newState + store[key] = JSON.stringify(updatedState) + return updatedState + }) + }) + + return [state, saveState] as const +} +``` + +--- + +For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook: + +```ts +export function useApiSubscription(opts: SubscriptionOptions) { + useEffect(() => { + const ws = client + if (ws != null) { + if (opts.enabled ?? true) { + ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + return () => { + ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + } + } + } + }, [opts.enabled, JSON.stringify(opts.topics)]) +} +``` + +In `use-bets`, we have this hook to get live updates with useApiSubscription: + +```ts +export const useContractBets = ( + contractId: string, + opts?: APIParams<'bets'> & { enabled?: boolean } +) => { + const { enabled = true, ...apiOptions } = { + contractId, + ...opts, + } + const optionsKey = JSON.stringify(apiOptions) + + const [newBets, setNewBets] = usePersistentInMemoryState( + [], + `${optionsKey}-bets` + ) + + const addBets = (bets: Bet[]) => { + setNewBets((currentBets) => { + const uniqueBets = sortBy( + uniqBy([...currentBets, ...bets], 'id'), + 'createdTime' + ) + return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions)) + }) + } + + const isPageVisible = useIsPageVisible() + + useEffect(() => { + if (isPageVisible && enabled) { + api('bets', apiOptions).then(addBets) + } + }, [optionsKey, enabled, isPageVisible]) + + useApiSubscription({ + topics: [`contract/${contractId}/new-bet`], + onBroadcast: (msg) => { + addBets(msg.data.bets as Bet[]) + }, + enabled, + }) + + return newBets +} +``` + +--- + +Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts` + +```ts +export function broadcastUpdatedPrivateUser(userId: string) { + // don't send private user info because it's private and anyone can listen + broadcast(`private-user/${userId}`, {}) +} + +export function broadcastUpdatedUser(user: Partial & { id: string }) { + broadcast(`user/${user.id}`, { user }) +} + +export function broadcastUpdatedComment(comment: Comment) { + broadcast(`user/${comment.onUserId}/comment`, { comment }) +} +``` + +--- + +We have our scripts in the directory `/backend/scripts`. + +To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env. + +Example from `/backend/scripts/manicode.ts` + +```ts +import { runScript } from 'run-script' + +runScript(async ({ pg }) => { + const userPrompt = process.argv[2] + await pg.none(...) +}) +``` + +Generally scripts should be run by me, especially if they modify backend state or schema. +But if you need to run a script, you can use `bun`. For example: + +```sh +bun run manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +if that doesn't work, try + +```sh +bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +--- + +Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`. + +E.g. Here is a hypothetical bet schema: + +```ts + bet: { + method: 'POST', + authed: true, + returns: {} as CandidateBet & { betId: string }, + props: z + .object({ + contractId: z.string(), + amount: z.number().gte(1), + replyToCommentId: z.string().optional(), + limitProb: z.number().gte(0.01).lte(0.99).optional(), + expiresAt: z.number().optional(), + // Used for binary and new multiple choice contracts (cpmm-multi-1). + outcome: z.enum(['YES', 'NO']).default('YES'), + //Multi + answerId: z.string().optional(), + dryRun: z.boolean().optional(), + }) + .strict(), + } +``` + +Then, we define the bet endpoint in `backend/api/src/place-bet.ts` + +```ts +export const placeBet: APIHandler<'bet'> = async (props, auth) => { + const isApi = auth.creds.kind === 'key' + return await betsQueue.enqueueFn( + () => placeBetMain(props, auth.uid, isApi), + [props.contractId, auth.uid] + ) +} +``` + +And finally, you need to register the handler in `backend/api/src/routes.ts` + +```ts +import { placeBet } from './place-bet' +... + +const handlers = { + bet: placeBet, + ... +} +``` + +--- + +We have two ways to access our postgres database. + +```ts +import { db } from 'web/lib/supabase/db' + +db.from('profiles').select('*').eq('user_id', userId) +``` + +and + +```ts +import { createSupabaseDirectClient } from 'shared/supabase/init' + +const pg = createSupabaseDirectClient() +pg.oneOrNone>('select * from profiles where user_id = $1', [userId]) +``` + +The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend. + +`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this. + +Another example using the direct client: + +```ts +export const getUniqueBettorIds = async ( + contractId: string, + pg: SupabaseDirectClient +) => { + const res = await pg.manyOrNone( + 'select distinct user_id from contract_bets where contract_id = $1', + [contractId] + ) + return res.map((r) => r.user_id as string) +} +``` + +(you may notice we write sql in lowercase) + +We have a few helper functions for updating and inserting data into the database. + +```ts +import { + buikInsert, + bulkUpdate, + bulkUpdateData, + bulkUpsert, + insert, + update, + updateData, +} from 'shared/supabase/utils' + +... + +const pg = createSupabaseDirectClient() + +// you are encouraged to use tryCatch for these +const { data, error } = await tryCatch( + insert(pg, 'profiles', { user_id: auth.uid, ...body }) +) + +if (error) throw APIError(500, 'Error creating profile: ' + error.message) + +await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 }) + +await updateData(pg, 'private_users', { id: userId, notifications: { ... } }) +``` + +The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including: + +- `select`: Specifies the columns to select +- `from`: Specifies the table to query +- `where`: Adds WHERE clauses +- `orderBy`: Specifies the order of results +- `limit`: Limits the number of results +- `renderSql`: Combines all parts into a final SQL string + +Example usage: + +```typescript +const query = renderSql( + select('distinct user_id'), + from('contract_bets'), + where('contract_id = ${id}', { id }), + orderBy('created_time desc'), + limitValue != null && limit(limitValue) +) + +const res = await pg.manyOrNone(query) +``` + +Use these functions instead of string concatenation. \ No newline at end of file diff --git a/.windsurf/rules/compass.md b/.windsurf/rules/compass.md new file mode 100644 index 00000000..a0e5318b --- /dev/null +++ b/.windsurf/rules/compass.md @@ -0,0 +1,422 @@ +--- +trigger: always_on +description: +globs: +--- + +## Project Structure + +- next.js react tailwind frontend `/web` + - broken down into pages, components, hooks, lib +- express node api server `/backend/api` +- one off scripts, like migrations `/backend/scripts` +- supabase postgres. schema in `/backend/supabase` + - supabase-generated types in `/backend/supabase/schema.ts` +- files shared between backend directories `/backend/shared` + - anything in `/backend` can import from `shared`, but not vice versa +- files shared between the frontend and backend in `/common` + - `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa. + +## Deployment + +- The project has both dev and prod environments. +- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform. +- Project ID is `compass-130ba`. + +## Code Guidelines + +--- + +Here's an example component from web in our style: + +```tsx +import clsx from 'clsx' +import Link from 'next/link' + +import { isAdminId, isModId } from 'common/envs/constants' +import { type Headline } from 'common/news' +import { EditNewsButton } from 'web/components/news/edit-news-button' +import { Carousel } from 'web/components/widgets/carousel' +import { useUser } from 'web/hooks/use-user' +import { track } from 'web/lib/service/analytics' +import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page' +import { removeEmojis } from 'common/util/string' + +export function HeadlineTabs(props: { + headlines: Headline[] + currentSlug: string + endpoint: DashboardEndpoints + hideEmoji?: boolean + notSticky?: boolean + className?: string +}) { + const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } = + props + const user = useUser() + + return ( +
+ + {headlines.map(({ id, slug, title }) => ( + + ))} + {user && } + {user && (isAdminId(user.id) || isModId(user.id)) && ( + + )} + +
+ ) +} +``` + +--- + +We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components. + +It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find. + +Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS: + +```ts +import { api } from 'web/lib/api/api' +// More imports... + +export async function getStaticProps() { + try { + const headlines = await api('headlines', {}) + return { + props: { + headlines, + revalidate: 30 * 60, // 30 minutes + }, + } + } catch (err) { + return { props: { headlines: [] }, revalidate: 60 } + } +} + +export default function Home(props: { headlines: Headline[] }) { ... } +``` + +--- + +If we are calling the API on the client, prefer using the `useAPIGetter` hook: + +```ts +export const YourTopicsSection = (props: { + user: User + className?: string +}) => { + const { user, className } = props + const { data, refresh } = useAPIGetter('get-followed-groups', { + userId: user.id, + }) + const followedGroups = data?.groups ?? [] + ... +``` + +This stores the result in memory, and allows you to call refresh() to get an updated version. + +--- + +We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly. + +Here's the definition of usePersistentInMemoryState: + +```ts +export const usePersistentInMemoryState = (initialValue: T, key: string) => { + const [state, setState] = useStateCheckEquality( + safeJsonParse(store[key]) ?? initialValue + ) + + useEffect(() => { + const storedValue = safeJsonParse(store[key]) ?? initialValue + setState(storedValue as T) + }, [key]) + + const saveState = useEvent((newState: T | ((prevState: T) => T)) => { + setState((prevState) => { + const updatedState = isFunction(newState) ? newState(prevState) : newState + store[key] = JSON.stringify(updatedState) + return updatedState + }) + }) + + return [state, saveState] as const +} +``` + +--- + +For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook: + +```ts +export function useApiSubscription(opts: SubscriptionOptions) { + useEffect(() => { + const ws = client + if (ws != null) { + if (opts.enabled ?? true) { + ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + return () => { + ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + } + } + } + }, [opts.enabled, JSON.stringify(opts.topics)]) +} +``` + +In `use-bets`, we have this hook to get live updates with useApiSubscription: + +```ts +export const useContractBets = ( + contractId: string, + opts?: APIParams<'bets'> & { enabled?: boolean } +) => { + const { enabled = true, ...apiOptions } = { + contractId, + ...opts, + } + const optionsKey = JSON.stringify(apiOptions) + + const [newBets, setNewBets] = usePersistentInMemoryState( + [], + `${optionsKey}-bets` + ) + + const addBets = (bets: Bet[]) => { + setNewBets((currentBets) => { + const uniqueBets = sortBy( + uniqBy([...currentBets, ...bets], 'id'), + 'createdTime' + ) + return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions)) + }) + } + + const isPageVisible = useIsPageVisible() + + useEffect(() => { + if (isPageVisible && enabled) { + api('bets', apiOptions).then(addBets) + } + }, [optionsKey, enabled, isPageVisible]) + + useApiSubscription({ + topics: [`contract/${contractId}/new-bet`], + onBroadcast: (msg) => { + addBets(msg.data.bets as Bet[]) + }, + enabled, + }) + + return newBets +} +``` + +--- + +Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts` + +```ts +export function broadcastUpdatedPrivateUser(userId: string) { + // don't send private user info because it's private and anyone can listen + broadcast(`private-user/${userId}`, {}) +} + +export function broadcastUpdatedUser(user: Partial & { id: string }) { + broadcast(`user/${user.id}`, { user }) +} + +export function broadcastUpdatedComment(comment: Comment) { + broadcast(`user/${comment.onUserId}/comment`, { comment }) +} +``` + +--- + +We have our scripts in the directory `/backend/scripts`. + +To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env. + +Example from `/backend/scripts/manicode.ts` + +```ts +import { runScript } from 'run-script' + +runScript(async ({ pg }) => { + const userPrompt = process.argv[2] + await pg.none(...) +}) +``` + +Generally scripts should be run by me, especially if they modify backend state or schema. +But if you need to run a script, you can use `bun`. For example: + +```sh +bun run manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +if that doesn't work, try + +```sh +bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +--- + +Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`. + +E.g. Here is a hypothetical bet schema: + +```ts + bet: { + method: 'POST', + authed: true, + returns: {} as CandidateBet & { betId: string }, + props: z + .object({ + contractId: z.string(), + amount: z.number().gte(1), + replyToCommentId: z.string().optional(), + limitProb: z.number().gte(0.01).lte(0.99).optional(), + expiresAt: z.number().optional(), + // Used for binary and new multiple choice contracts (cpmm-multi-1). + outcome: z.enum(['YES', 'NO']).default('YES'), + //Multi + answerId: z.string().optional(), + dryRun: z.boolean().optional(), + }) + .strict(), + } +``` + +Then, we define the bet endpoint in `backend/api/src/place-bet.ts` + +```ts +export const placeBet: APIHandler<'bet'> = async (props, auth) => { + const isApi = auth.creds.kind === 'key' + return await betsQueue.enqueueFn( + () => placeBetMain(props, auth.uid, isApi), + [props.contractId, auth.uid] + ) +} +``` + +And finally, you need to register the handler in `backend/api/src/routes.ts` + +```ts +import { placeBet } from './place-bet' +... + +const handlers = { + bet: placeBet, + ... +} +``` + +--- + +We have two ways to access our postgres database. + +```ts +import { db } from 'web/lib/supabase/db' + +db.from('profiles').select('*').eq('user_id', userId) +``` + +and + +```ts +import { createSupabaseDirectClient } from 'shared/supabase/init' + +const pg = createSupabaseDirectClient() +pg.oneOrNone>('select * from profiles where user_id = $1', [userId]) +``` + +The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend. + +`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this. + +Another example using the direct client: + +```ts +export const getUniqueBettorIds = async ( + contractId: string, + pg: SupabaseDirectClient +) => { + const res = await pg.manyOrNone( + 'select distinct user_id from contract_bets where contract_id = $1', + [contractId] + ) + return res.map((r) => r.user_id as string) +} +``` + +(you may notice we write sql in lowercase) + +We have a few helper functions for updating and inserting data into the database. + +```ts +import { + buikInsert, + bulkUpdate, + bulkUpdateData, + bulkUpsert, + insert, + update, + updateData, +} from 'shared/supabase/utils' + +... + +const pg = createSupabaseDirectClient() + +// you are encouraged to use tryCatch for these +const { data, error } = await tryCatch( + insert(pg, 'profiles', { user_id: auth.uid, ...body }) +) + +if (error) throw APIError(500, 'Error creating profile: ' + error.message) + +await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 }) + +await updateData(pg, 'private_users', { id: userId, notifications: { ... } }) +``` + +The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including: + +- `select`: Specifies the columns to select +- `from`: Specifies the table to query +- `where`: Adds WHERE clauses +- `orderBy`: Specifies the order of results +- `limit`: Limits the number of results +- `renderSql`: Combines all parts into a final SQL string + +Example usage: + +```typescript +const query = renderSql( + select('distinct user_id'), + from('contract_bets'), + where('contract_id = ${id}', { id }), + orderBy('created_time desc'), + limitValue != null && limit(limitValue) +) + +const res = await pg.manyOrNone(query) +``` + +Use these functions instead of string concatenation. \ No newline at end of file