diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index 193e044..a0de35f 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -233,33 +233,36 @@ Two aliases are declared in **both** `package.json` `imports` and `tsconfig.json ## Env vars -All env vars are validated by zod in `src/validate-required.ts`. `.env.local.example` is the canonical template. +Env validation lives in `src/env/`: `server-env.ts` (zod-validated `serverEnv`), `client-env.ts` (zod-validated `clientEnv`), and `validate-required.ts` (a startup presence check for every required key). `.env.local.example` is the canonical template. > [!IMPORTANT] -> Never read `process.env` directly. Always use the type-safe accessors below. +> Never read `process.env` or `import.meta.env` directly. Always use the type-safe accessors below. -**Server (private) vars** — import `serverEnv` from `#/src/server-env.ts`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `RESEND_KEY`. +**Server (private) vars** — import `serverEnv` from `#/env/server-env.ts`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `RESEND_KEY`. ```typescript -import { serverEnv } from "#/src/env"; +import { serverEnv } from "#/env/server-env.ts"; const url = serverEnv.DATABASE_URL; ``` -**Client (public) vars** — must be prefixed `VITE_*` and accessed via `clientEnv`. Keys: `VITE_APP_NAME`, `VITE_APP_URL`, `VITE_CLOUDFRONT_URL`. +**Client (public) vars** — must be prefixed `VITE_*` and accessed via `clientEnv` (imported from `#/env/client-env.ts`). Keys: `VITE_APP_NAME`, `VITE_APP_DESCRIPTION`, `VITE_APP_URL`, `VITE_APP_NOREPLY_EMAIL`, `VITE_CLOUDFRONT_URL`, `VITE_LOCAL_ADMIN_EMAIL`, `VITE_LOCAL_ADMIN_PASSWORD`, `VITE_LOCAL_USER_EMAIL`, `VITE_LOCAL_USER_PASSWORD`. The four `VITE_LOCAL_*` vars seed local admin/user accounts via `prisma/seed.ts`; `VITE_APP_NOREPLY_EMAIL` is the From address for Resend auth emails; `VITE_APP_DESCRIPTION` feeds the root HTML metadata. ```typescript +import { clientEnv } from "#/env/client-env.ts"; + const appUrl = clientEnv.VITE_APP_URL; ``` -Client-side `clientEnv` is **type-safe** — the `ImportMetaEnv` interface in `env.d.ts` (at the repo root) declares every `VITE_*` key. If a key isn't listed there, TypeScript will reject `clientEnv.VITE_FOO`. This is intentional: it forces every client-side env var to be explicitly opted in, so typos and missing config are caught at compile time rather than silently resolving to `undefined` at runtime. +Client-side `clientEnv` is **type-safe** — the `ImportMetaEnv` interface in `src/env.d.ts` declares every `VITE_*` key. If a key isn't listed there, TypeScript will reject `clientEnv.VITE_FOO`. This is intentional: it forces every client-side env var to be explicitly opted in, so typos and missing config are caught at compile time rather than silently resolving to `undefined` at runtime. If you need a new env var, add it to: 1. `.env.local.example` (with a comment explaining what it's for) -2. `src/config/server-env.ts` (the zod schema) -3. **`env.d.ts`** — only if it's a client-side `VITE_*` var. Add a `readonly VITE_FOO: string` line to the `ImportMetaEnv` interface so `clientEnv.VITE_FOO` is typed. -4. The README's "Configuration" section if it's user-facing +2. The matching zod schema — `src/env/server-env.ts` or `src/env/client-env.ts` +3. The required-key list in `src/env/validate-required.ts` +4. **`src/env.d.ts`** — only if it's a client-side `VITE_*` var. Add a `readonly VITE_FOO: string` line to the `ImportMetaEnv` interface so `clientEnv.VITE_FOO` is typed. +5. The README's "Configuration" section if it's user-facing ## Where to go next diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index bc93699..db23dae 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,10 +24,10 @@ If you ever get stuck, come say hi in the [Discord](https://discord.gg/VQF23tPKy ## Local setup -A full step-by-step is in [`docs/LOCALSETUP.md`](LOCALSETUP.md) — read that first. The TL;DR: +A full step-by-step is in [`LOCALSETUP.md`](LOCALSETUP.md) — read that first. The TL;DR: ```bash -# 1. Install Node 22+, pnpm 11+, and Docker (see docs/LOCALSETUP.md) +# 1. Install Node 22+, pnpm 11+, and Docker (see LOCALSETUP.md) # 2. Clone and install git clone https://github.com/toolkits-gg/toolkitsgg-web.git cd toolkitsgg-web @@ -58,11 +58,11 @@ src/ components/ # Shared React components integrations/ # Third-party glue (tanstack-query, prisma-idb) emails/ # React Email templates (auth) - config/server-env.ts # zod-validated env accessors — never read process.env directly + env/ # zod-validated env accessors — never read process.env directly prisma/ schema.prisma # GameId enum + base models models/ # Per-game .prisma files (auto-discovered) -docs/ # Architecture, themes, DAL, local setup +.github/ # Contributor docs: ARCHITECTURE, THEMES, DAL, LOCALSETUP ``` The most important rule for newcomers: **game-specific code never lives in `src/features/`**. If you find yourself reaching for a `switch (gameId)` inside a feature module, stop — that logic belongs in `src/games//`. See [Architecture](ARCHITECTURE.md) for the registry pattern. @@ -103,7 +103,7 @@ Biome enforces formatting and linting; the config is in `biome.json`. - **`import type`** for type-only imports (strict `verbatimModuleSyntax`). - **No manual memoization** (`useMemo`, `useCallback`, `React.memo`) for ordinary values — the React Compiler is enabled and handles this. Reach for them only when you genuinely need stable identity. - **Path aliases:** `#/` -> `./src/`, `@/prisma` -> `./prisma/client`. Never import from `prisma/generated/prisma` directly. -- **Env vars:** import `serverEnv` from `#/config/env` (server) or read `clientEnv.VITE_*` (client). Never use `process.env` directly. +- **Env vars:** import `serverEnv` from `#/env/server-env.ts` (server) or `clientEnv` from `#/env/client-env.ts` and read `clientEnv.VITE_*` (client). Never use `process.env` or `import.meta.env` directly. - **Comments are off by default.** Only add one when the *why* is non-obvious. Don't restate what the code does. ## Commit & PR conventions diff --git a/.github/DAL.md b/.github/DAL.md index d4f61c7..f82093c 100644 --- a/.github/DAL.md +++ b/.github/DAL.md @@ -26,6 +26,7 @@ core/ define-action.ts # defineDalRead / defineDalWrite - the action factories choose-backend.ts # remote-vs-local selection logic to-query-options.ts # adapts a DalRead to TanStack Query options + presence-sync-handler.ts # shared SyncHandler factory for presence-toggle entities types.ts # DalContext, DalRead, DalWrite, PendingOp, etc. hooks/ useDalQuery / useDalSuspenseQuery @@ -38,13 +39,10 @@ local/ IndexedDB constants, local-db.ts (prisma-idb wrapper), shared local row types queue/ pending-ops.ts # PendingOp storage in IndexedDB - syncOps() # the runner that drains the queue - Last-write-wins resolution + sync-runner.ts # syncOps() / forceSyncOp() - drains the queue + last-write-wins.ts # last-write-wins conflict resolution + apply-pending-ops.ts # applyPendingOpServerFn the runner calls; dispatches by entity usePendingOps # observe pending ops from React -server/ - apply-pending-ops.ts # server function the sync runner calls; dispatches by entity - presence-sync-handler.ts # shared SyncHandler factory for presence-toggle entities - Cross-cutting collected-item handler + sync glue ``` The sync runner uploads every queued op through the single `applyPendingOpServerFn`, which dispatches to the right `SyncHandler` by `op.entity`. There is no client-side write-action registry - server-side dispatch is the only routing layer. @@ -120,7 +118,7 @@ Write actions carry more metadata because they need to participate in the sync q - **`invalidates`** - query keys to invalidate after the write succeeds. This is what makes related lists re-fetch. - **`buildIdempotencyKey`** - used for de-duplication on the server when the same op flushes twice (network retry, etc.). Include the user id and a stable identifier from the input. - **`describe`** _(optional)_ - a `PendingOpSummary` snapshot so the data-sync UI can show a friendly description of the queued op. -- **`getServerUpdatedAt`** _(optional)_ - reads the server record's `updatedAt` before the local write and stores it on the op as the last-write-wins baseline (see DIAGRAM.md). Omit it for pure creates; actions without it fall back to comparing the op's own creation time. +- **`getServerUpdatedAt`** _(optional)_ - reads the server record's `updatedAt` before the local write and stores it on the op as the last-write-wins baseline (resolution lives in `queue/last-write-wins.ts`). Omit it for pure creates; actions without it fall back to comparing the op's own creation time. There is **no `sync` field**. Every queued op is uploaded by the sync runner through the same `applyPendingOpServerFn`, which dispatches by `entity` - so wiring sync is just registering the server-side handler. @@ -187,11 +185,11 @@ The shortest path: 4. **Define the actions** (`.actions.ts`) with `defineDalRead` / `defineDalWrite` - see the examples above. -5. **(Writes only)** Add the sync handler in `sync-handler.ts`. It receives the `PendingOp` and applies it to Postgres, with last-write-wins on conflicts. If the entity is a simple presence toggle (a row that either exists or not, with no mutable fields - like collected items or favorited games), reuse `createPresenceToggleSyncHandler` from `#/features/dal/server/presence-sync-handler` instead of hand-writing the delete/upsert + LWW branching. +5. **(Writes only)** Add the sync handler in `sync-handler.ts`. It receives the `PendingOp` and applies it to Postgres, with last-write-wins on conflicts. If the entity is a simple presence toggle (a row that either exists or not, with no mutable fields - like collected items or favorited games), reuse `createPresenceToggleSyncHandler` from `#/features/dal/core/presence-sync-handler` instead of hand-writing the delete/upsert + LWW branching. 6. **(Writes only)** Register the handler under its `entity` key so `applyPendingOpServerFn` can dispatch to it: - **Game-scoped** - add it to `src/features/game/registry/game-sync-handler-registry.ts`. - - **Cross-game** - add it to the `handlers` map in `src/features/dal/server/apply-pending-ops.ts`. + - **Cross-game** - add it to the `handlers` map in `src/features/dal/queue/apply-pending-ops.ts`. 7. **Use it from components** with `useDalQuery` / `useDalMutation`. diff --git a/.github/LOCALSETUP.md b/.github/LOCALSETUP.md index 98912fe..d73d24b 100644 --- a/.github/LOCALSETUP.md +++ b/.github/LOCALSETUP.md @@ -107,6 +107,13 @@ Then open `.env.local` in your editor and fill in the values below. | `BETTER_AUTH_URL` | Where the app lives | `http://localhost:3000` for local dev | | `VITE_APP_URL` | Same — public URL of the app | `http://localhost:3000` for local dev | | `VITE_APP_NAME` | Shown in page titles | `"Toolkits.gg"` | +| `VITE_APP_DESCRIPTION` | Site description used in HTML metadata | Leave the default from `.env.local.example` | +| `VITE_APP_NOREPLY_EMAIL` | From address for Resend auth emails | Leave the default from `.env.local.example` | +| `VITE_LOCAL_ADMIN_EMAIL` / `VITE_LOCAL_ADMIN_PASSWORD` | Credentials for the seeded local admin account (`pnpm db:seed`) | Leave the defaults from `.env.local.example` | +| `VITE_LOCAL_USER_EMAIL` / `VITE_LOCAL_USER_PASSWORD` | Credentials for the seeded local user account (`pnpm db:seed`) | Leave the defaults from `.env.local.example` | + +> [!NOTE] +> Env validation treats every variable in this required table as mandatory — the app throws at startup if any is missing. The `VITE_APP_*` and `VITE_LOCAL_*` rows above ship with working defaults in `.env.local.example`, so copying the template is enough; you only need to fill in `BETTER_AUTH_SECRET` yourself. **Optional — leave blank if you don't need them:** @@ -117,9 +124,9 @@ Then open `.env.local` in your editor and fill in the values below. | `VITE_CLOUDFRONT_URL` | Image CDN for almost all project images. (# TODO: Make assets available for self-hosted CDN). | > [!IMPORTANT] -> All env vars are validated by zod (a TypeScript validation library) in `src/config/server-env.ts`. If you start the app with a missing or malformed value, you'll see a clear error message at startup — read it; it usually tells you exactly what's wrong. +> All env vars are validated by zod (a TypeScript validation library) in `src/env/` (`server-env.ts` and `client-env.ts`), with a startup presence check in `src/env/validate-required.ts`. If you start the app with a missing or malformed value, you'll see a clear error message at startup — read it; it usually tells you exactly what's wrong. > -> In code, server-side files import `serverEnv` from `#/config/env`. Client-side files read `clientEnv.VITE_*`. You don't need to know this to get set up, but it's good to know once you start contributing. +> In code, server-side files import `serverEnv` from `#/env/server-env.ts`. Client-side files import `clientEnv` from `#/env/client-env.ts` and read `clientEnv.VITE_*`. You don't need to know this to get set up, but it's good to know once you start contributing. ## 5. Start Postgres diff --git a/.npmrc b/.npmrc index af3f51a..6930f3a 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,4 @@ save-exact=true save-prefix="" -min-release-age=7d +min-release-age=3d block-exotic-subdeps=true \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9d8f821..81a5c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,14 +205,11 @@ The DAL (`src/features/dal/`) is an offline-first data layer. Every read/write e `src/features/dal/` contains: -- `core/` — `define-action.ts` (action factories), `choose-backend.ts`, `to-query-options.ts`, `types.ts` +- `core/` — `define-action.ts` (action factories), `choose-backend.ts`, `to-query-options.ts`, `types.ts`, and `presence-sync-handler.ts` (shared `createPresenceToggleSyncHandler` factory for presence-toggle entities) - `hooks/` — `useDalQuery`, `useDalMutation`, `useBackend`, `useDalContextSource` - `identity/` — anon-id generation/persistence and `useEffectiveUserId` - `local/` — IndexedDB constants, `local-db.ts` (prisma-idb client wrapper), and shared local row types -- `queue/` — `PendingOp` storage (`pending-ops.ts`), the `syncOps()` runner, last-write-wins resolution, and the `usePendingOps` hook -- `server/` — `apply-pending-ops.ts` server function (dispatches each op to a `SyncHandler` by `entity`) and `presence-sync-handler.ts` (shared factory for presence-toggle entities) - -See `src/features/dal/DIAGRAM.md` for mermaid diagrams of the read/write, sync, and LWW conflict-resolution flows. +- `queue/` — `PendingOp` storage (`pending-ops.ts`), the `syncOps()`/`forceSyncOp()` runner (`sync-runner.ts`), last-write-wins resolution (`last-write-wins.ts`), the `usePendingOps` hook, and `apply-pending-ops.ts` (the `applyPendingOpServerFn` server function that dispatches each op to a `SyncHandler` by `entity`) ``` Component @@ -285,18 +282,19 @@ Two aliases are declared in both `package.json` `imports` **and** `tsconfig.json ### Env vars -Validated with zod in `src/config/server-env.ts`. `.env.local.example` is the template. **Never read `process.env` directly** — always use the type-safe accessors below. +Env validation lives in `src/env/`: `server-env.ts` (zod-validated `serverEnv`), `client-env.ts` (zod-validated `clientEnv`), and `validate-required.ts` (a startup presence check for every required key). `.env.local.example` is the template. **Never read `process.env` / `import.meta.env` directly** — always use the type-safe accessors below. -**Server (private) vars** — import `serverEnv` from `#/config/env`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `RESEND_KEY`. +**Server (private) vars** — import `serverEnv` from `#/env/server-env.ts`. Keys: `DATABASE_URL`, `NODE_ENV`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `RESEND_KEY`. ```typescript -import { serverEnv } from "#/config/env"; +import { serverEnv } from "#/env/server-env.ts"; const url = serverEnv.DATABASE_URL; ``` -**Client (public) vars** — must be prefixed `VITE_*` and accessed via `clientEnv`. Keys: `VITE_APP_NAME`, `VITE_APP_URL`, `VITE_CLOUDFRONT_URL`. +**Client (public) vars** — must be prefixed `VITE_*` and accessed via `clientEnv` (imported from `#/env/client-env.ts`). Keys: `VITE_APP_NAME`, `VITE_APP_DESCRIPTION`, `VITE_APP_URL`, `VITE_APP_NOREPLY_EMAIL`, `VITE_CLOUDFRONT_URL`, `VITE_LOCAL_ADMIN_EMAIL`, `VITE_LOCAL_ADMIN_PASSWORD`, `VITE_LOCAL_USER_EMAIL`, `VITE_LOCAL_USER_PASSWORD`. The four `VITE_LOCAL_*` vars are consumed by `prisma/seed.ts` to seed local admin/user accounts; `VITE_APP_NOREPLY_EMAIL` is the From address for Resend auth emails (`src/features/email/utils.ts`); `VITE_APP_DESCRIPTION` feeds the root HTML metadata. ```typescript +import { clientEnv } from "#/env/client-env.ts"; const appUrl = clientEnv.VITE_APP_URL; ``` @@ -312,23 +310,23 @@ const appUrl = clientEnv.VITE_APP_URL; **After any non-trivial code change, check whether the contributor docs need to be updated.** Stale docs are worse than missing docs — a contributor who follows an out-of-date guide loses time and trust. -The contributor-facing docs live in two places: +The contributor-facing docs all live under `.github/`: - `.github/CONTRIBUTING.md` — entry point: workflow, code style, commit/PR conventions, links to the deep dives. -- `docs/LOCALSETUP.md` — environment setup (Node, pnpm, Docker, env vars, db scripts, troubleshooting). -- `docs/ARCHITECTURE.md` — framework stack, routing, active-game store, the game registry pattern, the feature/game separation rule, "Adding a new game" checklist. -- `docs/THEMES.md` — per-game theming, palette generation, light/dark handling. -- `docs/DAL.md` — offline-first data layer, action factories, sync queue. +- `.github/LOCALSETUP.md` — environment setup (Node, pnpm, Docker, env vars, db scripts, troubleshooting). +- `.github/ARCHITECTURE.md` — framework stack, routing, active-game store, the game registry pattern, the feature/game separation rule, "Adding a new game" checklist. +- `.github/THEMES.md` — per-game theming, palette generation, light/dark handling. +- `.github/DAL.md` — offline-first data layer, action factories, sync queue. And `CLAUDE.md` itself (this file) is the source of truth for AI-assisted work — keep it in sync with the contributor docs when the underlying behavior changes. Use this checklist when finishing a change: - Did you add, remove, or rename a `pnpm` script? Update `CLAUDE.md` Commands, `CONTRIBUTING.md`, and `LOCALSETUP.md`. -- Did you add or change an env var? Update `.env.local.example`, `src/config/server-env.ts`, `CLAUDE.md` Env vars, `LOCALSETUP.md` config table, and `ARCHITECTURE.md` Env vars. **If it's a client-side `VITE_*` var, also add it to `env.d.ts`'s `ImportMetaEnv` interface** — without that, `import.meta.env.VITE_FOO` won't typecheck. -- Did you add a new game, change the registry shape, or change "Adding a new game" steps? Update `CLAUDE.md` Architecture and `docs/ARCHITECTURE.md`. -- Did you change the theme system (palette generator, light/dark switching, registry expansion)? Update `docs/THEMES.md`. -- Did you change DAL action shapes, the sync flow, or the file conventions under `src/features/dal/` or `src/games/*/dal/`? Update `docs/DAL.md` and `src/features/dal/DIAGRAM.md` if the diagrams are now wrong. -- Did you change routing structure (root shell, provider chain, profile redirect, `$gameId` route behavior)? Update `CLAUDE.md` Routing and `docs/ARCHITECTURE.md` Routing. +- Did you add or change an env var? Update `.env.local.example`, the matching schema in `src/env/server-env.ts` or `src/env/client-env.ts`, the required-key list in `src/env/validate-required.ts`, `CLAUDE.md` Env vars, `LOCALSETUP.md` config table, and `ARCHITECTURE.md` Env vars. **If it's a client-side `VITE_*` var, also add it to `src/env.d.ts`'s `ImportMetaEnv` interface** — without that, `import.meta.env.VITE_FOO` won't typecheck. +- Did you add a new game, change the registry shape, or change "Adding a new game" steps? Update `CLAUDE.md` Architecture and `.github/ARCHITECTURE.md`. +- Did you change the theme system (palette generator, light/dark switching, registry expansion)? Update `.github/THEMES.md`. +- Did you change DAL action shapes, the sync flow, or the file conventions under `src/features/dal/` or `src/games/*/dal/`? Update `.github/DAL.md`. +- Did you change routing structure (root shell, provider chain, profile redirect, `$gameId` route behavior)? Update `CLAUDE.md` Routing and `.github/ARCHITECTURE.md` Routing. If you're unsure whether a change warrants a doc update, mention it in your response so the user can decide — don't silently skip it.