Files
netris-nestri/CLAUDE.md
2026-08-06 22:13:51 +03:00

1325 lines
47 KiB
Markdown

# @nestri/core — Domain Module Guide
## Structure
Every domain module lives in `packages/core/src/<parent>/` as either a top-level namespace or a nested sub-module:
```
src/<parent>/
├── <parent>.sql.ts # (optional) Drizzle table for the parent entity
├── index.ts # Parent namespace (e.g. User, Game, Team)
├── <child>.sql.ts # Sub-module table (e.g. fingerprint.sql.ts)
└── <child>.ts # Sub-module namespace (e.g. export namespace Fingerprint)
```
### Sub-modules nested under parents
| File | Namespace | Why |
| ----------------------- | --------------- | ------------------------------------- |
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
| `user/library.*` | `Library` | User's owned games with playtime |
| `team/member.*` | `Member` | Team membership with role |
| `game/depot.*` | `Depot` | Platform-specific game content depots |
Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`.
Modules that don't own their own table (like `steam/`) only need a single `index.ts` exposing reusable `fn()` functions — no `.sql.ts` file.
## Pattern: `.sql.ts` (Drizzle Table)
```ts
// src/<module>/<module>.sql.ts
import { pgTable, text, boolean, jsonb, uniqueIndex, index, pgEnum } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
// Enum (only if needed — co-located with its table)
export const SomeEnum = pgEnum('some_enum', ['a', 'b']);
// FK imports — use the sql.ts files, never the index.ts (avoids circular deps)
import { UserTable } from '../user/user.sql.js';
export const SomeTable = pgTable(
'some_table',
{
...id, // char(30) PK, prefix: som_
...timestamps, // time_created, time_updated, time_deleted (all utc)
// FK column — always use ulid() + .references()
userId: ulid('user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
// Scalar columns
name: text('name').notNull(),
email: text('email'), // nullable = omit .notNull()
flag: boolean('flag').notNull().default(false),
metadata: jsonb('metadata').$type<{}>(), // JSON blob
// Enum column
provider: SomeEnum('provider').notNull()
},
(t) => [
uniqueIndex('some_table_provider_unique').on(t.provider, t.providerAccountId),
index('some_table_sync_idx')
.on(t.userId)
.where(sql`${t.localValue} is distinct from ${t.remoteValue}`),
index('some_table_user_idx').on(t.userId)
]
);
```
### DB types (`src/db/types.ts`)
| Helper | Output |
| ------------ | --------------------------------------------------------------- |
| `ulid(name)` | `char(30)` — for PKs and FKs |
| `id` | `{ id: ulid('id').primaryKey().notNull() }` — spread as `...id` |
| `utc(name)` | `timestamp with time zone` |
| `timestamps` | `{ timeCreated, timeUpdated (auto), timeDeleted }` |
### Naming conventions
- Table name: `snake_case` (e.g. `linked_account`, `team_member`)
- Column name: `snake_case` (e.g. `user_id`, `provider_account_id`, `time_created`)
- TypeScript field names: `camelCase` matching the column (drizzle maps them)
- Index names: `{table}_{column(s)}_unique` / `{table}_{column}_idx`
---
## Pattern: `index.ts` (Domain Namespace)
```ts
// src/<module>/index.ts
import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { SomeTable, SomeEnum } from './<module>.sql.js';
export namespace SomeModule {
// ── Info schema ─────────────────────────────────────────────────────
// Single source of truth for the entity shape.
// Every field typed here; .meta() adds OpenAPI metadata.
// When a field changes here, TypeScript catches every usage.
export const Info = z
.object({
id: z.string().meta({
description: '…',
example: Examples.SomeModule.id,
}),
// For enum fields, use z.enum(SomeEnum.enumValues) to stay in sync:
provider: z.enum(SomeEnum.enumValues).meta({ }),
// Nullable + optional for JSON-blob / optional fields:
metadata: z.record(z.string(), z.unknown()).nullable().optional().meta({ }),
})
.meta({
ref: 'SomeModule',
description: '…',
example: Examples.SomeModule,
});
export type Info = z.infer<typeof Info>;
// ── create ───────────────────────────────────────────────────────────
// Use Info.pick({…}) for the schema — keeps fields in sync with Info.
// Input is the parsed object.
// Use Database.use() for single-operation writes.
export const create = fn(
Info.pick({ id: true, name: true, email: true }),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(SomeTable).values({
id: input.id,
name: input.name,
email: input.email ?? null,
});
});
return input.id;
}
);
// ── Single-field lookups ─────────────────────────────────────────────
// Use Info.shape.<field> for the schema.
// The callback receives the raw value, not { field: value }.
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(SomeTable)
.where(and(eq(SomeTable.id, id), isNull(SomeTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
});
export const fromSlug = fn(Info.shape.slug, async (slug) => {
// …
});
// ── Multi-field lookups ──────────────────────────────────────────────
// Use Info.pick({ field1: true, field2: true })
export const findByProvider = fn(
Info.pick({ provider: true, providerAccountId: true }),
async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(SomeTable)
.where(and(
eq(SomeTable.provider, input.provider),
eq(SomeTable.providerAccountId, input.providerAccountId),
isNull(SomeTable.timeDeleted),
))
.then((rows) => rows.at(0) ?? null);
});
}
);
// ── List (no args) ───────────────────────────────────────────────────
// Plain async function — no fn() wrapper since there's no input.
export async function list() {
return Database.use(async (tx) => {
return tx
.select()
.from(SomeTable)
.where(isNull(SomeTable.timeDeleted))
.orderBy(SomeTable.timeCreated);
});
}
// ── List by FK ───────────────────────────────────────────────────────
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(SomeTable)
.where(and(eq(SomeTable.userId, userId), isNull(SomeTable.timeDeleted)))
.orderBy(SomeTable.timeCreated);
});
});
// ── Update ───────────────────────────────────────────────────────────
// If the update uses Info fields + possibly extra fields:
export const updateSomething = fn(
Info.pick({ id: true, otherField: true }).extend({
extraField: z.string(),
}),
async (input) => {
await Database.use(async (tx) => {
await tx
.update(SomeTable)
.set({ otherField: input.otherField })
.where(eq(SomeTable.id, input.id));
});
}
);
// ── Soft-delete ──────────────────────────────────────────────────────
// Use sql\`now()\` for the timestamp (consistent with DB time).
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(SomeTable)
.set({ timeDeleted: sql`now()` })
.where(eq(SomeTable.id, id));
});
});
// ── serialize ────────────────────────────────────────────────────────
// Converts a DB row into the public API shape. Keeps serialization
// decoupled from the DB layer.
export function serialize(input: typeof SomeTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
// Cast enums since drizzle returns a string at runtime:
provider: input.provider as Info['provider'],
};
}
// ── listByUserWithGames ──────────────────────────────────────────────
// JOIN query with serialization inside the fn() boundary.
// The .then() chain maps raw rows to the public shape immediately.
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select({
library: UserLibraryTable,
game: GameTable
})
.from(UserLibraryTable)
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
.where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted)))
.orderBy(UserLibraryTable.timeCreated)
.then((rows) =>
rows
.filter((row) => row.game !== null)
.map((row) => ({
id: row.library.id,
game: Game.serialize(row.game!),
playtime2w: row.library.playtime2w,
playtimeForever: row.library.playtimeForever,
lastPlayed: row.library.lastPlayed?.toISOString() ?? null
}))
);
});
});
}
```
### Key rules for `fn()` usage
| Case | Schema | Callback receives |
| -------------------- | ------------------------------------------ | ------------------------------------- |
| Single field | `Info.shape.field` | Raw value (`string`, `boolean`, etc.) |
| Multiple fields | `Info.pick({a:true, b:true})` | `{ a, b }` object |
| Full entity | `Info` | Full `Info` object |
| Info fields + extras | `Info.pick({…}).extend({extra: z.type()})` | `{ …fields, extra }` |
| No input | Regular `async function` | n/a |
### What `fn()` does
```ts
fn(schema, callback);
// → (input) => { schema.parse(input); return callback(parsed); }
// The returned function also has a .schema property for OpenAPI introspection.
```
---
## Serialization Boundary Rule
**All data transformation — including JOIN deserialization, field mapping, date stringification, and null filtering — happens INSIDE the `fn()` boundary, right after the DB query in a `.then()` chain. The API route is a dumb pass-through.**
### Why this matters
| Approach | Queries | Boundary clarity |
| ----------------------------------------------------- | ------------ | -------------------------------------- |
| **Bad**: Raw rows from core, map/filter in route | N+1 (or raw) | Leaky — route knows DB schema |
| **Bad**: JOIN in core, but serialize in route | 1 | Still leaky — route owns shaping logic |
| **Good**: JOIN + serialize in `.then()` inside `fn()` | 1 | Clean — core returns JSON-safe objects |
### The pattern
```ts
// GOOD: serialization inside fn()
export const listByUserWithGames = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select({ library: UserLibraryTable, game: GameTable })
.from(UserLibraryTable)
.leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id))
.where(...)
.orderBy(...)
.then((rows) =>
rows
.filter((row) => row.game !== null)
.map((row) => ({
id: row.library.id,
game: Game.serialize(row.game!),
lastPlayed: row.library.lastPlayed?.toISOString() ?? null
}))
);
});
});
// GOOD: API route is a thin pass-through
async (c) => {
const data = await Library.listByUserWithGames(Actor.userID);
return c.json({ data });
}
```
### Rules
1. **Never let raw Drizzle `$inferSelect` rows escape the core module.** If a function returns joined data, it must be shaped before the return.
2. **Use `.then()` after the query for map/filter/serialize.** Keeps the async pipeline declarative and co-located with the SQL.
3. **Re-use sibling `serialize()` functions for joined tables.** e.g. `Game.serialize(row.game!)` when joining `GameTable`.
4. **API routes only do:** auth checks, input validation, calling the core fn, and `c.json({ data })`. No `.map()`, no `.filter()`, no field remapping.
---
### Mutation Rule: Single-Query Reads via `.returning()`
Never execute a separate `tx.select()` or trigger a lookup function immediately after an `insert` or `upsert` mutation to fetch the updated state of a row.
Postgres natively supports the `RETURNING` clause. Always append `.returning()` directly to your mutation chains and destructure the resulting array (`const [row] = await tx...`). This ensures mutations remain atomic, avoids unnecessary connection pool overhead, and removes the latency penalty of running two sequential database operations.
---
## Pattern: ID generation (`src/id.ts`)
```ts
Identifier.ascending('user') // → "usr_<monotonic-id>"
Identifier.ascending('team') // → "tem_<monotonic-id>"
Identifier.ascending('linkedAccount') // → "lac_<monotonic-id>"
// Prefixes are defined in Identifier.prefixes:
{
user: 'usr',
linkedAccount: 'lac',
team: 'tem',
teamMember: 'mem',
verification: 'ver',
}
```
The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonically increasing (time-sortable) when using `ascending()`.
---
## Pattern: Examples (`src/examples.ts`)
```ts
export namespace Examples {
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const User = { id: Id('user'), name: '…', email: '…', };
export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', };
export const Team = { id: Id('team'), slug: 'my-team', };
export const Member = { id: Id('teamMember'), role: 'owner' as const, };
export const Fingerprint = { id: Id('userFingerprint'), fingerprint: '…', };
export const GameDownload = { id: Id('gameDownload'), hostId: 'hst_…', gameId: Id('game'), status: 'downloading', };
export const Library = { id: Id('userLibrary'), playtimeForever: 150000, };
export const Depot = { id: Id('gameDepot'), depotId: 730, };
}
```
Every entity in `Examples` must be added and imported by the `Info` schema's `.meta({ example: … })`.
---
## Pattern: Environment (`src/env.ts`)
```ts
export namespace Env {
export const Info = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
FRONTEND_URL: z.string().optional(),
STEAM_API_KEY: z.string().optional(),
AUTH_ISSUER_URL: z.string().optional() // used by API auth middleware to verify tokens
});
export type Info = z.infer<typeof Info>;
export const env: Info = Info.parse(process.env);
}
```
---
## Pattern: OpenAuth Subjects (`src/auth/subjects.ts`)
```ts
import { createSubjects } from '@nestri/auth/subject';
import { z } from 'zod';
export const subjects = createSubjects({
user: z.object({
userID: z.string(),
linkedAccountID: z.string()
})
});
```
The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`.
The API verifies via `client.verify(subjects, token)` from `@nestri/auth/client`.
---
## Entity relationships
```
User ───1:N─── LinkedAccount ← auth methods (Steam, Epic, etc.)
├─── 1:N ─── Fingerprint ← SSH public keys
├─── 1:N ─── Library ← owned games with playtime
└─── N:M ─── Team ← via Member
└─ role: owner | admin | member
Game ───1:N─── Download ← per-host game depot downloads
```
- **User**: Person record. Email is nullable (gaming accounts don't provide one).
- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique.
- **Team**: Organization for billing/collaboration. First team is auto-created as "personal" team.
- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique.
---
## Database access
```ts
// Auto-scoped transaction (creates one if outside a transaction):
await Database.use(async (tx) => {
await tx.insert(SomeTable).values({ });
const result = await tx.select().from(SomeTable).where();
});
// Explicit transaction:
await Database.transaction(async (tx) => {
// All operations in one atomic transaction
});
// Side-effect queued after transaction commits:
Database.effect(() => sendEmail());
```
---
## Soft-delete convention
Every table has `time_deleted` (nullable timestamp). Queries filter with `isNull(table.timeDeleted)`. Deletion sets `timeDeleted: sql\`now()\`` — never hard-deletes.
---
## Dependency rule
- `.sql.ts` files may import from other `.sql.ts` files (for FK references).
- `index.ts` files may import from other `index.ts` files and `.sql.ts` files.
- Never import an `index.ts` from within a `.sql.ts` — that creates circular deps.
---
## Actor Model (`packages/core/src/actor.ts`)
The Actor model identifies who/what is making a request. It uses `AsyncLocalStorage` (via `Context.create()`) so the actor is accessible anywhere in the call chain without passing it around.
### Actor types
```ts
type ActorInfo =
| { type: 'public'; properties: {} }
| { type: 'user'; properties: { userID: string; linkedAccountID: string } }
| {
type: 'member';
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
}
| { type: 'system'; properties: { teamID: string } }
| { type: 'admin'; properties: {} };
```
### API
```ts
Actor.use(); // → ActorInfo (throws if no context set)
Actor.with(value, fn); // Run fn in the given actor context
Actor.assert(type); // Assert current actor type, returns narrowed type
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin'
Actor.userID; // → string (user/member only)
Actor.linkedAccountID; // → string (user only)
Actor.useTeam; // → string (member/system only — the teamID)
Actor.role; // → 'owner' | 'admin' | 'member' (member only)
Actor.isSignedIn; // → boolean (true if not public)
```
### When to pull from Actor vs pass as param
Functions that create resources owned by the current user (e.g. `Team.create`) pull `userID` from the actor context rather than requiring it as a parameter. This avoids passing `ownerId`/`userId` through the entire call chain.
Domain functions that need the actor's identity import `Actor` and call `Actor.userID` inside the `fn()` callback:
```ts
export const create = fn(Info.pick({ id: true, name: true, slug: true }), async (input) => {
const ownerId = Actor.userID; // from AsyncLocalStorage
// ...
});
```
### Actor.userID inside Database.transaction()
When doing find-or-create logic that must scope to the authenticated user, pull `Actor.userID` **inside** the `Database.transaction()` callback. This keeps scoping co-located with the DB logic:
```ts
export const link = fn(
z.object({ steamId: z.string(), profile: z.record(z.string(), z.unknown()).optional() }),
async (input) => {
return Database.transaction(async () => {
const existing = await LinkedAccount.findByProvider({ ... });
if (existing) return existing.id;
const id = Identifier.ascending('linkedAccount');
await LinkedAccount.create({ id, userId: Actor.userID, provider: 'steam', ... });
return id;
});
}
);
```
This ensures the API endpoint is scoped to the user only — no `userId` param is passed from the route handler. The `Actor.userID` is set by the auth middleware's `Actor.with()`, propagated via `AsyncLocalStorage`.
Callers must wrap actor-dependent work in `Actor.with()` before calling these functions. The API middleware does this automatically for HTTP requests. The auth worker sets it up explicitly:
```ts
await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, async () => {
await Team.createPersonal({ displayName: personaname });
});
```
---
## Auth Flow
### Auth Worker (`apps/auth/src/index.ts`)
A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success` callback after OAuth:
1. Steam returns `steamid` → worker fetches profile from Steam API
2. Inside `Database.transaction()`: looks up existing `LinkedAccount.findByProvider`; if found returns existing user, else creates `User` + `LinkedAccount`
3. Wraps post-login setup in `Actor.with()`, then checks `Member.listByUser(userID)`
4. If no memberships, calls `Team.createPersonal({ displayName })`
5. Issues JWT via `context.subject('user', { userID, linkedAccountID })`
### Admin Auth via Shared Secret
Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks.
Configure `ADMIN_SHARED_SECRET` in `.env`; defaults to the same value as `SSH_AUTH_KEY` in dev (`dev-ssh-auth-key-change-in-prod`).
### Auth Middleware (`apps/api/app/middleware/auth.ts`)
Hono middleware that runs on every API request:
1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately
2. Otherwise, reads `Authorization: Bearer <token>` header
3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client`
4. If valid `user` subject:
- Checks `x-nestri-team` header for team-scoped access
- If team header present, verifies membership via `Member.findByTeamAndUser`
- Sets actor to `member` (with role) or `user` type
5. If no token/invalid: sets actor to `public` type
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route.
### OpenAuth Subjects (`src/auth/subjects.ts`)
```ts
export const subjects = createSubjects({
user: z.object({ userID: z.string(), linkedAccountID: z.string() })
});
```
The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`.
Env var `AUTH_ISSUER_URL` configures which issuer to trust for token verification.
---
## Team Discriminator System
When creating a personal team (`Team.createPersonal`), the slug is derived from the display name:
```ts
// 1. Sanitize: lowercase, replace non-alphanumeric with dashes, trim edges, max 50 chars
const baseSlug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')...
// 2. Check if slug exists (fromSlug)
// 3. If taken, append random 4-digit discriminator: "name-1234"
const slug = existing ? `${baseSlug}-${discriminator}` : baseSlug;
```
This mirrors Discord's discriminator pattern. The discriminator is part of the slug string — not a separate column.
---
## `fn()` and Actor Context
`fn()` itself is unchanged — it validates input against a zod schema and calls the callback. The actor context is available inside `fn()` callbacks because `Actor.with()` uses `AsyncLocalStorage`, which propagates automatically through `await` chains.
Every `fn()` callback can import and use `Actor` directly. No special wiring needed.
---
## API Error Pattern (`packages/core/src/error.ts`)
Centralized error types used by both the domain layer and the API.
### ErrorResponse
Zod schema for OpenAPI error responses:
```ts
import { z } from 'zod';
export const ErrorResponse = z
.object({
type: z.enum([
'validation',
'authentication',
'forbidden',
'not_found',
'already_exists',
'rate_limit',
'internal'
]),
code: z.string(),
message: z.string(),
param: z.string().optional(),
details: z.any().optional()
})
.meta({ ref: 'ErrorResponse' });
```
### ErrorCodes
Structured error code constants:
| Category | Codes |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Validation` | `MISSING_REQUIRED_FIELD`, `ALREADY_EXISTS`, `TEAM_ALREADY_EXISTS`, `INVALID_PARAMETER`, `INVALID_FORMAT`, `INVALID_STATE`, `IN_USE` |
| `Authentication` | `UNAUTHORIZED`, `INVALID_TOKEN`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
| `Permission` | `FORBIDDEN`, `INSUFFICIENT_PERMISSIONS`, `ACCOUNT_RESTRICTED` |
| `NotFound` | `RESOURCE_NOT_FOUND` |
| `RateLimit` | `TOO_MANY_REQUESTS`, `QUOTA_EXCEEDED` |
| `Server` | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, `DEPENDENCY_FAILURE` |
### VisibleError
Throw this for any user-facing error. It carries structured data and converts cleanly to HTTP:
```ts
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${id} does not exist`
);
```
- `.statusCode()` — maps `type` → HTTP status (validation→400, authentication→401, forbidden→403, not_found→404, already_exists→409, rate_limit→429, internal→500)
- `.toResponse()` → `{ type, code, message, param?, details? }`
The API's global `onError` handler catches `VisibleError` + `HTTPException` + unknown errors, logging each and returning the correct JSON shape.
---
## API Route Pattern (`apps/api/app/routes/`)
Every API domain is a TypeScript `namespace` with a `.route` property — a plain `new Hono()` instance with chained route definitions.
### Route → Domain function flow
```
HTTP request ──► Route handler (thin) ──► Core domain fn() ──► DB
│ │
│ validates input │ pulls Actor.userID
│ calls domain fn │ handles business logic
│ returns c.json({data}) │ inside Database.transaction()
```
Route handlers are **thin wrappers** — they validate input, call a core function, and return the result. All business logic lives in `packages/core/src/<module>/`.
### Creating a route module
```ts
// app/routes/<thing>.ts
import { z } from 'zod';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { Thing } from '@nestri/core/thing/index';
import { Examples } from '@nestri/core/examples';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
export namespace ThingApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['Thing'],
summary: 'List things',
description: 'List all things',
responses: {
200: {
content: {
'application/json': {
schema: Result(
Thing.Info.array().meta({
description: 'All things',
example: [Examples.Thing]
})
)
}
},
description: 'All things'
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429]
}
}),
async (c) => c.json({ data: await Thing.list() })
)
.get(
'/:id',
describeRoute({/* … */}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the thing',
example: Examples.Thing.id
})
})
),
async (c) => {
const thing = await Thing.fromID(c.req.valid('param').id);
if (!thing) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Thing ${id} not found`
);
}
return c.json({ data: thing });
}
);
}
```
### Grouped routes: `/(group)/(sub-route)`
For domains with multiple sub-routes (e.g. Steam with `/link`, `/sync`, `/unlink`), group them under one route file. The namespace name is `XxxApi` (e.g. `SteamApi`), the route path is `/(group)`:
```ts
// app/routes/steam.ts
import { z } from "zod";
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Steam } from "@nestri/core/steam/index";
import { ErrorResponses, notPublic, Result, validator } from "../utils";
export namespace SteamApi {
export const route = new Hono()
.use(notPublic)
.post("/link", // → POST /steam/link
describeRoute({ tags: ["Steam"], summary: "Link a Steam account", ... }),
validator("json", z.object({ steamId: z.string() })),
async (c) => {
const { steamId } = c.req.valid("json");
const result = await Steam.link({ steamId }); // ← calls core fn
return c.json({ data: { linkedAccountId: result, steamId } });
},
)
.post("/sync", // → POST /steam/sync
// ...
);
}
```
Registered in the app entry as `/steam`:
```ts
// app/index.ts
import { SteamApi } from "./routes/steam.js";
const routes = app
.route("/", IndexApi.route)
.route("/users", UserApi.route)
.route("/steam", SteamApi.route) // mounts all /steam/* routes
.onError(…);
```
This keeps the route path and the namespace name aligned — the Hono instance at `SteamApi.route` is mounted at `/steam`.
### Key conventions
| Element | Pattern |
| ---------------- | --------------------------------------------------------------------------------- |
| Structure | `export namespace XxxApi { export const route = new Hono() … }` |
| Group route | `POST "/link"` at `XxxApi` → mounted at `/xxx` → `POST /xxx/link` |
| Auth guard | `.use(notPublic)` at the namespace level (or per-route) |
| Route is thin | validates input → calls core fn → returns `c.json({ data: … })` |
| Core fn | reusable `fn()` in `packages/core/src/<module>/` owns all logic |
| OpenAPI | `describeRoute({ tags, summary, description, responses })` wraps each handler |
| Response schema | `Result(Schema)` → `resolver(z.object({ data: schema }))` |
| Error responses | `ErrorResponses[statusCode]` for 400, 401, 403, 404, 409, 429, 500 |
| Param validation | `validator("param", z.object({…}))` — uses custom wrapper that formats Zod errors |
| Body validation | `validator("json", z.object({…}))` — same wrapper for request body |
| Not found | `throw new VisibleError("not_found", ErrorCodes.NotFound.RESOURCE_NOT_FOUND, …)` |
| Metadata | Use `.meta()` (NOT `.openapi()`) — Zod v4 native + `zod-openapi` v6 |
### Registering a route in the app
```ts
// app/index.ts
import { SteamApi } from "./routes/steam.js";
import { ThingApi } from "./routes/thing.js";
const routes = app
.route("/", IndexApi.route)
.route("/users", UserApi.route)
.route("/steam", SteamApi.route) // mount group at /steam
.route("/things", ThingApi.route) // mount group at /things
.onError(…);
```
The first argument to `.route()` is the URL prefix. All sub-routes defined on that Hono instance are relative to this prefix.
---
## API Utils (`apps/api/app/utils/`)
| File | Export | Purpose |
| -------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
| `index.ts` | — | Barrel re-export of all utils |
| `auth.ts` | `auth`, `notPublic` | Re-exports from `middleware/auth` |
| `error.ts` | `ErrorResponses` | `{ 400, 401, 403, 404, 409, 429, 500 }` → OpenAPI response objects |
| `result.ts` | `Result<T>` | `resolver(z.object({ data: T }))` — standard `{ data: … }` response shape |
| `validator.ts` | `validator` | Wraps `hono-openapi/zod`'s validator with standardized Zod error formatting (400 + error code) |
| `hook.ts` | `Hook`, `zValidator` | Type declarations (re-exported from `@hono/zod-validator`) |
---
## Main API Entry (`apps/api/app/index.ts`)
The entry point wires everything together. Key structure:
```ts
import 'zod-openapi'; // augment Zod v4 with OpenAPI metadata types
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { showRoutes } from 'hono/dev';
import { openAPISpecs } from 'hono-openapi';
import { HTTPException } from 'hono/http-exception';
export const app = new Hono();
// Global middleware (order matters)
app
.use(logger())
.use(async (c, next) => {
c.header('Cache-Control', 'no-store');
return next();
})
.use(cors({ origin: Env.env.FRONTEND_URL || 'http://localhost:5173', credentials: true }))
.use(auth);
// Routes + error handler
const routes = app
.route('/', IndexApi.route)
.route('/things', ThingApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {
return c.json(error.toResponse(), error.statusCode());
}
if (error instanceof HTTPException) {
return c.json(
{
type: 'validation',
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: 'Invalid request'
},
error.status
);
}
return c.json(
{
type: 'internal',
code: ErrorCodes.Server.INTERNAL_ERROR,
message: 'Internal server error'
},
500
);
});
// OpenAPI spec at /doc
app.get(
'/doc',
openAPISpecs(routes, { documentation: { info: { title: 'API', version: '0.0.1' } } })
);
showRoutes(app);
export default { port: process.env.PORT ?? 3000, fetch: app.fetch };
```
### Dev / production
```
bun --watch app/index.ts # dev with hot reload
bun app/index.ts # production
```
No Vite needed — Bun runs TypeScript natively.
---
## Important: `.meta()` vs `.openapi()`
| Library | Method | Notes |
| -------------------------- | ------------ | ------------------------------------------------------------------------------------ |
| `zod-openapi` v4 (old) | `.openapi()` | Required `import "zod-openapi/extend"` |
| `zod-openapi` v6 (current) | `.meta()` | Native Zod v4 method; no import needed — auto-augments via `declare module 'zod/v4'` |
- **Domain schemas** (`@nestri/core/*/index.ts`) use `.meta()` for descriptions/examples.
- **API route schemas** (`apps/api/app/routes/*.ts`) use `.meta()` for OpenAPI response/docs metadata.
- **Never** use `.openapi()` — it doesn't exist in `zod-openapi` v6.
---
## Error flow summary
```
Route handler
├─ throws VisibleError ──► onError → c.json(error.toResponse(), error.statusCode())
├─ throws HTTPException ─► onError → c.json({ type: "validation", … }, error.status)
├─ throws raw Error ─────► onError → c.json({ type: "internal", … }, 500)
│ (includes VisibleError for Actor model / context issues)
└─ returns normally ─────► c.json({ data: … })
```
---
# Alchemy (IaC)
This project uses [Alchemy](https://alchemy.run) (v0.93.12) for infrastructure-as-code — the equivalent of SST, but targeting Cloudflare Workers instead of AWS Lambda.
## Project structure
```
web/
alchemy.run.ts # Entry point — creates scope, imports infra
infra/
stage.ts # Stage detection (Scope.getCurrentScope().stage)
secret.ts # Encrypted secrets via alchemy.secret()
auth.ts # Auth Worker resource
api.ts # API Worker resource
```
## `alchemy.run.ts` — entry point
```ts
import alchemy from 'alchemy';
const app = await alchemy('nestri', {
password: process.env.ALCHEMY_PASSWORD // required for secrets
});
// Import infra modules in dependency order (SST-style)
await import('./infra/stage.ts');
await import('./infra/secret.ts');
await import('./infra/auth.ts');
await import('./infra/api.ts');
await app.finalize();
```
Key rules:
- `alchemy(appName, opts)` creates a **scope** — resources register into this scope automatically
- `app.finalize()` must be called at the end to persist state
- Import order matters — resources that depend on others must be imported after
- `--dev` flag runs locally via Miniflare; omit it to deploy to Cloudflare
## Infra resources
Each resource is imported from `alchemy/cloudflare` and called with an ID + props:
```ts
import { Worker, KVNamespace, D1Database } from 'alchemy/cloudflare';
export const kv = await KVNamespace('my-kv');
export const db = await D1Database('my-db');
export const worker = await Worker('my-worker', {
entrypoint: 'apps/some-app/src/index.ts',
compatibility: 'node', // enables nodejs_compat flag
url: true, // assign workers.dev URL
bindings: {
KV: kv, // resource binding → KVNamespace at runtime
DB: db, // → D1Database
PLAIN_VAR: 'hello' // → plain_text binding
}
});
```
### Supported resources (subset)
| Resource | Import | Purpose |
| ------------- | -------------------- | ----------------------------------------------- |
| `Worker` | `alchemy/cloudflare` | Cloudflare Worker (entrypoint or inline script) |
| `KVNamespace` | `alchemy/cloudflare` | KV storage |
| `D1Database` | `alchemy/cloudflare` | D1 SQL database |
| `R2Bucket` | `alchemy/cloudflare` | R2 object storage |
| `Queue` | `alchemy/cloudflare` | Queue/pub-sub |
### Compatibility flag
Always add `compatibility: 'node'` to Workers that use Node.js built-ins (`node:async_hooks`, `crypto`, `node:stream`, etc.):
```ts
Worker('api', {
entrypoint: 'apps/api/app/index.ts',
compatibility: 'node' // enables nodejs_compat
});
```
## Stage detection
```ts
// infra/stage.ts
import { Scope } from 'alchemy';
const scope = Scope.getCurrentScope();
export const stage = scope?.stage ?? 'dev';
export const isPermanent = ['production', 'dev'].includes(stage);
```
Use stage for conditional infrastructure:
```ts
const api = await Worker('api', {
...(isPermanent && {
observability: { enabled: true },
logpush: true
})
});
```
Pass `--stage` flag at runtime: `bun alchemy.run.ts --stage production`
## Secrets and environment variables
Three levels of env management, from most-secure to least:
### 1. `alchemy.secret.env.X` (preferred)
```ts
// infra/secret.ts
import alchemy from 'alchemy';
export const secret = {
steamApiKey: alchemy.secret.env.STEAM_API_KEY // reads process.env at deploy time
// Equivalent to:
// steamApiKey: alchemy.secret(process.env.STEAM_API_KEY),
};
```
- Reads from `process.env` at deploy time
- Throws a descriptive error if the env var is missing
- Encrypted in Alchemy state files (`.alchemy/`)
- Deployed as `secret_text` binding (hidden from Cloudflare API)
### 2. `alchemy.env()` (non-secret config)
```ts
export const frontendUrl = alchemy.env('FRONTEND_URL', 'http://localhost:5173');
```
- Optional default value
- Plain text — not encrypted
- Deployed as `plain_text` binding
### 3. Plain strings in `bindings` (inline)
```ts
bindings: {
MY_VAR: 'hello';
}
```
- Hard-coded, visible in state files
- Deployed as `plain_text` binding
### How bindings map to runtime types
| Alchemy binding type | Deployed as | Runtime type |
| -------------------- | -------------- | -------------------------- |
| `Worker` | `service` | `Service` (has `.fetch()`) |
| `KVNamespace` | `kv_namespace` | `KVNamespace` |
| `D1Database` | `d1` | `D1Database` |
| `alchemy.secret()` | `secret_text` | `string` |
| plain `string` | `plain_text` | `string` |
| `Json(...)` | `json` | `typeof json` |
## Service bindings (Worker → Worker)
Pass one Worker as a binding to another:
```ts
// infra/auth.ts
export const auth = await Worker('auth', {
entrypoint: 'apps/auth/src/index.ts',
compatibility: 'node',
bindings: { ... },
});
// infra/api.ts
import { auth } from './auth.ts';
export const api = await Worker('api', {
entrypoint: 'apps/api/app/index.ts',
bindings: { AUTH: auth },
});
```
At runtime, `env.AUTH` is a `Service` — call it directly:
```ts
const response = await env.AUTH.fetch(request);
```
### OpenAuth client + service binding
The `@openauthjs/openauth/client` only accepts a URL string for `issuer`, so use a custom `fetch` to route through the service binding:
```ts
function getClient(env: Record<string, unknown>) {
return createClient({
issuer: 'https://auth.internal', // dummy — used for path construction
clientID: 'api',
fetch: (input, init) => {
const url = new URL(typeof input === 'string' ? input : input.url);
const request = new Request(url.pathname + url.search, init);
return (env.AUTH as { fetch: typeof fetch }).fetch(request);
}
});
}
```
## Env propagation to Workers
CF Workers receive env vars as the second argument to the `fetch` handler (`env`), NOT via `process.env`. Bridge the gap with a lazy + overridable schema:
```ts
// packages/core/src/env.ts
import { memo } from '../utils/memo.ts';
let _overrides: Record<string, unknown> = {};
export namespace Env {
export const Info = z.object({
FRONTEND_URL: z.string().optional(),
STEAM_API_KEY: z.string().optional(),
AUTH_ISSUER_URL: z.string().optional()
});
export type Info = z.infer<typeof Info>;
const _get = memo(() => Info.parse({ ...process.env, ..._overrides }));
export function get(): Info {
return _get();
}
export function init(bindings: Record<string, unknown>) {
_overrides = bindings;
_get.reset();
}
}
```
Wire in the Hono entrypoint:
```ts
export default {
fetch(request, env, ctx) {
Env.init(env); // merge CF bindings into Env
return app.fetch(request, env, ctx);
}
};
```
Now any module that imports `Env.get()` gets the correct values — on Bun dev `process.env` provides them, on CF Workers the bindings override.
## CLI usage
```sh
# Local dev (Miniflare)
bun alchemy.run.ts --dev
# Deploy to Cloudflare
bun alchemy.run.ts --stage production
# Destroy all resources
bun alchemy.run.ts --destroy
# With custom stage
bun alchemy.run.ts --stage wanjohiryan
# Password (for encrypting secrets)
export ALCHEMY_PASSWORD="some-passphrase"
```
When deploying, set `CLOUDFLARE_API_TOKEN` or configure `alchemy login`.
## Common patterns
### Conditional infra per-stage
```ts
Worker('api', {
...(isPermanent && { logpush: true }),
...(stage === 'production' && { scaling: { min: 3, max: 10 } })
});
```
### Across-app resource references
Alchemy uses top-level await in infra files — resources resolve at import time within the active scope. The scope propagates via `AsyncLocalStorage`, so any `await import()` after `alchemy(appName)` picks it up.
### .alchemy/ directory
Created automatically — contains Miniflare state, build output, and encrypted state files. Add to `.gitignore`.
```gitignore
.alchemy/
```
---
### Index Rule: Null-Safe Exclusions (`IS DISTINCT FROM`)
When writing indices to track data drift, synchronization deltas, or pending background worker states where values might be nullable, **always build a partial index utilizing Postgres-native `IS DISTINCT FROM`**.
Standard inequality operators (`!=` or `<>`) evaluate to `NULL` if either column is `NULL`, causing them to bypass standard `WHERE` index filters. Using `is distinct from` allows Postgres to treat `NULL` as a real value for state comparison:
- Excludes perfectly synchronized records completely from the index footprint.
- Optimizes heavy background worker poll queries directly into small, lightning-fast index scans.
2. Add to the Pattern: index.ts (Domain Namespace) section
Replace the existing create block and add the upsert block inside SomeModule:
```ts
// ── create ───────────────────────────────────────────────────────────
// Use Info.pick({…}) for the schema — keeps fields in sync with Info.
// Always use .returning() to get the updated row context in one database trip.
export const create = fn(Info.pick({ id: true, name: true, email: true }), async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.insert(SomeTable)
.values({
id: input.id,
name: input.name,
email: input.email ?? null
})
.returning();
return row;
});
});
// ── upsert ───────────────────────────────────────────────────────────
// Simple copies use the input values directly. For coalesce-style set
// expressions, reference the excluded pseudo-table with unqualified
// identifiers: sql`excluded.${sql.identifier(SomeTable.name.name)}` —
// interpolating a column object (or its .name string) is invalid.
export const upsert = fn(Info.pick({ id: true, name: true }), async (input) => {
return Database.use(async (tx) => {
const [row] = await tx
.insert(SomeTable)
.values({ id: input.id, name: input.name })
.onConflictDoUpdate({
target: SomeTable.id,
set: { name: input.name }
})
.returning();
return row;
});
});
```