/** * @sylphx/lens-core - Router * * Namespace support for organizing queries and mutations. * Routes can contain procedures (query/mutation) or nested routers. * * @example * ```typescript * import { router, query, mutation } from '@sylphx/lens-core'; * * export const appRouter = router({ * user: { * get: query().args(z.object({ id: z.string() })).resolve(...) * create: mutation().args(...).resolve(...) * }, * post: router({ * list: query().resolve(...) * }) * }); * ``` */ import type { AnyQueryDef, MutationDef, QueryDef, SubscriptionDef } from "../operations/index.js"; import { isMutationDef, isQueryDef, isSubscriptionDef } from "../operations/index.js"; import type { UnionToIntersection } from "../utils/types.js"; // ============================================================================= // Types // ============================================================================= /** Any procedure (query, mutation, or subscription) */ export type AnyProcedure = | AnyQueryDef | MutationDef | SubscriptionDef; /** Router routes - can contain procedures or nested routers */ export type RouterRoutes = { [key: string]: AnyProcedure | RouterDef; }; /** Router definition with context type */ export interface RouterDef { _type: "router"; _routes: TRoutes; /** Phantom type for context inference */ _context?: TContext; } // ============================================================================= // Context Inference // ============================================================================= /** * Extract context from a procedure (non-recursive, single level) */ type ExtractProcedureContext = T extends QueryDef ? C : T extends MutationDef ? C : T extends SubscriptionDef ? C : unknown; /** * Extract context from router's explicit context or from its routes */ type ExtractRouterContext = T extends RouterDef ? unknown extends C ? R extends Record ? ExtractProcedureContext : unknown : C : unknown; /** * Extract contexts from a routes object (one level deep) * Handles both direct procedures and nested routers */ type ExtractRoutesContext = T extends Record ? V extends RouterDef ? ExtractRouterContext : ExtractProcedureContext : unknown; /** * Infer merged context type from router or routes * * Each procedure can declare its own context requirements. * The final context is the intersection of all requirements. * * @example * ```typescript * // Each query declares what it needs * const userGet = query<{ db: DB; user: User }>().resolve(...) * const postList = query<{ db: DB; cache: Cache }>().resolve(...) * * const appRouter = router({ user: { get: userGet }, post: { list: postList } }) * * // InferRouterContext = { db: DB; user: User; cache: Cache } * ``` */ export type InferRouterContext = UnionToIntersection< T extends RouterDef ? unknown extends C ? ExtractRoutesContext : C : T extends Record ? ExtractRoutesContext : unknown >; // ============================================================================= // Type Guards // ============================================================================= /** Check if value is a router definition */ export function isRouterDef(value: unknown): value is RouterDef { return typeof value === "object" && value !== null && (value as RouterDef)._type === "router"; } // ============================================================================= // Router Factory // ============================================================================= /** * Create a router for namespacing operations * * The router automatically infers the context type from its routes. * When used with createApp, the context function must return * a matching type. * * @example * ```typescript * import { router, query, mutation } from '@sylphx/lens-core'; * import { z } from 'zod'; * * // Using typed lens instance * const lens = initLens.context().create() * * export const appRouter = router({ * user: { * get: lens.query() * .args(z.object({ id: z.string() })) * .resolve(({ args, ctx }) => ctx.db.user.find(args.id)), * create: lens.mutation() * .args(z.object({ name: z.string() })) * .resolve(({ args, ctx }) => ctx.db.user.create(args)), * }, * }); * // appRouter is RouterDef<..., MyContext> * * // createServer will enforce context type * const server = createApp({ * router: appRouter, * context: () => ({ * db: prisma, // Must match MyContext! * }), * }) * ``` */ export function router( routes: TRoutes, ): RouterDef> { return { _type: "router", _routes: routes, }; } // ============================================================================= // Router Utilities // ============================================================================= /** Flatten router to dot-notation paths for server processing */ export function flattenRouter(routerDef: RouterDef, prefix = ""): Map { const result = new Map(); const flatten = (routes: Record, currentPrefix: string) => { for (const [key, value] of Object.entries(routes)) { const path = currentPrefix ? `${currentPrefix}.${key}` : key; if (isRouterDef(value)) { // Recursively flatten nested RouterDef const nested = flattenRouter(value, path); for (const [nestedPath, procedure] of nested) { result.set(nestedPath, procedure); } } else if (isQueryDef(value) || isMutationDef(value) || isSubscriptionDef(value)) { // It's a procedure (query, mutation, or subscription) result.set(path, value); } else if (value && typeof value === "object" && !Array.isArray(value)) { // Plain nested object - recursively process flatten(value as Record, path); } } }; flatten(routerDef._routes as Record, prefix); return result; } // ============================================================================= // Client Type Inference // ============================================================================= /** * Query result type (thenable with reactive features) * Matches the client's QueryResult interface */ export interface QueryResultType { /** Current value (for peeking without subscribing) */ readonly value: T | null; /** Subscribe to updates */ subscribe(callback?: (data: T) => void): () => void; /** Promise interface - allows await */ then( onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): Promise; } /** * Mutation result type * Matches the client's MutationResult interface */ export interface MutationResultType { data: T; rollback?: () => void; } /** * Subscription result type (async iterable of events) * Matches the client's Subscription interface */ export interface SubscriptionResultType { /** Subscribe to events */ subscribe(callback: (data: T) => void): () => void; /** Async iterator interface */ [Symbol.asyncIterator](): AsyncIterator; } /** Infer the client type from a router definition */ export type InferRouterClient = TRouter extends RouterDef ? { [K in keyof TRoutes]: TRoutes[K] extends RouterDef ? InferRouterClient> : TRoutes[K] extends { _type: "query"; _brand: { input: infer TInput; output: infer TOutput }; } ? TInput extends void ? () => QueryResultType : (input: TInput) => QueryResultType : TRoutes[K] extends { _type: "mutation"; _brand: { input: infer TInput; output: infer TOutput }; } ? (input: TInput) => Promise> : TRoutes[K] extends { _type: "subscription"; _brand: { input: infer TInput; output: infer TOutput }; } ? TInput extends void ? () => SubscriptionResultType : (input: TInput) => SubscriptionResultType : never; } : never;