commit c08dab42438839348b68882015fa3bcd9905c3e6 Author: André Padez Date: Tue Aug 18 02:02:52 2026 +0000 Initial commit: Bun · Hono · React SPA · Postgres/Drizzle template The starting point for an app with a React frontend. Same server, env, database and API skeleton as template-htmx — the two differ only in what is served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top. Carried from a working project rather than designed in the abstract, and carrying the lessons that cost it time: - The dev script states NODE_ENV=development rather than inheriting it. React resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit production while transforming for development and the app dies at first paint. Verified: the dev bundle resolves the development runtime and contains no void-0 stub. - Asset URLs in components are relative and is in the HTML, so a path-prefix proxy can rewrite one attribute and every asset follows. A test enforces it. - Routing is in the hash, because the app may be mounted under a prefix it only learns at runtime and a hash means the same thing at either mount. - index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because Bun's HTML bundler fails the build on a root-relative favicon href. - APP_ENV failing closed, env files before the ambient environment, the database enforced by name, one Postgres client per process — as in the htmx template. The database has no tables. The App is a shell with one tab and no features, named as a placeholder. Renaming `app` is the first step and the README lists every place it appears. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67cf590 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to .env and adjust. Production uses .env.production.local, which wins over this file. + +# The dev server's port. +BUN_PORT=3000 + +# The absolute base URL this site is served from. Must be the public https origin in production: +# a loopback address means the reader's own machine, and mobile clients refuse cleartext silently. +PUBLIC_URL=http://localhost:3000 + +# Development and test connect here. Production uses POSTGRES_URL from .env.production.local, +# and the pairing is enforced — a dev process cannot reach the production database. +POSTGRES_URL_DEV=postgresql://postgres:PASSWORD@127.0.0.1:5432/app_dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eac07e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules +dist +*.tgz +coverage +*.log +.cache +*.tsbuildinfo +.idea +.DS_Store + +.env +.env.local +.env.production.local +.env.development.local +.env.test.local + +# generated by scripts/gen-index.ts +src/apps/frontend/index.gen.html diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..30b20dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# app + +A project started from `template-react`. **Rename `app` first** — README step 1. + +**Bun · Hono · React 19 · Tailwind v4 · shadcn/ui · Postgres with Drizzle.** + +## Read these before writing code + +| | | +|---|---| +| `CONVENTIONS.md` | React patterns — feature folders, manager hooks, colocated state — and layout | +| `docs/DATABASE.md` | Drizzle structure, `push` not migrations, `check()` vs open `text` | +| `docs/SURFACES.md` | The JSON API and the app: one process, one query layer | +| `README.md` | Setup, scripts, configuration | + +## Rules that are load-bearing + +**`APP_ENV`, never `NODE_ENV`.** Must be `production` or `development`; throws when unset. Hosts export +`NODE_ENV=production` into everything they spawn, so anything keyed on it treats development as +production silently. `NODE_ENV` is still set explicitly by both scripts because React's runtime +resolves against it — Bun emits `jsxDEV()` in development and React's production runtime defines it +as `void 0`. + +**Env files are read with the ambient environment LAST**: `.env.production.local` (production only) +→ `.env.local` → `.env` → `process.env`. + +**Asset URLs in components are relative — never start with `/`.** `` in `index.html` +is what a path-prefix proxy rewrites; a leading slash bypasses it. `asset-urls.test.ts` enforces this. +`fetch('/api/...')` is the exception. + +**Routing is in the hash.** The app may be mounted under a prefix it only learns at runtime, and a +hash means the same thing at either mount point. `lib/routes.ts`. + +**One feature folder per screen, one manager hook per feature.** State lives in the hook, components +receive the manager. `CONVENTIONS.md`. + +**Serializers live in `src/servers/serialize.ts` only.** Its test walks the module's exports for +camelCase keys and for serializers without a case. + +## Bun + +- `bun ` not `node`, `bun test` not jest/vitest, `bun install`, `bunx` +- `Bun.serve()` — not express. `Bun.file` over `node:fs`. `Bun.$` over execa +- Bun loads `.env` automatically; do not add dotenv +- `bun dev` and `bun start` set the env markers. Running `src/server.ts` directly throws, by design + +## Before you finish + +- `bunx tsc --noEmit` and `bun test` +- Verify claims rather than asserting them — start the server and request the thing +- Commit with a message that says *why*, not what the diff already shows diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..617d2fd --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,308 @@ +# Code Conventions + +This document outlines the coding patterns and conventions used in this project. + +A living document — adjust it as the project's needs change. + +## Component Organization + +### Feature Folder Pattern + +Components that represent a feature or screen should use a folder structure with a barrel export. + +``` +Feature/ +├── index.tsx # exports from Feature.tsx +├── Feature.tsx # main component +├── SubComponent.tsx +└── utils.ts +``` + +**index.tsx:** + +```tsx +export * from './Feature'; +``` + +**Rationale:** Keeps imports clean (`import { Feature } from './Feature'`) while allowing the feature to grow into multiple files without changing import paths. Subcomponents that only serve this feature live in the same directory rather than being abstracted to a shared components folder. + +### Types Alongside Components + +Export component prop types from the same file as the component. + +```tsx +export type FeatureProps = { + value: string; + onChange: (value: string) => void; +}; + +export const Feature = ({ value, onChange }: FeatureProps) => { + // ... +}; +``` + +**Rationale:** Keeps types discoverable and colocated with their usage. Consumers can import both the component and its types from the same path. + +## State Management + +### Manager Pattern for Hooks + +Hooks that manage complex state should return a "manager" object. Components receive this manager as a prop. + +```tsx +// Hook +export const useFeatureManager = () => { + const [state, setState] = useState(''); + const [filter, setFilter] = useState(''); + + const filteredItems = (() => { + // derived state computation + })(); + + return { + state, + setState, + filter, + setFilter, + filteredItems, + }; +}; + +export type FeatureManager = ReturnType; + +// Parent component +const Parent = () => { + const manager = useFeatureManager(); + return ; +}; + +// Child component +const Child = ({ manager }: { manager: FeatureManager }) => { + const { state, filteredItems } = manager; + // ... +}; +``` + +**Rationale:** Centralizes state logic in one place. Child components don't need to know about individual state setters - they just receive the manager. Makes refactoring easier since state shape changes only affect the hook. + +### State Colocation in Hooks + +All local state (search, filters, pagination, expanded states) should live in the feature's hook, not scattered across components. + +```tsx +// Good +export const useFeatureManager = () => { + const [search, setSearch] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [expandedId, setExpandedId] = useState(null); + // ... +}; + +// Avoid +const Component = () => { + const [search, setSearch] = useState(''); // state in component + // ... +}; +``` + +**Rationale:** Single source of truth for feature state. Easier to understand, test, and modify. Prevents state synchronization bugs. + +### Derived State in Hooks + +Computed/derived values should be calculated in the hook, not in components. + +```tsx +export const useFeatureManager = () => { + const [items, setItems] = useState([]); + const [filter, setFilter] = useState(''); + + // Derived state computed in hook + const filteredItems = (() => { + return items.filter((item) => item.name.includes(filter)); + })(); + + const itemCount = filteredItems.length; + + return { items, filter, setFilter, filteredItems, itemCount }; +}; +``` + +**Rationale:** Components stay focused on rendering. Business logic stays in one place. Derived values are computed once and shared across all consuming components. + +## React Patterns + +### Reach for useMemo / useCallback only when they do work + +Default to writing the code plainly. Most derived values are cheap and re-computing them per render costs +less than the memo that guards them. + +```tsx +// ✅ Fine — cheap, so just write it +const filteredItems = items.filter((item) => item.active); +const handleClick = () => doSomething(); +``` + +But they are ordinary tools, not forbidden ones. Use them where they earn it: + +- a value or callback in a **dependency array**, where an unstable identity re-runs an effect or + re-subscribes a socket every render; +- a genuinely **expensive** computation over a large list; +- a prop passed to a **memoized** child. + +```tsx +// ✅ Earns it — an unstable callback here would re-subscribe on every render +const onConnectionChange = useCallback((state) => setConnState(state), [setConnState]); +``` + +The React Compiler is a separate, opt-in build plugin and is **not installed here** (there is no +`babel-plugin-react-compiler` in `package.json`); React 19 on its own memoizes nothing. + +### Computation Functions Outside Components + +Extract complex computations into functions declared below the component, not as IIFEs inside. + +```tsx +export const Component = ({ data }: Props) => { + const metrics = computeMetrics(data); + const stats = computeStats(data, metrics.total); + + return
{/* ... */}
; +}; + +// Functions below component +function computeMetrics(data: Data[]): Metrics { + // complex computation +} + +function computeStats(data: Data[], total: number): Stats { + // complex computation +} +``` + +**Rationale:** Keeps the component body focused on rendering logic. Functions are testable in isolation. Easier to read and understand the component's purpose. + +### Fragment Shorthand + +Use `<>` for fragments. Only import and use `Fragment` when a `key` prop is required. + +```tsx +// Good - no key needed +return ( + <> +
+ + +); + +// Good - key required +import { Fragment } from 'react'; + +return items.map((item) => ( + + + + +)); +``` + +**Rationale:** `<>` is cleaner and more concise. `Fragment` is only needed for the `key` prop which `<>` doesn't support. + +## Imports + +### Single-Line Imports + +Keep imports on a single line. If an import has too many items, split into multiple import statements. + +```tsx +// Good +import { Button, Input, Select } from '@/components/ui'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; + +// Good - split when too long +import { TableBody, TableCell, TableHead } from '@/components/ui/table'; +import { TableHeader, TableRow } from '@/components/ui/table'; + +// Avoid - multiline imports +import { Button, Input, Select, Card } from '@/components/ui'; +``` + +**Rationale:** Single-line imports are easier to scan and take less vertical space. Splitting by source module keeps related imports together. + +## Utilities + +### Use Existing Helpers + +Prefer existing helper functions over inline implementations. + +```tsx +// Good +import { formatCurrency } from 'helpers/formatters'; + +const display = formatCurrency(amount / 100, currency, 0); + +// Avoid +const display = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, +}).format(amount / 100); +``` + +**Rationale:** Consistent formatting across the app. Single place to modify behavior. Less code duplication and potential for bugs. + +--- + +## Project Structure + +``` +src/ + server.ts Bun.serve — public routes, /api/*, then the SPA + env-file.ts APP_ENV, layered env files, ambient environment last + servers/ + create-router.ts createRouter() and HonoVariables + hono.ts the MOUNTS list, mounted under /api + errors.ts the one error envelope + serialize.ts every API serializer, in one module — its test walks the exports + api/.ts one router per resource + databases/app_db/ Drizzle — see docs/DATABASE.md + apps/frontend/ + index.html tracked template, holds __PUBLIC_URL__ + index.gen.html generated, gitignored, what the server imports + index.tsx React entry + App.tsx the shell + globals.css Tailwind and the shadcn theme + components/ui/ shadcn primitives + lib/routes.ts hash routing + lib/api.ts the one fetch helper + features// one folder per screen — see above +public/ served verbatim, globbed into routes at boot +scripts/gen-index.ts __PUBLIC_URL__ substitution +``` + +`@/*` resolves to `src/apps/frontend/*`, so `@/components/ui/button` and `@/lib/utils` work as shadcn +generates them. + +## Assets and URLs + +**Asset URLs in components must be relative — never start with `/`.** + +```tsx + // ✅ resolves against + // ❌ escapes a path-prefix proxy +``` + +The app is served at `/` directly, and may be served under a prefix by a proxy. `` in +`index.html` is what a rewriting proxy changes, so relative URLs follow the app; a leading slash +bypasses it. `src/apps/frontend/asset-urls.test.ts` enforces this. + +`fetch('/api/...')` is the exception — proxies that patch `window.fetch` rewrite absolute API paths. + +Anything in `index.html` that a bundler would resolve on disk (a favicon `href`) must be an absolute +URL via `__PUBLIC_URL__` — Bun fails the build on a root-relative one. + +## Configuration + +`APP_ENV`, never `NODE_ENV` — see README. `NODE_ENV` is still set explicitly by the `dev` script +because React's conditional exports resolve against it: Bun emits `jsxDEV()` in development, and +React's production jsx-dev-runtime defines `jsxDEV` as `void 0`. The transform and the runtime must +agree. diff --git a/README.md b/README.md new file mode 100644 index 0000000..97b410f --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# template-react + +A starting point for an app. **Bun · Hono · React 19 SPA · Tailwind v4 · shadcn/ui · Postgres with +Drizzle.** A JSON API at `/api` and a React app at `/`, over one query layer. + +## 1. Make it yours + +Everything project-specific is called `app`. Rename it once, before the first commit: + +| Find | Replace with | Where | +|---|---|---| +| `APP_ENV` | `MYPROJECT_ENV` | `src/env-file.ts`, `package.json` scripts, `test-setup.ts`, `src/databases/app_db/src/env.ts` | +| `appEnv` | `myprojectEnv` | `src/env-file.ts` and its importers | +| `appdb` / `app_db` | `myprojectdb` / `myproject_db` | `src/databases/`, `tsconfig.json` paths, `package.json` scripts | +| `app` / `app_dev` (database names) | `myproject` / `myproject_dev` | `src/databases/app_db/src/env.ts`, `.env.example` | +| `"name": "app"` | your package name | `package.json` | +| `App` | your brand | `src/apps/frontend/App.tsx`, `index.html` `` | +| `3000` | a port of your own | `.env.example`, `src/server.ts` | + +A case-sensitive search for `app`, `App` and `APP` finds all of it. Thirty seconds now, a chore later. + +## 2. Setup + +```bash +bun install +cp .env.example .env # BUN_PORT, PUBLIC_URL, POSTGRES_URL_DEV +createdb app && createdb app_dev # or however you make databases +bun run db:push # applies the schema — no tables yet, by design +bun dev +``` + +## Scripts + +```bash +bun dev # development on app_dev, with HMR +bun start # production on app +bun test +bun run build # bundle the SPA to dist/ +bun run gen:index # index.gen.html from index.html — also run by predev/prestart/prebuild +bun run db:push # schema -> app_dev; APP_ENV=production bun run db:push for production +``` + +## What is served + +| Path | | +|---|---| +| `/` and every unmatched path | the SPA — `src/apps/frontend` | +| `/api/*` | the JSON API — `src/servers/api` | +| everything in `public/` | from the root | + +## Configuration, and the rules behind it + +**`APP_ENV`, never `NODE_ENV`.** It must be `production` or `development` and throws when unset. +Some hosts export `NODE_ENV=production` into every process they spawn, so anything keyed on it +silently treats development as production. + +**`NODE_ENV` is still set by both scripts, and must stay explicit.** React resolves its runtime +against it: Bun's bundler emits `jsxDEV()` calls in development, and React's *production* +`jsx-dev-runtime` defines `jsxDEV` as `void 0`. Inherit production while transforming for development +and the app dies with "jsxDEV is not a function". + +**Env files are read with the ambient environment last:** `.env.production.local` (production only) +→ `.env.local` → `.env` → `process.env`. + +**The database follows from `APP_ENV`** and is enforced before a connection opens; tests cannot reach +production data. + +**`PUBLIC_URL`** is substituted into `index.html` by `scripts/gen-index.ts` because Bun's HTML bundler +fails the build on a root-relative favicon `href`. It must be the public origin in production. + +## The placeholder + +`src/apps/frontend/App.tsx` is a shell with one tab and no features. Add the first screen as a feature +folder — `CONVENTIONS.md` describes the shape. The database has **no tables**; `docs/DATABASE.md` +describes adding the first. + +## Documentation + +| | | +|---|---| +| `CLAUDE.md` | The rules an agent needs before touching anything | +| `CONVENTIONS.md` | React patterns — feature folders, manager hooks — and the project layout | +| `docs/DATABASE.md` | Drizzle structure, `push` not migrations, `check()` vs open `text` | +| `docs/SURFACES.md` | The API and the app over one query layer | diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..f8ff80e --- /dev/null +++ b/build.ts @@ -0,0 +1,26 @@ +import tailwind from "bun-plugin-tailwind"; +import { rm } from "node:fs/promises"; +import path from "node:path"; + +const outdir = path.join(process.cwd(), "dist"); +await rm(outdir, { recursive: true, force: true }); + +// Only the generated HTML. The tracked index.html still holds __PUBLIC_URL__ placeholders, and +// building it would emit a page whose favicon hrefs are the literal placeholder text. +const entrypoints = [...new Bun.Glob("src/**/*.gen.html").scanSync()]; + +const result = await Bun.build({ + entrypoints, + outdir, + plugins: [tailwind], + minify: true, + target: "browser", + sourcemap: "linked", + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, +}); + +for (const output of result.outputs) { + console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`); +} diff --git a/bun-env.d.ts b/bun-env.d.ts new file mode 100644 index 0000000..f5f2d2c --- /dev/null +++ b/bun-env.d.ts @@ -0,0 +1,43 @@ +// Generated by `bun init` + +declare module "*.svg" { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module "*.png" { + /** + * A path to the PNG file + */ + const path: `${string}.png`; + export = path; +} + +declare module "*.jpg" { + /** + * A path to the JPEG file + */ + const path: `${string}.jpg`; + export = path; +} + +declare module "*.webp" { + /** + * A path to the WebP file + */ + const path: `${string}.webp`; + export = path; +} + +declare module "*.css" {} + +declare module "*.module.css" { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5d04037 --- /dev/null +++ b/bun.lock @@ -0,0 +1,363 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "platform", + "dependencies": { + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "bun-plugin-tailwind": "^0.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "hono": "^4.13.2", + "lucide-react": "^1", + "postgres": "^3.4.9", + "react": "^19", + "react-dom": "^19", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + "drizzle-kit": "^0.31.10", + "tailwindcss": "^4.1.11", + }, + }, + }, + "packages": { + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-tsconfig": ["get-tsconfig@4.14.2", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw=="], + + "hono": ["hono@4.13.2", "", {}, "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA=="], + + "lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..aebaa5c --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,8 @@ +[serve.static] +plugins = ["bun-plugin-tailwind"] +env = "BUN_PUBLIC_*" + +[test] +# The repo, not just `src` — scripts/ are this project's tests too. +root = "." +preload = ["./test-setup.ts"] diff --git a/components.json b/components.json new file mode 100644 index 0000000..aff073a --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/apps/frontend/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..969bd5f --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,112 @@ +# Database + +One PostgreSQL database, `app`, exposed as the package `appdb`. Managed with Drizzle. There is no +second database and no cache database. + +``` +src/databases/app_db/ +├── drizzle.config.ts +├── package.json exports ".", "./db", "./schema", "./*" +└── src/ + ├── db.ts the connection + ├── env.ts which database this process may reach + ├── index.ts public surface: one `export * from './<feature>'` per line + ├── schema.ts what db:push creates — see below + └── <feature>/ + ├── index.ts what this feature exports, declared beside the code it describes + ├── schema.ts its tables + └── queries.ts everything that reads or writes them +``` + +## A feature owns its own public surface + +`src/index.ts` is one `export *` per feature and nothing else. What a feature exports is declared in +its own `index.ts`. + +Adding a query is then one file in one directory, rather than that file plus a hand-written list of +every symbol in the package. Those lists drift: the platform's reached 297 lines with twelve features +listed twice before it was replaced. + +## One directory per feature, holding both halves + +Not parallel `schema/` and `queries/` trees. Those drift too — the platform ended up with four +features named differently on each side, one with no query file and one with no schema file, and +nothing structural said so. + +A directory that is lopsided says so by its contents. + +## `src/schema.ts` is drizzle-kit's view, not the runtime's + +`drizzle.config.ts` points at it, so **commenting one line there removes a table from the DATABASE +without removing a line of code**. Every query imports its tables from `./schema` inside its own +feature directory, so the code still compiles. + +That is what lets a fresh install create only the tables it needs, with optional ones commented out +until something wants them. + +## Schema changes use `push`, not migrations + +```bash +bun run db:push # -> app_dev +APP_ENV=production bun run db:push # -> app, deliberately +``` + +`db:push` diffs the schema code against the live database and alters it directly. **Treat the schema +code as the source of truth.** `db:gen` writes files to `migrations/`, but nothing applies them — do +not start reading those files as though they were the record. + +## The schema is the source of truth for what the database may CONTAIN + +Not just for its shape. Where a column has a known set of legal values, say so with a `check()` rather +than leaving it free `text` — otherwise a value the code stopped supporting sits there unnoticed, and +nothing structural says it became illegal. + +```ts +export const ORDER_STATUSES = ["pending", "paid", "shipped"] as const; + +status: text("status").notNull().$type<(typeof ORDER_STATUSES)[number]>(), +// in the table's second argument: +check("orders_status_check", sql`${table.status} in ('pending', 'paid', 'shipped')`), +``` + +**But `text` without a check where the set must stay open.** A category, a provider name, anything a +third party can add: an enum cannot contain a value nobody has compiled yet, and adding one must not +be a schema change. The test is whether the set is closed by us or open to the world. + +## Which database a process reaches + +Decided by `APP_ENV` and **enforced** in `env.ts` before a connection opens: + +| `APP_ENV` | Database | +|---|---| +| `production` | `app` (`POSTGRES_URL`, from `.env.production.local`) | +| `development` | `app_dev` (`POSTGRES_URL_DEV`, from `.env`) | + +Any other pairing throws. The check is on the database **name**, because host and credentials are +usually shared and one word at the end of a long URL is all that separates them. + +## Things that bite, already handled here + +**The client is a per-process singleton on `globalThis`.** `bun --hot` re-evaluates modules on every +edit; without it each save builds a fresh pool and abandons the last, queries in flight during the +swap never resolve or release their connection, and the pool eventually wedges — every route that +reads hangs while stateless ones stay fast. + +**`waitForDatabase()` before startup work.** `postgres()` is lazy, so nothing fails at import when the +database is not up. Startup work that queries otherwise fires once, fails, and is swallowed by +whatever `.catch()` it was written with. + +**Money is `numeric`, never a float**, and it comes back as a string. Sums of binary floating point +are wrong in ways nobody notices until an invoice. + +**Denormalise what is historical fact.** A row pointing at configuration — a provider, a price list — +should also record what it actually used, as text. The configuration can be renamed, repointed or +deleted; what happened cannot. + +## Tests + +Tests run against `app_dev`; the `APP_ENV` guard makes production unreachable from them. + +Two rules learned expensively: **clean up what you write**, and **restore what you disturb**. A test +that sets a "default" flag clears it on every other row, so deleting only your own rows afterwards is +not enough — snapshot the previous value and put it back. diff --git a/docs/SURFACES.md b/docs/SURFACES.md new file mode 100644 index 0000000..d2bc99b --- /dev/null +++ b/docs/SURFACES.md @@ -0,0 +1,89 @@ +# Apps and surfaces + +Two surfaces, **one process, one port, one database**: + +``` + example.com/ ──┤ apps/frontend │ React SPA, talks to /api + example.com/api/thing ──┤ servers/api │ serializers -> JSON + └───────┬─────────┘ + │ + ┌───────┴─────────┐ + │ appdb │ queries + └─────────────────┘ +``` + +Here the SPA is a client of the API like any other, which is the point of a SPA. The rule below is +about the server side: the API is the only door to the data, and it must never return HTML. + +## HTML and JSON are siblings, not layers + +## The rule + +**Both surfaces call the query layer. Neither calls the other.** + +### The web app does not call its own API + +It runs in the same process. Going out over HTTP to talk to ourselves buys a round trip, a +serialisation, a deserialisation, and a second way for two screens to disagree. It also makes every +page render depend on the server being able to reach itself, which is a failure mode nobody wants to +debug. + +Import the query function. That is what it is for. + +### The API does not return HTML + +Not even for htmx. It is tempting — the fragment is right there — but the API is a documented, +external contract consumed by things that are not this website, and htmx's needs are not theirs. A +fragment that becomes a de facto API cannot be changed without breaking someone, and you find out +when they tell you. + +Fragments belong to `apps/web` and are free to change on any afternoon, precisely because nothing +outside this repo depends on them. + +## What they share, and what they must not + +| | | +|---|---| +| **Shared** | query functions, domain rules, database types | +| **Not shared** | serialisation, error presentation, validation of transport, URL shape | + +The API has serializers because JSON needs a stable, named, snake_case shape. The web app has +templates because HTML needs markup. Trying to make one produce the other gives you a serializer with +markup in it. + +## They are allowed to behave differently + +This is the part that feels wrong and is right. + +The two surfaces have different callers, and the same event can deserve different answers: + +| Situation | `/api` | the site | +|---|---|---| +| Creating something whose name is taken | **409** with an error envelope | show the list, with the existing one in it | +| A malformed id | **404**, in the envelope | render the page's empty state | +| Something failed | error object with a `type` a program can branch on | a sentence a person can read | + +A program needs to distinguish "already exists" from "created". A human clicking Create wants the +thing they asked for to be on screen; being shown an error for a collection that exists is a worse +answer than showing it to them. + +**What must never diverge is the query layer.** If the two surfaces can produce different *data* for +the same request, that is a bug. If they present the same data differently, that is the point. + +## Naming + +`/thing` and `/api/thing` stay parallel. `/api` is mounted before the site's catch-all in +`src/server.ts`, so it is resolved first and the site never swallows an API path. + +Within the site, pages and partials are **different URLs**, never the same URL switching on +`HX-Request` — see `docs/WEB.md`. Combined with the `/api` split, that gives three URL spaces with one +representation each, and every one of them is cacheable without a `Vary` header. + +## Adding a resource + +1. `app_db/src/<feature>/` — schema and queries. Both surfaces will import these. +2. `servers/api/<feature>.ts` — a router, plus a serializer in the shared serializer module. +3. `apps/web/<feature>/partials.tsx` and a page — both calling the same queries. +4. Mount the API router in `servers/hono.ts`; add the site's routes in `apps/web/index.tsx`. + +If step 3 finds itself wanting something step 2 already computed, that logic belongs in step 1. diff --git a/package.json b/package.json new file mode 100644 index 0000000..c495654 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "gen:index": "bun run ./scripts/gen-index.ts", + "predev": "bun run ./scripts/gen-index.ts", + "dev": "NODE_ENV=development APP_ENV=development bun --hot src/server.ts", + "prestart": "bun run ./scripts/gen-index.ts", + "start": "NODE_ENV=production APP_ENV=production bun src/server.ts", + "prebuild": "bun run ./scripts/gen-index.ts", + "build": "bun run build.ts", + "db:gen": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run generate", + "db:push": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run push", + "db:studio": "cd src/databases/app_db && APP_ENV=${APP_ENV:-development} bun run studio" + }, + "dependencies": { + "drizzle-orm": "^0.45.2", + "hono": "^4.13.2", + "postgres": "^3.4.9", + "react": "^19", + "react-dom": "^19", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "bun-plugin-tailwind": "^0.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.31.10", + "tailwindcss": "^4.1.11", + "@types/react": "^19", + "@types/react-dom": "^19" + } +} diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..326af0e --- /dev/null +++ b/public/README.md @@ -0,0 +1 @@ +Static files served from the root. Anything here is public. diff --git a/scripts/gen-index.ts b/scripts/gen-index.ts new file mode 100644 index 0000000..3c8c75a --- /dev/null +++ b/scripts/gen-index.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +// Generates src/apps/frontend/index.gen.html from index.html, substituting __PUBLIC_URL__ with +// PUBLIC_URL from .env. +// +// Favicon and OpenGraph hrefs have to be absolute. Bun's HTML bundler treats a root-relative href as +// an asset to resolve on disk and fails the build ("Could not resolve: /favicon.ico"), and crawlers +// fetch OG tags standalone so they cannot resolve a relative URL either. Hardcoding the domain would +// force every deployment to carry its own edit of index.html, and Bun's HTML import offers no +// substitution hook — so the swap happens here and the server imports the generated file. +// +// index.gen.html is gitignored; index.html is the tracked template and is identical on every instance. + +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const template = join(root, "src/apps/frontend/index.html"); +const output = join(root, "src/apps/frontend/index.gen.html"); + +// Where the URL comes from, most specific first: +// +// 1. the first argument `bun gen:index https://example.com` +// 2. PUBLIC_URL in this project's .env +// 3. PUBLIC_URL in the environment +// +// Note the order: .env BEATS the ambient environment, which is the reverse of the usual convention. +// A host that exports its own PUBLIC_URL into every process it spawns would otherwise stamp its +// domain onto this app's favicon hrefs. A value in this project's own .env is a statement about this +// project; an inherited one is a statement about whatever launched it. +const argUrl = process.argv[2]?.trim(); + +let dotenvUrl: string | undefined; +const envPath = join(root, ".env"); +if (!argUrl && existsSync(envPath)) { + for (const line of (await Bun.file(envPath).text()).split("\n")) { + const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/); + if (match) dotenvUrl = match[1]!.trim().replace(/^["']|["']$/g, ""); + } +} + +const publicUrl = (argUrl || dotenvUrl || process.env.PUBLIC_URL || "").replace(/\/+$/, ""); +if (!publicUrl) { + console.error("[gen-index] no public URL. Pass one — `bun gen:index http://localhost:3001` —"); + console.error("[gen-index] or set PUBLIC_URL in .env."); + process.exit(1); +} + +// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without +// complaint and produces tags nothing can resolve, which stays invisible until someone shares a link. +try { + const parsed = new URL(publicUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)"); +} catch { + console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL.`); + process.exit(1); +} + +const html = await Bun.file(template).text(); +const generated = html.replaceAll("__PUBLIC_URL__", publicUrl); + +if (generated.includes("__PUBLIC_URL__")) { + console.error("[gen-index] substitution left placeholders behind"); + process.exit(1); +} + +// Only write when the content actually changes, so `bun --hot` does not restart in a loop. +if (existsSync(output) && (await Bun.file(output).text()) === generated) { + console.log(`[gen-index] up to date (${publicUrl})`); +} else { + await Bun.write(output, generated); + console.log(`[gen-index] wrote index.gen.html (${publicUrl})`); +} diff --git a/src/apps/frontend/App.tsx b/src/apps/frontend/App.tsx new file mode 100644 index 0000000..55257a1 --- /dev/null +++ b/src/apps/frontend/App.tsx @@ -0,0 +1,51 @@ +import { TABS, TAB_LABELS, useHashRoute } from "@/lib/routes"; +import { cn } from "@/lib/utils"; + +/** + * The shell. One tab, no features — a placeholder that proves the routing and the theme, to be + * replaced by the first real screen. + * + * Routing is in the URL hash rather than the path, and not as a shortcut: this app is served at `/` + * directly and may be served under a path prefix by a proxy, and a path router needs a basename it + * can only learn at runtime. The hash means the same thing at either mount point. See lib/routes.ts. + */ +export function App() { + const { tab } = useHashRoute(); + + return ( + <div className="min-h-screen bg-background text-foreground"> + <header className="border-b border-border"> + <div className="mx-auto flex max-w-6xl items-center gap-3 px-6 py-4"> + <h1 className="flex-1 text-xl font-semibold tracking-tight">App</h1> + <nav className="flex gap-1"> + {TABS.map((name) => ( + // Anchors, not buttons: the hash is the state, so navigation is a link. Back and + // forward work, and the tab can be copied out of the address bar. + <a + key={name} + href={`#${name}`} + aria-current={tab === name ? "page" : undefined} + className={cn( + "rounded-md px-3 py-1.5 text-sm transition", + tab === name ? "bg-secondary text-secondary-foreground" : "text-muted-foreground hover:text-foreground", + )} + > + {TAB_LABELS[name]} + </a> + ))} + </nav> + </div> + </header> + + <main className="mx-auto max-w-6xl px-6 py-8"> + {tab === "home" && ( + <p className="text-sm text-muted-foreground"> + Placeholder. Delete this and add the first real screen as a feature folder — see CONVENTIONS.md. + </p> + )} + </main> + </div> + ); +} + +export default App; diff --git a/src/apps/frontend/asset-urls.test.ts b/src/apps/frontend/asset-urls.test.ts new file mode 100644 index 0000000..c724a9b --- /dev/null +++ b/src/apps/frontend/asset-urls.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Guards the one rule that broke the preview: an asset URL starting with "/" escapes the proxy prefix. +// +// Served through a path-prefix proxy the app lives under /<prefix>/, and only the HTML is +// rewritten — a root-absolute path inside a component reaches the platform root, which answers +// 200 text/html. The image is broken and the network tab shows no error, so this is worth a test +// rather than a note someone reads after losing an afternoon to it. + +const frontendDir = dirname(fileURLToPath(import.meta.url)); +const templatePath = resolve(frontendDir, "index.html"); + +describe("asset URLs", () => { + test("index.html declares a base so relative URLs can be rewritten", async () => { + const html = await Bun.file(templatePath).text(); + expect(html).toMatch(/<base\s+href="\/"\s*\/?>/); + }); + + test("no component uses a root-absolute asset URL", async () => { + const offenders: string[] = []; + + for await (const relative of new Bun.Glob("**/*.tsx").scan({ cwd: frontendDir })) { + const source = await Bun.file(resolve(frontendDir, relative)).text(); + + for (const [match] of source.matchAll(/\b(?:src|srcSet|poster)=["']\/[^/][^"']*["']/g)) { + offenders.push(`${relative}: ${match}`); + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/apps/frontend/components/ui/button.tsx b/src/apps/frontend/components/ui/button.tsx new file mode 100644 index 0000000..730e50f --- /dev/null +++ b/src/apps/frontend/components/ui/button.tsx @@ -0,0 +1,52 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />; +} + +export { Button, buttonVariants }; diff --git a/src/apps/frontend/components/ui/card.tsx b/src/apps/frontend/components/ui/card.tsx new file mode 100644 index 0000000..6b44ad4 --- /dev/null +++ b/src/apps/frontend/components/ui/card.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card" + className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", + className, + )} + {...props} + /> + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-action" + className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} + {...props} + /> + ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return <div data-slot="card-content" className={cn("px-6", className)} {...props} />; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> + ); +} + +export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/src/apps/frontend/components/ui/input.tsx b/src/apps/frontend/components/ui/input.tsx new file mode 100644 index 0000000..f16c2c0 --- /dev/null +++ b/src/apps/frontend/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + className, + )} + {...props} + /> + ); +} + +export { Input }; diff --git a/src/apps/frontend/components/ui/label.tsx b/src/apps/frontend/components/ui/label.tsx new file mode 100644 index 0000000..8e82341 --- /dev/null +++ b/src/apps/frontend/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import * as LabelPrimitive from "@radix-ui/react-label"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className, + )} + {...props} + /> + ); +} + +export { Label }; diff --git a/src/apps/frontend/components/ui/select.tsx b/src/apps/frontend/components/ui/select.tsx new file mode 100644 index 0000000..71e2a2c --- /dev/null +++ b/src/apps/frontend/components/ui/select.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { + return <SelectPrimitive.Root data-slot="select" {...props} />; +} + +function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { + return <SelectPrimitive.Group data-slot="select-group" {...props} />; +} + +function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { + return <SelectPrimitive.Value data-slot="select-value" {...props} />; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { + size?: "sm" | "default"; +}) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + data-size={size} + className={cn( + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDownIcon className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> + ); +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps<typeof SelectPrimitive.Content>) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + data-slot="select-content" + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + align={align} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { + return ( + <SelectPrimitive.Label + data-slot="select-label" + className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} + {...props} + /> + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className, + )} + {...props} + > + <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <CheckIcon className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} + {...props} + /> + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { + return ( + <SelectPrimitive.ScrollUpButton + data-slot="select-scroll-up-button" + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props} + > + <ChevronUpIcon className="size-4" /> + </SelectPrimitive.ScrollUpButton> + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { + return ( + <SelectPrimitive.ScrollDownButton + data-slot="select-scroll-down-button" + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props} + > + <ChevronDownIcon className="size-4" /> + </SelectPrimitive.ScrollDownButton> + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/src/apps/frontend/components/ui/textarea.tsx b/src/apps/frontend/components/ui/textarea.tsx new file mode 100644 index 0000000..0735a8c --- /dev/null +++ b/src/apps/frontend/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + <textarea + data-slot="textarea" + className={cn( + "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className, + )} + {...props} + /> + ); +} + +export { Textarea }; diff --git a/src/apps/frontend/globals.css b/src/apps/frontend/globals.css new file mode 100644 index 0000000..6f499d8 --- /dev/null +++ b/src/apps/frontend/globals.css @@ -0,0 +1,120 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/apps/frontend/index.html b/src/apps/frontend/index.html new file mode 100644 index 0000000..77029f2 --- /dev/null +++ b/src/apps/frontend/index.html @@ -0,0 +1,29 @@ +<!doctype html> +<!-- `dark`: the shadcn theme is class-driven. Remove the class for a light UI; without it every + primitive renders its light palette. --> +<html lang="en" class="dark"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <!-- Every relative asset URL on the page resolves against this. + Served directly it stays "/", the app's real root. Served through a path-prefix proxy + the app lives under /<prefix>/, and a proxy that rewrites root-relative href/src in + the HTML — so this becomes that prefix and relative URLs follow the app instead of pointing + at the platform root. Absolute paths in JS get no such treatment, which is why asset URLs in + components are relative rather than starting with "/". --> + <base href="/" /> + <title>App + + + + + + + + +
+ + diff --git a/src/apps/frontend/index.tsx b/src/apps/frontend/index.tsx new file mode 100644 index 0000000..6b844ba --- /dev/null +++ b/src/apps/frontend/index.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./globals.css"; + +const elem = document.getElementById("root")!; +const app = ( + + + +); + +(import.meta.hot.data.root ??= createRoot(elem)).render(app); diff --git a/src/apps/frontend/lib/api.ts b/src/apps/frontend/lib/api.ts new file mode 100644 index 0000000..7d259e8 --- /dev/null +++ b/src/apps/frontend/lib/api.ts @@ -0,0 +1,25 @@ +/** + * Every call the UI makes to its own server goes through here. + * + * Paths are absolute (`/api/...`) rather than relative, which is the opposite of the rule for assets. + * A path-prefix proxy that patches `window.fetch` rewrites absolute paths onto its prefix; it does + * not touch ``. So fetch wants the leading slash and images must not have one. + */ +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: init?.body ? { "content-type": "application/json", ...init?.headers } : init?.headers, + }); + + const body = await response.json().catch(() => null); + + if (!response.ok) { + // The server speaks one error envelope: { error: { message, type, param, code } }. + const message = (body as { error?: { message?: string } } | null)?.error?.message; + throw new Error(message || `Request failed with ${response.status}`); + } + + return body as T; +} + +export const health = () => request<{ status: string; env: string }>("/api/health"); diff --git a/src/apps/frontend/lib/routes.test.ts b/src/apps/frontend/lib/routes.test.ts new file mode 100644 index 0000000..d4af2c6 --- /dev/null +++ b/src/apps/frontend/lib/routes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { parseHash, TAB_LABELS, TABS } from "./routes"; + +describe("parseHash", () => { + test.each(["#home"])("reads %s", (hash) => { + expect(parseHash(hash).tab).toBe(hash.slice(1) as never); + }); + + // The hash a browser produces varies: some paths write `#/gallery`, and copied links arrive + // capitalised. All of them mean the same tab. + test.each(["#/home", "#Home", "home"])("normalises %s", (hash) => { + expect(parseHash(hash).tab).toBe("home"); + }); + + // A refresh on the bare URL, and any hash left over from elsewhere, land somewhere valid rather + // than rendering nothing. + test.each(["", "#", "#nope", "#/../etc"])("falls back to home for %p", (hash) => { + expect(parseHash(hash).tab).toBe("home"); + }); +}); + +describe("nested routes", () => { + test("a collection id rides along in param", () => { + const route = parseHash("#home/0004853c-e097-4af9-b40b-0666594703cf"); + expect(route.tab).toBe("home"); + expect(route.param).toBe("0004853c-e097-4af9-b40b-0666594703cf"); + }); + + test("a bare tab has no param", () => expect(parseHash("#home").param).toBeUndefined()); + + // A trailing slash is what you get from hand-editing the address bar. + test("a trailing slash is not a param", () => expect(parseHash("#home/").param).toBeUndefined()); + + test("an unknown tab with a param still falls back", () => { + expect(parseHash("#nope/123").tab).toBe("home"); + }); +}); + +describe("tabs", () => { + test("every tab has a label", () => { + for (const tab of TABS) expect(TAB_LABELS[tab]).toBeTruthy(); + }); + + test("the hash written for a tab parses back to it", () => { + for (const tab of TABS) expect(parseHash(`#${tab}`).tab).toBe(tab); + }); +}); diff --git a/src/apps/frontend/lib/routes.ts b/src/apps/frontend/lib/routes.ts new file mode 100644 index 0000000..7142f57 --- /dev/null +++ b/src/apps/frontend/lib/routes.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +export const TABS = ["home"] as const; +export type Tab = (typeof TABS)[number]; + +export const TAB_LABELS: Record = { + home: "Home", +}; + +const DEFAULT_TAB: Tab = "home"; + +/** + * Routing lives in the hash — `#gallery` — not in the path. + * + * Not a shortcut around a real router. The app is served at `/` directly and under + * `//` behind a path-prefix proxy, so a path router needs a basename it can only + * learn at runtime, and getting it wrong pushes a URL outside the proxy prefix — the same failure that + * made the splash image a broken icon, except this one takes the whole page down rather than one + * ``. + * + * The hash is invisible to all of that. `#gallery` means the same thing at `/` and at + * `//`, needs no server route, no basename and no build-time knowledge of where the + * app is mounted. Back and forward work, links are copyable, and a refresh lands where it left. + * + * When there is a reason for real paths — a URL per generation, say — react-router with a basename + * derived from `document.baseURI` is the upgrade. Three tabs is not that reason. + */ +/** + * `#tab/` — a tab and, optionally, one thing within it. + * + * One level of nesting, not a route tree. Anything deeper is the point at which react-router with a + * basename derived from `document.baseURI` becomes the right upgrade. + */ +export type Route = { tab: Tab; param?: string }; + +export function parseHash(hash: string): Route { + const [head, param] = hash.replace(/^#\/?/, "").split("/"); + const tab = (head ?? "").toLowerCase(); + + return { + tab: (TABS as readonly string[]).includes(tab) ? (tab as Tab) : DEFAULT_TAB, + param: param || undefined, + }; +} + +export function useHashRoute() { + const [route, setRoute] = useState(() => + typeof window === "undefined" ? { tab: DEFAULT_TAB } : parseHash(window.location.hash), + ); + + useEffect(() => { + const onChange = () => setRoute(parseHash(window.location.hash)); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + + /** For navigating without a click; anchors handle the rest. */ + const navigate = (tab: Tab, param?: string) => { + const next = param ? `${tab}/${param}` : tab; + if (window.location.hash.replace(/^#\/?/, "") !== next) window.location.hash = next; + }; + + return { ...route, navigate }; +} diff --git a/src/apps/frontend/lib/utils.ts b/src/apps/frontend/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/src/apps/frontend/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/databases/app_db/drizzle.config.ts b/src/databases/app_db/drizzle.config.ts new file mode 100644 index 0000000..a33a16f --- /dev/null +++ b/src/databases/app_db/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; +import { postgresUrl } from "./src/env"; + +// postgresUrl() reads this project's env files in preference to the ambient environment and refuses +// any database that does not match APP_ENV. Without that, drizzle-kit resolves whatever +// POSTGRES_URL happens to be exported into the shell and offers to push this schema onto it. +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "postgresql", + dbCredentials: { url: postgresUrl() }, +}); diff --git a/src/databases/app_db/package.json b/src/databases/app_db/package.json new file mode 100644 index 0000000..722c6d9 --- /dev/null +++ b/src/databases/app_db/package.json @@ -0,0 +1,18 @@ +{ + "name": "appdb", + "version": "0.0.1", + "main": "src/index.ts", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./db": "./src/db.ts", + "./schema": "./src/schema.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "generate": "drizzle-kit generate --config=drizzle.config.ts", + "push": "drizzle-kit push --config=drizzle.config.ts", + "studio": "drizzle-kit studio --config=drizzle.config.ts" + }, + "license": "MIT" +} diff --git a/src/databases/app_db/src/db.ts b/src/databases/app_db/src/db.ts new file mode 100644 index 0000000..30a3f7d --- /dev/null +++ b/src/databases/app_db/src/db.ts @@ -0,0 +1,58 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; +import { postgresUrl } from "./env"; + +/** + * One client per PROCESS, not one per module evaluation. + * + * `bun --hot` re-evaluates this module on every edit. Without the global, each reload builds a fresh + * pool and abandons the previous one — its sockets stay open, and any query in flight when the swap + * happened never resolves and never releases its connection. Connections climb with every save, and a + * pool whose connections are all reserved by promises nobody holds queues the next query forever: + * fast on everything stateless, hung on everything that reads. + */ +const globalForDb = globalThis as unknown as { __appPgClient?: ReturnType }; + +const client = (globalForDb.__appPgClient ??= postgres(postgresUrl(), { + max: 10, + // Recycle idle connections, so one left in a bad state by a failed query is replaced rather than + // poisoning the pool for the life of the process. + idle_timeout: 30, + connect_timeout: 10, + // Bounds the server-side half of a hang; the global above bounds the client-side half. + connection: { statement_timeout: 15_000 }, +})); + +export const db = drizzle(client, { schema }); + +/** + * Block until Postgres answers, or give up. + * + * `postgres()` is LAZY — no socket opens until the first query — so nothing fails at import when the + * database is not up yet. That is the right default and it has a cost: startup work that queries + * fires once, fails, and is swallowed by whatever `.catch()` it was written with. Callers that + * genuinely cannot proceed without the database await this first. + */ +export async function waitForDatabase(timeoutMs = 30_000): Promise { + const started = Date.now(); + let announced = false; + + for (;;) { + try { + await client`select 1`; + if (announced) console.log("[db] Postgres is up"); + return true; + } catch (error) { + if (Date.now() - started >= timeoutMs) { + console.error("[db] Postgres did not answer:", error instanceof Error ? error.message : error); + return false; + } + if (!announced) { + console.log("[db] waiting for Postgres…"); + announced = true; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } +} diff --git a/src/databases/app_db/src/env.ts b/src/databases/app_db/src/env.ts new file mode 100644 index 0000000..2f6a802 --- /dev/null +++ b/src/databases/app_db/src/env.ts @@ -0,0 +1,40 @@ +import { isProduction, readEnvFile } from "../../../env-file"; + +/** Shared with the server — see src/env-file.ts for why the ambient environment comes last. */ +export const projectEnv = readEnvFile; + +/** + * Which database this process may talk to, decided by APP_ENV and ENFORCED, not merely configured. + * + * APP_ENV=production -> app (POSTGRES_URL, from .env.production.local) + * APP_ENV=development -> app_dev (POSTGRES_URL_DEV, from .env) + * + * Any other pairing throws before a connection is opened, so a development process cannot reach + * production data even if someone edits the URL, and the test suite cannot write to rows a live + * instance is serving. + * + * The check is on the DATABASE NAME rather than on the whole URL, because host and credentials are + * usually shared: one word is all that separates the two, and it is at the end of a long string + * nobody reads carefully. + * + * `db:push` inherits the rule, so schema changes land on app_dev unless APP_ENV=production is + * stated out loud — which is the only time a migration should be near production anyway. + */ +export function postgresUrl(): string { + const production = isProduction(); + const expected = production ? "app" : "app_dev"; + const key = production ? "POSTGRES_URL" : "POSTGRES_URL_DEV"; + + const url = projectEnv(key); + if (!url) throw new Error(`${key} is required — set it in .env`); + + const name = new URL(url).pathname.replace(/^\//, ""); + if (name !== expected) { + throw new Error( + `Refusing to connect: APP_ENV=${process.env.APP_ENV} requires database "${expected}", ` + + `but ${key} points at "${name}".`, + ); + } + + return url; +} diff --git a/src/databases/app_db/src/index.ts b/src/databases/app_db/src/index.ts new file mode 100644 index 0000000..8a7a563 --- /dev/null +++ b/src/databases/app_db/src/index.ts @@ -0,0 +1,4 @@ +// One `export *` per feature, and nothing else. What a feature exports is declared in its own +// index.ts, beside the code it describes. + +export { db, waitForDatabase } from "./db"; diff --git a/src/databases/app_db/src/schema.ts b/src/databases/app_db/src/schema.ts new file mode 100644 index 0000000..90a74cb --- /dev/null +++ b/src/databases/app_db/src/schema.ts @@ -0,0 +1,16 @@ +// What drizzle-kit creates, and the runtime's view of the tables. +// +// Its own file rather than pointing at the feature directories, so a table can be taken out of the +// DATABASE by commenting one line here without removing a line of code — every query imports its +// tables from its own feature's ./schema. +// +// Empty on purpose. There is nothing to persist yet, and a speculative table is a guess that costs a +// migration to correct. Add the first feature when something real needs storing: +// +// src/databases/app_db/src//schema.ts the tables +// src/databases/app_db/src//queries.ts everything that reads or writes them +// src/databases/app_db/src//index.ts export * from both +// +// then one `export * from ".//schema"` here and one in index.ts, and `bun run db:push`. + +export {}; diff --git a/src/env-file.ts b/src/env-file.ts new file mode 100644 index 0000000..28f2fcc --- /dev/null +++ b/src/env-file.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * Which deployment this process is. Required, namespaced, and never inferred. + * + * NOT NODE_ENV. A host that exports NODE_ENV into the processes it spawns makes `bun dev` look like + * production to anything that asks, and the failure is silent — you get a plausible wrong value + * rather than an error. APP_ENV is prefixed, so nothing else on any machine sets it. + * + * NODE_ENV is still set by both scripts, deliberately: nothing here reads it, but third-party + * packages resolve conditional exports against it, and the build and the runtime have to agree. + * + * It THROWS when unset rather than defaulting. Either default is a silent disaster — assume + * production and a stray process writes to live data; assume development and the real instance + * serves the wrong thing. Both package scripts set it, so arriving here unset means someone bypassed + * them, which is exactly when nothing should be guessed. + */ +export function appEnv(): "production" | "development" { + const value = process.env.APP_ENV; + if (value === "production" || value === "development") return value; + + throw new Error( + `APP_ENV must be "production" or "development", got ${JSON.stringify(value)}. ` + + "Start through `bun start` or `bun dev`, which set it.", + ); +} + +export const isProduction = () => appEnv() === "production"; + +/** + * Configuration from this project's env files, with the ambient environment consulted LAST. + * + * .env.production.local only when APP_ENV=production + * .env.local a machine's own overrides + * .env the shared baseline + * process.env last resort + * + * Files before environment, which is the reverse of the usual convention and is the point: an + * inherited variable should never quietly outrank a value written down in this repo. Production runs + * with its own `.env.production.local`, which leaves `.env` free to be the development instance's. + */ +export function readEnvFile(key: string): string | undefined { + const candidates = [isProduction() ? ".env.production.local" : null, ".env.local", ".env"].filter( + (name): name is string => name !== null, + ); + + for (const name of candidates) { + const path = resolve(ROOT, name); + if (!existsSync(path)) continue; + + for (const line of readFileSync(path, "utf-8").split("\n")) { + const match = line.match(/^\s*(\w+)\s*=\s*(.*)$/); + if (match?.[1] === key) { + const value = match[2]!.trim().replace(/^["']|["']$/g, ""); + if (value) return value; + } + } + } + + return process.env[key]; +} + +/** The absolute base URL this site is served from — used anywhere a URL must survive leaving here. */ +export const PUBLIC_URL = (readEnvFile("PUBLIC_URL") ?? "http://localhost:3000").replace(/\/+$/, ""); + +{ + const url = new URL(PUBLIC_URL); + const isLoopback = ["localhost", "127.0.0.1", "::1", "0.0.0.0"].includes(url.hostname); + + // A URL that travels has no local case: `localhost` on the receiving machine is that machine. And + // iOS and Android both refuse cleartext silently — no error, just nothing rendering. + if (isProduction() && isLoopback) { + console.warn(`[env] PUBLIC_URL is a loopback address (${PUBLIC_URL}) in production.`); + } else if (!isLoopback && url.protocol === "http:") { + console.warn(`[env] PUBLIC_URL is cleartext (${PUBLIC_URL}). Mobile clients refuse http silently.`); + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..72507e2 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,47 @@ +import { existsSync } from "node:fs"; +import { serve } from "bun"; +import { honoServer } from "./servers/hono"; +import index from "./apps/frontend/index.gen.html"; +import { isProduction, PUBLIC_URL } from "./env-file"; + +// BUN_PORT rather than PORT: a PORT inherited from a parent process would be used silently, binding +// a port that belongs to something else. Bun reads .env from the working directory, so start through +// `bun dev` / `bun start`. +const port = Number(process.env.BUN_PORT ?? 3000); + +// Static files from public/. +// A snapshot taken once at boot: one exact route per file, each holding a `Bun.file` handle opened +// now. Exact routes rather than a wildcard, so a request for a file that does not exist falls through +// to the app below rather than answering 500 from a missing handle. +const publicRoutes: Record Response> = {}; +if (existsSync("./public")) { + for await (const file of new Bun.Glob("**").scan({ cwd: "./public" })) { + const bunFile = Bun.file(`./public/${file}`); + publicRoutes[`/${file}`] = () => new Response(bunFile); + } +} + +const server = serve({ + port, + + // Bun's default is 10 SECONDS, which silently kills anything held open longer — a long poll, a + // slow upstream, a large upload. 255 is the ceiling; 256 is rejected outright. + idleTimeout: 255, + + routes: { + ...publicRoutes, + + // Hono owns everything under /api. Bun matches this table before its own fallback. + "/api/*": (req: Request, server) => honoServer.fetch(req, server), + + // The SPA. Every unmatched path returns the same document and the client router reads the hash. + // Last, so public files and /api resolve first. + "/*": index, + }, + + // isProduction() rather than NODE_ENV: a host that exports NODE_ENV=production into every process + // would otherwise leave the development server running without HMR and never say so. + development: !isProduction() && { hmr: true, console: true }, +}); + +console.log(`🚀 app running at ${server.url} (public: ${PUBLIC_URL})`); diff --git a/src/servers/api/health.ts b/src/servers/api/health.ts new file mode 100644 index 0000000..c3711bb --- /dev/null +++ b/src/servers/api/health.ts @@ -0,0 +1,6 @@ +import { createRouter } from "../create-router"; +import { appEnv } from "../../env-file"; + +export const healthRouter = createRouter(); + +healthRouter.get("/", (ctx) => ctx.json({ status: "ok", env: appEnv(), at: new Date().toISOString() })); diff --git a/src/servers/create-router.ts b/src/servers/create-router.ts new file mode 100644 index 0000000..9680650 --- /dev/null +++ b/src/servers/create-router.ts @@ -0,0 +1,11 @@ +import { Hono } from "hono"; + +/** + * Request-scoped state shared by every router. + * + * Empty for now. Fields land here as middleware starts setting them, so a route reading `ctx.get(...)` + * is typed by the same declaration the middleware writing it is checked against. + */ +export type HonoVariables = {}; + +export const createRouter = () => new Hono<{ Variables: HonoVariables }>(); diff --git a/src/servers/errors.ts b/src/servers/errors.ts new file mode 100644 index 0000000..fd0be0b --- /dev/null +++ b/src/servers/errors.ts @@ -0,0 +1,10 @@ +/** + * One error envelope, shared by every route. + * + * In one place because it is a contract rather than a convenience: a client reads `err.error.message`, + * so a route that answers with a bare string does not merely return the wrong shape — it breaks the + * parse. `error` is always an object. + */ +export const apiError = (message: string, type: string, param?: string) => ({ + error: { message, type, param: param ?? null, code: null }, +}); diff --git a/src/servers/hono.test.ts b/src/servers/hono.test.ts new file mode 100644 index 0000000..ad42261 --- /dev/null +++ b/src/servers/hono.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { API_PREFIXES, honoServer } from "./hono"; + +describe("the API app", () => { + test("answers an unknown /api path with JSON in the error envelope, not HTML", async () => { + const res = await honoServer.request("/api/nothing-here"); + + expect(res.status).toBe(404); + // A JSON body matters: the site's catch-all answers everything else with a document, and an HTML + // 404 reaching a fetch() surfaces as "Unexpected token '<'" rather than as a missing route. + expect(res.headers.get("content-type")).toContain("application/json"); + const body = (await res.json()) as { error: { message: string; type: string } }; + expect(typeof body.error).toBe("object"); + expect(body.error.type).toBe("not_found"); + }); + + test("the mount list is well formed", () => { + expect(API_PREFIXES.length).toBeGreaterThan(0); + expect(API_PREFIXES.every((prefix) => prefix.startsWith("/"))).toBe(true); + expect(new Set(API_PREFIXES).size).toBe(API_PREFIXES.length); + }); + + test("/api/health answers", async () => { + const res = await honoServer.request("/api/health"); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("ok"); + }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts new file mode 100644 index 0000000..9a0af04 --- /dev/null +++ b/src/servers/hono.ts @@ -0,0 +1,33 @@ +import type { Hono } from "hono"; +import { createRouter } from "./create-router"; +import type { HonoVariables } from "./create-router"; +import { apiError } from "./errors"; +import { healthRouter } from "./api/health"; + +/** + * Every API router and the prefix it answers on. + * + * One list rather than a series of `app.route` calls, so the set of mounted prefixes is a value the + * rest of the server can read instead of something only the router knows. + */ +const MOUNTS: [string, Hono<{ Variables: HonoVariables }>][] = [["/health", healthRouter]]; + +export const API_PREFIXES: string[] = MOUNTS.map(([prefix]) => prefix); + +function buildHonoApp() { + const app = createRouter(); + const api = createRouter(); + + for (const [prefix, router] of MOUNTS) api.route(prefix, router); + app.route("/api", api); + + app.notFound((ctx) => ctx.json(apiError("Not found", "not_found"), 404)); + app.onError((error, ctx) => { + console.error(error); + return ctx.json(apiError("Internal Server Error", "server_error"), 500); + }); + + return app; +} + +export const honoServer = buildHonoApp(); diff --git a/src/servers/serialize.test.ts b/src/servers/serialize.test.ts new file mode 100644 index 0000000..c975a05 --- /dev/null +++ b/src/servers/serialize.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import * as serializers from "./serialize"; + +/** + * One sample per serializer. The test below fails if this map does not cover every function exported + * from ./serialize, so a new serializer cannot be added without a case — and every case is checked + * for camelCase keys. Test the rule, not the incident that motivated it. + */ +const CASES: Record unknown> = { + // serializeThing: () => serializers.serializeThing(sampleThing()), +}; + +const exported = Object.entries(serializers) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name); + +const CAMEL = /[a-z][A-Z]/; + +describe("every serializer", () => { + test("has a case in this file", () => { + expect(exported.filter((name) => !(name in CASES))).toEqual([]); + }); + + test.each(exported)("%s emits no camelCase keys", (name) => { + const output = CASES[name]!() as Record; + expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]); + }); +}); diff --git a/src/servers/serialize.ts b/src/servers/serialize.ts new file mode 100644 index 0000000..75f0bfb --- /dev/null +++ b/src/servers/serialize.ts @@ -0,0 +1,13 @@ +/** + * The one place a database row becomes JSON. + * + * Every API serializer lives in this file, and that is a structural choice rather than tidiness: the + * test beside it walks this module's exports and fails if any of them emits a camelCase key, or if a + * serializer is added without a case. Drizzle hands back camelCase because that is what the schema + * declares in TypeScript; the wire is snake_case; this is where one becomes the other, and keeping + * them in their feature folders is how a payload ships in the wrong case without anything noticing. + * + * Empty until there is a table. Add serializers here as features gain rows. + */ + +export {}; diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..ab1db0d --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,3 @@ +// Tests are a development process. Declared here rather than left to the caller: APP_ENV is +// required and fails closed, and `bun test` is run directly rather than through a package script. +process.env.APP_ENV ??= "development"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aed0cca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "lib": [ + "ESNext", + "DOM" + ], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": [ + "bun", + "react" + ], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "paths": { + "@/*": [ + "./src/apps/frontend/*" + ], + "appdb": [ + "./src/databases/app_db/src/index.ts" + ], + "appdb/*": [ + "./src/databases/app_db/src/*" + ], + "@/public/*": [ + "./public/*" + ] + }, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "exclude": [ + "dist", + "node_modules" + ] +}