Compare commits
17 Commits
d9210b3b0b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 393ff406a3 | |||
| 9073e38238 | |||
| 27e5f3c76f | |||
| 4a78ba2882 | |||
| fafe02bdbd | |||
| 815ee31f95 | |||
| f8af18cff5 | |||
| 34d2cbb1cd | |||
| ce39faf778 | |||
| cc3a5dc5ad | |||
| d206a3315f | |||
| c6027590a7 | |||
| dd1facd240 | |||
| 5174cff3c5 | |||
| 2209ab0b27 | |||
| 7f4cfc8973 | |||
| afc8b0b077 |
@@ -1 +1,6 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
|
||||||
|
# Optional logging knobs (defaults are usually fine):
|
||||||
|
# LOG_LEVEL=info # trace|debug|info|warning|error|fatal
|
||||||
|
# LOG_FORMAT=pretty # pretty|json — defaults to TTY ? pretty : json
|
||||||
|
# LOG_DB=false # true to log every Drizzle SQL query
|
||||||
|
|||||||
+14
-145
@@ -1,154 +1,23 @@
|
|||||||
### Custom ###
|
# Dependencies
|
||||||
|
|
||||||
# TanStack
|
|
||||||
.tanstack/
|
|
||||||
|
|
||||||
# Nitro
|
|
||||||
.output/
|
|
||||||
|
|
||||||
# Bun build
|
|
||||||
*.bun-build
|
|
||||||
|
|
||||||
### Node ###
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
# Build output
|
||||||
web_modules/
|
.output/
|
||||||
|
.tanstack/
|
||||||
# TypeScript cache
|
.vite/
|
||||||
|
out/
|
||||||
|
*.bun-build
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Env
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# Logs
|
||||||
.cache
|
*.log
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
# OS
|
||||||
.next
|
.DS_Store
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
.output
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Sveltekit cache directory
|
|
||||||
.svelte-kit/
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# Firebase cache directory
|
|
||||||
.firebase/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v3
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/sdks
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# Vite files
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
.vite/
|
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"biomejs.biome",
|
"biomejs.biome",
|
||||||
|
"codezombiech.gitignore",
|
||||||
"hverlin.mise-vscode",
|
"hverlin.mise-vscode",
|
||||||
"oven.bun-vscode",
|
"oven.bun-vscode",
|
||||||
"redhat.vscode-yaml",
|
"redhat.vscode-yaml",
|
||||||
"tamasfe.even-better-toml"
|
"tamasfe.even-better-toml",
|
||||||
|
"unional.vscode-sort-package-json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,31 @@ Compact, repo-specific notes for AI agents. Generic language/framework knowledge
|
|||||||
## Stack & runtime
|
## Stack & runtime
|
||||||
|
|
||||||
- **Bun-only** (`mise.toml` pins `bun = 1.3.13`). Never invoke `npm`/`npx`/`node`/`yarn`/`pnpm`. Use `bun run <script>` (bare `bun <script>` can collide with Bun built-in subcommands).
|
- **Bun-only** (`mise.toml` pins `bun = 1.3.13`). Never invoke `npm`/`npx`/`node`/`yarn`/`pnpm`. Use `bun run <script>` (bare `bun <script>` can collide with Bun built-in subcommands).
|
||||||
- TanStack Start (React 19 SSR, file-routed) + Vite 8 + Nitro (nightly, preset `bun`). Vite dev port is **strict 3000**.
|
- **Prefer Bun-native APIs over external packages and `node:*` polyfills.** UUIDv7 in app code → `Bun.randomUUIDv7()` (not the `uuid` package); DB primary keys are a separate matter — those go through PG18's `uuidv7()`, see "Drizzle" section. SHA-256 → `Bun.CryptoHasher.hash('sha256', s, 'hex')` (not `node:crypto.createHash`); short sleeps → `Bun.sleep(ms)` (not raw `setTimeout` with promise wrapping); file I/O in build scripts → `Bun.file` / `Bun.write` are fine. The runtime is Bun, the deployment target is Bun, the test runner is Bun — there is no "portability" concern that would justify dragging in npm packages or Node compat shims for things Bun ships natively.
|
||||||
- PostgreSQL + **Drizzle ORM `0.45.2` (0.x, NOT 1.0 beta)** — see "Drizzle" section, this matters a lot.
|
- TanStack Start (React 19 SSR, file-routed) + Vite 8 + Nitro (nightly, preset `bun`). Dev server defaults to Vite's port (3000); not pinned, override via `vite dev --port <n>` if you need to.
|
||||||
|
- **PostgreSQL 18+ only** (`compose.yaml` pins `postgres:18-alpine`). The starter relies on PG18's built-in `uuidv7()` function for primary-key generation — see "Drizzle" section. Do not soften this to support older PG; if you need PG <18 compatibility, fork and reintroduce app-side UUIDv7 (e.g. `Bun.randomUUIDv7()` or the `uuid` package) yourself.
|
||||||
|
- **Drizzle ORM `0.45.2` (0.x, NOT 1.0 beta)** — see "Drizzle" section, this matters a lot.
|
||||||
- ORPC (contract-first), TanStack Query v5, Tailwind v4.
|
- ORPC (contract-first), TanStack Query v5, Tailwind v4.
|
||||||
|
- **Logging via [LogTape](https://logtape.org/)** (zero-dep, runtime-agnostic) — see "Logging" section. `console.*` is forbidden in business code.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev # bunx --bun vite dev (localhost:3000)
|
bun run dev # bunx --bun vite dev (localhost:3000)
|
||||||
bun run build # bunx --bun vite build → .output/
|
bun run build # bunx --bun vite build → .output/
|
||||||
bun run compile # bun compile.ts → out/server-<target> (standalone CLI binary)
|
bun run compile # bun scripts/compile.ts → out/server-<target> (standalone CLI binary)
|
||||||
bun run cli <cmd> # bun bin.ts <cmd> — run a CLI subcommand in source (dev)
|
bun run cli <cmd> # bun src/bin.ts <cmd> — run a CLI subcommand in source (dev)
|
||||||
bun run typecheck # tsc --noEmit
|
bun run typecheck # tsc --noEmit
|
||||||
bun run test # bun test — runs all *.test.ts files (colocated with source)
|
bun run test # bun test — runs all *.test.ts files (colocated with source)
|
||||||
bun run fix # biome check --write (lint + format + organize imports)
|
bun run fix # biome check --write (lint + format + organize imports)
|
||||||
bun run db:push # dev only — push schema to DB, no migration file
|
bun run db:push # dev only — push schema to DB, no migration file
|
||||||
bun run db:generate # drizzle-kit generate && embed-migrations.ts (regenerates migrations.gen.ts)
|
bun run db:generate # drizzle-kit generate && scripts/embed-migrations.ts (regenerates migrations.gen.ts)
|
||||||
bun run db:embed # embed-migrations.ts only — regenerate migrations.gen.ts from ./drizzle/
|
bun run db:embed # scripts/embed-migrations.ts only — regenerate migrations.gen.ts from ./drizzle/
|
||||||
bun run db:migrate # apply migrations via drizzle-kit (local dev convenience; prod uses ./server migrate)
|
bun run db:migrate # apply migrations via drizzle-kit (local dev convenience; prod uses ./server migrate)
|
||||||
bun run db:studio # Drizzle Studio
|
bun run db:studio # Drizzle Studio
|
||||||
```
|
```
|
||||||
|
|
||||||
Cross-compile targets live under `compile:{linux,darwin,windows}[:arch]`. `compile.ts` accepts `--target bun-<os>-<arch>`; default derives from host.
|
Cross-compile targets live under `compile:{linux,darwin,windows}[:arch]`. `scripts/compile.ts` accepts `--target bun-<os>-<arch>`; default derives from host.
|
||||||
|
|
||||||
Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no pre-commit hooks, no lint-staged — so these are on you.
|
Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no pre-commit hooks, no lint-staged — so these are on you.
|
||||||
|
|
||||||
@@ -45,14 +48,14 @@ Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no
|
|||||||
```
|
```
|
||||||
Do NOT use the v2 object form (`orderBy: { createdAt: 'desc' }`, `where: { id }`) — it won't type-check.
|
Do NOT use the v2 object form (`orderBy: { createdAt: 'desc' }`, `where: { id }`) — it won't type-check.
|
||||||
- To add relations later: declare per-table with `relations()` from `drizzle-orm` and export them from the same file as the table; they get picked up automatically because `index.ts` does `drizzle({ schema })` via `import *`.
|
- To add relations later: declare per-table with `relations()` from `drizzle-orm` and export them from the same file as the table; they get picked up automatically because `index.ts` does `drizzle({ schema })` via `import *`.
|
||||||
- Every table must spread `...generatedFields` from `src/server/db/fields.ts` (`id` UUIDv7 via `$defaultFn(uuidv7)`, `createdAt`, `updatedAt` with `$onUpdateFn`). `generatedFieldKeys` is hand-written and uses `satisfies Record<keyof typeof generatedFields, true>` so any field-key drift fails typecheck; it feeds `createInsertSchema(...).omit(...)` / `createUpdateSchema(...).omit(...)`.
|
- Every table must spread `...generatedFields` from `src/server/db/fields.ts` (`id uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL` — **Postgres-side generation**, requires PG18+; `createdAt`, `updatedAt` with `$onUpdateFn`). The DB is the single source of UUIDv7 truth: monotonic per cluster, uses DB clock, no app-side round-trip. **Do not reintroduce `$defaultFn(() => Bun.randomUUIDv7())`** — the SQL default is what the migration emits and what `drizzle-zod` reads as "optional in insert schema". `generatedFieldKeys` is hand-written and uses `satisfies Record<keyof typeof generatedFields, true>` so any field-key drift fails typecheck; it feeds `createInsertSchema(...).omit(...)` / `createUpdateSchema(...).omit(...)`.
|
||||||
- `src/server/db/index.ts` exports a module-level `const db = drizzle(...)` — not a lazy singleton. On Bun this is a long-lived process, so top-level side effects are fine and requested. Don't reintroduce `getDB/closeDB` ceremony; the Nitro shutdown plugin calls `db.$client.end()` directly. (Cloudflare Workers would need per-request init — we don't support that deployment target.)
|
- `src/server/db/index.ts` exports a module-level `const db = drizzle(...)` — not a lazy singleton. On Bun this is a long-lived process, so top-level side effects are fine and requested. Don't reintroduce `getDB/closeDB` ceremony; the Nitro shutdown plugin calls `db.$client.end()` directly. (Cloudflare Workers would need per-request init — we don't support that deployment target.)
|
||||||
- `drizzle.config.ts` runs outside Vite — `@/*` path aliases do NOT resolve there. It currently does `import { env } from './src/env'` (relative). Preserve that.
|
- `drizzle.config.ts` runs outside Vite — `@/*` path aliases do NOT resolve there. It currently does `import { env } from './src/env'` (relative). Preserve that.
|
||||||
- **Migrations are embedded in the binary, not read from disk.** `bun run db:generate` chains `drizzle-kit generate && bun embed-migrations.ts`, which regenerates `src/server/db/migrations.gen.ts` (committed, AUTO-GENERATED header) by `import sql_<idx> from '../../../drizzle/<tag>.sql' with { type: 'text' }`. `src/cli/migrate.ts` reads `embeddedMigrations`, **validates SHA-256 hash of every already-applied migration against the embedded SQL** (rejects schema drift if anyone edited an applied migration), then applies pending entries via `db.execute(sql\`...\`)` + `db.transaction(...)` against the `drizzle.__drizzle_migrations` book-keeping table — public APIs only, no `db.dialect`/`db.session` (those are `@internal`). Each migration is split on `--> statement-breakpoint`; empty fragments are trimmed and skipped. Dev helpers `db:push` / `drizzle-kit migrate` still read `./drizzle/`.
|
- **Migrations are embedded in the binary, not read from disk.** `bun run db:generate` chains `drizzle-kit generate && bun scripts/embed-migrations.ts`, which regenerates `src/server/db/migrations.gen.ts` (committed, AUTO-GENERATED header) by `import sql_<idx> from '#drizzle/<tag>.sql' with { type: 'text' }`. `src/cli/migrate.ts` reads `embeddedMigrations`, **validates SHA-256 hash of every already-applied migration against the embedded SQL** (rejects schema drift if anyone edited an applied migration), then applies pending entries via `db.execute(sql\`...\`)` + `db.transaction(...)` against the `drizzle.__drizzle_migrations` book-keeping table — public APIs only, no `db.dialect`/`db.session` (those are `@internal`). Each migration is split on `--> statement-breakpoint`; empty fragments are trimmed and skipped. Dev helpers `db:push` / `drizzle-kit migrate` still read `./drizzle/`.
|
||||||
|
|
||||||
## CLI & single-binary deploy
|
## CLI & single-binary deploy
|
||||||
|
|
||||||
`bun run compile` produces a single executable that dispatches subcommands via [citty](https://github.com/unjs/citty). Entry is `bin.ts` at repo root, subcommands live in `src/cli/`.
|
`bun run compile` produces a single executable that dispatches subcommands via [citty](https://github.com/unjs/citty). Entry is `src/bin.ts`; subcommands live in `src/cli/`.
|
||||||
|
|
||||||
```
|
```
|
||||||
./server [serve] # default — start the HTTP server
|
./server [serve] # default — start the HTTP server
|
||||||
@@ -60,21 +63,21 @@ Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no
|
|||||||
./server --help
|
./server --help
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nitro side-effect pitfall (important).** Under the `bun` preset, `.output/server/index.mjs` has a top-level `serve(...)` call — merely importing it starts the HTTP server. `bin.ts` therefore must not eager-import any subcommand module, and `src/cli/serve.ts` reaches `.output/server/index.mjs` through the `src/cli/_serve-nitro.mjs` bridge (with `_serve-nitro.d.mts` for types, since `.output/` doesn't exist at typecheck time). Citty's `subCommands: { x: () => import('...') }` lazy-loader is what keeps `--help` and `migrate` from booting the server.
|
**Nitro side-effect pitfall (important).** Under the `bun` preset, `.output/server/index.mjs` has a top-level `serve(...)` call — merely importing it starts the HTTP server. `src/bin.ts` therefore must not eager-import any subcommand module, and `src/cli/serve.ts` reaches `.output/server/index.mjs` through the `src/cli/_serve-nitro.mjs` bridge (with `_serve-nitro.d.mts` for types, since `.output/` doesn't exist at typecheck time). Citty's `subCommands: { x: () => import('...') }` lazy-loader is what keeps `--help` and `migrate` from booting the server.
|
||||||
|
|
||||||
**Citty eager-loads subcommand modules for `--help`** to read each subcommand's `meta`. So every `src/cli/*.ts` module body must be side-effect-free: do NOT static-import `@/env`, `@/server/db/*`, or anything that reads env at module-load time. Use `await import('@/env')` inside `run()`. Otherwise `./server --help` (or any subcommand's help) will fail with env validation errors before printing.
|
**Citty eager-loads subcommand modules for `--help`** to read each subcommand's `meta`. So every `src/cli/*.ts` module body must be side-effect-free: do NOT static-import `@/env`, `@/server/db/*`, or anything that reads env at module-load time. Use `await import('@/env')` inside `run()`. Otherwise `./server --help` (or any subcommand's help) will fail with env validation errors before printing.
|
||||||
|
|
||||||
Add a subcommand: drop a file in `src/cli/` that default-exports `defineCommand({...})`, then register it in `bin.ts`'s `subCommands` with a `() => import(...)` thunk. Keep top-level imports limited to `citty` + Node built-ins; pull env / db / etc. via `await import(...)` inside `run()`.
|
Add a subcommand: drop a file in `src/cli/` that default-exports `defineCommand({...})`, then register it in `src/bin.ts`'s `subCommands` with a `() => import(...)` thunk. Keep top-level imports limited to `citty` + Node built-ins; pull env / db / etc. via `await import(...)` inside `run()`.
|
||||||
|
|
||||||
**Deploy flow is always migrate-then-serve.** Migrations are embedded in the binary (see "Drizzle" section), so the binary is the only artifact — no `./drizzle/` directory at runtime. Dockerfile copies just `./server`. `compose.yaml` models the pattern with a one-shot `migrate` service that `app` `depends_on: service_completed_successfully`. On k8s, run `./server migrate` as an initContainer or a Helm `pre-upgrade` Job; run `./server` (= `./server serve`) as the main container.
|
**Deploy flow is always migrate-then-serve.** Migrations are embedded in the binary (see "Drizzle" section), so the binary is the only artifact — no `./drizzle/` directory at runtime. Dockerfile copies just `./server`. `compose.yaml` models the pattern with a one-shot `migrate` service that `app` `depends_on: service_completed_successfully`. On k8s, run `./server migrate` as an initContainer or a Helm `pre-upgrade` Job; run `./server` (= `./server serve`) as the main container.
|
||||||
|
|
||||||
## Compile flags
|
## Compile flags
|
||||||
|
|
||||||
`compile.ts` builds with `--minify --bytecode --sourcemap=inline`:
|
`scripts/compile.ts` builds with `--minify --bytecode --sourcemap=inline`:
|
||||||
|
|
||||||
- **`bytecode`** — pre-compiles JS to bytecode and embeds it in the binary; ~2x startup on app-sized binaries (Bun docs benchmark). Requires `--compile`; top-level `await` must live inside `async` functions (it already does in this repo).
|
- **`bytecode`** — pre-compiles JS to bytecode and embeds it in the binary; ~2x startup on app-sized binaries (Bun docs benchmark). Requires `--compile`; top-level `await` must live inside `async` functions (it already does in this repo).
|
||||||
- **`minify`** — shrinks the binary and the bytecode it derives from.
|
- **`minify`** — shrinks the binary and the bytecode it derives from.
|
||||||
- **`sourcemap: 'inline'`** — embeds the source map in the binary so error stack traces stay decodable. Bun also writes a residual `out/bin.js.map` next to the output; `compile.ts` removes it so the binary is the only artifact.
|
- **`sourcemap: 'inline'`** — embeds the source map in the binary so error stack traces stay decodable. Bun also writes a residual `out/bin.js.map` next to the output; `scripts/compile.ts` removes it so the binary is the only artifact.
|
||||||
|
|
||||||
## ORPC
|
## ORPC
|
||||||
|
|
||||||
@@ -87,7 +90,7 @@ Contract → Router → Handler → Client, all type-safe from a single contract
|
|||||||
```
|
```
|
||||||
Barrel-aggregated in `contracts/index.ts` as `export const contract = { todo }`.
|
Barrel-aggregated in `contracts/index.ts` as `export const contract = { todo }`.
|
||||||
- Routers (`src/server/api/routers/*.router.ts`) import `db` directly from `@/server/db` in their handlers. There is **no `middlewares/` directory** by default — `db` doesn't need one (module-level const). When you actually need per-request context (auth, tenant, rate-limit), create `src/server/api/middlewares/<name>.middleware.ts` with `os.middleware(...)` and extend `BaseContext` in `context.ts`.
|
- Routers (`src/server/api/routers/*.router.ts`) import `db` directly from `@/server/db` in their handlers. There is **no `middlewares/` directory** by default — `db` doesn't need one (module-level const). When you actually need per-request context (auth, tenant, rate-limit), create `src/server/api/middlewares/<name>.middleware.ts` with `os.middleware(...)` and extend `BaseContext` in `context.ts`.
|
||||||
- **Interceptors are attached at the handler level, not in `server.ts` and not on `os`.** Both `src/routes/api/rpc.$.ts` (`RPCHandler`) and `src/routes/api/$.ts` (`OpenAPIHandler`) register `[onError(logError)]` (server) and `[onError(handleValidationError)]` (client). The validation interceptor rewrites `BAD_REQUEST + ValidationError` into `INPUT_VALIDATION_FAILED` (422) and output validation errors into `OUTPUT_VALIDATION_FAILED`. `logError` calls `logger.error` from `@/server/logger` — never `console.*` directly.
|
- **Interceptors are attached at the handler level, not in `server.ts` and not on `os`.** Both `src/routes/api/rpc.$.ts` (`RPCHandler`) and `src/routes/api/$.ts` (`OpenAPIHandler`) register `[onError(logError)]` (server) and `[onError(handleValidationError)]` (client). The validation interceptor rewrites `BAD_REQUEST + ValidationError` into `INPUT_VALIDATION_FAILED` (422) and output validation errors into `OUTPUT_VALIDATION_FAILED`. `logError` resolves a `getLogger(['api'])` LogTape category — never `console.*` directly. See "Logging" section.
|
||||||
- OpenAPI/Scalar: docs at `/api/docs`, spec at `/api/spec.json` (handler prefix `/api`, plugin paths `/docs` and `/spec.json`).
|
- OpenAPI/Scalar: docs at `/api/docs`, spec at `/api/spec.json` (handler prefix `/api`, plugin paths `/docs` and `/spec.json`).
|
||||||
- **SSR isomorphism** (`src/client/orpc.ts`): `createIsomorphicFn().server(createRouterClient(...)).client(new RPCLink(...))`. Server branch reads `getRequestHeaders()` for context; client branch POSTs to `${origin}/api/rpc`.
|
- **SSR isomorphism** (`src/client/orpc.ts`): `createIsomorphicFn().server(createRouterClient(...)).client(new RPCLink(...))`. Server branch reads `getRequestHeaders()` for context; client branch POSTs to `${origin}/api/rpc`.
|
||||||
- **Mutation invalidation is colocated at the call site** via `mutationOptions({ onSuccess })`, not in `src/client/orpc.ts`. `orpc` in `src/client/orpc.ts` is a plain `createTanstackQueryUtils(client)` — no `experimental_defaults`. Per-feature query helpers live in `src/client/queries/<feature>.ts` (e.g. `useInvalidateTodos`); routes/components compose those hooks rather than holding query keys inline. See `src/client/queries/todo.ts` + `src/routes/index.tsx` for the canonical shape.
|
- **Mutation invalidation is colocated at the call site** via `mutationOptions({ onSuccess })`, not in `src/client/orpc.ts`. `orpc` in `src/client/orpc.ts` is a plain `createTanstackQueryUtils(client)` — no `experimental_defaults`. Per-feature query helpers live in `src/client/queries/<feature>.ts` (e.g. `useInvalidateTodos`); routes/components compose those hooks rather than holding query keys inline. See `src/client/queries/todo.ts` + `src/routes/index.tsx` for the canonical shape.
|
||||||
@@ -96,6 +99,7 @@ Contract → Router → Handler → Client, all type-safe from a single contract
|
|||||||
## Code style (Biome)
|
## Code style (Biome)
|
||||||
|
|
||||||
- 2-space, LF, single quotes, **semicolons as-needed** (omitted unless required), 120-col, arrow parens always, `useArrowFunction: "error"` (covers function *expressions* only). Also `noReactPropAssignments: "error"`.
|
- 2-space, LF, single quotes, **semicolons as-needed** (omitted unless required), 120-col, arrow parens always, `useArrowFunction: "error"` (covers function *expressions* only). Also `noReactPropAssignments: "error"`.
|
||||||
|
- Lint domains enabled (Biome 2.4): `types: "all"` (catches `noFloatingPromises`, `noMisusedPromises`, `useAwaitThenable`, `noUnnecessaryConditions` — TS-aware async/promise traps), `drizzle: "recommended"`, `react: "recommended"`. `noImportCycles` is on under `suspicious`.
|
||||||
- **Route components use `function Foo()` declarations**, placed below the `Route` config so the file reads top-down (route on top, component below). This is the official TanStack Router/Start pattern and relies on hoisting — `const Foo = () => {}` would TDZ-error when referenced from `createFileRoute({ component: Foo })` above it. Inline arrows are fine for trivial leaf components (e.g. plain redirect routes). Non-route components (UI primitives in `src/components/`) use `const Foo = () => {}`.
|
- **Route components use `function Foo()` declarations**, placed below the `Route` config so the file reads top-down (route on top, component below). This is the official TanStack Router/Start pattern and relies on hoisting — `const Foo = () => {}` would TDZ-error when referenced from `createFileRoute({ component: Foo })` above it. Inline arrows are fine for trivial leaf components (e.g. plain redirect routes). Non-route components (UI primitives in `src/components/`) use `const Foo = () => {}`.
|
||||||
- Imports are auto-organized into two groups (external, then `@/*`), each alphabetical, with `import type` interleaved (NOT a separate group). `bun run fix` handles this; don't hand-sort.
|
- Imports are auto-organized into two groups (external, then `@/*`), each alphabetical, with `import type` interleaved (NOT a separate group). `bun run fix` handles this; don't hand-sort.
|
||||||
- Files: utils `kebab-case.ts`, components `PascalCase.tsx`.
|
- Files: utils `kebab-case.ts`, components `PascalCase.tsx`.
|
||||||
@@ -120,11 +124,29 @@ Path alias: `@/* → src/*`. For files outside `src/` use `@/../<file>` (example
|
|||||||
|
|
||||||
## Env
|
## Env
|
||||||
|
|
||||||
`src/env.ts` via `@t3-oss/env-core`. Server: `DATABASE_URL` (required, `z.url()`). `client: {}` is empty by default — any client-side env must be `VITE_`-prefixed. Never commit `.env`.
|
`src/env.ts` via `@t3-oss/env-core`. Server: `DATABASE_URL` (required, `z.url()`), `LOG_LEVEL` (`trace|debug|info|warning|error|fatal`, default `info`), `LOG_FORMAT` (`pretty|json`, default = TTY ? `pretty` : `json`), `LOG_DB` (`stringbool`, default `false` — flips on Drizzle SQL query logging). `client: {}` is empty by default — any client-side env must be `VITE_`-prefixed. Never commit `.env`.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
All server-side logging goes through `src/server/logger.ts`, a thin wrapper over [LogTape](https://logtape.org/). The module configures LogTape on import (via `configureSync`, no top-level await — works under `--bytecode`) and re-exports `getLogger`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { getLogger } from '@/server/logger'
|
||||||
|
const logger = getLogger(['feature', 'subsystem'])
|
||||||
|
logger.info('Created todo {id}', { id })
|
||||||
|
logger.error('DB write failed', { error })
|
||||||
|
```
|
||||||
|
|
||||||
|
- Categories are hierarchical arrays — they show up as dot-paths in JSON output (`"logger":"feature.subsystem"`) and let you filter by prefix when shipping logs.
|
||||||
|
- The `{name}` placeholders are for **primitive** values you want rendered inline (numbers, short strings, IDs). For objects, errors, and anything multi-field, omit the placeholder and just pass the value in properties — `logger.error('Auth failed', { error, userId })` keeps the message clean while properties stay structured. Never string-concatenate or template-literal — that defeats structured logging.
|
||||||
|
- Format is `pretty` (icons + ANSI) on TTY, `json` (one-line JSON) when piped — perfect for Loki/Datadog/CloudWatch ingestion. Override with `LOG_FORMAT`.
|
||||||
|
- Drizzle SQL queries are logged at `info` under category `['db']` when `LOG_DB=true`, via `@logtape/drizzle-orm`'s `DrizzleLogger` adapter (constructed in `src/server/db/index.ts`). The `info` level is intentional: flipping `LOG_DB=true` alone is enough — no need to also lower `LOG_LEVEL`.
|
||||||
|
- `src/server/api/interceptors.ts` calls `getLogger(['api']).error(...)` from `logError`. CLI subcommands lazy-import the logger inside `run()` — they are still required to be side-effect-free at module top (citty eager-loads for `--help`).
|
||||||
|
- Bun-specific: `process.env.NODE_ENV` is **inlined at build time** by `bun build --minify` — do NOT branch on it for logger config (use `process.stdout.isTTY` or `LOG_FORMAT` instead). pino is unusable here because its worker-thread transports crash inside the `/$bunfs/` virtual filesystem of compiled binaries; LogTape has zero workers and zero dynamic require, so it ships cleanly into the single binary.
|
||||||
|
|
||||||
## Docker / deploy
|
## Docker / deploy
|
||||||
|
|
||||||
- Multi-stage: `oven/bun:1.3.13` builds and runs `bun compile.ts`, then `gcr.io/distroless/cc-debian13:nonroot` runs the single `./server` binary. The `cc` (glibc) distroless variant is required because Bun's compiled binary links glibc.
|
- Multi-stage: `oven/bun:1.3.13` builds and runs `bun scripts/compile.ts`, then `gcr.io/distroless/cc-debian13:nonroot` runs the single `./server` binary. The `cc` (glibc) distroless variant is required because Bun's compiled binary links glibc.
|
||||||
- `compose.yaml`: one-shot `migrate` service runs `./server migrate` with `restart: "no"`, then `app` starts (`depends_on: migrate: service_completed_successfully`). `DATABASE_URL=postgres://postgres:postgres@db:5432/postgres` for both.
|
- `compose.yaml`: one-shot `migrate` service runs `./server migrate` with `restart: "no"`, then `app` starts (`depends_on: migrate: service_completed_successfully`). `DATABASE_URL=postgres://postgres:postgres@db:5432/postgres` for both.
|
||||||
- Distroless has no shell, so any init-then-serve pattern must use exec-form `command: [...]`, not `sh -c`.
|
- Distroless has no shell, so any init-then-serve pattern must use exec-form `command: [...]`, not `sh -c`.
|
||||||
|
|
||||||
@@ -132,13 +154,14 @@ Path alias: `@/* → src/*`. For files outside `src/` use `@/../<file>` (example
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
|
├── bin.ts # citty entry — keep imports minimal (see "CLI" section)
|
||||||
├── client/
|
├── client/
|
||||||
│ ├── orpc.ts # isomorphic ORPC client + TanStack Query utils (no global invalidation defaults)
|
│ ├── orpc.ts # isomorphic ORPC client + TanStack Query utils (no global invalidation defaults)
|
||||||
│ └── queries/ # per-feature query hooks: keys, options, `useInvalidate<Feature>` helpers
|
│ └── queries/ # per-feature query hooks: keys, options, `useInvalidate<Feature>` helpers
|
||||||
├── cli/ # CLI subcommands (loaded lazily by bin.ts via citty)
|
├── cli/ # CLI subcommands (loaded lazily by src/bin.ts via citty)
|
||||||
│ ├── serve.ts # `./server serve` — imports the Nitro bridge on demand
|
│ ├── serve.ts # `./server serve` — imports the Nitro bridge on demand
|
||||||
│ ├── migrate.ts # `./server migrate` — applies embedded migrations via public `db.execute(sql)` + `db.transaction()`
|
│ ├── migrate.ts # `./server migrate` — applies embedded migrations via public `db.execute(sql)` + `db.transaction()`
|
||||||
│ ├── _serve-nitro.mjs # bridge: `import('../../.output/server/index.mjs')`
|
│ ├── _serve-nitro.mjs # bridge: `import('#server')` (subpath import → .output/server/index.mjs)
|
||||||
│ └── _serve-nitro.d.mts # types for the bridge (build output has no .d.ts)
|
│ └── _serve-nitro.d.mts # types for the bridge (build output has no .d.ts)
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── __root.tsx # root route + RootDocument shell
|
│ ├── __root.tsx # root route + RootDocument shell
|
||||||
@@ -148,7 +171,7 @@ src/
|
|||||||
│ ├── $.ts # OpenAPI + Scalar; interceptors registered here
|
│ ├── $.ts # OpenAPI + Scalar; interceptors registered here
|
||||||
│ └── rpc.$.ts # RPC; interceptors registered here
|
│ └── rpc.$.ts # RPC; interceptors registered here
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── logger.ts # the only log entrypoint — wrap before swapping to pino/otel
|
│ ├── logger.ts # LogTape `configureSync` + `getLogger` re-export — the only log entrypoint
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── server.ts # the ONLY place to build `os`
|
│ │ ├── server.ts # the ONLY place to build `os`
|
||||||
│ │ ├── context.ts # BaseContext (add per-request fields when you add middlewares)
|
│ │ ├── context.ts # BaseContext (add per-request fields when you add middlewares)
|
||||||
@@ -160,18 +183,18 @@ src/
|
|||||||
│ │ ├── index.ts # module-level `export const db = drizzle({...})`
|
│ │ ├── index.ts # module-level `export const db = drizzle({...})`
|
||||||
│ │ ├── fields.ts # generatedFields (id/createdAt/updatedAt) + generatedFieldKeys
|
│ │ ├── fields.ts # generatedFields (id/createdAt/updatedAt) + generatedFieldKeys
|
||||||
│ │ ├── migrations.gen.ts # AUTO-GENERATED by `bun run db:embed`; embeds ./drizzle/*.sql via `with { type: 'text' }`
|
│ │ ├── migrations.gen.ts # AUTO-GENERATED by `bun run db:embed`; embeds ./drizzle/*.sql via `with { type: 'text' }`
|
||||||
|
│ │ ├── sql.d.ts # ambient `declare module '*.sql'` — load-bearing for `with { type: 'text' }` imports in migrations.gen.ts
|
||||||
│ │ └── schema/ # pgTable definitions; also put `relations()` here when adding
|
│ │ └── schema/ # pgTable definitions; also put `relations()` here when adding
|
||||||
│ └── plugins/
|
│ └── plugins/
|
||||||
│ └── shutdown.ts # SIGINT/SIGTERM → db.$client.end() with 500ms delay (prod only)
|
│ └── shutdown.ts # SIGINT/SIGTERM → db.$client.end() with 500ms delay (prod only)
|
||||||
├── components/ # non-route UI primitives (PascalCase, arrow const)
|
├── components/ # non-route UI primitives (PascalCase, arrow const)
|
||||||
├── env.ts # t3-oss env validation
|
├── env.ts # t3-oss env validation
|
||||||
├── router.tsx # QueryClient + setupRouterSsrQueryIntegration
|
├── router.tsx # QueryClient + setupRouterSsrQueryIntegration
|
||||||
├── sql.d.ts # ambient `declare module '*.sql'` — load-bearing for `with { type: 'text' }` imports in migrations.gen.ts
|
|
||||||
├── styles.css # Tailwind v4 entry
|
├── styles.css # Tailwind v4 entry
|
||||||
└── routeTree.gen.ts # auto-generated, do not edit
|
└── routeTree.gen.ts # auto-generated, do not edit
|
||||||
bin.ts # citty entry (root) — keep imports minimal (see "CLI" section)
|
scripts/
|
||||||
compile.ts # `bun build --compile` driver; resolves --target; sets minify/bytecode/sourcemap
|
├── compile.ts # `bun build --compile` driver; resolves --target; sets minify/bytecode/sourcemap
|
||||||
embed-migrations.ts # codegen: scans ./drizzle/meta/_journal.json → src/server/db/migrations.gen.ts
|
└── embed-migrations.ts # codegen: scans ./drizzle/meta/_journal.json → src/server/db/migrations.gen.ts
|
||||||
drizzle/ # SQL migrations (source of truth for `db:generate`; not shipped in binary)
|
drizzle/ # SQL migrations (source of truth for `db:generate`; not shipped in binary)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,8 +202,9 @@ Nitro plugins are wired in `vite.config.ts` (`nitro({ plugins: [...] })`), not v
|
|||||||
|
|
||||||
## Don'ts (specific, non-obvious)
|
## Don'ts (specific, non-obvious)
|
||||||
|
|
||||||
|
- **Don't run `bun run db:generate` (or `drizzle-kit generate`) as an AI agent.** Migration generation is reserved for the human. Make schema changes in `src/server/db/schema/*` + `src/server/db/fields.ts`, push the code changes, and stop — the human will run `bun run db:generate` and commit the resulting `drizzle/*.sql` + `src/server/db/migrations.gen.ts` themselves. (`bun run db:embed` is also off-limits because it's the codegen tail of `db:generate`.)
|
||||||
- Don't edit `routeTree.gen.ts` or `src/server/db/migrations.gen.ts`.
|
- Don't edit `routeTree.gen.ts` or `src/server/db/migrations.gen.ts`.
|
||||||
- Don't eager-import anything from `.output/` in `bin.ts` or any module it statically imports — it starts the HTTP server as a side effect. Subcommands must be lazy via citty's `() => import(...)` thunks.
|
- Don't eager-import anything from `.output/` in `src/bin.ts` or any module it statically imports — it starts the HTTP server as a side effect. Subcommands must be lazy via citty's `() => import(...)` thunks.
|
||||||
- Don't re-add an auto-migrate Nitro plugin. Migrations are an explicit deploy step via `./server migrate`.
|
- Don't re-add an auto-migrate Nitro plugin. Migrations are an explicit deploy step via `./server migrate`.
|
||||||
- Don't reach into `db.dialect`/`db.session` from `migrate.ts` — they're `@internal`. The current implementation uses public `db.execute(sql)` + `db.transaction(...)` against the documented `drizzle.__drizzle_migrations` schema.
|
- Don't reach into `db.dialect`/`db.session` from `migrate.ts` — they're `@internal`. The current implementation uses public `db.execute(sql)` + `db.transaction(...)` against the documented `drizzle.__drizzle_migrations` schema.
|
||||||
- Don't add `./drizzle/` back to the runtime image — migrations are embedded into the binary.
|
- Don't add `./drizzle/` back to the runtime image — migrations are embedded into the binary.
|
||||||
@@ -203,6 +227,6 @@ These keep the starter from setting bad precedents as it grows. Append, don't re
|
|||||||
5. Interceptors (`src/server/api/interceptors.ts`) do cross-cutting error logging, transport normalization, and validation rewrites. They do NOT read business data.
|
5. Interceptors (`src/server/api/interceptors.ts`) do cross-cutting error logging, transport normalization, and validation rewrites. They do NOT read business data.
|
||||||
6. One file per Drizzle table. Relations live in the same file and are exported as `<entity>Relations`. No global `relations.ts`.
|
6. One file per Drizzle table. Relations live in the same file and are exported as `<entity>Relations`. No global `relations.ts`.
|
||||||
7. One router file per feature (`routers/<feature>.router.ts`). Only introduce `routers/<domain>/index.ts` when a domain grows past ~5 router files or needs shared domain helpers.
|
7. One router file per feature (`routers/<feature>.router.ts`). Only introduce `routers/<domain>/index.ts` when a domain grows past ~5 router files or needs shared domain helpers.
|
||||||
8. All server-side logging goes through `src/server/logger.ts`. Do not call `console.error/info/warn` directly from business code.
|
8. All server-side logging goes through `getLogger([...])` from `@/server/logger`. Use a hierarchical category (`['api']`, `['db']`, `['cli', 'migrate']`, etc.) — these become dot-paths in JSON output and let you filter by prefix. Use the `{name}` placeholder + properties form, not string interpolation. `console.*` is forbidden in business code.
|
||||||
9. CLI subcommand modules keep top-level imports to `citty` + Node built-ins. Env, db, and server code are `await import(...)`-ed inside `run()` (see `bin.ts` comment for why).
|
9. CLI subcommand modules keep top-level imports to `citty` + Node built-ins. Env, db, and server code are `await import(...)`-ed inside `run()` (see `src/bin.ts` comment for why).
|
||||||
10. Every new business feature ships with at least one `bun test` covering a contract schema, a pure helper, or a router behavior.
|
10. Every new business feature ships with at least one `bun test` covering a contract schema, a pure helper, or a router behavior.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ FROM oven/bun:1.3.13 AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
COPY patches ./patches
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,64 +1,112 @@
|
|||||||
# fullstack-starter
|
# fullstack-starter
|
||||||
|
|
||||||
Opinionated single-binary fullstack starter. Bun + TanStack Start (React 19 SSR) + ORPC (contract-first) + Drizzle + PostgreSQL, deployed as one compiled executable.
|
一个**单二进制**的全栈应用 starter——`bun run compile` 出来的 `./server` 文件就是你要部署的全部产物,自带 HTTP 服务、SSR、API、嵌入式 SQL 迁移,运行时不依赖 Node、不依赖源码、不依赖外部 migration 目录。
|
||||||
|
|
||||||
> Agent notes and non-obvious invariants live in [AGENTS.md](./AGENTS.md). Read it before making structural changes.
|
技术栈:Bun · TanStack Start (React 19 SSR) · ORPC(契约优先 API)· Drizzle ORM · PostgreSQL 18+ · Tailwind v4 · Biome。
|
||||||
|
|
||||||
## Quick start
|
## 为什么用这个
|
||||||
|
|
||||||
|
- **部署最简**:发布只拷一个二进制文件。先 `./server migrate` 再 `./server`,完事。
|
||||||
|
- **契约优先**:在 `*.contract.ts` 用 Zod 定义一次,前端、后端、OpenAPI 文档自动同步。
|
||||||
|
- **类型严格**:TypeScript strict,杜绝 `any` / `@ts-ignore` / `as any` 等类型逃逸。
|
||||||
|
- **开箱可跑**:路径别名、文件路由、ORPC 接线、Tailwind、热重载、错误页全部预接好。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
> **需要 PostgreSQL 18+**——schema 用 PG 原生的 `uuidv7()` 生成主键(`compose.yaml` 已锁 `postgres:18-alpine`)。要兼容更老的 PG,把 `src/server/db/fields.ts` 里的 `default(sql\`uuidv7()\`)` 换成 `$defaultFn(() => Bun.randomUUIDv7())`,再跑 `bun run db:generate`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env # 把里面的 DATABASE_URL 改成你的 Postgres
|
||||||
bun install
|
bun install
|
||||||
bun run db:push # dev: sync schema without migration files
|
bun run db:push # 开发期:把 schema 直接同步到 DB(不写 migration 文件)
|
||||||
bun run dev # http://localhost:3000
|
bun run dev # http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
RPC endpoint: `/api/rpc` · OpenAPI docs: `/api/docs` · Spec: `/api/spec.json` · Liveness: `/health`.
|
打开浏览器:
|
||||||
|
|
||||||
## Scripts
|
- `http://localhost:3000/` — Todo 示例页
|
||||||
|
- `http://localhost:3000/api/docs` — Scalar 渲染的 API 文档
|
||||||
|
|
||||||
| Command | What it does |
|
## 目录结构(你需要关心的部分)
|
||||||
| --- | --- |
|
|
||||||
| `bun run dev` | Vite dev server on port 3000 (strict) |
|
|
||||||
| `bun run build` | Build to `.output/` |
|
|
||||||
| `bun run compile` | Single-binary `out/server-<target>` via `bun build --compile` |
|
|
||||||
| `bun run cli <cmd>` | Run a CLI subcommand in source (`serve`, `migrate`) |
|
|
||||||
| `bun run typecheck` | `tsc --noEmit` |
|
|
||||||
| `bun run test` | `bun test` (colocated `*.test.ts`) |
|
|
||||||
| `bun run fix` | Biome lint + format + organize imports |
|
|
||||||
| `bun run db:push` | Dev-only schema sync (no migration files) |
|
|
||||||
| `bun run db:generate` | Write SQL migrations to `./drizzle` and regenerate `migrations.gen.ts` |
|
|
||||||
| `bun run db:embed` | Regenerate `src/server/db/migrations.gen.ts` from `./drizzle` (run if you hand-edit migrations) |
|
|
||||||
| `bun run db:migrate` | Apply migrations locally via drizzle-kit (dev convenience) |
|
|
||||||
| `bun run db:studio` | Drizzle Studio |
|
|
||||||
|
|
||||||
Cross-compile: `bun run compile:{linux,darwin,windows}[:arch]`.
|
```
|
||||||
|
src/
|
||||||
## Add a feature (e.g. `post`)
|
├── routes/ # 文件路由:页面 + API 端点
|
||||||
|
├── server/
|
||||||
Contract-first, additive. Create or touch files in this order:
|
│ ├── api/
|
||||||
|
│ │ ├── contracts/ # Zod 契约(client / server 共享)
|
||||||
1. `src/server/db/schema/post.ts` — define `postTable`, spread `...generatedFields`.
|
│ │ └── routers/ # 业务实现
|
||||||
2. `src/server/db/schema/index.ts` — `export * from './post'`.
|
│ └── db/ # Drizzle schema + 嵌入式 migrations
|
||||||
3. `src/server/api/contracts/post.contract.ts` — derive Zod from the table via `drizzle-zod`.
|
├── client/ # 前端 hooks、ORPC 客户端
|
||||||
4. `src/server/api/contracts/index.ts` — add `post` to the `contract` object.
|
└── components/ # UI 组件
|
||||||
5. `src/server/api/routers/post.router.ts` — implement `os.post.*.handler(...)`.
|
|
||||||
6. `src/server/api/routers/index.ts` — add `post` to the `router` object.
|
|
||||||
7. `src/client/queries/post.ts` — export `useInvalidatePosts` (or finer-grained helpers) for affected list keys.
|
|
||||||
8. `src/routes/<page>.tsx` — UI with `useSuspenseQuery` + loader `ensureQueryData`; call the helper from mutation `onSuccess`.
|
|
||||||
9. `bun run db:generate` — emit SQL migrations to `./drizzle` and embed them into `src/server/db/migrations.gen.ts`.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
Always **migrate-then-serve**. Migrations are embedded in the binary; the binary is the only artifact you ship.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./server migrate # applies embedded migrations against $DATABASE_URL
|
|
||||||
./server # starts HTTP server (default subcommand)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`compose.yaml` models the pattern with a one-shot `migrate` service that `app` depends on (`service_completed_successfully`). On Kubernetes: run `./server migrate` as an initContainer or Helm `pre-upgrade` Job.
|
## 加一个功能(以 `post` 为例)
|
||||||
|
|
||||||
|
每一步都很短,按顺序填即可:
|
||||||
|
|
||||||
|
1. **建表**:`src/server/db/schema/post.ts` 定义 `postTable`,记得展开 `...generatedFields`(自动注入 `id` / `createdAt` / `updatedAt`)。
|
||||||
|
2. **导出表**:在 `src/server/db/schema/index.ts` 加 `export * from './post'`。
|
||||||
|
3. **写契约**:`src/server/api/contracts/post.contract.ts` 用 `drizzle-zod` 从表派生 Zod schema。
|
||||||
|
4. **挂契约**:在 `src/server/api/contracts/index.ts` 把 `post` 加进 `contract` 对象。
|
||||||
|
5. **写实现**:`src/server/api/routers/post.router.ts` 实现 `os.post.*.handler(...)`。
|
||||||
|
6. **挂路由**:在 `src/server/api/routers/index.ts` 把 `post` 加进 `router` 对象。
|
||||||
|
7. **写前端 hook**:`src/client/queries/post.ts` 导出 `useInvalidatePosts` 等失效辅助。
|
||||||
|
8. **写页面**:`src/routes/<page>.tsx` 用 `useSuspenseQuery` 读、`mutate` 写;mutation 的 `onSuccess` 调用第 7 步的 helper。
|
||||||
|
9. **生成 migration**:`bun run db:generate` 把 SQL 写到 `./drizzle/` 并嵌入二进制。
|
||||||
|
|
||||||
|
完工。`bun run dev` 已自动热重载。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
**永远先 migrate 再 serve**。Migration 已嵌入二进制;部署只发一个 `./server` 文件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./server migrate # 应用嵌入式 migration(用 $DATABASE_URL)
|
||||||
|
./server # 启动 HTTP 服务(默认子命令)
|
||||||
|
./server --help # 列出所有子命令
|
||||||
|
```
|
||||||
|
|
||||||
|
仓库自带 `compose.yaml`(一次性 `migrate` 服务先跑完,再启动 `app`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Kubernetes 上:把 `./server migrate` 放进 initContainer 或 Helm `pre-upgrade` Job,主容器跑 `./server`。
|
||||||
|
|
||||||
|
## 脚本一览
|
||||||
|
|
||||||
|
| 命令 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `bun run dev` | Vite 开发服务器(默认端口 3000) |
|
||||||
|
| `bun run build` | 构建到 `.output/`(`bun run compile` 会用到) |
|
||||||
|
| `bun run compile` | 生成单二进制 `out/server-<target>` |
|
||||||
|
| `bun run typecheck` | TypeScript 类型检查 |
|
||||||
|
| `bun run test` | 运行所有 `*.test.ts` |
|
||||||
|
| `bun run fix` | Biome 格式化 + lint + 整理 imports |
|
||||||
|
| `bun run db:push` | 开发期:直接同步 schema 到 DB(不写 migration 文件) |
|
||||||
|
| `bun run db:generate` | 写 SQL migration 到 `./drizzle/` 并嵌入二进制 |
|
||||||
|
| `bun run db:embed` | 仅重生 `migrations.gen.ts`(手改了 `./drizzle/*.sql` 后用) |
|
||||||
|
| `bun run db:migrate` | 通过 drizzle-kit 在本地应用 migration(开发便利) |
|
||||||
|
| `bun run db:studio` | Drizzle Studio(可视化 DB) |
|
||||||
|
|
||||||
|
跨平台编译:`bun run compile:{linux,darwin,windows}[:arch]`。
|
||||||
|
|
||||||
|
## 端点
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Todo 示例 UI |
|
||||||
|
| `/health` | 存活探针(不查 DB,纯文本 `ok`) |
|
||||||
|
| `/api/rpc` | ORPC RPC 端点(client 直连) |
|
||||||
|
| `/api/docs` | Scalar 渲染的 API 文档 |
|
||||||
|
| `/api/spec.json` | OpenAPI spec |
|
||||||
|
|
||||||
|
## 提交前
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run fix && bun run typecheck && bun run test
|
||||||
|
```
|
||||||
|
|
||||||
|
没有 CI、没有 pre-commit hook——上面三条由你自觉跑。
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"domains": {
|
||||||
|
"drizzle": "recommended",
|
||||||
|
"react": "recommended",
|
||||||
|
"types": "all"
|
||||||
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
@@ -30,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "error",
|
"noExplicitAny": "error",
|
||||||
|
"noImportCycles": "error",
|
||||||
"noTsIgnore": "error"
|
"noTsIgnore": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "fullstack-starter",
|
"name": "fullstack-starter",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@logtape/drizzle-orm": "^2.0.5",
|
||||||
|
"@logtape/logtape": "^2.0.5",
|
||||||
|
"@logtape/pretty": "^2.0.5",
|
||||||
"@orpc/client": "^1.14.0",
|
"@orpc/client": "^1.14.0",
|
||||||
"@orpc/contract": "^1.14.0",
|
"@orpc/contract": "^1.14.0",
|
||||||
"@orpc/openapi": "^1.14.0",
|
"@orpc/openapi": "^1.14.0",
|
||||||
@@ -22,7 +25,6 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"uuid": "^14.0.0",
|
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.13",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"nitro": "npm:nitro-nightly@3.0.1-20260423-183501-f92fb7b7",
|
"nitro": "npm:nitro-nightly@3.0.1-20260424-182106-f8cf6ccc",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.10",
|
||||||
@@ -173,6 +175,12 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@logtape/drizzle-orm": ["@logtape/drizzle-orm@2.0.5", "", { "peerDependencies": { "@logtape/logtape": "^2.0.5" } }, "sha512-woM4x9J7B4XzoQTY1bzR6uJVlqezn6oY6flV+rDD6lsuwP2llNVvptgCOGjRutDye+6WZldSUIGe7UFK/faBzA=="],
|
||||||
|
|
||||||
|
"@logtape/logtape": ["@logtape/logtape@2.0.5", "", {}, "sha512-UizDkh20ZPJVOddRxG1F77WhHdlNl/sbQgoO8T534R7XvUBMAJ9En9f35u+meW2tRsNLvjz6R87Zanwf53tspQ=="],
|
||||||
|
|
||||||
|
"@logtape/pretty": ["@logtape/pretty@2.0.5", "", { "peerDependencies": { "@logtape/logtape": "^2.0.5" } }, "sha512-jU5pYL0CW0tFmxBS5umMF5VEMq1vXLvkqKrj7KRHnSlb5SrBOSCYl0w4q7FmPPFVADmgTmzVVr6IcIWwK/2Nig=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||||
|
|
||||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||||
@@ -553,7 +561,7 @@
|
|||||||
|
|
||||||
"nf3": ["nf3@0.3.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="],
|
"nf3": ["nf3@0.3.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="],
|
||||||
|
|
||||||
"nitro": ["nitro-nightly@3.0.1-20260423-183501-f92fb7b7", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.17", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.2", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KAR36bmbCyR7EMBbAaJJLtOSTGzpBB0sW3custX1tH8VtTz4q8xiRop+lMkmL2c+KDSAfkUdEECUwotVLQlKMw=="],
|
"nitro": ["nitro-nightly@3.0.1-20260424-182106-f8cf6ccc", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.17", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.2", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-NngKuHxMQ7CApL2QwKBLNvnmYEtL0ItmXORa1sUPb6XGrIXUhW28gLX9PwXsymLUBhNS10VTJ/NweAnycJSmGQ=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||||
|
|
||||||
@@ -657,8 +665,6 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
|
||||||
|
|
||||||
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
|
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|||||||
+18
-11
@@ -3,20 +3,25 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"imports": {
|
||||||
|
"#drizzle/*.sql": "./drizzle/*.sql",
|
||||||
|
"#package": "./package.json",
|
||||||
|
"#server": "./.output/server/index.mjs"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx --bun vite build",
|
"build": "bunx --bun vite build",
|
||||||
"cli": "bun bin.ts",
|
"cli": "bun src/bin.ts",
|
||||||
"compile": "bun compile.ts",
|
"compile": "bun scripts/compile.ts",
|
||||||
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
|
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
|
||||||
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
|
"compile:darwin:arm64": "bun scripts/compile.ts --target bun-darwin-arm64",
|
||||||
"compile:darwin:x64": "bun compile.ts --target bun-darwin-x64",
|
"compile:darwin:x64": "bun scripts/compile.ts --target bun-darwin-x64",
|
||||||
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
|
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
|
||||||
"compile:linux:arm64": "bun compile.ts --target bun-linux-arm64",
|
"compile:linux:arm64": "bun scripts/compile.ts --target bun-linux-arm64",
|
||||||
"compile:linux:x64": "bun compile.ts --target bun-linux-x64",
|
"compile:linux:x64": "bun scripts/compile.ts --target bun-linux-x64",
|
||||||
"compile:windows": "bun run compile:windows:x64",
|
"compile:windows": "bun run compile:windows:x64",
|
||||||
"compile:windows:x64": "bun compile.ts --target bun-windows-x64",
|
"compile:windows:x64": "bun scripts/compile.ts --target bun-windows-x64",
|
||||||
"db:generate": "drizzle-kit generate && bun embed-migrations.ts",
|
"db:embed": "bun scripts/embed-migrations.ts",
|
||||||
"db:embed": "bun embed-migrations.ts",
|
"db:generate": "drizzle-kit generate && bun scripts/embed-migrations.ts",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
@@ -26,6 +31,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@logtape/drizzle-orm": "^2.0.5",
|
||||||
|
"@logtape/logtape": "^2.0.5",
|
||||||
|
"@logtape/pretty": "^2.0.5",
|
||||||
"@orpc/client": "^1.14.0",
|
"@orpc/client": "^1.14.0",
|
||||||
"@orpc/contract": "^1.14.0",
|
"@orpc/contract": "^1.14.0",
|
||||||
"@orpc/openapi": "^1.14.0",
|
"@orpc/openapi": "^1.14.0",
|
||||||
@@ -43,7 +51,6 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"uuid": "^14.0.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -56,7 +63,7 @@
|
|||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.13",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"nitro": "npm:nitro-nightly@3.0.1-20260423-183501-f92fb7b7",
|
"nitro": "npm:nitro-nightly@3.0.1-20260424-182106-f8cf6ccc",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { mkdir, rm } from 'node:fs/promises'
|
import { mkdir, rm } from 'node:fs/promises'
|
||||||
|
import { basename } from 'node:path'
|
||||||
import { parseArgs } from 'node:util'
|
import { parseArgs } from 'node:util'
|
||||||
|
|
||||||
const ENTRYPOINT = 'bin.ts'
|
const ENTRYPOINT = 'src/bin.ts'
|
||||||
const OUTDIR = 'out'
|
const OUTDIR = 'out'
|
||||||
|
|
||||||
const SUPPORTED_TARGETS: readonly Bun.Build.CompileTarget[] = [
|
const SUPPORTED_TARGETS: readonly Bun.Build.CompileTarget[] = [
|
||||||
@@ -61,7 +62,7 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bun bundler still writes *.js.map next to the binary even with inline sourcemap.
|
// Bun bundler still writes *.js.map next to the binary even with inline sourcemap.
|
||||||
await rm(`${OUTDIR}/${ENTRYPOINT.replace(/\.ts$/, '')}.js.map`, { force: true })
|
await rm(`${OUTDIR}/${basename(ENTRYPOINT, '.ts')}.js.map`, { force: true })
|
||||||
|
|
||||||
console.log(`✓ ${target} → ${OUTDIR}/${outfile}`)
|
console.log(`✓ ${target} → ${OUTDIR}/${outfile}`)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
const JOURNAL = './drizzle/meta/_journal.json'
|
const JOURNAL = './drizzle/meta/_journal.json'
|
||||||
const OUTPUT = './src/server/db/migrations.gen.ts'
|
const OUTPUT = './src/server/db/migrations.gen.ts'
|
||||||
const SQL_RELATIVE_FROM_OUTPUT = '../../../drizzle'
|
|
||||||
|
|
||||||
const journalEntrySchema = z.object({
|
const journalEntrySchema = z.object({
|
||||||
idx: z.number().int().nonnegative(),
|
idx: z.number().int().nonnegative(),
|
||||||
@@ -28,7 +27,7 @@ const main = async () => {
|
|||||||
const entries = await readJournalEntries()
|
const entries = await readJournalEntries()
|
||||||
|
|
||||||
const imports = entries
|
const imports = entries
|
||||||
.map((e) => `import sql_${e.idx} from '${SQL_RELATIVE_FROM_OUTPUT}/${e.tag}.sql' with { type: 'text' }`)
|
.map((e) => `import sql_${e.idx} from '#drizzle/${e.tag}.sql' with { type: 'text' }`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
const arrayBody = entries.length
|
const arrayBody = entries.length
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { defineCommand, runMain } from 'citty'
|
import { defineCommand, runMain } from 'citty'
|
||||||
import { name, version } from './package.json' with { type: 'json' }
|
import { name, version } from '#package'
|
||||||
|
|
||||||
// IMPORTANT: keep this file's static imports minimal. Nitro's bun preset
|
// IMPORTANT: keep this file's static imports minimal. Nitro's bun preset
|
||||||
// emits `.output/server/index.mjs` with a top-level `serve(...)` call, so any
|
// emits `.output/server/index.mjs` with a top-level `serve(...)` call, so any
|
||||||
@@ -13,8 +13,8 @@ const main = defineCommand({
|
|||||||
},
|
},
|
||||||
default: 'serve',
|
default: 'serve',
|
||||||
subCommands: {
|
subCommands: {
|
||||||
serve: () => import('./src/cli/serve').then((m) => m.default),
|
serve: () => import('@/cli/serve').then((m) => m.default),
|
||||||
migrate: () => import('./src/cli/migrate').then((m) => m.default),
|
migrate: () => import('@/cli/migrate').then((m) => m.default),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export default async function startNitroServer() {
|
export default async function startNitroServer() {
|
||||||
await import('../../.output/server/index.mjs')
|
await import('#server')
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -6,22 +6,30 @@ export default defineCommand({
|
|||||||
description: 'Apply pending database migrations',
|
description: 'Apply pending database migrations',
|
||||||
},
|
},
|
||||||
async run() {
|
async run() {
|
||||||
const [{ env }, { drizzle }, { sql }, { embeddedMigrations }, { createHash }] = await Promise.all([
|
const [{ env }, { drizzle }, { sql }, { embeddedMigrations }, { getLogger }] = await Promise.all([
|
||||||
import('@/env'),
|
import('@/env'),
|
||||||
import('drizzle-orm/postgres-js'),
|
import('drizzle-orm/postgres-js'),
|
||||||
import('drizzle-orm'),
|
import('drizzle-orm'),
|
||||||
import('@/server/db/migrations.gen'),
|
import('@/server/db/migrations.gen'),
|
||||||
import('node:crypto'),
|
import('@/server/logger'),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const logger = getLogger(['cli', 'migrate'])
|
||||||
|
|
||||||
if (embeddedMigrations.length === 0) {
|
if (embeddedMigrations.length === 0) {
|
||||||
console.log('No migrations bundled into this binary.')
|
logger.info('No migrations bundled into this binary.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex')
|
const sha256 = (s: string) => Bun.CryptoHasher.hash('sha256', s, 'hex')
|
||||||
|
|
||||||
const db = drizzle({ connection: { url: env.DATABASE_URL, max: 1, onnotice: () => {} } })
|
const db = drizzle({
|
||||||
|
connection: {
|
||||||
|
url: env.DATABASE_URL,
|
||||||
|
max: 1,
|
||||||
|
onnotice: (n) => logger.debug('pg notice', { notice: n.message }),
|
||||||
|
},
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "drizzle"`)
|
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "drizzle"`)
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
@@ -51,11 +59,11 @@ export default defineCommand({
|
|||||||
const appliedWhens = new Set(applied.map((r) => Number(r.created_at)))
|
const appliedWhens = new Set(applied.map((r) => Number(r.created_at)))
|
||||||
const pending = embeddedMigrations.filter((m) => !appliedWhens.has(m.when))
|
const pending = embeddedMigrations.filter((m) => !appliedWhens.has(m.when))
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
console.log('Database is up to date.')
|
logger.info('Database is up to date.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Applying ${pending.length} migration(s)...`)
|
logger.info('Applying {count} migration(s)...', { count: pending.length })
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
for (const m of pending) {
|
for (const m of pending) {
|
||||||
for (const rawStmt of m.sql.split('--> statement-breakpoint')) {
|
for (const rawStmt of m.sql.split('--> statement-breakpoint')) {
|
||||||
@@ -68,7 +76,7 @@ export default defineCommand({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('Migrations applied.')
|
logger.info('Migrations applied.')
|
||||||
} finally {
|
} finally {
|
||||||
await db.$client.end()
|
await db.$client.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { z } from 'zod'
|
|||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
|
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
|
||||||
|
LOG_DB: z.stringbool().default(false),
|
||||||
|
LOG_FORMAT: z.enum(['pretty', 'json']).optional(),
|
||||||
|
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']).default('info'),
|
||||||
},
|
},
|
||||||
clientPrefix: 'VITE_',
|
clientPrefix: 'VITE_',
|
||||||
client: {},
|
client: {},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
|||||||
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
|
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { name } from '@/../package.json'
|
import { name } from '#package'
|
||||||
import { ErrorComponent } from '@/components/Error'
|
import { ErrorComponent } from '@/components/Error'
|
||||||
import { NotFoundComponent } from '@/components/NotFound'
|
import { NotFoundComponent } from '@/components/NotFound'
|
||||||
import appCss from '@/styles.css?url'
|
import appCss from '@/styles.css?url'
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
|
|||||||
import { onError } from '@orpc/server'
|
import { onError } from '@orpc/server'
|
||||||
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
|
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { name, version } from '@/../package.json'
|
import { name, version } from '#package'
|
||||||
import { handleValidationError, logError } from '@/server/api/interceptors'
|
import { handleValidationError, logError } from '@/server/api/interceptors'
|
||||||
import { router } from '@/server/api/routers'
|
import { router } from '@/server/api/routers'
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { ORPCError, ValidationError } from '@orpc/server'
|
import { ORPCError, ValidationError } from '@orpc/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { logger } from '@/server/logger'
|
import { getLogger } from '@/server/logger'
|
||||||
|
|
||||||
|
const logger = getLogger(['api'])
|
||||||
|
|
||||||
export const logError = (error: unknown) => {
|
export const logError = (error: unknown) => {
|
||||||
logger.error(error)
|
logger.error('Unhandled error in ORPC handler', { error })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleValidationError = (error: unknown) => {
|
export const handleValidationError = (error: unknown) => {
|
||||||
if (error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause instanceof ValidationError) {
|
if (!(error instanceof ORPCError) || !(error.cause instanceof ValidationError)) return
|
||||||
|
|
||||||
|
if (error.code === 'BAD_REQUEST') {
|
||||||
// ORPC widens issues to the Standard Schema shape; every contract here is built from Zod/drizzle-zod,
|
// ORPC widens issues to the Standard Schema shape; every contract here is built from Zod/drizzle-zod,
|
||||||
// so the runtime objects are Zod issues. Rehydrate to reuse z.prettifyError / z.flattenError.
|
// so the runtime objects are Zod issues. Rehydrate to reuse z.prettifyError / z.flattenError.
|
||||||
const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[])
|
const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[])
|
||||||
@@ -20,7 +24,7 @@ export const handleValidationError = (error: unknown) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause instanceof ValidationError) {
|
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||||
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
|
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
|
||||||
cause: error.cause,
|
cause: error.cause,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ContractRouterClient, InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract'
|
import type { ContractRouterClient, InferContractRouterOutputs } from '@orpc/contract'
|
||||||
import type { Contract } from './contracts'
|
import type { Contract } from './contracts'
|
||||||
|
|
||||||
export type RouterClient = ContractRouterClient<Contract>
|
export type RouterClient = ContractRouterClient<Contract>
|
||||||
export type RouterInputs = InferContractRouterInputs<Contract>
|
|
||||||
export type RouterOutputs = InferContractRouterOutputs<Contract>
|
export type RouterOutputs = InferContractRouterOutputs<Contract>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
import { timestamp, uuid } from 'drizzle-orm/pg-core'
|
import { timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||||
import { v7 as uuidv7 } from 'uuid'
|
|
||||||
|
|
||||||
export const generatedFields = {
|
export const generatedFields = {
|
||||||
id: uuid('id')
|
id: uuid('id').primaryKey().default(sql`uuidv7()`),
|
||||||
.primaryKey()
|
|
||||||
.$defaultFn(() => uuidv7()),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { DrizzleLogger } from '@logtape/drizzle-orm'
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
import * as schema from '@/server/db/schema'
|
import * as schema from '@/server/db/schema'
|
||||||
|
import { getLogger } from '@/server/logger'
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
connection: env.DATABASE_URL,
|
connection: env.DATABASE_URL,
|
||||||
schema,
|
schema,
|
||||||
|
logger: env.LOG_DB ? new DrizzleLogger(getLogger(['db']), 'info') : false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// AUTO-GENERATED by `bun run db:embed`. Do not edit.
|
// AUTO-GENERATED by `bun run db:embed`. Do not edit.
|
||||||
import sql_0 from '../../../drizzle/0000_loving_thunderbird.sql' with { type: 'text' }
|
import sql_0 from '#drizzle/0000_loving_thunderbird.sql' with { type: 'text' }
|
||||||
|
|
||||||
export type EmbeddedMigration = { tag: string; sql: string; when: number; breakpoints: boolean }
|
export type EmbeddedMigration = { tag: string; sql: string; when: number; breakpoints: boolean }
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -1,5 +1,19 @@
|
|||||||
export const logger = {
|
import { configureSync, getConfig, getConsoleSink, getJsonLinesFormatter } from '@logtape/logtape'
|
||||||
error: (error: unknown) => console.error(error),
|
import { prettyFormatter } from '@logtape/pretty'
|
||||||
warn: (...args: unknown[]) => console.warn(...args),
|
import { env } from '@/env'
|
||||||
info: (...args: unknown[]) => console.info(...args),
|
|
||||||
|
if (getConfig() === null) {
|
||||||
|
const format = env.LOG_FORMAT ?? (process.stdout.isTTY ? 'pretty' : 'json')
|
||||||
|
|
||||||
|
configureSync({
|
||||||
|
sinks: {
|
||||||
|
console: getConsoleSink({ formatter: format === 'pretty' ? prettyFormatter : getJsonLinesFormatter() }),
|
||||||
|
},
|
||||||
|
loggers: [
|
||||||
|
{ category: [], lowestLevel: env.LOG_LEVEL, sinks: ['console'] },
|
||||||
|
{ category: ['logtape', 'meta'], lowestLevel: 'warning', sinks: ['console'], parentSinks: 'override' },
|
||||||
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { getLogger } from '@logtape/logtape'
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
|
import { getLogger } from '@/server/logger'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
if (import.meta.dev) return
|
if (import.meta.dev) return
|
||||||
|
|
||||||
|
const logger = getLogger(['shutdown'])
|
||||||
let exiting = false
|
let exiting = false
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = async (signal: NodeJS.Signals) => {
|
||||||
if (exiting) {
|
if (exiting) {
|
||||||
|
logger.warn('Forcing exit on repeated signal', { signal })
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
exiting = true
|
exiting = true
|
||||||
|
logger.info('Draining for shutdown', { signal, graceMs: 500 })
|
||||||
|
|
||||||
setTimeout(() => {
|
await Bun.sleep(500)
|
||||||
db.$client.end().finally(() => process.exit(0))
|
try {
|
||||||
}, 500)
|
await db.$client.end()
|
||||||
|
logger.info('DB pool closed, exiting')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DB pool close failed during shutdown', { error })
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', shutdown)
|
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||||
process.on('SIGTERM', shutdown)
|
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,4 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
strictPort: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user