# @nestri/core — Domain Module Guide ## Structure Every domain module lives in `packages/core/src//` as either a top-level namespace or a nested sub-module: ``` src// ├── .sql.ts # (optional) Drizzle table for the parent entity ├── index.ts # Parent namespace (e.g. User, Game, Team) ├── .sql.ts # Sub-module table (e.g. fingerprint.sql.ts) └── .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//.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//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 './.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; // ── 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. 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 { 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_" Identifier.ascending('team') // → "tem_" Identifier.ascending('linkedAccount') // → "lac_" // 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; 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 ` 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//`. ### Creating a route module ```ts // app/routes/.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//` 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` | `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) { 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 = {}; 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; const _get = memo(() => Info.parse({ ...process.env, ..._overrides })); export function get(): Info { return _get(); } export function init(bindings: Record) { _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; }); }); ```