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 <base href="/"> 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 02:02:52 +00:00
co-authored by Claude Opus 5
commit c08dab4243
47 changed files with 2424 additions and 0 deletions
+12
View File
@@ -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
+18
View File
@@ -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
+51
View File
@@ -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 `/`.** `<base href="/">` 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 <file>` 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
+308
View File
@@ -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<typeof useFeatureManager>;
// Parent component
const Parent = () => {
const manager = useFeatureManager();
return <Child manager={manager} />;
};
// 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<string | null>(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 <div>{/* ... */}</div>;
};
// 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 (
<>
<Header />
<Content />
</>
);
// Good - key required
import { Fragment } from 'react';
return items.map((item) => (
<Fragment key={item.id}>
<ItemHeader item={item} />
<ItemContent item={item} />
</Fragment>
));
```
**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/<name>.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/<Feature>/ 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
<img src="hero.webp" /> // ✅ resolves against <base>
<img src="/hero.webp" /> // ❌ escapes a path-prefix proxy
```
The app is served at `/` directly, and may be served under a prefix by a proxy. `<base href="/">` 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.
+84
View File
@@ -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` `<title>` |
| `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 |
+26
View File
@@ -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`);
}
+43
View File
@@ -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;
}
+363
View File
@@ -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=="],
}
}
+8
View File
@@ -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"]
+21
View File
@@ -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"
}
+112
View File
@@ -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.
+89
View File
@@ -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.
+41
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
Static files served from the root. Anything here is public.
+73
View File
@@ -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})`);
}
+51
View File
@@ -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;
+34
View File
@@ -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([]);
});
});
@@ -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 };
+56
View File
@@ -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 };
+21
View File
@@ -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 };
+21
View File
@@ -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 };
+162
View File
@@ -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,
};
@@ -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 };
+120
View File
@@ -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;
}
}
+29
View File
@@ -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</title>
<!-- Favicons. These must stay 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").
__PUBLIC_URL__ is substituted by scripts/gen-index.ts into index.gen.html, which is what the
server imports. -->
<link rel="icon" type="image/x-icon" href="__PUBLIC_URL__/favicon.ico" />
<link rel="icon" type="image/png" sizes="256x256" href="__PUBLIC_URL__/icon-256.png" />
<script type="module" src="./index.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+13
View File
@@ -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 = (
<StrictMode>
<App />
</StrictMode>
);
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
+25
View File
@@ -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 `<img src>`. So fetch wants the leading slash and images must not have one.
*/
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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");
+47
View File
@@ -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);
});
});
+64
View File
@@ -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<Tab, string> = {
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
* `/<prefix>/` 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
* `<img>`.
*
* The hash is invisible to all of that. `#gallery` means the same thing at `/` and at
* `/<prefix>/`, 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/<id>` — 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<Route>(() =>
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 };
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+12
View File
@@ -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() },
});
+18
View File
@@ -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"
}
+58
View File
@@ -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<typeof postgres> };
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<boolean> {
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));
}
}
}
+40
View File
@@ -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;
}
+4
View File
@@ -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";
+16
View File
@@ -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/<feature>/schema.ts the tables
// src/databases/app_db/src/<feature>/queries.ts everything that reads or writes them
// src/databases/app_db/src/<feature>/index.ts export * from both
//
// then one `export * from "./<feature>/schema"` here and one in index.ts, and `bun run db:push`.
export {};
+81
View File
@@ -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.`);
}
}
+47
View File
@@ -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<string, (req: Request) => 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})`);
+6
View File
@@ -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() }));
+11
View File
@@ -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 }>();
+10
View File
@@ -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 },
});
+28
View File
@@ -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");
});
});
+33
View File
@@ -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();
+28
View File
@@ -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<string, () => 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<string, unknown>;
expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]);
});
});
+13
View File
@@ -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 {};
+3
View File
@@ -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";
+47
View File
@@ -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"
]
}